diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f83b181fa..4e5914ac5 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1200,7 +1200,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.1-rc2"; + version = "0.1.1-rc4"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a921d6b3..01e826f17 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "01de5dd232d2b22f132d066697cce041700e7136", - "version" : "0.1.1-rc2" + "revision" : "d56e051b4b1479a8da30c0a05f3fade868ff6477", + "version" : "0.1.1-rc4" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 234cfa827..b56d45bbb 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -501,21 +501,24 @@ struct AppScene: View { private func configurePrivatePaykitContactResolvers() { CoreService.shared.activity.setPrivatePaykitContactResolvers( invoice: { paymentHash in - await PrivatePaykitService.shared.contactPublicKey(forPrivateInvoicePaymentHash: paymentHash) + await PrivatePaykitService.shared.contactPublicKeyForReceipt(paymentHash: paymentHash) }, onchainAddresses: { @MainActor address, outputAddresses in - let identity = pubkyProfile.publicKey - let contacts = paykitPaymentRequestManager.receivedPaymentContacts + guard (try? ProfileOperationAdmission.shared.checkReceiptAttribution()) != nil else { return nil } let reservations = PrivatePaykitAddressReservationStore.shared - let reservationRevision = await reservations.attributionRevision - guard let combined = try? await contacts.includingReservations(for: outputAddresses, lookup: { - try await reservations.contactPublicKeyForAttribution(forReservedAddress: $0) - }), pubkyProfile.authState == .authenticated, - PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey), - contacts == paykitPaymentRequestManager.receivedPaymentContacts, - await reservations.attributionRevision == reservationRevision + let revision = await reservations.attributionRevision + let contacts = pubkyProfile.authState == .authenticated + ? paykitPaymentRequestManager.receivedPaymentContacts : PaykitReceivedPaymentContacts() + guard let combined = try? await reservations.contactsForReceipt( + contacts, + outputAddresses: outputAddresses, + expectedRevision: revision + ) else { return nil } return combined.contact(receivingAddress: address, outputAddresses: outputAddresses) + }, + receiptOperation: { operation in + try await ProfileOperationAdmission.shared.withReceiptAttribution(operation) } ) } @@ -541,7 +544,7 @@ struct AppScene: View { ) { config in AppUpdateSheet(config: config) } - .task(priority: .userInitiated, setupTask) + .task(id: pubkyProfile.removalRecovery.blocksWalletStartup, priority: .userInitiated, setupTask) .task(id: [scenePhase == .active, wallet.walletExists == true, isWalletBackupRestoreRunning, network.isConnected]) { guard scenePhase == .active, wallet.walletExists == true, !isWalletBackupRestoreRunning, !BackupService.shared.hasPendingWalletRestore() @@ -825,13 +828,15 @@ struct AppScene: View { } private var isLockedForDeepLinks: Bool { - settings.pinEnabled && !isPinVerified + pubkyProfile.removalRecovery.blocksWalletStartup || (settings.pinEnabled && !isPinVerified) } private var mainContent: some View { ZStack { if Env.isTrezorEmulatorTesting { trezorEmulatorTestContent + } else if pubkyProfile.removalRecovery.blocksWalletStartup && !hasCriticalUpdate { + NavigationStack { ProfileRemovalRecoveryView() } } else if migrations.isShowingMigrationLoading { migrationLoadingContent } else if showRecoveryScreen { @@ -843,7 +848,7 @@ struct AppScene: View { walletContent } - if !Env.isTrezorEmulatorTesting, !removeSplash, !session.skipSplashOnce { + if !Env.isTrezorEmulatorTesting, !pubkyProfile.removalRecovery.blocksWalletStartup, !removeSplash, !session.skipSplashOnce { SplashView() .opacity(hideSplash ? 0 : 1) } @@ -994,7 +999,7 @@ struct AppScene: View { } } - guard wallet.walletExists == true else { return } + guard wallet.walletExists == true, !pubkyProfile.removalRecovery.blocksWalletStartup else { return } // Don't start wallet if we're in recovery mode guard !showRecoveryScreen else { return } @@ -1024,6 +1029,7 @@ struct AppScene: View { } private func startWallet(completingBackupRestore: Bool = false, playsErrorHaptic: Bool = true) async { + guard !pubkyProfile.removalRecovery.blocksWalletStartup else { return } let hasPendingRestore = BackupService.shared.hasPendingWalletRestore() guard !WalletBackupRestoreGate.blocksWalletStart( isRestoreRunning: isWalletBackupRestoreRunning, @@ -1109,6 +1115,8 @@ struct AppScene: View { private func setupTask() async { configurePrivatePaykitContactResolvers() AppReset.hardwareWallets = hwWalletManager + pubkyProfile.refreshRemovalRecovery() + guard !pubkyProfile.removalRecovery.blocksWalletStartup else { return } do { // Handle orphaned keychain before anything else handleOrphanedKeychain() @@ -1176,26 +1184,32 @@ struct AppScene: View { } private func restoreWalletBackupAndStart() async { - guard !isWalletBackupRestoreRunning else { return } + guard !isWalletBackupRestoreRunning, !pubkyProfile.removalRecovery.blocksWalletStartup else { return } isWalletBackupRestoreRunning = true walletIsInitializing = true didWalletBackupRestoreFail = false defer { isWalletBackupRestoreRunning = false } - let didRestore: Bool = if BackupService.shared.hasPendingWalletRestore() { - await restoreVssBackup() - } else { - await restoreFromMostRecentBackup() - } - guard didRestore else { + do { + let didRestore = try await ProfileOperationAdmission.shared.withWalletRestore { + let restored: Bool = if BackupService.shared.hasPendingWalletRestore() { + await restoreVssBackup() + } else { + await restoreFromMostRecentBackup() + } + guard restored else { return false } + + widgets.loadSavedWidgets() + widgets.objectWillChange.send() + pubkyProfile.scheduleInitializationAfterRestore() + await startWallet(completingBackupRestore: true) + return true + } + didWalletBackupRestoreFail = !didRestore + } catch { + Logger.warn("Wallet backup restore could not complete: \(error)", context: "AppScene") didWalletBackupRestoreFail = true - return } - - widgets.loadSavedWidgets() - widgets.objectWillChange.send() - pubkyProfile.scheduleInitializationAfterRestore() - await startWallet(completingBackupRestore: true) } private func retryWalletStart() async { @@ -1210,7 +1224,7 @@ struct AppScene: View { } private func retryPendingWalletRestoreIfNeeded() -> Bool { - if isWalletBackupRestoreRunning { + if pubkyProfile.removalRecovery.blocksWalletStartup || ProfileOperationAdmission.shared.isWalletWiping || isWalletBackupRestoreRunning { return true } guard BackupService.shared.hasPendingWalletRestore() else { return false } @@ -1249,7 +1263,9 @@ struct AppScene: View { if shouldRestoreRN { do { - try await MigrationsService.shared.restoreFromRNRemoteBackup(mnemonic: mnemonic, passphrase: passphrase) + try await MigrationsService.shared.restoreFromRNRemoteBackup(mnemonic: mnemonic, passphrase: passphrase) { + try await ProfileOperationAdmission.shared.withWalletRestore($0) + } } catch { Logger.error("RN remote backup restore failed: \(error)", context: "AppScene") // Fall back to VSS @@ -2061,35 +2077,37 @@ struct AppScene: View { } private func startAutomaticPaykitSession() { - guard let session = pubkyProfile.automaticPaykitSession, - !PubkyPublicKeyFormat.matches(session.publicKey, activatedAutomaticPaykitIdentity) - else { return } - let pk = session.publicKey - activatedAutomaticPaykitIdentity = pk - Task { - await paykitAllowanceManager.activate(identity: pk) - await handlePendingPaykitSubscriptionNotification() - guard pubkyProfile.automaticPaykitSession == session else { - // A deletion or session change cut this start short; let the session's next appearance run it again. - if activatedAutomaticPaykitIdentity == pk { - activatedAutomaticPaykitIdentity = nil + do { + try pubkyProfile.scheduleAutomaticPaykitSession { session in + let pk = session.publicKey + guard !PubkyPublicKeyFormat.matches(pk, activatedAutomaticPaykitIdentity) else { return } + activatedAutomaticPaykitIdentity = pk + await paykitAllowanceManager.activate(identity: pk) + await handlePendingPaykitSubscriptionNotification() + guard pubkyProfile.automaticPaykitSession == session else { + // A deletion or session change cut this start short; let the session's next appearance run it again. + if activatedAutomaticPaykitIdentity == pk { + activatedAutomaticPaykitIdentity = nil + } + // A new session for the same key may already have appeared while this one waited. + startAutomaticPaykitSession() + return + } + try? await contactsManager.loadContacts(for: pk) + if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { + await retryPendingPaykitEndpointRemoval() + } + guard isPaykitSceneActive, pubkyProfile.authState == .authenticated, + PubkyPublicKeyFormat.matches(pk, pubkyProfile.publicKey) + else { return } + await refreshPrivateOnlyPaykitApp() + await refreshIncomingPaykitPaymentRequests(presentItems: false, onlyWhileActive: true) + if PaykitSubscriptionNotificationTargetStore.load() == nil { + await presentNextIncomingPaykitItem() } - // A new session for the same key may already have appeared while this one waited. - startAutomaticPaykitSession() - return - } - try? await contactsManager.loadContacts(for: pk) - if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { - await retryPendingPaykitEndpointRemoval() - } - guard isPaykitSceneActive, pubkyProfile.authState == .authenticated, - PubkyPublicKeyFormat.matches(pk, pubkyProfile.publicKey) - else { return } - await refreshPrivateOnlyPaykitApp() - await refreshIncomingPaykitPaymentRequests(presentItems: false, onlyWhileActive: true) - if PaykitSubscriptionNotificationTargetStore.load() == nil { - await presentNextIncomingPaykitItem() } + } catch { + Logger.debug("Automatic Paykit startup was not admitted: \(error)", context: "AppScene") } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 64da4682a..c88f8747c 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -566,7 +566,9 @@ struct MainNavView: View { } else if let profile = contactsManager.pendingImportProfile { ContactImportOverviewView( profile: profile, - contacts: contactsManager.pendingImportContacts + contacts: contactsManager.pendingImportContacts, + importGeneration: contactsManager.importGeneration, + owner: pubkyProfile.removalPresentationOwner ) } else { missingPendingImportView(fallbackRoute: .payContacts) @@ -577,7 +579,11 @@ struct MainNavView: View { } else if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { missingPendingImportView(fallbackRoute: fallbackRoute) } else { - ContactImportSelectView(contacts: contactsManager.pendingImportContacts) + ContactImportSelectView( + contacts: contactsManager.pendingImportContacts, + importGeneration: contactsManager.importGeneration, + owner: pubkyProfile.removalPresentationOwner + ) } case let .addContact(publicKey): if isPaykitUIActive { @@ -592,7 +598,9 @@ struct MainNavView: View { paykitDisabledRedirectView } case .profile: - if !isPaykitUIActive { + if pubkyProfile.removalRecovery != .ready { + ProfileRemovalRecoveryView() + } else if !isPaykitUIActive { ComingSoonScreen() } else if let initializationErrorMessage = pubkyProfile.initializationErrorMessage { pubkyInitializationErrorView(message: initializationErrorMessage) @@ -602,25 +610,33 @@ struct MainNavView: View { ProfileDestinationView(hasSeenIntro: app.hasSeenProfileIntro) } case .profileIntro: - if isPaykitUIActive { + if pubkyProfile.removalRecovery != .ready { + ProfileRemovalRecoveryView() + } else if isPaykitUIActive { ProfileIntroView() } else { ComingSoonScreen() } case .pubkyChoice: - if isPaykitUIActive { + if pubkyProfile.removalRecovery != .ready { + ProfileRemovalRecoveryView() + } else if isPaykitUIActive { PubkyChoiceView() } else { paykitDisabledRedirectView } case .createProfile: - if isPaykitUIActive { + if pubkyProfile.removalRecovery != .ready { + ProfileRemovalRecoveryView() + } else if isPaykitUIActive { CreateProfileView() } else { paykitDisabledRedirectView } case .editProfile: - if isPaykitUIActive { + if pubkyProfile.removalRecovery != .ready { + ProfileRemovalRecoveryView() + } else if isPaykitUIActive { EditProfileView() } else { paykitDisabledRedirectView diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index fde215be3..b47d2c94d 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -175,6 +175,7 @@ class ContactsManager: ObservableObject { private let removeContactRecord: @Sendable (_ publicKey: String) async throws -> Void private let removeContactRecords: @Sendable (_ publicKeys: [String]) async throws -> [ContactRecord] private let forgetRemovedContacts: @Sendable (_ publicKeys: [String]) async -> Void + private let profileAdmission: ProfileOperationAdmission private let currentDate: @Sendable () -> Date init( @@ -199,8 +200,10 @@ class ContactsManager: ObservableObject { await PrivatePaykitService.shared.removeSavedContacts(publicKeys: $0) }, currentDate: @escaping @Sendable () -> Date = { Date() }, - shopContactImporter: ShopContactImporter? = nil + shopContactImporter: ShopContactImporter? = nil, + profileAdmission: ProfileOperationAdmission = .shared ) { + self.profileAdmission = profileAdmission self.contactRecords = contactRecords self.observedContactRecords = observedContactRecords self.fetchFollows = fetchFollows @@ -281,7 +284,10 @@ class ContactsManager: ObservableObject { clearPendingImport() } + private(set) var importGeneration = UUID() + func clearPendingImport() { + importGeneration = UUID() pendingImportProfile = nil pendingImportContacts = [] } @@ -289,8 +295,8 @@ class ContactsManager: ObservableObject { /// Clears the pending import once an import of it finishes and returns whether it was still pending. An import /// outlives its screens and leaving the import flow discards the pending import, so false means the user left and /// must not be taken to Pay Contacts. - func completePendingImport() -> Bool { - guard hasPendingImport else { return false } + func completePendingImport(expectedGeneration: UUID) -> Bool { + guard hasPendingImport, importGeneration == expectedGeneration else { return false } clearPendingImport() return true } @@ -694,62 +700,67 @@ class ContactsManager: ObservableObject { // MARK: - Add Contact func addContact(publicKey: String, existingProfile: PubkyProfile? = nil, ownPublicKey: String? = nil) async throws { - guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) else { - throw ContactsManagerError.invalidPublicKey - } + try await profileAdmission.withOperation { + guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw ContactsManagerError.invalidPublicKey + } - if PubkyPublicKeyFormat.matches(prefixedKey, ownPublicKey) { - throw ContactsManagerError.cannotAddYourself - } + if PubkyPublicKeyFormat.matches(prefixedKey, ownPublicKey) { + throw ContactsManagerError.cannotAddYourself + } - guard !contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) }) else { - throw ContactsManagerError.alreadyExists - } + guard !contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) }) else { + throw ContactsManagerError.alreadyExists + } - let profile: PubkyProfile = if let existingProfile { - PubkyProfile( + let profile: PubkyProfile = if let existingProfile { + PubkyProfile( + publicKey: prefixedKey, + name: existingProfile.name, + bio: existingProfile.bio, + imageUrl: existingProfile.imageUrl, + links: existingProfile.links, + tags: existingProfile.tags, + status: existingProfile.status + ) + } else { + try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true, retryTransient: true) + } + + _ = try await PubkyService.saveContact( publicKey: prefixedKey, - name: existingProfile.name, - bio: existingProfile.bio, - imageUrl: existingProfile.imageUrl, - links: existingProfile.links, - tags: existingProfile.tags, - status: existingProfile.status + label: profile.name, + restorePrivateConnection: true ) - } else { - try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true, retryTransient: true) - } - - _ = try await PubkyService.saveContact( - publicKey: prefixedKey, - label: profile.name, - restorePrivateConnection: true - ) - Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") + try profileAdmission.checkCurrent() + Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") - rememberResolvedProfile(profile, for: prefixedKey) - let contact = PubkyContact(publicKey: prefixedKey, profile: profile) - contacts.append(contact) - contacts.sort(by: Self.isOrderedByName) + rememberResolvedProfile(profile, for: prefixedKey) + let contact = PubkyContact(publicKey: prefixedKey, profile: profile) + contacts.append(contact) + contacts.sort(by: Self.isOrderedByName) + } } func refreshContactLink(publicKey: String, wallet: WalletViewModel) async { - guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey), - let contact = contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) }) - else { return } - do { - let identity = await PubkyService.currentPublicKey() - _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name, expectedIdentity: identity) - await PrivatePaykitService.shared.startExplicitContactLink(publicKey: prefixedKey, expectedIdentity: identity, wallet: wallet) + try await profileAdmission.withOperation { + guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey), + let contact = contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) }) + else { return } + guard let identity = try await PaykitSdkService.shared.currentPublicKey() else { + throw PubkyServiceError.sessionNotActive + } + try profileAdmission.checkCurrent() + _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name, expectedIdentity: identity) + try profileAdmission.checkCurrent() + await PrivatePaykitService.shared.startExplicitContactLink(publicKey: prefixedKey, expectedIdentity: identity, wallet: wallet) + } } catch is CancellationError { return } catch { - Logger.warn( - "Failed to refresh contact link for \(PubkyPublicKeyFormat.redacted(prefixedKey)): \(error)", - context: "ContactsManager" - ) + Logger.warn("Failed to refresh contact link: \(error)", context: "ContactsManager") } } @@ -765,34 +776,37 @@ class ContactsManager: ObservableObject { _ = try await PubkyService.saveContacts(updates: updates, expectedIdentity: expectedIdentity) } ) async throws { - let profilesGeneration = resolvedProfilesGeneration - let expectedIdentity = resolvedProfilesOwner - var existingKeys = Set(contacts.map(\.publicKey)) - let imported = selected.filter { existingKeys.insert($0.publicKey).inserted } - try Task.checkCancellation() - guard !imported.isEmpty else { return } - - do { - try await saveContacts(imported.map { ContactUpdate(publicKey: $0.publicKey, label: $0.displayName) }, expectedIdentity) - } catch is CancellationError { - throw CancellationError() - } catch { + try await profileAdmission.withOperation { + let profilesGeneration = resolvedProfilesGeneration + let expectedIdentity = resolvedProfilesOwner + var existingKeys = Set(contacts.map(\.publicKey)) + let imported = selected.filter { existingKeys.insert($0.publicKey).inserted } try Task.checkCancellation() - guard profilesGeneration == resolvedProfilesGeneration else { return } - throw error - } + guard !imported.isEmpty else { return } - try Task.checkCancellation() - guard profilesGeneration == resolvedProfilesGeneration else { - Logger.info("Discarded a contact import that a reset overtook", context: "ContactsManager") - return - } - for contact in imported { - rememberResolvedProfile(contact.profile, for: contact.publicKey) + do { + try await saveContacts(imported.map { ContactUpdate(publicKey: $0.publicKey, label: $0.displayName) }, expectedIdentity) + } catch is CancellationError { + throw CancellationError() + } catch { + try Task.checkCancellation() + guard profilesGeneration == resolvedProfilesGeneration else { return } + throw error + } + + try Task.checkCancellation() + guard profilesGeneration == resolvedProfilesGeneration else { + Logger.info("Discarded a contact import that a reset overtook", context: "ContactsManager") + return + } + try profileAdmission.checkCurrent() + for contact in imported { + rememberResolvedProfile(contact.profile, for: contact.publicKey) + } + let currentKeys = Set(contacts.map(\.publicKey)) + contacts = (contacts + imported.filter { !currentKeys.contains($0.publicKey) }).sorted(by: Self.isOrderedByName) + Logger.info("Imported \(imported.count) new contacts", context: "ContactsManager") } - let currentKeys = Set(contacts.map(\.publicKey)) - contacts = (contacts + imported.filter { !currentKeys.contains($0.publicKey) }).sorted(by: Self.isOrderedByName) - Logger.info("Imported \(imported.count) new contacts", context: "ContactsManager") } // MARK: - Update Contact @@ -802,7 +816,8 @@ class ContactsManager: ObservableObject { private func sessionCheck(_ isSessionCurrent: @escaping @MainActor () -> Bool) -> @MainActor () -> Bool { let generation = resolvedProfilesGeneration return { [weak self] in - self?.resolvedProfilesGeneration == generation && isSessionCurrent() + self?.resolvedProfilesGeneration == generation && isSessionCurrent() && + self.map { (try? $0.profileAdmission.checkCurrent()) != nil } == true } } @@ -823,29 +838,36 @@ class ContactsManager: ObservableObject { isSessionCurrent: @escaping @MainActor () -> Bool, makeEdit: @MainActor () async throws -> ContactEdit ) async throws -> PubkyProfile? { - let isCurrent = sessionCheck(isSessionCurrent) - await resolvePendingContactProfile(publicKey: publicKey) - guard isCurrent() else { return nil } - let edit: ContactEdit - do { - edit = try await makeEdit() - } catch PubkyServiceError.identityChanged { - Logger.info("Dropped a contact edit whose avatar upload was for an identity that is no longer signed in", context: "ContactsManager") - return nil - } catch { + let scope = try profileAdmission.capture() + defer { profileAdmission.release(scope) } + do { return try await profileAdmission.withScope(scope) { + let isCurrent = sessionCheck(isSessionCurrent) + await resolvePendingContactProfile(publicKey: publicKey) guard isCurrent() else { return nil } + let edit: ContactEdit + do { + edit = try await makeEdit() + } catch PubkyServiceError.identityChanged { + Logger.info("Dropped a contact edit whose avatar upload was for an identity that is no longer signed in", context: "ContactsManager") + return nil + } catch { + guard isCurrent() else { return nil } + throw error + } + return try await updateContact( + publicKey: publicKey, + name: edit.name, + bio: edit.bio, + imageUrl: edit.imageUrl, + links: edit.links, + tags: edit.tags, + expectedIdentity: expectedIdentity, + isCurrent: isCurrent + ) + } } catch { + guard (try? profileAdmission.validate(scope)) != nil else { return nil } throw error } - return try await updateContact( - publicKey: publicKey, - name: edit.name, - bio: edit.bio, - imageUrl: edit.imageUrl, - links: edit.links, - tags: edit.tags, - expectedIdentity: expectedIdentity, - isCurrent: isCurrent - ) } /// `isCurrent` is checked right before the save and again once it returns, with nothing suspending between that check @@ -880,9 +902,9 @@ class ContactsManager: ObservableObject { guard isCurrent() else { return nil } let saveContactLabel = saveContactLabel do { - try await Task.detached { + try await profileAdmission.withDetached { try await saveContactLabel(prefixedKey, name, expectedIdentity) - }.value + } } catch PubkyServiceError.identityChanged { Logger.info("Dropped a contact update for an identity that is no longer signed in", context: "ContactsManager") return nil @@ -923,25 +945,34 @@ class ContactsManager: ObservableObject { isSessionCurrent: @escaping @MainActor () -> Bool, transform: @escaping ([String]) -> [String] ) -> Task { + let scope: ProfileOperationAdmission.Scope + do { scope = try profileAdmission.capture() } + catch { return Task { throw error } } let key = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey let isCurrent = sessionCheck(isSessionCurrent) let previousChange = tagChanges[key] let change = Task { - _ = await previousChange?.result - guard isCurrent() else { return } - await resolvePendingContactProfile(publicKey: publicKey) - guard isCurrent() else { return } - let latest = contacts.first(where: { $0.publicKey == publicKey })?.profile ?? shownProfile - try await updateContact( - publicKey: publicKey, - name: latest.name, - bio: latest.bio, - imageUrl: latest.imageUrl, - links: latest.links, - tags: transform(latest.tags), - expectedIdentity: expectedIdentity, - isCurrent: isCurrent - ) + defer { profileAdmission.release(scope) } + do { try await profileAdmission.withScope(scope) { + _ = await previousChange?.result + guard isCurrent() else { return } + await resolvePendingContactProfile(publicKey: publicKey) + guard isCurrent() else { return } + let latest = contacts.first(where: { $0.publicKey == publicKey })?.profile ?? shownProfile + try await updateContact( + publicKey: publicKey, + name: latest.name, + bio: latest.bio, + imageUrl: latest.imageUrl, + links: latest.links, + tags: transform(latest.tags), + expectedIdentity: expectedIdentity, + isCurrent: isCurrent + ) + } } catch { + guard (try? profileAdmission.validate(scope)) != nil else { return } + throw error + } } tagChanges[key] = change return change @@ -951,44 +982,102 @@ class ContactsManager: ObservableObject { /// Also stops the profile lookups of the contact, the running refresh's and any a screen started. func removeContact(publicKey: String) async throws { - let prefixedKey = ensurePubkyPrefix(publicKey) - let removeContactRecord = removeContactRecord + try await profileAdmission.withOperation { + let prefixedKey = ensurePubkyPrefix(publicKey) + let removeContactRecord = removeContactRecord - try await Task.detached { - try await removeContactRecord(prefixedKey) - }.value - contacts.removeAll { $0.publicKey == prefixedKey } - stopProfileLookups(for: [prefixedKey]) - Self.removeContactProfileOverride(publicKey: prefixedKey) - await forgetRemovedContacts([prefixedKey]) + try await profileAdmission.withDetached { + try await removeContactRecord(prefixedKey) + } + try profileAdmission.checkCurrent() + contacts.removeAll { $0.publicKey == prefixedKey } + stopProfileLookups(for: [prefixedKey]) + Self.removeContactProfileOverride(publicKey: prefixedKey) + await forgetRemovedContacts([prefixedKey]) - Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") + Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") + } } - /// Stops the running profile refresh and every lookup a screen started first: every contact is going, their reads would - /// only compete with the ones removing them, and a contact can be added back with a newer profile before one returns. - func deleteAllContacts() async throws { + struct RemovalSnapshot { + fileprivate let revision: Int + fileprivate let records: [Paykit.ContactRecord] + var publicKeys: [String] { + records.map(\.publicKey) + } + } + + func prepareRemovalSnapshot() async throws -> RemovalSnapshot { + let revision = contactsRevision + let read = contactRecords + let records = try await profileAdmission.withDetached { try await read() } + guard revision == contactsRevision else { throw PubkyServiceError.identityChanged } + return RemovalSnapshot(revision: revision, records: records) + } + + func requireCurrentRemovalSnapshot(_ snapshot: RemovalSnapshot) throws { + guard snapshot.revision == contactsRevision else { throw PubkyServiceError.identityChanged } + } + + func validateRemovalSnapshot(_ snapshot: RemovalSnapshot) async throws { + try requireCurrentRemovalSnapshot(snapshot) + let read = contactRecords + let records = try await profileAdmission.withDetached { try await read() } + try requireCurrentRemovalSnapshot(snapshot) + guard records.sorted(by: { $0.publicKey < $1.publicKey }) == snapshot.records.sorted(by: { $0.publicKey < $1.publicKey }) else { + throw PubkyServiceError.identityChanged + } + } + + func removeConfirmedContacts(_ snapshot: RemovalSnapshot) async throws { + try requireCurrentRemovalSnapshot(snapshot) + stopProfileRefresh() + stopPendingProfileLookups() + let keys = snapshot.records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) } + guard keys.count == snapshot.records.count else { throw ContactsManagerError.invalidPublicKey } + let remove = removeContactRecords + let removed = try await profileAdmission.withDetached { try await remove(keys) } + try profileAdmission.checkCurrent() + let acknowledged = Set(removed.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) }) + guard acknowledged == Set(keys) else { throw PrivatePaykitError.privateUnavailable } + // Whole-profile cleanup retains consumed-payment watermarks until credentials are durably forgotten. + } + + func finishProfileRemoval() { stopProfileRefresh() stopPendingProfileLookups() - let contactRecords = contactRecords - let removeContactRecords = removeContactRecords - let records = try await Task.detached { try await contactRecords() }.value - - let keys = records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) } - let removed = try await Task.detached { try await removeContactRecords(keys) }.value - let deletedKeys = Set(removed.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) }) - await forgetRemovedContacts(Array(deletedKeys)) - Self.removeContactProfileOverrides(publicKeys: deletedKeys) - contacts.removeAll { deletedKeys.contains($0.publicKey) } - guard deletedKeys.isSuperset(of: keys) else { - throw PrivatePaykitError.privateUnavailable - } - - // All remote deletes succeeded, so clear any local-only contacts too. - Self.clearContactProfileOverrides() - await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts.removeAll() - Logger.info("Deleted all contacts", context: "ContactsManager") + } + + /// Stops the running profile refresh and every lookup a screen started first: every contact is going, their reads would + /// only compete with the ones removing them, and a contact can be added back with a newer profile before one returns. + func deleteAllContacts() async throws { + try await profileAdmission.withOperation { + stopProfileRefresh() + stopPendingProfileLookups() + let contactRecords = contactRecords + let removeContactRecords = removeContactRecords + let records = try await profileAdmission.withDetached { try await contactRecords() } + + let keys = records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) } + let removed = try await profileAdmission.withDetached { try await removeContactRecords(keys) } + try profileAdmission.checkCurrent() + let deletedKeys = Set(removed.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) }) + await forgetRemovedContacts(Array(deletedKeys)) + try profileAdmission.checkCurrent() + Self.removeContactProfileOverrides(publicKeys: deletedKeys) + contacts.removeAll { deletedKeys.contains($0.publicKey) } + guard deletedKeys.isSuperset(of: keys) else { + throw PrivatePaykitError.privateUnavailable + } + + // All remote deletes succeeded, so clear any local-only contacts too. + Self.clearContactProfileOverrides() + await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) + try profileAdmission.checkCurrent() + contacts.removeAll() + Logger.info("Deleted all contacts", context: "ContactsManager") + } } // MARK: - Remote Contact Discovery @@ -997,16 +1086,21 @@ class ContactsManager: ObservableObject { /// overview opens, and the bulk lane would use only four of the six read slots for those lookups. @discardableResult func prepareImport(profile: PubkyProfile?, publicKey: String) async -> Bool { + guard let scope = try? profileAdmission.capture() else { return false } + defer { profileAdmission.release(scope) } clearPendingImport() - useResolvedProfiles(of: publicKey) - await discoverRemoteContacts(publicKey: publicKey) - - guard !pendingImportContacts.isEmpty else { - return false - } + let generation = importGeneration + do { return try await profileAdmission.withScope(scope) { + useResolvedProfiles(of: publicKey) + await discoverRemoteContacts(publicKey: publicKey) + try profileAdmission.checkCurrent() + guard generation == importGeneration, !pendingImportContacts.isEmpty else { + return false + } - pendingImportProfile = profile ?? PubkyProfile.placeholder(publicKey: ensurePubkyPrefix(publicKey)) - return true + pendingImportProfile = profile ?? PubkyProfile.placeholder(publicKey: ensurePubkyPrefix(publicKey)) + return true + } } catch { return false } } func destinationAfterAuthentication(profile: PubkyProfile?, publicKey: String) async -> Route { @@ -1029,11 +1123,15 @@ class ContactsManager: ObservableObject { resolveProfile: @escaping @Sendable (String) async throws -> PubkyProfile ) async { let prefixedKey = ensurePubkyPrefix(publicKey) - - do { - let contactKeys = try await Task.detached { + guard let scope = try? profileAdmission.capture() else { return } + defer { profileAdmission.release(scope) } + let generation = importGeneration + do { try await profileAdmission.withScope(scope) { + let contactKeys = try await profileAdmission.withDetached { try await fetchContactKeys(prefixedKey) - }.value + } + try profileAdmission.checkCurrent() + guard generation == importGeneration else { return } Logger.info("Discovered \(contactKeys.count) contacts from pubky.app", context: "ContactsManager") @@ -1070,8 +1168,12 @@ class ContactsManager: ObservableObject { Logger.warn("Skipped \(discoveryResult.failures) remote contacts during discovery", context: "ContactsManager") } + try profileAdmission.checkCurrent() + guard generation == importGeneration else { return } pendingImportContacts = discoveryResult.contacts.sorted(by: Self.isOrderedByName) - } catch { + } } catch { + guard !Task.isCancelled, generation == importGeneration, + (try? profileAdmission.validate(scope)) != nil else { return } Logger.warn("Failed to discover remote contacts: \(error)", context: "ContactsManager") pendingImportContacts = [] } @@ -1220,7 +1322,7 @@ class ContactsManager: ObservableObject { return true } - private nonisolated static let contactProfileOverridesKey = "pubkyContactProfileOverrides" + nonisolated static let contactProfileOverridesKey = "pubkyContactProfileOverrides" private nonisolated static func isOrderedByName(_ lhs: PubkyContact, _ rhs: PubkyContact) -> Bool { lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index ab93044f7..5b8d8201f 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1,6 +1,7 @@ import Combine import Foundation import enum Paykit.PaykitError +import struct Paykit.PaykitProfileRecord import struct Paykit.PubkySessionBootstrapResult import SwiftUI @@ -61,7 +62,158 @@ class PubkyProfileManager: ObservableObject { typealias RemoteProfileResolver = @Sendable (String) async throws -> PubkyProfile typealias ProfilePublisher = @Sendable (_ profile: PubkyProfileData, _ expectedIdentity: String?) async throws -> Void typealias AvatarUploader = @Sendable (_ jpegData: Data, _ expectedIdentity: String?) async throws -> String - typealias ProfileDeleter = @Sendable (_ expectedIdentity: String) async throws -> Void + + enum RemovalAction: Equatable { + case deleteProfile + case unlink + case disconnect + case disconnectLocally + } + + struct RemovalConfirmation: Identifiable, Equatable { + let id: UUID + let action: RemovalAction + } + + struct RemovalPresentationOwner: Equatable { + fileprivate let revision: UUID + fileprivate let publicKey: String? + } + + var removalPresentationOwner: RemovalPresentationOwner { + RemovalPresentationOwner(revision: Self.sessionRevision, publicKey: publicKey) + } + + func ownsRemovalPresentation(_ owner: RemovalPresentationOwner) -> Bool { + removalPresentationOwner == owner && !removalAdmission.isWalletWiping + } + + /// A failed, reconciled transition can return its own new session revision to the original screen. + /// A successor invalidates the supplied scope before any retry context is installed. + func schedulePresentationContext(_ update: @escaping @MainActor (RemovalPresentationOwner, ProfileOperationAdmission.Generation) -> Void) throws { + let owner = removalPresentationOwner + let admission = removalAdmission + try admission.checkCurrent() + try admission.scheduleAfterTransition { scope in + Task { @MainActor [weak self] in + defer { admission.release(scope) } + guard let self, ownsRemovalPresentation(owner), (try? admission.validate(scope)) != nil, + let generation = try? admission.snapshot() else { return } + update(owner, generation) + } + } + } + + func ownsRemovalFailure(_ confirmation: RemovalConfirmation, owner: RemovalPresentationOwner) -> Bool { + guard !removalAdmission.isWalletWiping else { return false } + if ownsRemovalPresentation(owner) { + return true + } + // Strict local teardown ends the session before fallible durable cleanup. Its exact pending attempt + // still owns failure presentation, without granting any remote retry authority. + guard case let .pending(record) = removalRecovery else { return false } + return record.id == confirmation.id && record.scope == .profile + } + + func ownsRecoveryFailure(id: UUID, scope: ProfileRemovalStore.Scope) -> Bool { + guard !isDeletingProfile, !removalAdmission.isWalletWiping, case let .pending(record) = removalRecovery else { return false } + return record.id == id && record.scope == scope + } + + struct RemovalCompletion { + let remoteProfileDeleted: Bool + fileprivate let endedRevision: UUID + fileprivate let admissionGeneration: ProfileOperationAdmission.Generation + } + + enum RemovalError: Error { + case confirmationRequired + case confirmationChanged + } + + struct RemovalServices { + var readProfile: @Sendable (String) async throws -> PaykitProfileRecord? + var deleteProfile: @Sendable (String, String) async throws -> Void + var withdrawPrivate: @MainActor () async throws -> Void + var withdrawPublic: @MainActor () async throws -> Void + var syncApp: @MainActor () async throws -> Void + var validateSubscriptions: @MainActor (String) async throws -> Void + var captureRevocation: @MainActor (String) async throws -> ProfileSessionRevocation + var completeLocal: @MainActor () async throws -> Void + var completeRevokedLocal: @MainActor (ProfileSessionRevocation) async throws -> Void + var pause: @MainActor (UUID) async -> Void + var resume: @MainActor (UUID) async -> Void + + @MainActor static var live: RemovalServices { + RemovalServices( + readProfile: { try await PaykitSdkService.shared.profileRemovalRecord(publicKey: $0) }, + deleteProfile: { try await PaykitSdkService.shared.deleteConfirmedProfile(expectedIdentity: $0, expectedRevision: $1) }, + withdrawPrivate: { try await removePrivatePaykitEndpoints(context: "Profile removal", deferAppSync: true) }, + withdrawPublic: { try await PublicPaykitService.removePublishedEndpoints() }, + syncApp: { try await PublicPaykitService.syncPaykitApp(privateSharingEnabled: false) }, + validateSubscriptions: { try await PaykitSdkService.shared.requireNoActiveSubscriptions(expectedIdentity: $0) }, + captureRevocation: { try await PaykitSdkService.shared.captureProfileRevocation(expectedOwner: $0) }, + completeLocal: { try await clearLocalState() }, + completeRevokedLocal: { try await clearLocalState(forgetSessionAccess: $0.clearCredentials) }, + pause: { await PrivatePaykitService.shared.beginProfileDeletion(id: $0) }, + resume: { await PrivatePaykitService.shared.endProfileDeletion(id: $0) } + ) + } + } + + private let removalServices: RemovalServices + private let removalAdmission: ProfileOperationAdmission + private let sourceIsMissing: (String, String) -> Bool + #if DEBUG + var beforeCommittedIdentityInstallForTesting: (() async -> Void)? + #endif + private let removalStore: ProfileRemovalStore + + private struct RemovalPlan { + let confirmation: RemovalConfirmation + let sessionRevision: UUID + let owner: String? + let profileGeneration: Int + let admissionGeneration: ProfileOperationAdmission.Generation + let profile: PaykitProfileRecord? + let withdrawEndpoints: Bool + let contacts: ContactsManager? + let contactSnapshot: ContactsManager.RemovalSnapshot? + let revocation: ProfileSessionRevocation? + } + + struct RemoteRemovalConfirmation: Identifiable, Equatable { + let id: UUID + let removalID: UUID + } + + private final class LiveRemoval { + let plan: RemovalPlan + let lease: ProfileRemovalLease + var privateAcknowledged = false + var publicAcknowledged = false + var appAcknowledged = false + var remoteProfileDeleted = false + var logoutStarted = false + var logoutAcknowledged = false + + var canRetry: Bool { + privateAcknowledged && !logoutAcknowledged && + (plan.confirmation.action == .disconnect || (logoutStarted && publicAcknowledged && appAcknowledged)) + } + + init(plan: RemovalPlan, lease: ProfileRemovalLease) { + self.plan = plan + self.lease = lease + } + } + + private var liveRemoval: LiveRemoval? + private var remoteRemovalConfirmation: RemoteRemovalConfirmation? + + private var removalPreparation: UUID? + private var removalPlan: RemovalPlan? + private var failedRemovalID: UUID? private enum SessionInitializationMode { case userVisible @@ -82,6 +234,23 @@ class PubkyProfileManager: ObservableObject { let generation: Int } + enum RemovalRecovery: Equatable { + case ready + case pending(ProfileRemovalStore.Pending) + case unavailable + + var blocksWalletStartup: Bool { + switch self { + case .ready: false + case let .pending(record): record.scope == .walletWipe + case .unavailable: true + } + } + } + + @Published private(set) var removalRecovery: RemovalRecovery = .ready + private var removalChanges: AnyCancellable? + @Published var authState: PubkyAuthState = .idle @Published var profile: PubkyProfile? @Published var publicKey: String? @@ -122,8 +291,6 @@ class PubkyProfileManager: ObservableObject { private let remoteProfileResolver: RemoteProfileResolver private let profilePublisher: ProfilePublisher private let avatarUploader: AvatarUploader - private let profileDeletionValidator: ProfileDeleter - private let profileDeleter: ProfileDeleter /// Bumped whenever a profile save starts, `profile` is written or the identity changes, so a remote read that started /// earlier is dropped. private var profileWriteGeneration = 0 @@ -170,22 +337,47 @@ class PubkyProfileManager: ObservableObject { avatarUploader: @escaping AvatarUploader = { try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1) }, - profileDeletionValidator: @escaping ProfileDeleter = { - try await PubkyService.requireNoActiveSubscriptions(expectedIdentity: $0) - }, - profileDeleter: @escaping ProfileDeleter = { - try await PubkyService.deletePaykitProfile(expectedIdentity: $0) - } + removalServices: RemovalServices? = nil, + removalStore: ProfileRemovalStore = .shared, + removalAdmission: ProfileOperationAdmission = .shared, + sourceIsMissing: @escaping (String, String) -> Bool = { SharedPubkyKeychain.isDefinitelyMissing(sourceApp: $0, pubky: $1) } ) { + self.removalServices = removalServices ?? .live + self.removalStore = removalStore + self.removalAdmission = removalAdmission + self.sourceIsMissing = sourceIsMissing self.remoteProfileResolver = remoteProfileResolver self.profilePublisher = profilePublisher self.avatarUploader = avatarUploader - self.profileDeletionValidator = profileDeletionValidator - self.profileDeleter = profileDeleter cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) cachedProfileOwner = UserDefaults.standard.string(forKey: Self.cachedProfileOwnerKey) isProfileSetupPending = UserDefaults.standard.bool(forKey: Self.profileSetupPendingKey) + refreshRemovalRecovery() + removalChanges = NotificationCenter.default.publisher(for: ProfileRemovalStore.didChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.refreshRemovalRecovery() } + } + + func refreshRemovalRecovery() { + do { + guard !removalAdmission.hasUnpersistedLocalFailure else { throw ProfileRemovalStore.StoreError.unavailable } + removalRecovery = try removalStore.pending().map(RemovalRecovery.pending) ?? .ready + } catch { + removalRecovery = .unavailable + } + if removalRecovery != .ready { + isInitialized = true + isRestoringSession = false + } + } + + func retryLocalRecoveryRecord() { + do { + try removalAdmission.persistLocalFailure(recoverMalformed: true) + try removalStore.recoverMalformedRecord() + } catch { initializationErrorMessage = error.localizedDescription } + refreshRemovalRecovery() } var hasExistingIdentity: Bool { @@ -270,18 +462,59 @@ class PubkyProfileManager: ObservableObject { return initializationTask } guard Self.sessionMutationCount == 0, !Self.isProfileDeletionInFlight else { return nil } + refreshRemovalRecovery() + guard removalRecovery == .ready else { return nil } + let admission = removalAdmission + let scope: ProfileOperationAdmission.Scope + do { scope = try admission.reserveSessionTransition(backgroundInitialization: true) } + catch { + refreshRemovalRecovery() + isInitialized = true + initializationErrorMessage = error.localizedDescription + return nil + } isRestoringSession = true let revision = Self.sessionRevision let task = Task { + var releaseOriginal = true + var missingSource: MissingAdoptedSource? defer { + if releaseOriginal { + admission.release(scope) + } initializationTask = nil initializationRevision = nil isRestoringSession = false } guard revision == Self.sessionRevision, Self.sessionMutationCount == 0, !Self.isProfileDeletionInFlight else { return } - await initializeSessionState(mode: mode, initializeSession: initializeSession) - if case .automaticRecovery = mode, revision == Self.sessionRevision { - completedRecoveryVersion += 1 + do { + try await admission.withScope(scope, access: .control) { + try await admission.drain(before: scope) + missingSource = confirmedMissingSource() + if missingSource == nil { + await initializeSessionState(mode: mode, initializeSession: initializeSession) + missingSource = confirmedMissingSource() + } + try admission.checkCurrent() + if case .automaticRecovery = mode, revision == Self.sessionRevision { + completedRecoveryVersion += 1 + } + } + if let missingSource { + let id = UUID() + let recovery = try admission.handoffToLocalRecovery(from: scope, id: id) + admission.release(scope) + releaseOriginal = false + let completion = try await runRemoval(scope: recovery, id: id, plan: nil) { try self.requireMissingSource(missingSource) } + if ownsRemovalCompletion(completion) { + adoptedSourceLost = true + } + isInitialized = true + } + } catch { + initializationErrorMessage = error.localizedDescription + refreshRemovalRecovery() + isInitialized = true } } initializationTask = task @@ -304,7 +537,8 @@ class PubkyProfileManager: ObservableObject { if completedRecoveryVersion != recoveryVersion, revision == Self.sessionRevision { return } - guard !Task.isCancelled else { return } + refreshRemovalRecovery() + guard removalRecovery == .ready, !Task.isCancelled else { return } if let session = automaticPaykitSession { if isInitialized, profile == nil { Task { @@ -339,7 +573,7 @@ class PubkyProfileManager: ObservableObject { var deferredRetries = 0 while !Task.isCancelled, revision == Self.sessionRevision { await restoreSessionIfNeeded(hasStoredIdentity: hasStoredIdentity, initializeSession: initializeSession) - guard !Task.isCancelled, revision == Self.sessionRevision, + guard removalRecovery == .ready, !Task.isCancelled, revision == Self.sessionRevision, !isAuthenticated, (try? hasStoredIdentity()) != false else { return } let nextDelay: Duration @@ -371,9 +605,11 @@ class PubkyProfileManager: ObservableObject { let result: SessionInitializationResult do { - result = try await Task.detached { + let initialized = try await removalAdmission.withDetached { try await initializeSession() - }.value + } + try removalAdmission.checkCurrent() + result = initialized } catch { guard revision == Self.sessionRevision else { isInitialized = true @@ -397,6 +633,7 @@ class PubkyProfileManager: ObservableObject { isInitialized = true return } + guard confirmedMissingSource() == nil else { return } initializationErrorMessage = nil Self.publishOwnSharedRecord() @@ -417,7 +654,7 @@ class PubkyProfileManager: ObservableObject { authState = .authenticated sessionRestorationFailed = false Logger.info("Paykit session restored for \(pk)", context: "PubkyProfileManager") - Task { await loadProfile() } + scheduleProfileRead { await self.loadProfile() } case .restorationFailed: sessionRestorationDeferred = false clearAuthenticatedState(clearCachedProfile: false) @@ -430,19 +667,57 @@ class PubkyProfileManager: ObservableObject { sessionRestorationDeferred = true } - await checkAdoptedSource() isInitialized = true } - /// Drops the adopted identity when its owner app has provably removed the shared record. - func checkAdoptedSource() async { - guard !Self.isProfileDeletionInFlight, let adopted = AdoptedPubkyReference.current, - SharedPubkyKeychain.isDefinitelyMissing(sourceApp: adopted.sourceApp, pubky: adopted.pubky) - else { return } + private struct MissingAdoptedSource { + let sourceApp: String + let publicKey: String + let revision: UUID + } + + private func confirmedMissingSource() -> MissingAdoptedSource? { + guard let adopted = AdoptedPubkyReference.current, + sourceIsMissing(adopted.sourceApp, adopted.pubky) else { return nil } + return MissingAdoptedSource(sourceApp: adopted.sourceApp, publicKey: adopted.pubky, revision: Self.sessionRevision) + } - clearAuthenticatedState() - await Self.clearLocalState() - adoptedSourceLost = true + private func requireMissingSource(_ expected: MissingAdoptedSource) throws { + guard let current = confirmedMissingSource(), current.sourceApp == expected.sourceApp, + current.publicKey == expected.publicKey, current.revision == expected.revision + else { + throw ProfileRemovalStore.StoreError.changed + } + } + + /// A definitely removed Ring source ends only this captured local session, with the same durable cleanup rules. + func checkAdoptedSource() async { + guard !Self.isProfileDeletionInFlight, let expected = confirmedMissingSource() else { return } + let admission = removalAdmission + do { + let scope = try admission.reserveSessionTransition(backgroundInitialization: true) + var releaseOriginal = true + defer { + if releaseOriginal { + admission.release(scope) + } + } + try await admission.withScope(scope, access: .control) { + try await admission.drain(before: scope) + try requireMissingSource(expected) + } + let id = UUID() + let recovery = try admission.handoffToLocalRecovery(from: scope, id: id) + admission.release(scope) + releaseOriginal = false + let completion = try await runRemoval(scope: recovery, id: id, plan: nil) { try self.requireMissingSource(expected) } + if ownsRemovalCompletion(completion) { + adoptedSourceLost = true + } + } catch { + refreshRemovalRecovery() + initializationErrorMessage = error.localizedDescription + } } // MARK: - Key Derivation & Identity Creation @@ -450,23 +725,27 @@ class PubkyProfileManager: ObservableObject { /// Derive the Pubky keypair from the wallet's BIP39 seed. /// Returns (publicKeyZ32, secretKeyHex). func deriveKeys() async throws -> (String, String) { - return try await Task.detached { - let secretKeyHex = try Self.deriveLocalSecretKeyFromWalletSeed() - let rawKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) - let publicKeyZ32 = rawKey.hasPrefix("pubky") ? rawKey : "pubky\(rawKey)" - return (publicKeyZ32, secretKeyHex) - }.value + return try await ProfileOperationAdmission.shared.withOperation { + return try await ProfileOperationAdmission.shared.withDetached { + let secretKeyHex = try Self.deriveLocalSecretKeyFromWalletSeed() + let rawKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) + let publicKeyZ32 = rawKey.hasPrefix("pubky") ? rawKey : "pubky\(rawKey)" + return (publicKeyZ32, secretKeyHex) + } + } } /// The public key of the identity in use: the adopted pubky when one exists, otherwise the wallet-derived one. func activePublicKey() async throws -> String { - if let adopted = AdoptedPubkyReference.current, - let publicKey = PubkyPublicKeyFormat.normalized(adopted.pubky) - { - return publicKey - } + return try await ProfileOperationAdmission.shared.withOperation { + if let adopted = AdoptedPubkyReference.current, + let publicKey = PubkyPublicKeyFormat.normalized(adopted.pubky) + { + return publicKey + } - return try await deriveKeys().0 + return try await deriveKeys().0 + } } /// Fetch a signup code and homeserver public key from Homegate's IP verification endpoint. @@ -494,9 +773,11 @@ class PubkyProfileManager: ObservableObject { /// Upload an avatar image to the user's homeserver blob storage. Returns the `pubky://` URI. With `expectedIdentity`, /// the upload writes nothing and throws `identityChanged` unless that identity is still signed in when it runs. func uploadAvatar(image: UIImage, expectedIdentity: String? = nil) async throws -> String { - _ = try activeSessionSecret() - let imageData = try compressAvatar(image) - return try await avatarUploader(imageData, expectedIdentity) + return try await ProfileOperationAdmission.shared.withOperation { + _ = try activeSessionSecret() + let imageData = try compressAvatar(image) + return try await avatarUploader(imageData, expectedIdentity) + } } private func compressAvatar(_ image: UIImage, maxSize: CGFloat = 400) throws -> Data { @@ -529,44 +810,11 @@ class PubkyProfileManager: ObservableObject { await Task.detached { PubkyProfileManager.activeSecretKeyHex() }.value } ) async throws { - try Self.beginSessionMutation() - defer { Self.endSessionMutation() } - if let publicKey, isProfileSetupPending || AdoptedPubkyReference.current != nil { - try await createProfile( - publicKey: publicKey, - name: name, - bio: bio, - links: links, - tags: tags, - existingImageUrl: existingImageUrl, - avatarImage: avatarImage - ) - return - } - - setProfileSetupPending(false) - try await Self.completeIdentityCreation( - loadStoredSecretKey: loadStoredSecretKey, - signIn: { secretKeyHex in - try await Task.detached { - _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) - return try Self.publicKeyFromSecretKey(secretKeyHex) - }.value - }, - signUp: { - let (publicKey, secretKeyHex) = try await self.deriveKeys() - _ = try await Task.detached { - do { - return try await Self.signUpToHomeserver(secretKeyHex: secretKeyHex) - } catch { - Logger.info("signUp failed (likely already registered), trying signIn: \(error)", context: "PubkyProfileManager") - return try await PubkyService.signIn(secretKeyHex: secretKeyHex) - } - }.value - return publicKey - }, - createProfile: { publicKey in - try await self.createProfile( + return try await ProfileOperationAdmission.shared.withSessionTransition { + try Self.beginSessionMutation() + defer { Self.endSessionMutation() } + if let publicKey, isProfileSetupPending || AdoptedPubkyReference.current != nil { + try await createProfile( publicKey: publicKey, name: name, bio: bio, @@ -575,25 +823,95 @@ class PubkyProfileManager: ObservableObject { existingImageUrl: existingImageUrl, avatarImage: avatarImage ) - }, - discardSessionAccess: { await self.discardAbandonedSession() } - ) + return + } - Self.publishOwnSharedRecord() + setProfileSetupPending(false) + var attempt: PaykitSdkService.CredentialAttempt? + var preparedProfile: PubkyProfile? + var acknowledged = false + do { + try await Self.completeIdentityCreation( + loadStoredSecretKey: loadStoredSecretKey, + signIn: { secretKeyHex in + try await ProfileOperationAdmission.shared.withDetached { + _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + return try Self.publicKeyFromSecretKey(secretKeyHex) + } + }, + signUp: { + attempt = try await PaykitSdkService.shared.beginCredentialAttempt() + let (publicKey, secretKeyHex) = try await self.deriveKeys() + _ = try await ProfileOperationAdmission.shared.withDetached { + try await Self.signUpOrSignIn( + signUp: { try await Self.signUpToHomeserver(secretKeyHex: secretKeyHex) }, + signIn: { try await PubkyService.signIn(secretKeyHex: secretKeyHex) } + ) + } + return publicKey + }, + createProfile: { publicKey in + preparedProfile = try await self.createProfile( + publicKey: publicKey, + name: name, + bio: bio, + links: links, + tags: tags, + existingImageUrl: existingImageUrl, + avatarImage: avatarImage, + installsProfile: attempt == nil + ) + }, + discardSessionAccess: {} + ) + if let attempt { + let activation = try await PaykitSdkService.shared.commitCredentialAttempt(attempt) + acknowledged = true + #if DEBUG + await beforeCommittedIdentityInstallForTesting?() + #endif + try ProfileOperationAdmission.shared.validateAcknowledgedLocalCompletion() + Self.applyCommittedActivation(activation) + if let preparedProfile { + installCreatedProfile(preparedProfile) + } + } + } catch { + if let attempt, !acknowledged { + do { + try await Task.detached { try await PaykitSdkService.shared.rollbackCredentialAttempt(attempt) }.value + } catch { + refreshRemovalRecovery() + initializationErrorMessage = error.localizedDescription + Logger.error("New identity requires local recovery: \(error)", context: "PubkyProfileManager") + } + } + throw error + } + Self.publishOwnSharedRecord() + } + } + + static func applyCommittedActivation(_ activation: PaykitSdkService.CredentialActivation?) { + guard let activation else { return } + activateCachedIdentity(publicKey: activation.publicKey, previousPublicKey: activation.previousPublicKey) + if activation.removesOwnSharedRecord { + SharedPubkyKeychain.removeAllOwn() + } } /// Publishes Bitkit's own pubky into the shared keychain group so Pubky Ring can offer it. Best effort. nonisolated static func publishOwnSharedRecord() { - Task.detached { - do { - guard let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey), !secretKeyHex.isEmpty else { - return - } + let admission = ProfileOperationAdmission.shared + do { + try admission.withSynchronousOperation { + guard let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey), !secretKeyHex.isEmpty else { return } let pubky = try SharedPubkyKeychain.derivedPubky(fromSecretKeyHex: secretKeyHex) + try admission.checkCurrent() SharedPubkyKeychain.publishOwn(pubky: pubky, secretKeyHex: secretKeyHex) - } catch { - Logger.warn("Failed to publish the shared pubky record: \(error)", context: "PubkyProfileManager") } + } catch { + Logger.warn("Failed to publish the shared pubky record: \(error)", context: "PubkyProfileManager") } } @@ -622,62 +940,103 @@ class PubkyProfileManager: ObservableObject { ) } + nonisolated static func signUpOrSignIn( + signUp: () async throws -> String, signIn: () async throws -> String + ) async throws -> String { + do { return try await signUp() } + catch is CancellationError { throw CancellationError() } + catch { + try validateAuthenticationFallback(after: error) + Logger.info("signUp failed (likely already registered), trying signIn: \(error)", context: "PubkyProfileManager") + return try await signIn() + } + } + + private nonisolated static func validateAuthenticationFallback(after error: Error) throws { + if PubkyCredentialLocalError.isLocalFailure(error) || error is ProfileRemovalStore.StoreError { + throw error + } + try ProfileOperationAdmission.shared.checkCurrent() + } + /// Signs in with a pubky owned by Pubky Ring. The secret is read just-in-time and never persisted here. func adoptRingIdentity( pubky: String, loadSecret: (String, String) -> String? = SharedPubkyKeychain.loadSecret, signIn: @escaping @Sendable (String) async throws -> Void = { try await PubkyProfileManager.signInWithRingKey($0) }, - fetchProfile: (@Sendable (String) async -> PubkyProfile?)? = nil + fetchProfile: (@Sendable (String) async -> PubkyProfile?)? = nil, + sdkService: PaykitSdkService = .shared ) async throws -> PubkyProfile? { guard !Self.isRingAdoptionInFlight else { throw PubkyServiceError.authFailed("Pubky Ring sign-in already in progress") } - try Self.beginSessionMutation() - Self.isRingAdoptionInFlight = true - defer { - Self.isRingAdoptionInFlight = false - isAdoptingRingIdentity = false - Self.endSessionMutation() - } - let sourceApp = SharedPubkyKeychain.ringSourceApp - guard let secretKeyHex = loadSecret(sourceApp, pubky) else { - throw PubkyServiceError.authFailed("Pubky Ring key unavailable") - } + let admission = sdkService.profileAdmission + return try await admission.withSessionTransition { + try Self.beginSessionMutation() + Self.isRingAdoptionInFlight = true + defer { + Self.isRingAdoptionInFlight = false + isAdoptingRingIdentity = false + Self.endSessionMutation() + } + let sourceApp = SharedPubkyKeychain.ringSourceApp + guard let secretKeyHex = loadSecret(sourceApp, pubky) else { + throw PubkyServiceError.authFailed("Pubky Ring key unavailable") + } - let previousAdoptedIdentity = AdoptedPubkyReference.current - isAdoptingRingIdentity = true - AdoptedPubkyReference.current = (sourceApp, pubky) - let adoptedPublicKey: String - do { - adoptedPublicKey = try await Task.detached { - let publicKey = try Self.publicKeyFromSecretKey(secretKeyHex) - try await signIn(secretKeyHex) - return publicKey - }.value - } catch { - if let currentAdoptedIdentity = AdoptedPubkyReference.current, - currentAdoptedIdentity.sourceApp == sourceApp, - currentAdoptedIdentity.pubky == pubky - { - AdoptedPubkyReference.current = previousAdoptedIdentity + let previousAdoptedIdentity = AdoptedPubkyReference.current + let attempt = try await sdkService.beginCredentialAttempt() + var acknowledged = false + isAdoptingRingIdentity = true + AdoptedPubkyReference.current = (sourceApp, pubky) + do { + let adoptedPublicKey = try await admission.withDetached { + let publicKey = try Self.publicKeyFromSecretKey(secretKeyHex) + try await signIn(secretKeyHex) + return publicKey + } + let prepared = try await prepareRingAdoption(publicKey: adoptedPublicKey, fetchProfile: fetchProfile) + try admission.checkCurrent() + let activation = try await sdkService.commitCredentialAttempt(attempt) + acknowledged = true + #if DEBUG + await beforeCommittedIdentityInstallForTesting?() + #endif + try admission.validateAcknowledgedLocalCompletion() + Self.applyCommittedActivation(activation) + installRingAdoption(publicKey: adoptedPublicKey, prepared: prepared) + return prepared.profile + } catch { + if !acknowledged, let currentAdoptedIdentity = AdoptedPubkyReference.current, + currentAdoptedIdentity.sourceApp == sourceApp, + currentAdoptedIdentity.pubky == pubky + { + AdoptedPubkyReference.current = previousAdoptedIdentity + } + if !acknowledged { + do { try await Task.detached { try await sdkService.rollbackCredentialAttempt(attempt) }.value } + catch { + refreshRemovalRecovery() + initializationErrorMessage = error.localizedDescription + Logger.error("Adopted identity requires local recovery: \(error)", context: "PubkyProfileManager") + } + } + throw error } - throw error } + } - return try await completeRingAdoption(publicKey: adoptedPublicKey, fetchProfile: fetchProfile) + private struct PreparedRingAdoption { + let profile: PubkyProfile? + let reusesRowProfile: Bool } - private func completeRingAdoption( + private func prepareRingAdoption( publicKey adoptedPublicKey: String, fetchProfile: (@Sendable (String) async -> PubkyProfile?)? = nil - ) async throws -> PubkyProfile? { - invalidateProfileLoads() - reloadCachedProfileMetadata() - publicKey = adoptedPublicKey - authState = .authenticated - Self.notifyAppStateBackupChanged() - + ) async throws -> PreparedRingAdoption { let adoptionRevision = Self.sessionRevision + let previousPublicKey = publicKey // Read only after sign-in: when lookups are contended, the tapped row's result lands after the tap. Only a found // row profile for this key is reused; anything else takes the fetch that decides profile setup. let rowKey = PubkyPublicKeyFormat.normalized(adoptedPublicKey) @@ -695,38 +1054,57 @@ class PubkyProfileManager: ObservableObject { await fetchRemoteProfile(publicKey: adoptedPublicKey) } } - guard adoptionRevision == Self.sessionRevision, publicKey == adoptedPublicKey else { + try Task.checkCancellation() + guard adoptionRevision == Self.sessionRevision, publicKey == previousPublicKey else { throw CancellationError() } + return PreparedRingAdoption(profile: adoptedProfile, reusesRowProfile: reusesRowProfile) + } + + private func installRingAdoption(publicKey adoptedPublicKey: String, prepared: PreparedRingAdoption) { + invalidateProfileLoads() + reloadCachedProfileMetadata() + publicKey = adoptedPublicKey + authState = .authenticated + Self.notifyAppStateBackupChanged() + let adoptionRevision = Self.sessionRevision clearRingIdentityProfiles() - setProfileSetupPending(adoptedProfile == nil) - if let adoptedProfile { - commitProfile(adoptedProfile) + setProfileSetupPending(prepared.profile == nil) + if let profile = prepared.profile { + commitProfile(profile) } - if reusesRowProfile { + if prepared.reusesRowProfile { // A row profile can be from earlier in the session, so it is refreshed behind navigation. let generation = profileWriteGeneration - Task { - await refreshReusedRingProfile(publicKey: adoptedPublicKey, generation: generation, adoptionRevision: adoptionRevision) + scheduleProfileRead { + await self.refreshReusedRingProfile(publicKey: adoptedPublicKey, generation: generation, adoptionRevision: adoptionRevision) } } - return adoptedProfile + } + + private func completeRingAdoption(publicKey: String) async throws -> PubkyProfile? { + let prepared = try await prepareRingAdoption(publicKey: publicKey) + installRingAdoption(publicKey: publicKey, prepared: prepared) + return prepared.profile } /// Only a definitive not-found undoes the reused profile, leaving the key as adopting one without a profile does so /// routing prompts profile setup. Any other failure may just mean offline and keeps it. Dropped if the identity /// changed or a newer profile write landed. private func refreshReusedRingProfile(publicKey adoptedPublicKey: String, generation: Int, adoptionRevision: UUID) async { - guard let read = beginProfileRead( - .reusedRingProfileRefresh(adoptionRevision: adoptionRevision), - publicKey: adoptedPublicKey, - generation: generation - ) else { return } + guard let removalGeneration = try? ProfileRemovalStore.shared.requireNoPending(), !Self.isProfileDeletionInFlight, + let read = beginProfileRead( + .reusedRingProfileRefresh(adoptionRevision: adoptionRevision), + publicKey: adoptedPublicKey, + generation: generation + ) else { return } defer { endProfileRead(read) } let resolve = remoteProfileResolver func isStillCurrent() -> Bool { - adoptionRevision == Self.sessionRevision && publicKey == adoptedPublicKey && profileWriteGeneration == generation + !Task.isCancelled && !Self.isProfileDeletionInFlight && + (try? ProfileRemovalStore.shared.validate(generation: removalGeneration)) != nil && + adoptionRevision == Self.sessionRevision && publicKey == adoptedPublicKey && profileWriteGeneration == generation } do { @@ -755,9 +1133,11 @@ class PubkyProfileManager: ObservableObject { do { _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) } catch { + try Self.validateAuthenticationFallback(after: error) Logger.warn("Sign-in with the Pubky Ring key failed: \(error)", context: "PubkyProfileManager") // Signup rewrites the homeserver record, so only a Ring key that was never published signs up. guard await Self.isUnpublishedIdentity(publicKey: publicKey) else { throw error } + try ProfileOperationAdmission.shared.checkCurrent() _ = try await Self.signUpToHomeserver(secretKeyHex: secretKeyHex) } } @@ -784,6 +1164,7 @@ class PubkyProfileManager: ObservableObject { } } + @discardableResult private func createProfile( publicKey: String, name: String, @@ -791,8 +1172,9 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], existingImageUrl: String?, - avatarImage: UIImage? - ) async throws { + avatarImage: UIImage?, + installsProfile: Bool = true + ) async throws -> PubkyProfile { var avatarUri: String? if let avatarImage { avatarUri = try await uploadAvatar(image: avatarImage) @@ -811,42 +1193,51 @@ class PubkyProfileManager: ObservableObject { tags: tags, status: nil ) - self.publicKey = publicKey + if installsProfile { + installCreatedProfile(createdProfile) + } + return createdProfile + } + + private func installCreatedProfile(_ createdProfile: PubkyProfile) { + publicKey = createdProfile.publicKey authState = .authenticated commitProfile(createdProfile) setProfileSetupPending(false) } func approveSignupAuth(request: PubkyAuthRequest) async throws { - guard request.isSignup, request.bitkitClaim == nil, let homeserver = request.homeserverPublicKey else { - throw PubkyServiceError.invalidAuthUrl - } - guard publicKey == nil, try !Self.hasStoredIdentity() else { - throw PubkySignupError.alreadySignedIn - } + return try await ProfileOperationAdmission.shared.withSessionTransition { + guard request.isSignup, request.bitkitClaim == nil, let homeserver = request.homeserverPublicKey else { + throw PubkyServiceError.invalidAuthUrl + } + guard publicKey == nil, try !Self.hasStoredIdentity() else { + throw PubkySignupError.alreadySignedIn + } - let revision = Self.sessionRevision - let (publicKey, secretKeyHex) = try await deriveKeys() - guard revision == Self.sessionRevision else { throw CancellationError() } - guard self.publicKey == nil, try !Self.hasStoredIdentity() else { - throw PubkySignupError.alreadySignedIn - } + let revision = Self.sessionRevision + let (publicKey, secretKeyHex) = try await deriveKeys() + guard revision == Self.sessionRevision else { throw CancellationError() } + guard self.publicKey == nil, try !Self.hasStoredIdentity() else { + throw PubkySignupError.alreadySignedIn + } - try await completeSignupAuthentication( - publicKey: publicKey, - registerIdentity: { - try await PubkyService.registerIdentity( - secretKeyHex: secretKeyHex, - homeserverZ32: homeserver, - signupCode: request.signupToken - ) - }, - approveAuth: { - try await PubkyService.approveSignupAuthorization(request: request, secretKeyHex: secretKeyHex) - }, - activateIdentity: { try await PubkyService.activateRegisteredIdentity($0) } - ) - Self.publishOwnSharedRecord() + try await completeSignupAuthentication( + publicKey: publicKey, + registerIdentity: { + try await PubkyService.registerIdentity( + secretKeyHex: secretKeyHex, + homeserverZ32: homeserver, + signupCode: request.signupToken + ) + }, + approveAuth: { + try await PubkyService.approveSignupAuthorization(request: request, secretKeyHex: secretKeyHex) + }, + activateIdentity: { try await PubkyService.activateRegisteredIdentity($0) } + ) + Self.publishOwnSharedRecord() + } } private func completeSignupAuthentication( @@ -856,38 +1247,45 @@ class PubkyProfileManager: ObservableObject { activateIdentity: (PubkyRegisteredIdentity) async throws -> Void, authorizationTimeout: Duration = .seconds(30) ) async throws { - guard !isSignupInFlight else { throw PubkySignupError.inProgress } - try Self.beginSessionMutation() - isSignupInFlight = true - let revision = Self.sessionRevision - defer { - isSignupInFlight = false - Self.endSessionMutation() - } - - setProfileSetupPending(false) - let registeredSession = try await registerIdentity() - guard revision == Self.sessionRevision else { throw CancellationError() } - try await approveSignupWithTimeout(authorizationTimeout, operation: approveAuth) - guard revision == Self.sessionRevision else { throw CancellationError() } - try await activateIdentity(registeredSession) - guard revision == Self.sessionRevision else { throw CancellationError() } + return try await removalAdmission.withSessionTransition { + guard !isSignupInFlight else { throw PubkySignupError.inProgress } + try Self.beginSessionMutation() + isSignupInFlight = true + let revision = Self.sessionRevision + defer { + isSignupInFlight = false + Self.endSessionMutation() + } - UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - reloadCachedProfileMetadata() - self.publicKey = publicKey - authState = .authenticated - setProfileSetupPending(true) - Self.notifyAppStateBackupChanged() + setProfileSetupPending(false) + let registeredSession = try await registerIdentity() + try removalAdmission.checkCurrent() + guard revision == Self.sessionRevision else { throw CancellationError() } + try await approveSignupWithTimeout(authorizationTimeout, operation: approveAuth) + try removalAdmission.checkCurrent() + guard revision == Self.sessionRevision else { throw CancellationError() } + try await activateIdentity(registeredSession) + try removalAdmission.validateCommittedOrCurrentLocalCompletion() + guard revision == Self.sessionRevision else { throw CancellationError() } + + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + reloadCachedProfileMetadata() + self.publicKey = publicKey + authState = .authenticated + setProfileSetupPending(true) + Self.notifyAppStateBackupChanged() + } } private func approveSignupWithTimeout(_ timeout: Duration, operation: @escaping () async throws -> Void) async throws { // The FFI request may ignore cancellation. Only race approval, so a late response cannot activate an abandoned signup. let (stream, continuation) = AsyncStream>.makeStream(bufferingPolicy: .bufferingOldest(1)) + let scope = try removalAdmission.capture() let operationTask = Task { + defer { removalAdmission.release(scope) } do { try Task.checkCancellation() - try await operation() + try await removalAdmission.withScope(scope) { try await operation() } continuation.yield(.success(())) } catch { if !(error is CancellationError) { @@ -933,9 +1331,11 @@ class PubkyProfileManager: ObservableObject { tags: [String] = [], avatarImage: UIImage? = nil ) async throws -> Bool { - guard let session = currentSession else { return false } - _ = try activeSessionSecret() - return try await saveProfileEdit(name: name, bio: bio, links: links, tags: tags, avatarImage: avatarImage, session: session) + return try await ProfileOperationAdmission.shared.withOperation { + guard let session = currentSession else { return false } + _ = try activeSessionSecret() + return try await saveProfileEdit(name: name, bio: bio, links: links, tags: tags, avatarImage: avatarImage, session: session) + } } /// Starting the edit drops every profile read that started before it, ahead of the avatar upload, so a refresh that finds @@ -1051,65 +1451,400 @@ class PubkyProfileManager: ObservableObject { return true } - func performProfileDeletion(_ operation: () async throws -> Void) async throws { - try Task.checkCancellation() - guard !Self.isProfileDeletionInFlight, Self.sessionMutationCount == 0, initializationTask == nil else { + #if DEBUG + /// Publication tests simulate an in-process pause independently of destructive cleanup. + /// Actual removal tests enter through prepareRemoval/confirmRemoval and the persistent store. + func withAutomaticWorkPausedForTesting(_ operation: () async throws -> Void) async throws { + guard !Self.isProfileDeletionInFlight, Self.sessionMutationCount == 0, initializationTask == nil else { + throw PubkyServiceError.identityChanged + } + Self.isProfileDeletionInFlight = true + isDeletingProfile = true + let pause = await PrivatePaykitService.shared.beginProfileDeletion() + do { + try await operation() + } catch { + await PrivatePaykitService.shared.endProfileDeletion(id: pause) + Self.isProfileDeletionInFlight = false + isDeletingProfile = false + throw error + } + await PrivatePaykitService.shared.endProfileDeletion(id: pause) + Self.isProfileDeletionInFlight = false + isDeletingProfile = false + } + #endif + + func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true, expectedSession: SignedInSession? = nil) async throws { + guard let plan = removalPlan, plan.confirmation.action == .deleteProfile || plan.confirmation.action == .unlink else { + throw RemovalError.confirmationRequired + } + if let expectedSession { + guard expectedSession.revision == plan.sessionRevision, expectedSession.publicKey == plan.owner else { + throw RemovalError.confirmationChanged + } + } + _ = try await confirmRemoval(plan.confirmation) + } + + func prepareRemoval(action: RemovalAction, contacts: ContactsManager? = nil, + expectedOwner: RemovalPresentationOwner? = nil) async throws -> RemovalConfirmation + { + if action == .disconnectLocally, expectedOwner == nil { + throw RemovalError.confirmationRequired + } + if let expectedOwner, !ownsRemovalPresentation(expectedOwner) { + throw RemovalError.confirmationChanged + } + let admission = removalAdmission + let generation = try admission.snapshot() + guard Self.sessionMutationCount == 0, !Self.isProfileDeletionInFlight, initializationTask == nil else { throw PubkyServiceError.identityChanged } + let owner = publicKey + let revision = Self.sessionRevision + let profileGeneration = profileWriteGeneration + let removesContacts = action == .deleteProfile || action == .unlink + if removesContacts { + guard currentSession != nil, contacts != nil else { throw PubkyServiceError.sessionNotActive } + } + let confirmation = RemovalConfirmation(id: UUID(), action: action) + removalPreparation = confirmation.id + removalPlan?.revocation?.revoke() + removalPlan = nil + var captured: ProfileSessionRevocation? + do { + return try await admission.withOperation { + let profile = action == .deleteProfile && owner != nil + ? try await removalServices.readProfile(owner!) : nil + let snapshot = removesContacts ? try await contacts?.prepareRemovalSnapshot() : nil + if snapshot != nil, let owner { + try await removalServices.validateSubscriptions(owner) + } + let withdrawEndpoints = action != .disconnectLocally && owner != nil && authState == .authenticated + if withdrawEndpoints, let owner { + captured = try await removalServices.captureRevocation(owner) + } + try admission.validate(generation) + guard removalPreparation == confirmation.id, Self.sessionRevision == revision, + publicKey == owner, profileWriteGeneration == profileGeneration else { throw RemovalError.confirmationChanged } + removalPlan = RemovalPlan(confirmation: confirmation, sessionRevision: revision, owner: owner, + profileGeneration: profileGeneration, admissionGeneration: generation, + profile: profile, + withdrawEndpoints: withdrawEndpoints, + contacts: removesContacts ? contacts : nil, contactSnapshot: snapshot, revocation: captured) + return confirmation + } + } catch { + captured?.revoke() + if removalPreparation == confirmation.id { + removalPreparation = nil + } + if removalPlan?.confirmation.id == confirmation.id { + removalPlan = nil + } + throw error + } + } + + func cancelRemovalConfirmation(_ confirmation: RemovalConfirmation?) { + guard let confirmation else { return } + if removalPreparation == confirmation.id { + removalPreparation = nil + } + if removalPlan?.confirmation.id == confirmation.id { + removalPlan?.revocation?.revoke() + removalPlan = nil + } + } + + func confirmRemoval(_ confirmation: RemovalConfirmation) async throws -> RemovalCompletion { + try Task.checkCancellation() + guard let plan = removalPlan, plan.confirmation == confirmation else { throw RemovalError.confirmationRequired } + try validateRemovalOwner(plan) + if let snapshot = plan.contactSnapshot { + try plan.contacts?.requireCurrentRemovalSnapshot(snapshot) + } + let admission = removalAdmission + try admission.validate(plan.admissionGeneration) + if let snapshot = plan.contactSnapshot, let owner = plan.owner { + try await admission.withOperation { + try await plan.contacts?.validateRemovalSnapshot(snapshot) + try await removalServices.validateSubscriptions(owner) + } + } + // The dialog or its owner may have changed while eligibility was being checked. + // Reserve only after leaving the ordinary scope, without suspending after these checks. + guard removalPlan?.confirmation == confirmation, removalPreparation == confirmation.id else { + throw RemovalError.confirmationChanged + } + try validateRemovalOwner(plan) + if let snapshot = plan.contactSnapshot { + try plan.contacts?.requireCurrentRemovalSnapshot(snapshot) + } + try admission.validate(plan.admissionGeneration) + removalPlan = nil + removalPreparation = nil + let kind: ProfileOperationAdmission.Kind = plan.withdrawEndpoints ? .removal : .localRecovery + let scope = try admission.reserve(kind: kind, id: confirmation.id, expected: plan.admissionGeneration) + return try await runRemoval(scope: scope, id: confirmation.id, plan: plan) + } + + private func validateRemovalOwner(_ plan: RemovalPlan) throws { + try Task.checkCancellation() + guard Self.sessionRevision == plan.sessionRevision, Self.sessionMutationCount == 0, + publicKey == plan.owner, profileWriteGeneration == plan.profileGeneration + else { + throw RemovalError.confirmationChanged + } + } + + private func validateRemoteRemovalOwner(_ plan: RemovalPlan) async throws { + try validateRemovalOwner(plan) + guard let revocation = plan.revocation else { throw RemovalError.confirmationChanged } + try await revocation.validate() + try validateRemovalOwner(plan) + } + + private func validateRemoteRemovalOwner(_ live: LiveRemoval) async throws { + do { + try await validateRemoteRemovalOwner(live.plan) + } catch { + if !(error is CancellationError) { + live.lease.retire() + } + throw error + } + } + + private func completePublicRemoval(_ live: LiveRemoval) async throws { + if !live.publicAcknowledged { + try await removalServices.withdrawPublic() + live.publicAcknowledged = true + try await validateRemoteRemovalOwner(live) + } + if !live.appAcknowledged { + try await removalServices.syncApp() + live.appAcknowledged = true + try await validateRemoteRemovalOwner(live) + } + } + + func finishPendingRemoval(id: UUID) async throws -> RemovalCompletion { + guard !isDeletingProfile else { throw RemovalError.confirmationChanged } + let pending = try removalStore.pending() + guard pending?.id == id, pending?.scope == .profile else { throw RemovalError.confirmationChanged } + liveRemoval?.lease.retire() + liveRemoval = nil + remoteRemovalConfirmation = nil + let scope = try removalAdmission.reserve(kind: .localRecovery, id: id) + return try await runRemoval(scope: scope, id: id, plan: nil) + } + + func canRetryRemoteRemoval(id: UUID) -> Bool { + guard !Self.isProfileDeletionInFlight, !isDeletingProfile, let live = liveRemoval, + live.plan.confirmation.id == id, live.canRetry, live.lease.canResume, + (try? validateRemovalOwner(live.plan)) != nil, + let pending = try? removalStore.pending(), pending.id == id, pending.scope == .profile else { return false } + return true + } + + func prepareRemoteRemovalRetry(id: UUID) async throws -> RemoteRemovalConfirmation { + guard canRetryRemoteRemoval(id: id), let live = liveRemoval, let revocation = live.plan.revocation else { + throw RemovalError.confirmationChanged + } + remoteRemovalConfirmation = nil + // This checks only the original local binding. Protocol negotiation waits for the new confirmation. + do { + try await live.lease.withOperation { try await revocation.validate() } + } catch { + if !(error is CancellationError) { + live.lease.retire() + } + throw error + } + guard canRetryRemoteRemoval(id: id), liveRemoval === live else { throw RemovalError.confirmationChanged } + let confirmation = RemoteRemovalConfirmation(id: UUID(), removalID: id) + remoteRemovalConfirmation = confirmation + return confirmation + } + + func cancelRemoteRemovalRetry(_ confirmation: RemoteRemovalConfirmation?) { + if remoteRemovalConfirmation == confirmation { + remoteRemovalConfirmation = nil + } + } + + func confirmRemoteRemovalRetry(_ confirmation: RemoteRemovalConfirmation) async throws -> RemovalCompletion { + guard remoteRemovalConfirmation == confirmation, canRetryRemoteRemoval(id: confirmation.removalID), + let live = liveRemoval, let revocation = live.plan.revocation else { throw RemovalError.confirmationRequired } + remoteRemovalConfirmation = nil Self.isProfileDeletionInFlight = true isDeletingProfile = true defer { - isDeletingProfile = false Self.isProfileDeletionInFlight = false + isDeletingProfile = false + refreshRemovalRecovery() } - await PrivatePaykitService.shared.beginProfileDeletion() + let id = confirmation.removalID + let scope = live.lease.scope + var completion: RemovalCompletion? do { - try await operation() + try await withRemovalWork(lease: live.lease, id: id, live: live) { + try await validateRemoteRemovalOwner(live) + try await completePublicRemoval(live) + live.logoutStarted = true + try await revocation.execute() + live.logoutAcknowledged = await revocation.acknowledged() + guard live.logoutAcknowledged else { throw RemovalError.confirmationChanged } + try validateRemovalOwner(live.plan) + completion = try await completeRemoval(scope: scope, plan: live.plan, remoteDeleted: live.remoteProfileDeleted) + } } catch { - await PrivatePaykitService.shared.endProfileDeletion() + if live.logoutAcknowledged || Task.isCancelled || + !live.lease.canResume || (try? validateRemovalOwner(live.plan)) == nil + { + live.lease.retire() + } + if let completion { + return completion + } throw error } - await PrivatePaykitService.shared.endProfileDeletion() + live.lease.retire() + liveRemoval = nil + guard let completion else { throw ProfileRemovalStore.StoreError.changed } + return completion } - func validateProfileDeletion(for session: SignedInSession) async throws { - try requireCurrentSession(session) - try await profileDeletionValidator(session.publicKey) - try requireCurrentSession(session) + private func withRemovalWork(lease: ProfileRemovalLease, id: UUID, live: LiveRemoval?, + operation: () async throws -> Void) async throws + { + try await lease.withOperation { + await removalServices.pause(id) + do { + try await operation() + } catch { + await removalServices.resume(id) + if let live, let revocation = live.plan.revocation { + // The native attempt and SDK-lock tail have returned, but our local tail still owns the drain holder. + live.logoutAcknowledged = await revocation.acknowledged() + if live.logoutStarted || live.canRetry { + liveRemoval = live + } + } + throw error + } + await removalServices.resume(id) + } } - func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true, expectedSession: SignedInSession? = nil) async throws { - guard let session = expectedSession ?? currentSession else { throw PubkyServiceError.sessionNotActive } - try requireCurrentSession(session) - if expectedSession != nil { - // The UI supplies its captured session from inside performProfileDeletion. - guard isDeletingProfile else { throw PubkyServiceError.identityChanged } - try await deleteProfile(for: session, cleanPrivatePaykitEndpoints: cleanPrivatePaykitEndpoints) + private func completeRemoval(scope: ProfileOperationAdmission.Scope, plan: RemovalPlan?, + remoteDeleted: Bool) async throws -> RemovalCompletion + { + try removalAdmission.requireLocalOnly(scope) + if let revocation = plan?.revocation { + try await removalServices.completeRevokedLocal(revocation) } else { - try await performProfileDeletion { - try await deleteProfile(for: session, cleanPrivatePaykitEndpoints: cleanPrivatePaykitEndpoints) + try await removalServices.completeLocal() + } + clearAuthenticatedStateAfterLocalCleanup() + plan?.contacts?.finishProfileRemoval() + let endedRevision = Self.sessionRevision + let completedGeneration = try removalAdmission.complete(scope) + failedRemovalID = nil + return RemovalCompletion(remoteProfileDeleted: remoteDeleted, endedRevision: endedRevision, + admissionGeneration: completedGeneration) + } + + private func runRemoval(scope: ProfileOperationAdmission.Scope, id: UUID, plan: RemovalPlan?, + validateLocalIntent: (() throws -> Void)? = nil) async throws -> RemovalCompletion + { + let admission = removalAdmission + let lease = ProfileRemovalLease(scope: scope, revokeRemoteAdmission: { plan?.revocation?.revoke() }) + let live = plan.map { LiveRemoval(plan: $0, lease: lease) } + var retainIntent = false + defer { + if !retainIntent { + lease.retire() } } + Self.isProfileDeletionInFlight = true + isDeletingProfile = true + defer { + Self.isProfileDeletionInFlight = false + isDeletingProfile = false + refreshRemovalRecovery() + } + var completion: RemovalCompletion? + do { + try await withRemovalWork(lease: lease, id: id, live: live) { + try await admission.drain(before: scope) + if let plan { + try validateRemovalOwner(plan) + } + try validateLocalIntent?() + try admission.persist(scope) + failedRemovalID = id + refreshRemovalRecovery() + var remoteDeleted = false + if let live, live.plan.withdrawEndpoints { + let plan = live.plan + try await validateRemoteRemovalOwner(live) + if let snapshot = plan.contactSnapshot { + try await plan.contacts?.validateRemovalSnapshot(snapshot) + if let owner = plan.owner { + try await removalServices.validateSubscriptions(owner) + } + } + try await validateRemoteRemovalOwner(live) + try await removalServices.withdrawPrivate() + live.privateAcknowledged = true + try await validateRemoteRemovalOwner(live) + try await completePublicRemoval(live) + if let snapshot = plan.contactSnapshot { + try await plan.contacts?.removeConfirmedContacts(snapshot) + } + try await validateRemoteRemovalOwner(live) + if plan.confirmation.action == .deleteProfile, let record = plan.profile, let owner = plan.owner { + try await removalAdmission + .withDetached { [removalServices] in try await removalServices.deleteProfile(owner, record.revision) } + remoteDeleted = true + live.remoteProfileDeleted = true + } + try await validateRemoteRemovalOwner(live) + guard let revocation = plan.revocation else { throw RemovalError.confirmationChanged } + live.logoutStarted = true + try await revocation.execute() + live.logoutAcknowledged = await revocation.acknowledged() + guard live.logoutAcknowledged else { throw RemovalError.confirmationChanged } + try validateRemovalOwner(plan) + } + try validateLocalIntent?() + completion = try await completeRemoval(scope: scope, plan: plan, remoteDeleted: remoteDeleted) + } + } catch { + if let live, live.canRetry { + retainIntent = !Task.isCancelled && lease.canResume && + (try? validateRemovalOwner(live.plan)) != nil + } + if let completion { + return completion + } + throw error + } + guard let completion else { throw ProfileRemovalStore.StoreError.changed } + return completion } - private func deleteProfile(for session: SignedInSession, cleanPrivatePaykitEndpoints: Bool) async throws { - try await validateProfileDeletion(for: session) - if cleanPrivatePaykitEndpoints { - try await Self.removePrivatePaykitEndpoints( - context: "PubkyProfileManager.deleteProfile", deferAppSync: true, - isSessionCurrent: { self.currentSession == session } - ) - } - try requireCurrentSession(session) - let delete = profileDeleter - try await Task.detached { - try await delete(session.publicKey) - }.value + func ownsRemovalCompletion(_ completion: RemovalCompletion) -> Bool { + Self.sessionRevision == completion.endedRevision && publicKey == nil && + (try? removalAdmission.validate(completion.admissionGeneration)) != nil + } - try requireCurrentSession(session) - invalidateProfileLoads() - Self.clearPaykitSharingAfterProfileDeletion() - try await signOut(cleanPrivatePaykitEndpoints: false, allowDuringProfileDeletion: true) + var pendingProfileRemovalID: UUID? { + guard case let .pending(record) = removalRecovery, record.scope == .profile else { return nil } + return record.id } private func writeProfile( @@ -1129,24 +1864,9 @@ class PubkyProfileManager: ObservableObject { ) let publish = profilePublisher - try await Task.detached { + try await ProfileOperationAdmission.shared.withDetached { try await publish(profileData, expectedIdentity) - }.value - } - - private func discardAbandonedSession() async { - await discardAbandonedSession( - revokeSessionAccess: { - try await Task.detached { - try await PubkyService.signOut() - }.value - }, - forgetSessionAccess: { - try await Task.detached { - try await PubkyService.forgetSessionAccess() - }.value - } - ) + } } private func discardAbandonedSession( @@ -1173,13 +1893,15 @@ class PubkyProfileManager: ObservableObject { activateIdentity: (PubkyRegisteredIdentity) async throws -> Void, authorizationTimeout: Duration = .seconds(30) ) async throws { - try await completeSignupAuthentication( - publicKey: publicKey, - registerIdentity: registerIdentity, - approveAuth: approveAuth, - activateIdentity: activateIdentity, - authorizationTimeout: authorizationTimeout - ) + try await removalAdmission.withSessionTransition { + try await completeSignupAuthentication( + publicKey: publicKey, + registerIdentity: registerIdentity, + approveAuth: approveAuth, + activateIdentity: activateIdentity, + authorizationTimeout: authorizationTimeout + ) + } } func discardAbandonedSessionForTesting( @@ -1221,6 +1943,7 @@ class PubkyProfileManager: ObservableObject { await initializationTask.value guard !Task.isCancelled else { return } } + guard let removalGeneration = try? ProfileRemovalStore.shared.requireNoPending(), !Self.isProfileDeletionInFlight else { return } guard publicKey != nil else { await loadReadOnlyProfile() return @@ -1233,7 +1956,10 @@ class PubkyProfileManager: ObservableObject { let loadedProfile = try await Task.detached { try await resolve(pk) }.value - if publicKey == pk, profileWriteGeneration == read.generation { + if !Task.isCancelled, !Self.isProfileDeletionInFlight, + (try? ProfileRemovalStore.shared.validate(generation: removalGeneration)) != nil, + publicKey == pk, profileWriteGeneration == read.generation + { profile = loadedProfile readOnlyProfile = nil cacheProfileMetadata(loadedProfile) @@ -1244,12 +1970,15 @@ class PubkyProfileManager: ObservableObject { } private func loadReadOnlyProfile() async { - guard sessionRestorationDeferred, Self.sessionMutationCount == 0 else { return } + guard sessionRestorationDeferred, Self.sessionMutationCount == 0, + let removalGeneration = try? ProfileRemovalStore.shared.requireNoPending(), !Self.isProfileDeletionInFlight else { return } let revision = Self.sessionRevision let pk = await Task.detached { Self.activeSecretKeyHex().flatMap { try? Self.publicKeyFromSecretKey($0) } }.value - guard revision == Self.sessionRevision, Self.sessionMutationCount == 0, + guard !Task.isCancelled, !Self.isProfileDeletionInFlight, + (try? ProfileRemovalStore.shared.validate(generation: removalGeneration)) != nil, + revision == Self.sessionRevision, Self.sessionMutationCount == 0, sessionRestorationDeferred, publicKey == nil else { return } guard let pk else { @@ -1266,7 +1995,9 @@ class PubkyProfileManager: ObservableObject { let resolve = remoteProfileResolver do { let loaded = try await Task.detached { try await resolve(pk) }.value - guard revision == Self.sessionRevision, Self.sessionMutationCount == 0, + guard !Task.isCancelled, !Self.isProfileDeletionInFlight, + (try? ProfileRemovalStore.shared.validate(generation: removalGeneration)) != nil, + revision == Self.sessionRevision, Self.sessionMutationCount == 0, sessionRestorationDeferred, publicKey == nil, profileWriteGeneration == read.generation else { return } readOnlyProfile = (pk, loaded, revision) @@ -1331,7 +2062,7 @@ class PubkyProfileManager: ObservableObject { /// Runs the read that saves dropped again once no save is in flight, as long as the failed save's session is still signed /// in. A save that starts before it runs takes the read over instead. private func rerunProfileReadAwaitingSaves(afterFailedSaveIn session: SignedInSession) { - Task { + scheduleProfileRead { [self] in guard profileSavesInFlight == 0, currentSession == session, let awaiting = profileReadAwaitingSaves, @@ -1351,6 +2082,16 @@ class PubkyProfileManager: ObservableObject { } } + private func scheduleProfileRead(_ operation: @escaping @MainActor @Sendable () async -> Void) { + let revision = Self.sessionRevision + guard let removalGeneration = try? ProfileRemovalStore.shared.requireNoPending() else { return } + Task { @MainActor in + guard revision == Self.sessionRevision, + (try? ProfileRemovalStore.shared.validate(generation: removalGeneration)) != nil else { return } + await ProfileOperationAdmission.$current.withValue(nil) { await operation() } + } + } + // MARK: - Pubky Ring Choice Rows /// Looks up the rows that have no cached result, joining the live lookup of a row that has one. Cancelling the caller @@ -1443,32 +2184,59 @@ class PubkyProfileManager: ObservableObject { // MARK: - Sign Out - static func clearLocalState() async { - // A wallet reset may revoke deletion's owner, but no replacement identity can start until deletion drains. - try? beginSessionMutation(allowDuringProfileDeletion: true) - defer { endSessionMutation() } - do { - try await PubkyService.forgetSessionAccess() - } catch { - Logger.warn("Failed to forget local Pubky session access: \(error)", context: "PubkyProfileManager") - try? PubkySessionAccessTeardown.clear { try Keychain.delete(key: $0) } + static func clearLocalState( + forgetSessionAccess: @Sendable () async throws -> Void = { try await PubkyService.forgetSessionAccess() }, + removeSharedCredentials: () throws -> Void = { try SharedPubkyKeychain.removeAllOwnOrThrow() }, + flushPreferences: () -> Bool = { UserDefaults.standard.synchronize() } + ) async throws { + return try await ProfileOperationAdmission.shared.withOperation(access: .local) { + // A wallet reset may revoke deletion's owner, but no replacement identity can start until deletion drains. + try beginSessionMutation(allowDuringProfileDeletion: true) + defer { endSessionMutation() } + try await forgetSessionAccess() + try await clearLocalAppState(removeSharedCredentials: removeSharedCredentials, flushPreferences: flushPreferences) } - await clearLocalAppState() } - private static func clearLocalAppState() async { - SharedPubkyKeychain.removeAllOwn() + private static func clearLocalAppState( + removeSharedCredentials: () throws -> Void = { try SharedPubkyKeychain.removeAllOwnOrThrow() }, + flushPreferences: () -> Bool = { UserDefaults.standard.synchronize() } + ) async throws { + try removeSharedCredentials() AdoptedPubkyReference.current = nil - await PrivatePaykitService.shared.closeAndClear() - await PrivatePaykitAddressReservationStore.shared.clearContactAssignments() + let reservations = try await PrivatePaykitAddressReservationStore.shared.clearContactAssignmentsForRemoval() + let privateState = try await PrivatePaykitService.shared.closeAndClear() await PubkyImageCache.shared.clear() clearCachedIdentityMetadata() UserDefaults.standard.removeObject(forKey: profileSetupPendingKey) clearPublicPaykitSharingState() + #if DEBUG + try ProfileRemovalTestFault.check("preferences:flush") + #endif + guard flushPreferences(), let domain = Bundle.main.bundleIdentifier, + let stored = UserDefaults.standard.persistentDomain(forName: domain) + else { throw ProfileRemovalStore.StoreError.unavailable } + let removedKeys = [ + cachedIdentityKey, cachedNameKey, cachedImageUriKey, cachedProfileOwnerKey, profileSetupPendingKey, + PublicPaykitService.reconciliationRevisionKey, + AdoptedPubkyReference.sourceAppKey, AdoptedPubkyReference.pubkyKey, ContactsManager.contactProfileOverridesKey, + PrivatePaykitService.deletedContactCleanupKeysKey, "publicPaykitBolt11", "publicPaykitBolt11PaymentHash", "publicPaykitBolt11ExpiresAt", + ] + let disabledKeys = [ + PublicPaykitService.publishingEnabledKey, PrivatePaykitService.publishingEnabledKey, + ContactPaymentsService.confirmedPreferenceKey, PrivatePaykitService.cleanupPendingKey, PublicPaykitService.cleanupPendingKey, + ] + guard removedKeys.allSatisfy({ stored[$0] == nil }), disabledKeys.allSatisfy({ stored[$0] as? Bool == false }), + stored[PrivatePaykitService.cacheStateKey] as? Data == privateState, + stored[PrivatePaykitAddressReservationStore.defaultsKey] as? Data == reservations + else { throw ProfileRemovalStore.StoreError.changed } + try await PrivatePaykitService.shared.validateRemovalState(privateState) + try await PrivatePaykitAddressReservationStore.shared.validateRemovalState(reservations) notifyAppStateBackupChanged() } private static func clearPublicPaykitSharingState() { + PublicPaykitService.clearRemovedProfileReconciliation() UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) UserDefaults.standard.set(false, forKey: ContactPaymentsService.confirmedPreferenceKey) @@ -1545,54 +2313,57 @@ class PubkyProfileManager: ObservableObject { } func signOut() async throws { - try await signOut(cleanPrivatePaykitEndpoints: true) + let confirmation = try await prepareRemoval(action: .disconnect) + _ = try await confirmRemoval(confirmation) } - private func signOut(cleanPrivatePaykitEndpoints: Bool, allowDuringProfileDeletion: Bool = false) async throws { - try await signOut(allowDuringProfileDeletion: allowDuringProfileDeletion) { - if cleanPrivatePaykitEndpoints { - try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") - } - await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") - try await PubkyService.signOut() + #if DEBUG + func signOut(performSessionCleanup: @escaping @Sendable () async throws -> Void) async throws { + try await signOut(allowDuringProfileDeletion: false, performSessionCleanup: performSessionCleanup) } - } - func signOut(performSessionCleanup: @escaping @Sendable () async throws -> Void) async throws { - try await signOut(allowDuringProfileDeletion: false, performSessionCleanup: performSessionCleanup) - } + private func signOut( + allowDuringProfileDeletion: Bool, performSessionCleanup: @escaping @Sendable () async throws -> Void + ) async throws { + return try await ProfileOperationAdmission.shared.withOperation { + let retainedProfile = hasReadOnlyProfileIdentity ? readOnlyProfile : nil + try Self.beginSessionMutation(allowDuringProfileDeletion: allowDuringProfileDeletion) + if let retainedProfile { + readOnlySignOutRevision = Self.sessionRevision + readOnlyProfile = (retainedProfile.publicKey, retainedProfile.profile, Self.sessionRevision) + } + defer { + readOnlySignOutRevision = nil + Self.endSessionMutation() + } + let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) + let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) - private func signOut( - allowDuringProfileDeletion: Bool, performSessionCleanup: @escaping @Sendable () async throws -> Void - ) async throws { - let retainedProfile = hasReadOnlyProfileIdentity ? readOnlyProfile : nil - try Self.beginSessionMutation(allowDuringProfileDeletion: allowDuringProfileDeletion) - if let retainedProfile { - readOnlySignOutRevision = Self.sessionRevision - readOnlyProfile = (retainedProfile.publicKey, retainedProfile.profile, Self.sessionRevision) - } - defer { - readOnlySignOutRevision = nil - Self.endSessionMutation() - } - let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) - let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) + do { + try await ProfileOperationAdmission.shared.withDetached { + try await performSessionCleanup() + try await Self.clearLocalAppState() + } + } catch { + Self.markPaykitReconciliationPendingAfterFailedSignOut( + publicSharingEnabled: publicSharingEnabled, + privateSharingEnabled: privateSharingEnabled + ) + throw error + } - do { - try await Task.detached { - try await performSessionCleanup() - await Self.clearLocalAppState() - }.value - } catch { - Self.markPaykitReconciliationPendingAfterFailedSignOut( - publicSharingEnabled: publicSharingEnabled, - privateSharingEnabled: privateSharingEnabled - ) - throw error + clearAuthenticatedStateAfterLocalCleanup() + } } - setProfileSetupPending(false) - clearAuthenticatedState() + #endif + + private func clearAuthenticatedStateAfterLocalCleanup() { + isProfileSetupPending = false + cachedName = nil + cachedImageUri = nil + cachedProfileOwner = nil + clearAuthenticatedState(clearCachedProfile: false) } static func markPaykitReconciliationPendingAfterFailedSignOut( @@ -1759,7 +2530,7 @@ class PubkyProfileManager: ObservableObject { /// Nil while signed out, and from the moment a sign-in, sign-out or other session change starts until it ends, so /// before that change clears any state. The session after a change compares different from the one before. var currentSession: SignedInSession? { - guard let publicKey, Self.sessionMutationCount == 0 else { return nil } + guard removalRecovery == .ready, let publicKey, Self.sessionMutationCount == 0 else { return nil } return SignedInSession(publicKey: publicKey, revision: Self.sessionRevision) } @@ -1773,6 +2544,25 @@ class PubkyProfileManager: ObservableObject { return currentSession } + /// Automatic work announced inside a transition must inherit its next ordinary scope, not its expiring token. + func scheduleAutomaticPaykitSession(_ operation: @escaping @MainActor (SignedInSession) async -> Void) throws { + guard let session = automaticPaykitSession else { return } + let admission = removalAdmission + try admission.scheduleAfterTransition { scope in + Task { @MainActor [weak self] in + defer { admission.release(scope) } + do { + try await admission.withScope(scope) { + guard let self, self.automaticPaykitSession == session else { return } + await operation(session) + } + } catch { + Logger.debug("Automatic Paykit startup stopped: \(error)", context: "PubkyProfileManager") + } + } + } + } + func storedIdentityGuard() -> (@MainActor () -> Bool)? { guard !isAuthenticated, Self.sessionMutationCount == 0, (try? Self.hasStoredIdentity()) == true else { return nil } let revision = Self.sessionRevision @@ -1795,6 +2585,7 @@ class PubkyProfileManager: ObservableObject { } nonisolated static func hasStoredIdentityForBackup() throws -> Bool { + try ProfileRemovalStore.shared.requireNoPending() if AdoptedPubkyReference.current != nil { return true } @@ -1905,8 +2696,8 @@ class PubkyProfileManager: ObservableObject { deleteKeychainValue: (KeychainEntryType) throws -> Void = { try Keychain.delete(key: $0) }, - removeOwnSharedRecords: () -> Void = { - SharedPubkyKeychain.removeAllOwn() + removeOwnSharedRecords: () throws -> Void = { + try SharedPubkyKeychain.removeAllOwnOrThrow() }, forgetSessionAccess: @escaping () async throws -> Void = { try await PubkyService.forgetSessionAccess() @@ -1915,31 +2706,38 @@ class PubkyProfileManager: ObservableObject { try await PubkyService.signIn(secretKeyHex: $0) } ) async throws { - try await beginSessionMutation() - do { + return try await ProfileOperationAdmission.shared.withSessionTransition { + try await beginSessionMutation() do { - try await forgetSessionAccess() + let secretKeyHex: String? + do { + try await forgetSessionAccess() + try ProfileOperationAdmission.shared.checkCurrent() + try deleteKeychainValue(.paykitSession) + try deleteKeychainValue(.pubkySecretKey) + try removeOwnSharedRecords() + switch backup?.kind { + case .none: + secretKeyHex = nil + case .localSeed: + let derived = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString) + try persistKeychainString(.pubkySecretKey, derived) + secretKeyHex = derived + } + } catch { + throw ProfileSessionRestoreLocalError(underlyingError: error) + } + if let secretKeyHex { + // Remote authorization stays awaited, after every required local replacement has acknowledged. + _ = try await signInWithSecretKey(secretKeyHex) + try ProfileOperationAdmission.shared.checkCurrent() + } } catch { - Logger.warn("Failed to forget existing Pubky session before restore: \(error)", context: "PubkyProfileManager") - } - - switch backup?.kind { - case .none: - // No kind, or one this version no longer restores: the backup carries no usable pubky credentials. - try? deleteKeychainValue(.paykitSession) - try? deleteKeychainValue(.pubkySecretKey) - removeOwnSharedRecords() - case .localSeed: - let secretKeyHex = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString) - try persistKeychainString(.pubkySecretKey, secretKeyHex) - try? deleteKeychainValue(.paykitSession) - _ = try await signInWithSecretKey(secretKeyHex) + await endSessionMutation() + throw error } - } catch { await endSessionMutation() - throw error } - await endSessionMutation() } private nonisolated static func initializePersistedSession() async throws -> SessionInitializationResult { @@ -2032,3 +2830,7 @@ class PubkyProfileManager: ObservableObject { } } } + +struct ProfileSessionRestoreLocalError: Error { + let underlyingError: Error +} diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 3499e6343..af87c0493 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -916,6 +916,7 @@ "profile__sign_out" = "قطع الاتصال"; "profile__sign_out_title" = "فصل الملف الشخصي"; "profile__sign_out_description" = "سيؤدي هذا إلى فصل ملف Pubky الشخصي عن Bitkit. يمكنك إعادة الاتصال في أي وقت."; +"profile__disconnect_local_description" = "أزل وصول هذا الجهاز وأكمل التنظيف المحلي. لا يُجري هذا أي تغييرات إضافية على ملفك الشخصي أو اشتراكاتك أو صلاحيات وصولك على الخادم."; "profile__choice_title" = "ادخل إلى\nويب الحرية"; "profile__choice_description" = "أنشئ pubky وملفًا شخصيًا جديدين في Bitkit."; "profile__choice_description_ring" = "استخدم pubky موجودًا من Pubky Ring."; @@ -952,13 +953,13 @@ "profile__delete_description" = "يؤدي حذف ملفك الشخصي إلى إزالته من Pubky. قد تحتفظ جهات الاتصال بمعلومات الملف الشخصي والدفع التي شاركتها معهم سابقًا."; "profile__delete_confirm" = "نعم، احذف"; "profile__delete_error_title" = "تعذر حذف الملف الشخصي"; -"profile__delete_error_description" = "تعذر علينا حذف بيانات ملفك الشخصي. أعد المحاولة، أو افصل هذا الـ pubky عن Bitkit."; +"profile__delete_error_description" = "انقطع تنظيف الملف الشخصي. ربما طُبّقت التغييرات البعيدة بالفعل. افصل الملف الشخصي لإكمال التنظيف على هذا الجهاز دون إعادة محاولة الحذف البعيد."; "profile__delete_label" = "حذف الملف الشخصي"; "profile__unlink_title" = "إلغاء ربط الـ pubky؟"; "profile__unlink_description" = "سيؤدي هذا إلى حذف بيانات Paykit الخاصة بك وإلغاء ربط هذا الـ pubky من Bitkit. سيظل متاحًا في Pubky Ring."; "profile__unlink_confirm" = "نعم، ألغِ الربط"; "profile__unlink_error_title" = "تعذر إلغاء ربط الـ pubky"; -"profile__unlink_error_description" = "تعذر علينا حذف بيانات Paykit الخاصة بك. أعد المحاولة، أو افصل هذا الـ pubky عن Bitkit."; +"profile__unlink_error_description" = "انقطع تنظيف الملف الشخصي. ربما طُبّقت التغييرات البعيدة بالفعل. افصل الملف الشخصي لإكمال التنظيف على هذا الجهاز دون إعادة محاولة الحذف البعيد."; "profile__unlink_label" = "إلغاء ربط الـ pubky"; "profile__edit" = "تعديل"; "profile__pay_contacts_nav_title" = "الدفع لجهات الاتصال"; @@ -1567,4 +1568,14 @@ "subscriptions__allowance_limit_description" = "طلب بقيمة {amount} من {name} يتجاوز مخصصك. راجعه للدفع."; "subscriptions__allowance_auto_paid" = "مدفوع تلقائيًا"; +"profile__recovery_title" = "إكمال التنظيف المحلي"; + +"profile__recovery_pending" = "انقطع تنظيف الملف الشخصي. ربما طُبّقت التغييرات البعيدة بالفعل. افصل الملف الشخصي لإكمال التنظيف على هذا الجهاز دون إعادة محاولة الحذف البعيد."; + +"profile__recovery_wipe" = "انقطعت إعادة ضبط المحفظة. أكملها قبل استخدام هذه المحفظة."; + +"profile__recovery_unavailable" = "يتعذر على Bitkit قراءة حالة التنظيف المحفوظة. تم إيقاف تغييرات الملف الشخصي مؤقتًا. حاول قراءتها مجددًا."; + +"profile__recovery_working" = "جارٍ إكمال تنظيف الملف الشخصي…"; + "wallet__balance_updating" = "جارٍ تحديث الرصيد…"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 37b5eee6c..01957a456 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1032,6 +1032,7 @@ "profile__sign_out" = "Desconnectar"; "profile__sign_out_title" = "Desconnectar el perfil"; "profile__sign_out_description" = "Això desconnectarà el teu perfil de Pubky de Bitkit. Pots tornar a connectar-lo en qualsevol moment."; +"profile__disconnect_local_description" = "Elimina l’accés d’aquest dispositiu i acaba la neteja local. Això no fa cap altre canvi al perfil, les subscripcions ni l’accés al servidor."; "profile__choice_title" = "Entra a la\nweb de la llibertat"; "profile__choice_description" = "Crea un nou pubky i un perfil a Bitkit."; "profile__choice_description_ring" = "Utilitza un pubky existent de Pubky Ring."; @@ -1068,13 +1069,13 @@ "profile__delete_description" = "Eliminar el teu perfil l'esborra de Pubky. És possible que els contactes conservin la informació de perfil i de pagament que s'hi hagi compartit abans."; "profile__delete_confirm" = "Sí, elimina"; "profile__delete_error_title" = "No s'ha pogut eliminar el perfil"; -"profile__delete_error_description" = "No hem pogut eliminar les dades del teu perfil. Torna-ho a provar o desconnecta aquest pubky de Bitkit."; +"profile__delete_error_description" = "La neteja del perfil s’ha interromput. És possible que els canvis remots ja s’hagin aplicat. Desconnecta el perfil per completar la neteja en aquest dispositiu sense repetir la supressió remota."; "profile__delete_label" = "Eliminar perfil"; "profile__unlink_title" = "Desvincular el pubky?"; "profile__unlink_description" = "Això eliminarà les teves dades de Paykit i desvincularà aquest pubky de Bitkit. Continuarà disponible a Pubky Ring."; "profile__unlink_confirm" = "Sí, desvincula"; "profile__unlink_error_title" = "No s'ha pogut desvincular el pubky"; -"profile__unlink_error_description" = "No hem pogut eliminar les teves dades de Paykit. Torna-ho a provar o desconnecta aquest pubky de Bitkit."; +"profile__unlink_error_description" = "La neteja del perfil s’ha interromput. És possible que els canvis remots ja s’hagin aplicat. Desconnecta el perfil per completar la neteja en aquest dispositiu sense repetir la supressió remota."; "profile__unlink_label" = "Desvincular el pubky"; "profile__edit" = "Editar"; "profile__pay_contacts_nav_title" = "Pagar contactes"; @@ -1751,4 +1752,14 @@ "subscriptions__allowance_limit_description" = "Una sol·licitud de {amount} de {name} supera la teva assignació. Revisa-la per pagar."; "subscriptions__allowance_auto_paid" = "Pagat automàticament"; +"profile__recovery_title" = "Completa la neteja local"; + +"profile__recovery_pending" = "La neteja del perfil s’ha interromput. És possible que els canvis remots ja s’hagin aplicat. Desconnecta el perfil per completar la neteja en aquest dispositiu sense repetir la supressió remota."; + +"profile__recovery_wipe" = "El restabliment de la cartera s’ha interromput. Completa’l abans d’utilitzar-la."; + +"profile__recovery_unavailable" = "Bitkit no pot llegir l’estat desat de la neteja. Els canvis del perfil estan en pausa. Torna a intentar llegir-lo."; + +"profile__recovery_working" = "S’està completant la neteja del perfil…"; + "wallet__balance_updating" = "S’està actualitzant el saldo…"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 5133077ea..3f66b015b 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1137,6 +1137,7 @@ "profile__sign_out" = "Odpojit"; "profile__sign_out_title" = "Odpojit profil"; "profile__sign_out_description" = "Tímto odpojíte svůj profil Pubky od Bitkitu. Kdykoli se můžete znovu připojit."; +"profile__disconnect_local_description" = "Odeberte přístup tohoto zařízení a dokončete místní vyčištění. Tím se neprovedou žádné další změny vašeho profilu, předplatných ani přístupu na serveru."; "profile__choice_title" = "Vstupte do\nsvobodného webu"; "profile__choice_description" = "Vytvořte si v Bitkitu nový pubky a profil."; "profile__choice_description_ring" = "Použijte existující pubky z Pubky Ring."; @@ -1173,13 +1174,13 @@ "profile__delete_description" = "Smazáním profilu ho odstraníte z Pubky. Kontakty si mohou ponechat informace o profilu a platbách, které jste s nimi dříve sdíleli."; "profile__delete_confirm" = "Ano, smazat"; "profile__delete_error_title" = "Profil nelze smazat"; -"profile__delete_error_description" = "Data vašeho profilu se nepodařilo smazat. Zkuste to znovu, nebo tento pubky od Bitkitu odpojte."; +"profile__delete_error_description" = "Vyčištění profilu bylo přerušeno. Vzdálené změny už mohly být provedeny. Odpojte profil a dokončete vyčištění v tomto zařízení bez opakování vzdáleného smazání."; "profile__delete_label" = "Smazat profil"; "profile__unlink_title" = "Zrušit propojení pubky?"; "profile__unlink_description" = "Tímto smažete svá data Paykit a zrušíte propojení tohoto pubky s Bitkitem. V Pubky Ring zůstane k dispozici."; "profile__unlink_confirm" = "Ano, zrušit propojení"; "profile__unlink_error_title" = "Propojení pubky nelze zrušit"; -"profile__unlink_error_description" = "Vaše data Paykit se nepodařilo smazat. Zkuste to znovu, nebo tento pubky od Bitkitu odpojte."; +"profile__unlink_error_description" = "Vyčištění profilu bylo přerušeno. Vzdálené změny už mohly být provedeny. Odpojte profil a dokončete vyčištění v tomto zařízení bez opakování vzdáleného smazání."; "profile__unlink_label" = "Zrušit propojení pubky"; "profile__edit" = "Upravit"; "profile__pay_contacts_nav_title" = "Platby kontaktům"; @@ -1890,4 +1891,14 @@ "subscriptions__allowance_limit_description" = "Žádost o {amount} od {name} přesahuje vaše povolení. Zkontrolujte ji a zaplaťte."; "subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; +"profile__recovery_title" = "Dokončit místní vyčištění"; + +"profile__recovery_pending" = "Vyčištění profilu bylo přerušeno. Vzdálené změny už mohly být provedeny. Odpojte profil a dokončete vyčištění v tomto zařízení bez opakování vzdáleného smazání."; + +"profile__recovery_wipe" = "Obnovení peněženky do výchozího stavu bylo přerušeno. Dokončete je před použitím této peněženky."; + +"profile__recovery_unavailable" = "Bitkit nemůže přečíst uložený stav vyčištění. Změny profilu jsou pozastaveny. Zkuste jej přečíst znovu."; + +"profile__recovery_working" = "Dokončování vyčištění profilu…"; + "wallet__balance_updating" = "Aktualizace zůstatku…"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index c5d8a136a..4cef62bf0 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1135,6 +1135,7 @@ "profile__sign_out" = "Trennen"; "profile__sign_out_title" = "Profil trennen"; "profile__sign_out_description" = "Dadurch wird dein Pubky-Profil von Bitkit getrennt. Du kannst es jederzeit wieder verbinden."; +"profile__disconnect_local_description" = "Entferne den Zugriff dieses Geräts und schließe die lokale Bereinigung ab. Dadurch werden keine weiteren Änderungen an deinem Profil, deinen Abonnements oder deinem Zugriff auf dem Server vorgenommen."; "profile__choice_title" = "Betritt das\nfreie Web"; "profile__choice_description" = "Erstelle einen neuen pubky und ein Profil in Bitkit."; "profile__choice_description_ring" = "Verwende einen bestehenden pubky aus Pubky Ring."; @@ -1171,13 +1172,13 @@ "profile__delete_description" = "Durch das Löschen deines Profils wird es von Pubky entfernt. Kontakte können zuvor mit ihnen geteilte Profil- und Zahlungsinformationen weiterhin behalten."; "profile__delete_confirm" = "Ja, löschen"; "profile__delete_error_title" = "Profil kann nicht gelöscht werden"; -"profile__delete_error_description" = "Wir konnten deine Profildaten nicht löschen. Versuche es erneut oder trenne diesen pubky von Bitkit."; +"profile__delete_error_description" = "Die Profilbereinigung wurde unterbrochen. Entfernte Änderungen wurden möglicherweise bereits angewendet. Trenne die Verbindung, um dieses Gerät zu bereinigen, ohne die entfernte Löschung erneut auszuführen."; "profile__delete_label" = "Profil löschen"; "profile__unlink_title" = "pubky-Verknüpfung aufheben?"; "profile__unlink_description" = "Dadurch werden deine Paykit-Daten gelöscht und die Verknüpfung dieses pubky mit Bitkit aufgehoben. In Pubky Ring bleibt er weiterhin verfügbar."; "profile__unlink_confirm" = "Ja, aufheben"; "profile__unlink_error_title" = "pubky-Verknüpfung kann nicht aufgehoben werden"; -"profile__unlink_error_description" = "Wir konnten deine Paykit-Daten nicht löschen. Versuche es erneut oder trenne diesen pubky von Bitkit."; +"profile__unlink_error_description" = "Die Profilbereinigung wurde unterbrochen. Entfernte Änderungen wurden möglicherweise bereits angewendet. Trenne die Verbindung, um dieses Gerät zu bereinigen, ohne die entfernte Löschung erneut auszuführen."; "profile__unlink_label" = "pubky-Verknüpfung aufheben"; "profile__edit" = "Bearbeiten"; "profile__pay_contacts_nav_title" = "Kontakte bezahlen"; @@ -1887,4 +1888,14 @@ "subscriptions__allowance_limit_description" = "Eine Anforderung über {amount} von {name} übersteigt dein Kontingent. Prüfe sie, um zu bezahlen."; "subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; +"profile__recovery_title" = "Lokale Bereinigung abschließen"; + +"profile__recovery_pending" = "Die Profilbereinigung wurde unterbrochen. Entfernte Änderungen wurden möglicherweise bereits angewendet. Trenne die Verbindung, um dieses Gerät zu bereinigen, ohne die entfernte Löschung erneut auszuführen."; + +"profile__recovery_wipe" = "Das Zurücksetzen der Wallet wurde unterbrochen. Schließe es ab, bevor du diese Wallet verwendest."; + +"profile__recovery_unavailable" = "Bitkit kann den gespeicherten Bereinigungsstatus nicht lesen. Profiländerungen sind pausiert. Versuche erneut, ihn zu lesen."; + +"profile__recovery_working" = "Profilbereinigung wird abgeschlossen…"; + "wallet__balance_updating" = "Kontostand wird aktualisiert…"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index ea48bfe52..ecf486e57 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -933,6 +933,7 @@ "profile__sign_out" = "Αποσύνδεση"; "profile__sign_out_title" = "Αποσύνδεση προφίλ"; "profile__sign_out_description" = "Αυτό θα αποσυνδέσει το προφίλ Pubky σου από το Bitkit. Μπορείς να το συνδέσεις ξανά οποιαδήποτε στιγμή."; +"profile__disconnect_local_description" = "Αφαιρέστε την πρόσβαση αυτής της συσκευής και ολοκληρώστε την τοπική εκκαθάριση. Αυτό δεν κάνει άλλες αλλαγές στο προφίλ, τις συνδρομές ή την πρόσβασή σας στον διακομιστή."; "profile__choice_title" = "Μπες στον\nελεύθερο ιστό"; "profile__choice_description" = "Δημιούργησε νέο pubky και προφίλ στο Bitkit."; "profile__choice_description_ring" = "Χρησιμοποίησε ένα υπάρχον pubky από το Pubky Ring."; @@ -969,13 +970,13 @@ "profile__delete_description" = "Η διαγραφή του προφίλ σας το αφαιρεί από το Pubky. Οι επαφές μπορεί να διατηρούν ακόμα πληροφορίες προφίλ και πληρωμών που τους κοινοποιήθηκαν παλαιότερα."; "profile__delete_confirm" = "Ναι, Διαγραφή"; "profile__delete_error_title" = "Αδυναμία διαγραφής προφίλ"; -"profile__delete_error_description" = "Δεν μπορέσαμε να διαγράψουμε τα δεδομένα του προφίλ σου. Δοκίμασε ξανά ή αποσύνδεσε αυτό το pubky από το Bitkit."; +"profile__delete_error_description" = "Η εκκαθάριση του προφίλ διακόπηκε. Οι απομακρυσμένες αλλαγές μπορεί να έχουν ήδη εφαρμοστεί. Αποσυνδέστε το προφίλ για να ολοκληρώσετε την εκκαθάριση σε αυτή τη συσκευή χωρίς επανάληψη της απομακρυσμένης διαγραφής."; "profile__delete_label" = "Διαγραφή Προφίλ"; "profile__unlink_title" = "Αποσύνδεση pubky;"; "profile__unlink_description" = "Αυτό θα διαγράψει τα δεδομένα Paykit σου και θα αποσυνδέσει αυτό το pubky από το Bitkit. Θα παραμείνει διαθέσιμο στο Pubky Ring."; "profile__unlink_confirm" = "Ναι, Αποσύνδεση"; "profile__unlink_error_title" = "Αδυναμία αποσύνδεσης pubky"; -"profile__unlink_error_description" = "Δεν μπορέσαμε να διαγράψουμε τα δεδομένα Paykit σου. Δοκίμασε ξανά ή αποσύνδεσε αυτό το pubky από το Bitkit."; +"profile__unlink_error_description" = "Η εκκαθάριση του προφίλ διακόπηκε. Οι απομακρυσμένες αλλαγές μπορεί να έχουν ήδη εφαρμοστεί. Αποσυνδέστε το προφίλ για να ολοκληρώσετε την εκκαθάριση σε αυτή τη συσκευή χωρίς επανάληψη της απομακρυσμένης διαγραφής."; "profile__unlink_label" = "Αποσύνδεση pubky"; "profile__edit" = "Επεξεργασία"; "profile__pay_contacts_nav_title" = "Πληρωμή Επαφών"; @@ -1622,4 +1623,14 @@ "subscriptions__allowance_limit_description" = "Ένα αίτημα {amount} από {name} υπερβαίνει την προέγκρισή σου. Έλεγξέ το για να πληρώσεις."; "subscriptions__allowance_auto_paid" = "Πληρώθηκε αυτόματα"; +"profile__recovery_title" = "Ολοκλήρωση τοπικής εκκαθάρισης"; + +"profile__recovery_pending" = "Η εκκαθάριση του προφίλ διακόπηκε. Οι απομακρυσμένες αλλαγές μπορεί να έχουν ήδη εφαρμοστεί. Αποσυνδέστε το προφίλ για να ολοκληρώσετε την εκκαθάριση σε αυτή τη συσκευή χωρίς επανάληψη της απομακρυσμένης διαγραφής."; + +"profile__recovery_wipe" = "Η επαναφορά του πορτοφολιού διακόπηκε. Ολοκληρώστε την πριν χρησιμοποιήσετε αυτό το πορτοφόλι."; + +"profile__recovery_unavailable" = "Το Bitkit δεν μπορεί να διαβάσει την αποθηκευμένη κατάσταση εκκαθάρισης. Οι αλλαγές προφίλ έχουν παγώσει. Δοκιμάστε να τη διαβάσετε ξανά."; + +"profile__recovery_working" = "Ολοκλήρωση εκκαθάρισης προφίλ…"; + "wallet__balance_updating" = "Ενημέρωση υπολοίπου…"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 69d0c189c..430fa5445 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1148,6 +1148,7 @@ "profile__sign_out" = "Disconnect"; "profile__sign_out_title" = "Disconnect Profile"; "profile__sign_out_description" = "This will disconnect your Pubky profile from Bitkit. You can reconnect at any time."; +"profile__disconnect_local_description" = "Remove this device’s access and finish local cleanup. This makes no further changes to your profile, subscriptions or access on the server."; "profile__choice_title" = "Enter the\nfreedom web"; "profile__choice_description" = "Create a new pubky and profile in Bitkit."; "profile__choice_description_ring" = "Use an existing pubky from Pubky Ring."; @@ -1186,13 +1187,13 @@ "profile__delete_description" = "Deleting your profile removes it from Pubky. Contacts may still retain profile and payment information previously shared with them."; "profile__delete_confirm" = "Yes, Delete"; "profile__delete_error_title" = "Unable to Delete Profile"; -"profile__delete_error_description" = "We couldn't delete your profile data. Retry, or disconnect this pubky from Bitkit."; +"profile__delete_error_description" = "Profile cleanup was interrupted. Remote changes may already have been applied. Disconnect to finish cleanup on this device without retrying remote deletion."; "profile__delete_label" = "Delete Profile"; "profile__unlink_title" = "Unlink pubky?"; "profile__unlink_description" = "This will delete your Paykit data and unlink this pubky from Bitkit. It will still be available in Pubky Ring."; "profile__unlink_confirm" = "Yes, Unlink"; "profile__unlink_error_title" = "Unable to Unlink Pubky"; -"profile__unlink_error_description" = "We couldn't delete your Paykit data. Retry, or disconnect this pubky from Bitkit."; +"profile__unlink_error_description" = "Profile cleanup was interrupted. Remote changes may already have been applied. Disconnect to finish cleanup on this device without retrying remote deletion."; "profile__unlink_label" = "Unlink pubky"; "profile__edit" = "Edit"; "profile__pay_contacts_nav_title" = "Pay Contacts"; @@ -1979,4 +1980,11 @@ "usdt__error_bridge_liquidity" = "This route cannot complete the transfer right now. Try another network or try again later."; "usdt__receive_copied" = "Address copied to clipboard.\nMake sure USDT is only sent on the selected network."; +"profile__recovery_title" = "Finish local cleanup"; +"profile__recovery_pending" = "Profile cleanup was interrupted. Remote changes may already have been applied. Disconnect to finish cleanup on this device without retrying remote deletion."; +"profile__recovery_wipe" = "Wallet reset was interrupted. Finish the reset before using this wallet."; +"profile__recovery_unavailable" = "Bitkit cannot read the saved cleanup state. Profile changes are paused. Try reading it again."; + +"profile__recovery_working" = "Finishing profile cleanup…"; + "wallet__balance_updating" = "Updating balance…"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 2c18ef97a..8952bd545 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1140,6 +1140,7 @@ "profile__sign_out" = "Desconectar"; "profile__sign_out_title" = "Desconectar perfil"; "profile__sign_out_description" = "Esto desconectará su perfil de Pubky de Bitkit. Puede volver a conectarlo en cualquier momento."; +"profile__disconnect_local_description" = "Elimina el acceso de este dispositivo y finaliza la limpieza local. Esto no realiza más cambios en tu perfil, tus suscripciones ni tu acceso en el servidor."; "profile__choice_title" = "Entre a la\nweb de la libertad"; "profile__choice_description" = "Cree un nuevo pubky y perfil en Bitkit."; "profile__choice_description_ring" = "Use un pubky existente de Pubky Ring."; @@ -1176,13 +1177,13 @@ "profile__delete_description" = "Al eliminar su perfil, se borra de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; "profile__delete_error_title" = "No se pudo eliminar el perfil"; -"profile__delete_error_description" = "No pudimos eliminar los datos de su perfil. Vuelva a intentarlo o desconecte este pubky de Bitkit."; +"profile__delete_error_description" = "La limpieza del perfil se interrumpió. Es posible que los cambios remotos ya se hayan aplicado. Desconecta el perfil para terminar la limpieza en este dispositivo sin repetir la eliminación remota."; "profile__delete_label" = "Eliminar perfil"; "profile__unlink_title" = "¿Desvincular pubky?"; "profile__unlink_description" = "Esto eliminará sus datos de Paykit y desvinculará este pubky de Bitkit. Seguirá disponible en Pubky Ring."; "profile__unlink_confirm" = "Sí, desvincular"; "profile__unlink_error_title" = "No se pudo desvincular el pubky"; -"profile__unlink_error_description" = "No pudimos eliminar sus datos de Paykit. Vuelva a intentarlo o desconecte este pubky de Bitkit."; +"profile__unlink_error_description" = "La limpieza del perfil se interrumpió. Es posible que los cambios remotos ya se hayan aplicado. Desconecta el perfil para terminar la limpieza en este dispositivo sin repetir la eliminación remota."; "profile__unlink_label" = "Desvincular pubky"; "profile__edit" = "Editar"; "profile__pay_contacts_nav_title" = "Pagar a contactos"; @@ -1893,4 +1894,14 @@ "subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; +"profile__recovery_title" = "Finalizar limpieza local"; + +"profile__recovery_pending" = "La limpieza del perfil se interrumpió. Es posible que los cambios remotos ya se hayan aplicado. Desconecta el perfil para terminar la limpieza en este dispositivo sin repetir la eliminación remota."; + +"profile__recovery_wipe" = "Se interrumpió el restablecimiento de la billetera. Complétalo antes de usarla."; + +"profile__recovery_unavailable" = "Bitkit no puede leer el estado guardado de la limpieza. Los cambios del perfil están en pausa. Intenta leerlo de nuevo."; + +"profile__recovery_working" = "Finalizando limpieza del perfil…"; + "wallet__balance_updating" = "Actualizando saldo…"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index a4ca620d9..d815eaf04 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1049,6 +1049,7 @@ "profile__sign_out" = "Desconectar"; "profile__sign_out_title" = "Desconectar perfil"; "profile__sign_out_description" = "Esto desconectará su perfil de Pubky de Bitkit. Puede volver a conectarlo en cualquier momento."; +"profile__disconnect_local_description" = "Elimina el acceso de este dispositivo y finaliza la limpieza local. Esto no realiza más cambios en tu perfil, tus suscripciones ni tu acceso en el servidor."; "profile__choice_title" = "Entre en la\nweb libre"; "profile__choice_description" = "Cree un nuevo pubky y un perfil en Bitkit."; "profile__choice_description_ring" = "Use un pubky existente de Pubky Ring."; @@ -1085,13 +1086,13 @@ "profile__delete_description" = "Al eliminar su perfil, se elimina de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; "profile__delete_error_title" = "No se pudo eliminar el perfil"; -"profile__delete_error_description" = "No pudimos eliminar los datos de su perfil. Vuelva a intentarlo o desconecte este pubky de Bitkit."; +"profile__delete_error_description" = "La limpieza del perfil se interrumpió. Es posible que los cambios remotos ya se hayan aplicado. Desconecta el perfil para terminar la limpieza en este dispositivo sin repetir la eliminación remota."; "profile__delete_label" = "Eliminar perfil"; "profile__unlink_title" = "¿Desvincular el pubky?"; "profile__unlink_description" = "Esto eliminará sus datos de Paykit y desvinculará este pubky de Bitkit. Seguirá disponible en Pubky Ring."; "profile__unlink_confirm" = "Sí, desvincular"; "profile__unlink_error_title" = "No se pudo desvincular el pubky"; -"profile__unlink_error_description" = "No pudimos eliminar sus datos de Paykit. Vuelva a intentarlo o desconecte este pubky de Bitkit."; +"profile__unlink_error_description" = "La limpieza del perfil se interrumpió. Es posible que los cambios remotos ya se hayan aplicado. Desconecta el perfil para terminar la limpieza en este dispositivo sin repetir la eliminación remota."; "profile__unlink_label" = "Desvincular pubky"; "profile__edit" = "Editar"; "profile__pay_contacts_nav_title" = "Pagar a contactos"; @@ -1771,4 +1772,14 @@ "subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; +"profile__recovery_title" = "Finalizar limpieza local"; + +"profile__recovery_pending" = "La limpieza del perfil se interrumpió. Es posible que los cambios remotos ya se hayan aplicado. Desconecta el perfil para terminar la limpieza en este dispositivo sin repetir la eliminación remota."; + +"profile__recovery_wipe" = "Se interrumpió el restablecimiento de la cartera. Complétalo antes de usarla."; + +"profile__recovery_unavailable" = "Bitkit no puede leer el estado guardado de la limpieza. Los cambios del perfil están en pausa. Intenta leerlo de nuevo."; + +"profile__recovery_working" = "Finalizando limpieza del perfil…"; + "wallet__balance_updating" = "Actualizando saldo…"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index a37d50338..2040e78b2 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1148,6 +1148,7 @@ "profile__sign_out" = "Déconnecter"; "profile__sign_out_title" = "Déconnecter le profil"; "profile__sign_out_description" = "Votre profil Pubky sera déconnecté de Bitkit. Vous pouvez vous reconnecter à tout moment."; +"profile__disconnect_local_description" = "Retirez l’accès de cet appareil et terminez le nettoyage local. Cela n’apporte aucune autre modification à votre profil, à vos abonnements ou à votre accès sur le serveur."; "profile__choice_title" = "Entrez dans le\nweb de la liberté"; "profile__choice_description" = "Créez un nouveau pubky et un profil dans Bitkit."; "profile__choice_description_ring" = "Utilisez un pubky existant de Pubky Ring."; @@ -1184,13 +1185,13 @@ "profile__delete_description" = "La suppression de votre profil le retire de Pubky. Vos contacts peuvent encore conserver les informations de profil et de paiement que vous leur avez partagées."; "profile__delete_confirm" = "Oui, supprimer"; "profile__delete_error_title" = "Impossible de supprimer le profil"; -"profile__delete_error_description" = "Nous n'avons pas pu supprimer les données de votre profil. Réessayez ou déconnectez ce pubky de Bitkit."; +"profile__delete_error_description" = "Le nettoyage du profil a été interrompu. Des modifications distantes ont peut-être déjà été appliquées. Déconnectez le profil pour terminer le nettoyage sur cet appareil sans relancer la suppression distante."; "profile__delete_label" = "Supprimer le profil"; "profile__unlink_title" = "Dissocier le pubky ?"; "profile__unlink_description" = "Cela supprimera vos données Paykit et dissociera ce pubky de Bitkit. Il restera disponible dans Pubky Ring."; "profile__unlink_confirm" = "Oui, dissocier"; "profile__unlink_error_title" = "Impossible de dissocier le pubky"; -"profile__unlink_error_description" = "Nous n'avons pas pu supprimer vos données Paykit. Réessayez ou déconnectez ce pubky de Bitkit."; +"profile__unlink_error_description" = "Le nettoyage du profil a été interrompu. Des modifications distantes ont peut-être déjà été appliquées. Déconnectez le profil pour terminer le nettoyage sur cet appareil sans relancer la suppression distante."; "profile__unlink_label" = "Dissocier le pubky"; "profile__edit" = "Modifier"; "profile__pay_contacts_nav_title" = "Payer des contacts"; @@ -1901,4 +1902,14 @@ "subscriptions__allowance_limit_description" = "Une demande de {amount} de {name} dépasse votre allocation. Examinez-la pour payer."; "subscriptions__allowance_auto_paid" = "Payé automatiquement"; +"profile__recovery_title" = "Terminer le nettoyage local"; + +"profile__recovery_pending" = "Le nettoyage du profil a été interrompu. Des modifications distantes ont peut-être déjà été appliquées. Déconnectez le profil pour terminer le nettoyage sur cet appareil sans relancer la suppression distante."; + +"profile__recovery_wipe" = "La réinitialisation du portefeuille a été interrompue. Terminez-la avant d’utiliser ce portefeuille."; + +"profile__recovery_unavailable" = "Bitkit ne peut pas lire l’état enregistré du nettoyage. Les modifications du profil sont suspendues. Réessayez de le lire."; + +"profile__recovery_working" = "Finalisation du nettoyage du profil…"; + "wallet__balance_updating" = "Mise à jour du solde…"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index b79d45659..49f594614 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1114,6 +1114,7 @@ "profile__sign_out" = "Disconnetti"; "profile__sign_out_title" = "Disconnetti Profilo"; "profile__sign_out_description" = "Questo disconnetterà il tuo profilo Pubky da Bitkit. Puoi ricollegarlo in qualsiasi momento."; +"profile__disconnect_local_description" = "Rimuovi l’accesso di questo dispositivo e completa la pulizia locale. Questa operazione non apporta ulteriori modifiche al profilo, agli abbonamenti o all’accesso sul server."; "profile__choice_title" = "Entra nel\nweb della libertà"; "profile__choice_description" = "Crea un nuovo pubky e un profilo in Bitkit."; "profile__choice_description_ring" = "Usa un pubky esistente da Pubky Ring."; @@ -1150,13 +1151,13 @@ "profile__delete_description" = "Eliminando il profilo lo rimuovi da Pubky. I contatti potrebbero conservare le informazioni di profilo e di pagamento condivise in precedenza con loro."; "profile__delete_confirm" = "Sì, Elimina"; "profile__delete_error_title" = "Impossibile Eliminare il Profilo"; -"profile__delete_error_description" = "Non è stato possibile eliminare i dati del tuo profilo. Riprova, oppure disconnetti questo pubky da Bitkit."; +"profile__delete_error_description" = "La pulizia del profilo è stata interrotta. Le modifiche remote potrebbero essere già state applicate. Disconnetti il profilo per completare la pulizia su questo dispositivo senza ripetere l’eliminazione remota."; "profile__delete_label" = "Elimina Profilo"; "profile__unlink_title" = "Scollegare il pubky?"; "profile__unlink_description" = "I tuoi dati Paykit verranno eliminati e questo pubky verrà scollegato da Bitkit. Resterà disponibile in Pubky Ring."; "profile__unlink_confirm" = "Sì, Scollega"; "profile__unlink_error_title" = "Impossibile Scollegare il Pubky"; -"profile__unlink_error_description" = "Non è stato possibile eliminare i tuoi dati Paykit. Riprova, oppure disconnetti questo pubky da Bitkit."; +"profile__unlink_error_description" = "La pulizia del profilo è stata interrotta. Le modifiche remote potrebbero essere già state applicate. Disconnetti il profilo per completare la pulizia su questo dispositivo senza ripetere l’eliminazione remota."; "profile__unlink_label" = "Scollega pubky"; "profile__edit" = "Modifica"; "profile__pay_contacts_nav_title" = "Paga Contatti"; @@ -1860,4 +1861,14 @@ "subscriptions__allowance_limit_description" = "Una richiesta di {amount} da {name} supera il tuo plafond. Controllala per pagare."; "subscriptions__allowance_auto_paid" = "Pagato automaticamente"; +"profile__recovery_title" = "Completa la pulizia locale"; + +"profile__recovery_pending" = "La pulizia del profilo è stata interrotta. Le modifiche remote potrebbero essere già state applicate. Disconnetti il profilo per completare la pulizia su questo dispositivo senza ripetere l’eliminazione remota."; + +"profile__recovery_wipe" = "Il ripristino del portafoglio è stato interrotto. Completalo prima di usare questo portafoglio."; + +"profile__recovery_unavailable" = "Bitkit non riesce a leggere lo stato salvato della pulizia. Le modifiche al profilo sono sospese. Prova a leggerlo di nuovo."; + +"profile__recovery_working" = "Completamento della pulizia del profilo…"; + "wallet__balance_updating" = "Aggiornamento del saldo…"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 9cfed30c6..771ca3e5a 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1143,6 +1143,7 @@ "profile__sign_out" = "Ontkoppelen"; "profile__sign_out_title" = "Profiel ontkoppelen"; "profile__sign_out_description" = "Hiermee wordt uw Pubky-profiel ontkoppeld van Bitkit. U kunt op elk moment opnieuw verbinden."; +"profile__disconnect_local_description" = "Verwijder de toegang van dit apparaat en voltooi de lokale opschoning. Dit brengt geen verdere wijzigingen aan in je profiel, abonnementen of toegang op de server."; "profile__choice_title" = "Betreed het\nvrije web"; "profile__choice_description" = "Maak een nieuwe pubky en een profiel aan in Bitkit."; "profile__choice_description_ring" = "Gebruik een bestaande pubky uit Pubky Ring."; @@ -1179,13 +1180,13 @@ "profile__delete_description" = "Als u uw profiel verwijdert, wordt het van Pubky verwijderd. Contacten kunnen eerder gedeelde profiel- en betaalgegevens nog bewaren."; "profile__delete_confirm" = "Ja, verwijderen"; "profile__delete_error_title" = "Kan profiel niet verwijderen"; -"profile__delete_error_description" = "We konden uw profielgegevens niet verwijderen. Probeer het opnieuw, of ontkoppel deze pubky van Bitkit."; +"profile__delete_error_description" = "Het opschonen van het profiel is onderbroken. Externe wijzigingen zijn mogelijk al toegepast. Koppel het profiel los om de opschoning op dit apparaat af te ronden zonder de externe verwijdering te herhalen."; "profile__delete_label" = "Profiel verwijderen"; "profile__unlink_title" = "pubky ontkoppelen?"; "profile__unlink_description" = "Hiermee worden uw Paykit-gegevens verwijderd en wordt deze pubky losgekoppeld van Bitkit. Deze blijft beschikbaar in Pubky Ring."; "profile__unlink_confirm" = "Ja, ontkoppelen"; "profile__unlink_error_title" = "Kan pubky niet ontkoppelen"; -"profile__unlink_error_description" = "We konden uw Paykit-gegevens niet verwijderen. Probeer het opnieuw, of ontkoppel deze pubky van Bitkit."; +"profile__unlink_error_description" = "Het opschonen van het profiel is onderbroken. Externe wijzigingen zijn mogelijk al toegepast. Koppel het profiel los om de opschoning op dit apparaat af te ronden zonder de externe verwijdering te herhalen."; "profile__unlink_label" = "pubky ontkoppelen"; "profile__edit" = "Bewerken"; "profile__pay_contacts_nav_title" = "Contacten betalen"; @@ -1895,4 +1896,14 @@ "subscriptions__allowance_limit_description" = "Een verzoek van {amount} van {name} overschrijdt uw toelage. Bekijk het om te betalen."; "subscriptions__allowance_auto_paid" = "Automatisch betaald"; +"profile__recovery_title" = "Lokale opschoning afronden"; + +"profile__recovery_pending" = "Het opschonen van het profiel is onderbroken. Externe wijzigingen zijn mogelijk al toegepast. Koppel het profiel los om de opschoning op dit apparaat af te ronden zonder de externe verwijdering te herhalen."; + +"profile__recovery_wipe" = "Het opnieuw instellen van de wallet is onderbroken. Rond dit af voordat je deze wallet gebruikt."; + +"profile__recovery_unavailable" = "Bitkit kan de opgeslagen opschoningsstatus niet lezen. Profielwijzigingen zijn gepauzeerd. Probeer de status opnieuw te lezen."; + +"profile__recovery_working" = "Profielopschoning afronden…"; + "wallet__balance_updating" = "Saldo bijwerken…"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 34636b5e1..0b07546ab 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1147,6 +1147,7 @@ "profile__sign_out" = "Odłącz"; "profile__sign_out_title" = "Odłącz profil"; "profile__sign_out_description" = "Spowoduje to odłączenie Twojego profilu Pubky od Bitkit. Możesz połączyć się ponownie w dowolnym momencie."; +"profile__disconnect_local_description" = "Usuń dostęp tego urządzenia i zakończ lokalne czyszczenie. Nie wprowadzi to dalszych zmian w profilu, subskrypcjach ani dostępie na serwerze."; "profile__choice_title" = "Wejdź do\nwolnej sieci"; "profile__choice_description" = "Utwórz nowy pubky i profil w Bitkit."; "profile__choice_description_ring" = "Użyj istniejącego pubky z Pubky Ring."; @@ -1183,13 +1184,13 @@ "profile__delete_description" = "Usunięcie profilu usuwa go z Pubky. Kontakty mogą nadal przechowywać udostępnione im wcześniej dane profilu i płatności."; "profile__delete_confirm" = "Tak, usuń"; "profile__delete_error_title" = "Nie można usunąć profilu"; -"profile__delete_error_description" = "Nie udało się usunąć danych Twojego profilu. Spróbuj ponownie lub odłącz ten pubky od Bitkit."; +"profile__delete_error_description" = "Czyszczenie profilu zostało przerwane. Zdalne zmiany mogły już zostać zastosowane. Odłącz profil, aby dokończyć czyszczenie na tym urządzeniu bez ponawiania zdalnego usuwania."; "profile__delete_label" = "Usuń profil"; "profile__unlink_title" = "Odłączyć pubky?"; "profile__unlink_description" = "Spowoduje to usunięcie Twoich danych Paykit i odłączenie tego pubky od Bitkit. Nadal będzie dostępny w Pubky Ring."; "profile__unlink_confirm" = "Tak, odłącz"; "profile__unlink_error_title" = "Nie można odłączyć pubky"; -"profile__unlink_error_description" = "Nie udało się usunąć Twoich danych Paykit. Spróbuj ponownie lub odłącz ten pubky od Bitkit."; +"profile__unlink_error_description" = "Czyszczenie profilu zostało przerwane. Zdalne zmiany mogły już zostać zastosowane. Odłącz profil, aby dokończyć czyszczenie na tym urządzeniu bez ponawiania zdalnego usuwania."; "profile__unlink_label" = "Odłącz pubky"; "profile__edit" = "Edytuj"; "profile__pay_contacts_nav_title" = "Płać kontaktom"; @@ -1900,4 +1901,14 @@ "subscriptions__allowance_limit_description" = "Żądanie {amount} od {name} przekracza Twój przydział. Przejrzyj je, aby zapłacić."; "subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; +"profile__recovery_title" = "Dokończ czyszczenie lokalne"; + +"profile__recovery_pending" = "Czyszczenie profilu zostało przerwane. Zdalne zmiany mogły już zostać zastosowane. Odłącz profil, aby dokończyć czyszczenie na tym urządzeniu bez ponawiania zdalnego usuwania."; + +"profile__recovery_wipe" = "Resetowanie portfela zostało przerwane. Dokończ je przed użyciem tego portfela."; + +"profile__recovery_unavailable" = "Bitkit nie może odczytać zapisanego stanu czyszczenia. Zmiany profilu są wstrzymane. Spróbuj odczytać go ponownie."; + +"profile__recovery_working" = "Kończenie czyszczenia profilu…"; + "wallet__balance_updating" = "Aktualizowanie salda…"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 3ce738c92..1e7e1d5f6 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1148,6 +1148,7 @@ "profile__sign_out" = "Desconectar"; "profile__sign_out_title" = "Desconectar Perfil"; "profile__sign_out_description" = "Isso desconectará seu perfil Pubky da Bitkit. Você pode reconectar a qualquer momento."; +"profile__disconnect_local_description" = "Remova o acesso deste dispositivo e conclua a limpeza local. Isso não faz outras alterações no seu perfil, nas assinaturas ou no acesso no servidor."; "profile__choice_title" = "Entre na\nweb da liberdade"; "profile__choice_description" = "Crie uma nova pubky e um perfil na Bitkit."; "profile__choice_description_ring" = "Use uma pubky existente do Pubky Ring."; @@ -1184,13 +1185,13 @@ "profile__delete_description" = "Excluir seu perfil o remove do Pubky. Os contatos ainda podem manter informações de perfil e de pagamento compartilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; "profile__delete_error_title" = "Não Foi Possível Excluir o Perfil"; -"profile__delete_error_description" = "Não conseguimos excluir os dados do seu perfil. Tente novamente ou desconecte esta pubky da Bitkit."; +"profile__delete_error_description" = "A limpeza do perfil foi interrompida. As alterações remotas podem já ter sido aplicadas. Desconecte o perfil para concluir a limpeza neste dispositivo sem repetir a exclusão remota."; "profile__delete_label" = "Excluir Perfil"; "profile__unlink_title" = "Desvincular pubky?"; "profile__unlink_description" = "Isso excluirá seus dados do Paykit e desvinculará esta pubky da Bitkit. Ela continuará disponível no Pubky Ring."; "profile__unlink_confirm" = "Sim, Desvincular"; "profile__unlink_error_title" = "Não Foi Possível Desvincular a Pubky"; -"profile__unlink_error_description" = "Não conseguimos excluir seus dados do Paykit. Tente novamente ou desconecte esta pubky da Bitkit."; +"profile__unlink_error_description" = "A limpeza do perfil foi interrompida. As alterações remotas podem já ter sido aplicadas. Desconecte o perfil para concluir a limpeza neste dispositivo sem repetir a exclusão remota."; "profile__unlink_label" = "Desvincular pubky"; "profile__edit" = "Editar"; "profile__pay_contacts_nav_title" = "Pagar Contatos"; @@ -1901,4 +1902,14 @@ "subscriptions__allowance_limit_description" = "Uma solicitação de {amount} de {name} excede sua pré-aprovação. Revise-a para pagar."; "subscriptions__allowance_auto_paid" = "Pago automaticamente"; +"profile__recovery_title" = "Concluir limpeza local"; + +"profile__recovery_pending" = "A limpeza do perfil foi interrompida. As alterações remotas podem já ter sido aplicadas. Desconecte o perfil para concluir a limpeza neste dispositivo sem repetir a exclusão remota."; + +"profile__recovery_wipe" = "A redefinição da carteira foi interrompida. Conclua-a antes de usar esta carteira."; + +"profile__recovery_unavailable" = "O Bitkit não consegue ler o estado salvo da limpeza. As alterações do perfil estão pausadas. Tente lê-lo novamente."; + +"profile__recovery_working" = "Concluindo a limpeza do perfil…"; + "wallet__balance_updating" = "Atualizando saldo…"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index fe17a3a85..c85ec324a 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -908,6 +908,7 @@ "profile__sign_out" = "Desligar"; "profile__sign_out_title" = "Desligar perfil"; "profile__sign_out_description" = "Isto vai desligar o seu perfil Pubky da Bitkit. Pode voltar a ligá-lo a qualquer momento."; +"profile__disconnect_local_description" = "Remove o acesso deste dispositivo e conclui a limpeza local. Isto não faz mais alterações ao teu perfil, às subscrições ou ao acesso no servidor."; "profile__choice_title" = "Entre na\nweb da liberdade"; "profile__choice_description" = "Crie um novo pubky e perfil na Bitkit."; "profile__choice_description_ring" = "Use um pubky existente do Pubky Ring."; @@ -944,13 +945,13 @@ "profile__delete_description" = "Eliminar o seu perfil remove-o do Pubky. Os contactos podem manter as informações de perfil e pagamento partilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; "profile__delete_error_title" = "Não foi possível eliminar o perfil"; -"profile__delete_error_description" = "Não foi possível eliminar os dados do seu perfil. Tente novamente ou desligue este pubky da Bitkit."; +"profile__delete_error_description" = "A limpeza do perfil foi interrompida. As alterações remotas podem já ter sido aplicadas. Desliga o perfil para concluir a limpeza neste dispositivo sem repetir a eliminação remota."; "profile__delete_label" = "Eliminar perfil"; "profile__unlink_title" = "Desassociar pubky?"; "profile__unlink_description" = "Isto vai eliminar os seus dados Paykit e desassociar este pubky da Bitkit. Continuará disponível no Pubky Ring."; "profile__unlink_confirm" = "Sim, desassociar"; "profile__unlink_error_title" = "Não foi possível desassociar o pubky"; -"profile__unlink_error_description" = "Não foi possível eliminar os seus dados Paykit. Tente novamente ou desligue este pubky da Bitkit."; +"profile__unlink_error_description" = "A limpeza do perfil foi interrompida. As alterações remotas podem já ter sido aplicadas. Desliga o perfil para concluir a limpeza neste dispositivo sem repetir a eliminação remota."; "profile__unlink_label" = "Desassociar pubky"; "profile__edit" = "Editar"; "profile__pay_contacts_nav_title" = "Pagar a contactos"; @@ -1547,4 +1548,14 @@ "subscriptions__allowance_limit_description" = "Um pedido de {amount} de {name} excede a sua pré-aprovação. Reveja-o para pagar."; "subscriptions__allowance_auto_paid" = "Pago automaticamente"; +"profile__recovery_title" = "Concluir limpeza local"; + +"profile__recovery_pending" = "A limpeza do perfil foi interrompida. As alterações remotas podem já ter sido aplicadas. Desliga o perfil para concluir a limpeza neste dispositivo sem repetir a eliminação remota."; + +"profile__recovery_wipe" = "A reposição da carteira foi interrompida. Conclui-a antes de utilizares esta carteira."; + +"profile__recovery_unavailable" = "O Bitkit não consegue ler o estado guardado da limpeza. As alterações ao perfil estão em pausa. Tenta lê-lo novamente."; + +"profile__recovery_working" = "A concluir a limpeza do perfil…"; + "wallet__balance_updating" = "A atualizar o saldo…"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 8124db956..317b02e0a 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1133,6 +1133,7 @@ "profile__sign_out" = "Отключить"; "profile__sign_out_title" = "Отключить Профиль"; "profile__sign_out_description" = "Ваш профиль Pubky будет отключён от Bitkit. Вы можете переподключиться в любое время."; +"profile__disconnect_local_description" = "Удалите доступ этого устройства и завершите локальную очистку. Это не вносит дополнительных изменений в ваш профиль, подписки или доступ на сервере."; "profile__choice_title" = "Войдите в\nсвободный веб"; "profile__choice_description" = "Создайте новый pubky и профиль в Bitkit."; "profile__choice_description_ring" = "Используйте существующий pubky из Pubky Ring."; @@ -1169,13 +1170,13 @@ "profile__delete_description" = "Удаление профиля удалит его из Pubky. У контактов могут остаться данные профиля и платёжные данные, которыми вы делились ранее."; "profile__delete_confirm" = "Да, Удалить"; "profile__delete_error_title" = "Не Удалось Удалить Профиль"; -"profile__delete_error_description" = "Не удалось удалить данные вашего профиля. Повторите попытку или отключите этот pubky от Bitkit."; +"profile__delete_error_description" = "Очистка профиля была прервана. Удалённые изменения могли уже примениться. Отключите профиль, чтобы завершить очистку на этом устройстве без повторного удалённого удаления."; "profile__delete_label" = "Удалить Профиль"; "profile__unlink_title" = "Отвязать pubky?"; "profile__unlink_description" = "Ваши данные Paykit будут удалены, а этот pubky будет отвязан от Bitkit. Он останется доступен в Pubky Ring."; "profile__unlink_confirm" = "Да, Отвязать"; "profile__unlink_error_title" = "Не Удалось Отвязать pubky"; -"profile__unlink_error_description" = "Не удалось удалить ваши данные Paykit. Повторите попытку или отключите этот pubky от Bitkit."; +"profile__unlink_error_description" = "Очистка профиля была прервана. Удалённые изменения могли уже примениться. Отключите профиль, чтобы завершить очистку на этом устройстве без повторного удалённого удаления."; "profile__unlink_label" = "Отвязать pubky"; "profile__edit" = "Редактировать"; "profile__pay_contacts_nav_title" = "Платежи Контактам"; @@ -1881,4 +1882,14 @@ "subscriptions__allowance_limit_description" = "Запрос на {amount} от {name} превышает ваш лимит автооплаты. Проверьте его, чтобы оплатить."; "subscriptions__allowance_auto_paid" = "Оплачено автоматически"; +"profile__recovery_title" = "Завершить локальную очистку"; + +"profile__recovery_pending" = "Очистка профиля была прервана. Удалённые изменения могли уже примениться. Отключите профиль, чтобы завершить очистку на этом устройстве без повторного удалённого удаления."; + +"profile__recovery_wipe" = "Сброс кошелька был прерван. Завершите его перед использованием кошелька."; + +"profile__recovery_unavailable" = "Bitkit не может прочитать сохранённое состояние очистки. Изменения профиля приостановлены. Попробуйте прочитать его снова."; + +"profile__recovery_working" = "Завершение очистки профиля…"; + "wallet__balance_updating" = "Обновление баланса…"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index ff21c2c0b..8dd9ae321 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -7,8 +7,21 @@ import VssRustClientFfi // MARK: - BackupService enum BackupRestoreFailurePolicy { - static func isFatal(_ category: BackupCategory) -> Bool { - category == .wallet + static func isFatal(_ category: BackupCategory, error: Error? = nil) -> Bool { + if category == .wallet { + return true + } + var current = error + for _ in 0 ..< 8 { + if current is CancellationError || current is ProfileRemovalStore + .StoreError || current is ProfileSessionRestoreLocalError || current is PubkyCredentialLocalError + { + return true + } + guard let wrapped = current as? AppError else { break } + current = wrapped.underlyingError + } + return false } } @@ -112,7 +125,7 @@ class BackupService { } private let vssBackupClient = VssBackupClient.shared - private let walletRestoreGate = WalletBackupRestoreGate.keychain + private let walletRestoreGate: WalletBackupRestoreGate private let walletBackupWriter = WalletBackupWriter() private let usdtBackupWriter = WalletBackupWriter() private let usdtRecoveryBackup: UsdtRecoveryBackupStore @@ -131,6 +144,7 @@ class BackupService { private var backupFailureNotificationGate = BackupFailureNotificationGate() private let defaults: UserDefaults + private let profileAdmission: ProfileOperationAdmission private let backupData: ((BackupCategory) async throws -> Data)? private let uploadBackup: (String, Data) async throws -> Void private let waitForBackupDelay: () async throws -> Void @@ -160,6 +174,8 @@ class BackupService { init( defaults: UserDefaults = .standard, + profileAdmission: ProfileOperationAdmission = .shared, + walletRestoreGate: WalletBackupRestoreGate = .keychain, backupData: ((BackupCategory) async throws -> Data)? = nil, usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(), uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in @@ -176,6 +192,8 @@ class BackupService { } ) { self.defaults = defaults + self.profileAdmission = profileAdmission + self.walletRestoreGate = walletRestoreGate self.backupData = backupData self.usdtRecoveryBackup = usdtRecoveryBackup self.uploadBackup = uploadBackup @@ -456,7 +474,9 @@ class BackupService { } else if error is WalletBackupSnapshotError { outcome = .snapshotChanged Logger.warn("Wallet backup deferred after three changing snapshots", context: "BackupService") - } else if Self.isTemporaryBackupFailure(error) { + } else if Self.isTemporaryBackupFailure(error) || + ((category == .wallet || category == .metadata) && profileAdmission.isTransientBackupCaptureFailure(error)) + { outcome = .temporarilyUnavailable Logger.warn("Backup temporarily unavailable for: '\(category.rawValue)'", context: "BackupService") } else { @@ -593,6 +613,12 @@ class BackupService { /// Performs full restore from latest backup func performFullRestoreFromLatestBackup() async throws { + try await profileAdmission.withWalletRestore { + try await restoreLatestBackup() + } + } + + private func restoreLatestBackup() async throws { if UsdtRecoveryRestoreGate.isPending, try !walletRestoreGate.hasPendingRestore() { try await restoreUsdtRecoveryAfterLegacyMigration() return @@ -650,6 +676,12 @@ class BackupService { } func restoreUsdtRecoveryAfterLegacyMigration() async throws { + try await profileAdmission.withWalletRestore { + try await restoreMigratedUsdtRecovery() + } + } + + private func restoreMigratedUsdtRecovery() async throws { let retained = try UsdtRecoveryRestoreGate.begin() await usdtWallet?.pauseForRecoveryRestore() await usdtBackupWriter.waitUntilIdle() @@ -763,12 +795,11 @@ class BackupService { categoriesNeedingRewrite.insert(.metadata) } - do { + try await Self.restoreProfileMetadata { try await PubkyProfileManager.restoreSessionBackupState(payload.pubkySession) - } catch { - Logger.warn("Failed to restore pubky session backup state: \(error)", context: "BackupService") + } installOverrides: { + ContactsManager.restoreContactProfileOverrides(payload.pubkyContactProfileOverrides) } - ContactsManager.restoreContactProfileOverrides(payload.pubkyContactProfileOverrides) // App-owned, so it takes no part in the core field migration above and never sets // needsRewrite. Restored names wait as pending ones until each wallet is paired again. @@ -1301,9 +1332,11 @@ class BackupService { case .metadata: let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) + let profileGeneration = try ProfileOperationAdmission.shared.snapshot() let cache = await SettingsViewModel.shared.getAppCacheData() - let pubkySession = try PubkyProfileManager.snapshotSessionBackupState() - let pubkyContactProfileOverrides = ContactsManager.backupContactProfileOverrides() + let (pubkySession, pubkyContactProfileOverrides) = try ProfileOperationAdmission.shared.withSynchronousOperation(access: .local) { + try (PubkyProfileManager.snapshotSessionBackupState(), ContactsManager.backupContactProfileOverrides()) + } // Neither read may fall back to an empty list: this envelope is the only copy of the // tags, so uploading a partial payload would replace the stored ones. Throwing here @@ -1327,6 +1360,7 @@ class BackupService { // envelope this app writes stays byte-comparable with one bitkit-android writes. let hwWalletNames = HwKnownDeviceStorage.backupSnapshot() + try ProfileOperationAdmission.shared.validate(profileGeneration) let payload = MetadataBackupV1( version: 1, createdAt: currentTime, @@ -1393,7 +1427,22 @@ class BackupService { } } - private func performRestore( + static func restoreProfileMetadata(restoreSession: () async throws -> Void, installOverrides: () -> Void) async throws { + try await ProfileOperationAdmission.shared.withSessionTransition { + do { try await restoreSession() } + catch { + // Failed local credential replacement must keep the required wallet restore pending. + if BackupRestoreFailurePolicy.isFatal(.metadata, error: error) { + throw error + } + Logger.warn("Failed to restore pubky session backup state: \(error)", context: "BackupService") + } + try ProfileOperationAdmission.shared.checkCurrent() + installOverrides() + } + } + + func performRestore( category: BackupCategory, retainedData: Data? = nil, restoreAction: (Data) async throws -> Void @@ -1415,7 +1464,7 @@ class BackupService { // Only a total VSS failure surfaces otherwise, so without the error every category // fails silently. The benign "nothing backed up yet" case takes the branch above. Logger.warn("Restore error for: '\(category.rawValue)': \(error)", context: "BackupService") - if BackupRestoreFailurePolicy.isFatal(category) { + if BackupRestoreFailurePolicy.isFatal(category, error: error) { throw error } } diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 18eb556e2..87f2f6cc1 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -12,6 +12,13 @@ enum ContactPaymentsService { @MainActor private static var latestChange = 0 @MainActor private static var latestPublicChange = 0 + @MainActor + private static func ownedChange(_ isCurrent: @escaping ChangeCheck) throws -> ChangeCheck { + guard let scope = ProfileOperationAdmission.current else { throw ProfileRemovalStore.StoreError.changed } + try scope.admission.validate(scope) + return { (try? scope.admission.validate(scope)) != nil && isCurrent() } + } + struct Operations { let syncPublicEndpoints: (_ publish: Bool, _ isChangeCurrent: @escaping ChangeCheck) async throws -> Void let syncPrivateCapability: (_ enabled: Bool, _ isChangeCurrent: @escaping ChangeCheck) async throws -> Void @@ -88,51 +95,55 @@ enum ContactPaymentsService { defaults: UserDefaults = .standard, privatePaymentAccess: (String) throws -> Bool = { try PubkyProfileManager.hasPrivatePaymentAccess(for: $0) } ) async throws -> Bool { - latestChange += 1 - let change = latestChange - guard let session = pubkyProfile.currentSession else { return false } - let isChangeCurrent: ChangeCheck = { Self.latestChange == change && pubkyProfile.currentSession == session } - try Task.checkCancellation() - let canUsePrivatePayments = enabled ? try privatePaymentAccess(session.publicKey) : false - try Task.checkCancellation() - if enabled, canUsePrivatePayments { + return try await ProfileOperationAdmission.shared.withOperation { + latestChange += 1 + let change = latestChange + guard let session = pubkyProfile.currentSession else { return false } + let isChangeCurrent: ChangeCheck = { Self.latestChange == change && pubkyProfile.currentSession == session } + try Task.checkCancellation() + let canUsePrivatePayments = enabled ? try privatePaymentAccess(session.publicKey) : false + try Task.checkCancellation() + if enabled, canUsePrivatePayments { + do { + try await contactsManager.loadContactsIfNeeded(for: session.publicKey) + } catch { + try ProfileOperationAdmission.shared.checkCurrent() + guard isChangeCurrent() else { return false } + throw error + } + } + guard isChangeCurrent() else { return false } + + var currentOperations = operations + currentOperations.preparePrivateEndpoints = { @MainActor _, immediate, isCurrent in + while !Task.isCancelled, isCurrent() { + let snapshot = contactsManager.savedContactsSnapshot() + let error = await operations.preparePrivateEndpoints(snapshot.publicKeys, immediate) { + isCurrent() && snapshot.isCurrent() + } + if snapshot.isCurrent() { + return error + } + } + return Task.isCancelled && isCurrent() ? CancellationError() : nil + } + do { - try await contactsManager.loadContactsIfNeeded(for: session.publicKey) + try await setEnabled( + enabled, + contactPublicKeys: contactsManager.contacts.map(\.publicKey), + canUsePrivatePayments: canUsePrivatePayments, + operations: currentOperations, + defaults: defaults, + isChangeCurrent: isChangeCurrent + ) } catch { + try ProfileOperationAdmission.shared.checkCurrent() guard isChangeCurrent() else { return false } throw error } + return isChangeCurrent() } - guard isChangeCurrent() else { return false } - - var currentOperations = operations - currentOperations.preparePrivateEndpoints = { @MainActor _, immediate, isCurrent in - while !Task.isCancelled, isCurrent() { - let snapshot = contactsManager.savedContactsSnapshot() - let error = await operations.preparePrivateEndpoints(snapshot.publicKeys, immediate) { - isCurrent() && snapshot.isCurrent() - } - if snapshot.isCurrent() { - return error - } - } - return Task.isCancelled && isCurrent() ? CancellationError() : nil - } - - do { - try await setEnabled( - enabled, - contactPublicKeys: contactsManager.contacts.map(\.publicKey), - canUsePrivatePayments: canUsePrivatePayments, - operations: currentOperations, - defaults: defaults, - isChangeCurrent: isChangeCurrent - ) - } catch { - guard isChangeCurrent() else { return false } - throw error - } - return isChangeCurrent() } /// Serializes sharing changes. Endpoint operations recheck ownership under their publication locks. @@ -145,46 +156,60 @@ enum ContactPaymentsService { defaults: UserDefaults = .standard, isChangeCurrent: @escaping ChangeCheck = { true } ) async throws { - await acquireOperation() - defer { releaseOperation() } - try Task.checkCancellation() - guard isChangeCurrent() else { return } + let callerIsCurrent = isChangeCurrent + return try await ProfileOperationAdmission.shared.withOperation { + let isChangeCurrent = try ownedChange(callerIsCurrent) + await acquireOperation() + defer { releaseOperation() } + try ProfileOperationAdmission.shared.checkCurrent() + try Task.checkCancellation() + guard isChangeCurrent() else { return } - if enabled, !canUsePrivatePayments { - throw PrivatePaykitError.privateUnavailable - } + if enabled, !canUsePrivatePayments { + throw PrivatePaykitError.privateUnavailable + } - enableAllPaymentOptions(defaults: defaults) + enableAllPaymentOptions(defaults: defaults) - if !enabled { - if let error = await disable(operations: operations, defaults: defaults, isChangeCurrent: isChangeCurrent) { - throw error + if !enabled { + if let error = await disable(operations: operations, defaults: defaults, isChangeCurrent: isChangeCurrent) { + throw error + } + return } - return - } - let previousState = StoredState( - sharesPrivateEndpoints: defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), - hasConfirmedPreference: defaults.bool(forKey: confirmedPreferenceKey), - privateCleanupPending: defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) - ) - - do { - try await enable( - contactPublicKeys: contactPublicKeys, - operations: operations, - defaults: defaults, - isChangeCurrent: isChangeCurrent + let previousState = StoredState( + sharesPrivateEndpoints: defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), + hasConfirmedPreference: defaults.bool(forKey: confirmedPreferenceKey), + privateCleanupPending: defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) ) - } catch { - await restore( - previousState, - contactPublicKeys: contactPublicKeys, - operations: operations, - defaults: defaults, - isChangeCurrent: isChangeCurrent - ) - throw error + + do { + try await enable( + contactPublicKeys: contactPublicKeys, + operations: operations, + defaults: defaults, + isChangeCurrent: isChangeCurrent + ) + } catch { + if Task.isCancelled, let scope = ProfileOperationAdmission.current, + (try? scope.admission.validateCancelledPreference(scope)) != nil, callerIsCurrent() + { + defaults.set(previousState.sharesPrivateEndpoints && canUsePrivatePayments, forKey: PrivatePaykitService.publishingEnabledKey) + defaults.set(previousState.hasConfirmedPreference, forKey: confirmedPreferenceKey) + operations.setPublicCleanupPending(true) + operations.setPrivateCleanupPending(true) + throw error + } + await restore( + previousState, + contactPublicKeys: contactPublicKeys, + operations: operations, + defaults: defaults, + isChangeCurrent: isChangeCurrent + ) + throw error + } } } @@ -199,46 +224,57 @@ enum ContactPaymentsService { ) async throws -> Bool { latestPublicChange += 1 let change = latestPublicChange - guard let session = pubkyProfile.currentSession else { return false } - let isChangeCurrent: ChangeCheck = { Self.latestPublicChange == change && pubkyProfile.currentSession == session } - await acquireOperation() - defer { releaseOperation() } - try Task.checkCancellation() - guard isChangeCurrent() else { return false } - - let wasEnabled = defaults.bool(forKey: PublicPaykitService.publishingEnabledKey) - let wasCleanupPending = defaults.bool(forKey: PublicPaykitService.cleanupPendingKey) - defaults.set(enabled, forKey: PublicPaykitService.publishingEnabledKey) - operations.setPublicCleanupPending(true) - do { - try await operations.syncPublicEndpoints(enabled, isChangeCurrent) + return try await ProfileOperationAdmission.shared.withOperation { + guard let session = pubkyProfile.currentSession else { return false } + let callerIsCurrent: ChangeCheck = { Self.latestPublicChange == change && pubkyProfile.currentSession == session } + let isChangeCurrent = try ownedChange(callerIsCurrent) + await acquireOperation() + defer { releaseOperation() } + try ProfileOperationAdmission.shared.checkCurrent() try Task.checkCancellation() guard isChangeCurrent() else { return false } - if !enabled { - operations.setPublicCleanupPending(false) - } - } catch { - guard isChangeCurrent() else { return false } - if enabled { - defaults.set(wasEnabled, forKey: PublicPaykitService.publishingEnabledKey) - operations.setPublicCleanupPending(true) - do { - try await operations.syncPublicEndpoints(wasEnabled, isChangeCurrent) - try Task.checkCancellation() - if !wasEnabled, isChangeCurrent() { - operations.setPublicCleanupPending(wasCleanupPending) - } - } catch { - if isChangeCurrent() { - operations.setPublicCleanupPending(true) - Logger.warn("Failed to restore public payments: \(error)", context: "ContactPaymentsService") + + let wasEnabled = defaults.bool(forKey: PublicPaykitService.publishingEnabledKey) + let wasCleanupPending = defaults.bool(forKey: PublicPaykitService.cleanupPendingKey) + defaults.set(enabled, forKey: PublicPaykitService.publishingEnabledKey) + operations.setPublicCleanupPending(true) + do { + try await operations.syncPublicEndpoints(enabled, isChangeCurrent) + try Task.checkCancellation() + guard isChangeCurrent() else { return false } + if !enabled { + operations.setPublicCleanupPending(false) + } + } catch { + if Task.isCancelled, let scope = ProfileOperationAdmission.current, + (try? scope.admission.validateCancelledPreference(scope)) != nil, callerIsCurrent() + { + defaults.set(wasEnabled, forKey: PublicPaykitService.publishingEnabledKey) + operations.setPublicCleanupPending(true) + throw error + } + guard isChangeCurrent() else { return false } + if enabled { + defaults.set(wasEnabled, forKey: PublicPaykitService.publishingEnabledKey) + operations.setPublicCleanupPending(true) + do { + try await operations.syncPublicEndpoints(wasEnabled, isChangeCurrent) + try Task.checkCancellation() + if !wasEnabled, isChangeCurrent() { + operations.setPublicCleanupPending(wasCleanupPending) + } + } catch { + if isChangeCurrent() { + operations.setPublicCleanupPending(true) + Logger.warn("Failed to restore public payments: \(error)", context: "ContactPaymentsService") + } } } + guard isChangeCurrent() else { return false } + throw error } - guard isChangeCurrent() else { return false } - throw error + return isChangeCurrent() } - return isChangeCurrent() } @MainActor @@ -247,87 +283,98 @@ enum ContactPaymentsService { operations: Operations, defaults: UserDefaults = .standard ) async throws -> Bool { - latestChange += 1 - latestPublicChange += 1 - let change = latestChange - let publicChange = latestPublicChange - let session = pubkyProfile.currentSession - let isChangeCurrent: ChangeCheck = { - Self.latestChange == change && Self.latestPublicChange == publicChange && - pubkyProfile.currentSession == session && !defaults.bool(forKey: PaykitFeatureFlags.uiEnabledKey) - } - let hadPublicState = PaykitFeatureFlags.hasPublicPublishedState(defaults: defaults) || - defaults.bool(forKey: PublicPaykitService.cleanupPendingKey) - let hadPrivateState = PaykitFeatureFlags.hasPrivatePublishedState(defaults: defaults) || - defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) - - defaults.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) - defaults.set(false, forKey: confirmedPreferenceKey) - defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) - defaults.removeObject(forKey: "publicPaykitBolt11") - defaults.removeObject(forKey: "publicPaykitBolt11PaymentHash") - defaults.removeObject(forKey: "publicPaykitBolt11ExpiresAt") - if hadPublicState { - operations.setPublicCleanupPending(true) - } - if hadPrivateState { - operations.setPrivateCleanupPending(true) - } + return try await ProfileOperationAdmission.shared.withOperation { + latestChange += 1 + latestPublicChange += 1 + let change = latestChange + let publicChange = latestPublicChange + let session = pubkyProfile.currentSession + let isChangeCurrent = try ownedChange { + Self.latestChange == change && Self.latestPublicChange == publicChange && + pubkyProfile.currentSession == session && !defaults.bool(forKey: PaykitFeatureFlags.uiEnabledKey) + } + let hadPublicState = PaykitFeatureFlags.hasPublicPublishedState(defaults: defaults) || + defaults.bool(forKey: PublicPaykitService.cleanupPendingKey) + let hadPrivateState = PaykitFeatureFlags.hasPrivatePublishedState(defaults: defaults) || + defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) + + defaults.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) + defaults.set(false, forKey: confirmedPreferenceKey) + defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) + defaults.removeObject(forKey: "publicPaykitBolt11") + defaults.removeObject(forKey: "publicPaykitBolt11PaymentHash") + defaults.removeObject(forKey: "publicPaykitBolt11ExpiresAt") + if hadPublicState { + operations.setPublicCleanupPending(true) + } + if hadPrivateState { + operations.setPrivateCleanupPending(true) + } - await acquireOperation() - defer { releaseOperation() } - try Task.checkCancellation() - guard isChangeCurrent() else { return false } + await acquireOperation() + defer { releaseOperation() } + try ProfileOperationAdmission.shared.checkCurrent() + try Task.checkCancellation() + guard isChangeCurrent() else { return false } - var cleanupError: Error? - if hadPublicState || defaults.bool(forKey: PublicPaykitService.cleanupPendingKey) { - operations.setPublicCleanupPending(true) - do { - try await operations.syncPublicEndpoints(false, isChangeCurrent) - try Task.checkCancellation() - guard isChangeCurrent() else { return false } - operations.setPublicCleanupPending(false) - } catch { - try Task.checkCancellation() - if error is CancellationError { - throw error + var cleanupError: Error? + if hadPublicState || defaults.bool(forKey: PublicPaykitService.cleanupPendingKey) { + operations.setPublicCleanupPending(true) + do { + try await operations.syncPublicEndpoints(false, isChangeCurrent) + try Task.checkCancellation() + guard isChangeCurrent() else { return false } + operations.setPublicCleanupPending(false) + } catch { + try Task.checkCancellation() + if error is CancellationError { + throw error + } + guard isChangeCurrent() else { return false } + cleanupError = error } - guard isChangeCurrent() else { return false } - cleanupError = error } - } - if hadPrivateState || defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) { - operations.setPrivateCleanupPending(true) - do { - try await operations.removePrivateEndpoints(isChangeCurrent) - try Task.checkCancellation() - guard isChangeCurrent() else { return false } - operations.setPrivateCleanupPending(false) - } catch { - try Task.checkCancellation() - if error is CancellationError { - throw error + if hadPrivateState || defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) { + operations.setPrivateCleanupPending(true) + do { + try await operations.removePrivateEndpoints(isChangeCurrent) + try Task.checkCancellation() + guard isChangeCurrent() else { return false } + operations.setPrivateCleanupPending(false) + } catch { + try Task.checkCancellation() + if error is CancellationError { + throw error + } + guard isChangeCurrent() else { return false } + cleanupError = cleanupError ?? error } - guard isChangeCurrent() else { return false } - cleanupError = cleanupError ?? error } + if let cleanupError { + throw cleanupError + } + return isChangeCurrent() } - if let cleanupError { - throw cleanupError - } - return isChangeCurrent() } @MainActor static func reconcilePendingEndpoints(reconcilePrivate: () async -> Void = {}, _ reconcile: () async -> Void) async { - guard !isOperationActive, !Task.isCancelled else { return } - isOperationActive = true - defer { releaseOperation() } - await reconcilePrivate() - guard await !PrivatePaykitService.shared.isDeletingProfile, !Task.isCancelled else { return } - await reconcile() + do { + return try await ProfileOperationAdmission.shared.withOperation { + guard !isOperationActive, !Task.isCancelled else { return } + isOperationActive = true + defer { releaseOperation() } + try ProfileOperationAdmission.shared.checkCurrent() + await reconcilePrivate() + guard await !PrivatePaykitService.shared.isDeletingProfile, !Task.isCancelled else { return } + try ProfileOperationAdmission.shared.checkCurrent() + await reconcile() + } + } catch { + Logger.debug("Endpoint reconciliation stopped: \(error)", context: "ContactPayments") + } } @MainActor diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 460eac965..0ec1f50c2 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -41,6 +41,10 @@ enum ReceiveTagMutation { } class ActivityService { + typealias ReceiptContactValidation = @Sendable () -> Bool + typealias ReceiptOperationBody = @Sendable (@escaping ReceiptContactValidation) async throws -> Void + typealias ReceiptOperation = @Sendable (@escaping ReceiptOperationBody) async throws -> Void + private let coreService: CoreService private static let detachedContactsKey = "activityDetachedContacts" @@ -100,13 +104,32 @@ class ActivityService { private var privateInvoiceContactResolver: (@Sendable (String) async -> String?)? private var privateOnchainAddressContactResolver: (@Sendable (String, [String]) async -> String?)? + private var receiptOperation: ReceiptOperation? func setPrivatePaykitContactResolvers( invoice: (@Sendable (String) async -> String?)?, - onchainAddresses: (@Sendable (String, [String]) async -> String?)? + onchainAddresses: (@Sendable (String, [String]) async -> String?)?, + receiptOperation: ReceiptOperation? = nil ) { privateInvoiceContactResolver = invoice privateOnchainAddressContactResolver = onchainAddresses + self.receiptOperation = receiptOperation + } + + func withPaymentSync(_ operation: @escaping @Sendable () async throws -> Void) async throws { + if let receiptOperation { + try await receiptOperation { _ in try await operation() } + } else { + try await operation() + } + } + + private func withReceiptContacts(for payment: PaymentDetails, _ operation: @escaping ReceiptOperationBody) async throws { + if payment.direction == .inbound, let receiptOperation { + try await receiptOperation(operation) + } else { + try await operation { true } + } } // MARK: - Constants @@ -611,6 +634,16 @@ class ActivityService { private func processOnchainPayment( _ payment: PaymentDetails, transactionDetails: BitkitCore.TransactionDetails? = nil + ) async throws { + try await withReceiptContacts(for: payment) { canAttribute in + try await self.storeOnchainPayment(payment, transactionDetails: transactionDetails, canAttribute: canAttribute) + } + } + + private func storeOnchainPayment( + _ payment: PaymentDetails, + transactionDetails: BitkitCore.TransactionDetails?, + canAttribute: @escaping ReceiptContactValidation ) async throws { guard case let .onchain(txid, txStatus) = payment.kind else { return } let paymentTimestamp = payment.latestUpdateTimestamp @@ -710,7 +743,7 @@ class ActivityService { let isTransfer = (existingOnchain?.isTransfer ?? false) || resolvedChannelId != nil let channelId = existingOnchain?.channelId ?? resolvedChannelId let transferTxId = existingOnchain?.transferTxId - var contact = existingOnchain?.contact ?? resolvedContact + var contact = existingOnchain?.contact ?? (canAttribute() ? resolvedContact : nil) let feeRate = existingOnchain?.feeRate ?? 1 let address = resolvedAddress ?? existingOnchain?.address ?? "Loading..." let doesExist = existingOnchain?.doesExist ?? true @@ -800,110 +833,116 @@ class ActivityService { } func handleOnchainTransactionReceived(txid: String, details: LDKNode.TransactionDetails) async throws { - let coreDetails = mapToCoreTransactionDetails(txid: txid, details) + try await withPaymentSync { [self] in + let coreDetails = mapToCoreTransactionDetails(txid: txid, details) - try await ServiceQueue.background(.core) { - try BitkitCore.upsertTransactionDetails(detailsList: [coreDetails]) - try await self.processOnchainTransaction(txid: txid, details: coreDetails, context: "CoreService.handleOnchainTransactionReceived") + try await ServiceQueue.background(.core) { + try BitkitCore.upsertTransactionDetails(detailsList: [coreDetails]) + try await self.processOnchainTransaction(txid: txid, details: coreDetails, context: "CoreService.handleOnchainTransactionReceived") + } } } func handleOnchainTransactionConfirmed(txid: String, details: LDKNode.TransactionDetails) async throws { - let coreDetails = mapToCoreTransactionDetails(txid: txid, details) + try await withPaymentSync { [self] in + let coreDetails = mapToCoreTransactionDetails(txid: txid, details) - try await ServiceQueue.background(.core) { - try BitkitCore.upsertTransactionDetails(detailsList: [coreDetails]) - try await self.processOnchainTransaction(txid: txid, details: coreDetails, context: "CoreService.handleOnchainTransactionConfirmed") + try await ServiceQueue.background(.core) { + try BitkitCore.upsertTransactionDetails(detailsList: [coreDetails]) + try await self.processOnchainTransaction(txid: txid, details: coreDetails, context: "CoreService.handleOnchainTransactionConfirmed") + } } } func handleOnchainTransactionReplaced(txid: String, conflicts: [String]) async throws { - try await ServiceQueue.background(.core) { - // Find the activity for the replaced transaction - let replacedActivity = try await self.getOnchainActivityByTxId(txid: txid) - - if var existing = replacedActivity { - Logger.info( - "Transaction \(txid) replaced by \(conflicts.count) conflict(s): \(conflicts.joined(separator: ", "))", - context: "CoreService.handleOnchainTransactionReplaced" - ) - - // Mark the replaced transaction as not existing - existing.doesExist = false - existing.isBoosted = false - existing.updatedAt = UInt64(Date().timeIntervalSince1970) - try await self.update(id: existing.id, activity: .onchain(existing)) - Logger.info("Marked transaction \(txid) as replaced", context: "CoreService.handleOnchainTransactionReplaced") - } else { - Logger.info( - "Activity not found for replaced transaction \(txid) - will be created when transaction is processed", - context: "CoreService.handleOnchainTransactionReplaced" - ) - } + try await withPaymentSync { [self] in + try await ServiceQueue.background(.core) { + // Find the activity for the replaced transaction + let replacedActivity = try await self.getOnchainActivityByTxId(txid: txid) - // For each replacement transaction, update its boostTxIds to include the replaced txid - for conflictTxid in conflicts { - // Try to get the replacement activity, or process it if it doesn't exist - var replacementActivity = try? await self.getOnchainActivityByTxId(txid: conflictTxid) + if var existing = replacedActivity { + Logger.info( + "Transaction \(txid) replaced by \(conflicts.count) conflict(s): \(conflicts.joined(separator: ", "))", + context: "CoreService.handleOnchainTransactionReplaced" + ) - if replacementActivity == nil, - let payments = await LightningService.shared.listPayments(), - let replacementPayment = payments.first(where: { payment in - if case let .onchain(paymentTxid, _) = payment.kind { - return paymentTxid == conflictTxid - } - return false - }) - { + // Mark the replaced transaction as not existing + existing.doesExist = false + existing.isBoosted = false + existing.updatedAt = UInt64(Date().timeIntervalSince1970) + try await self.update(id: existing.id, activity: .onchain(existing)) + Logger.info("Marked transaction \(txid) as replaced", context: "CoreService.handleOnchainTransactionReplaced") + } else { Logger.info( - "Processing replacement transaction \(conflictTxid) that was already in payments list", + "Activity not found for replaced transaction \(txid) - will be created when transaction is processed", context: "CoreService.handleOnchainTransactionReplaced" ) - do { - try await self.processOnchainPayment(replacementPayment, transactionDetails: nil) - replacementActivity = try? await self.getOnchainActivityByTxId(txid: conflictTxid) - } catch { - Logger.error( - "Failed to process replacement transaction \(conflictTxid): \(error)", - context: "CoreService.handleOnchainTransactionReplaced" - ) - continue - } } - // Update the replacement transaction's boostTxIds to include the replaced txid - if var activity = replacementActivity, - !activity.boostTxIds.contains(txid) - { - activity.boostTxIds.append(txid) - activity.isBoosted = true - activity.contact = activity.contact ?? replacedActivity?.contact - activity.updatedAt = UInt64(Date().timeIntervalSince1970) - try await self.update(id: activity.id, activity: .onchain(activity)) - - // Move tags from the replaced transaction - if let replacedActivity { + // For each replacement transaction, update its boostTxIds to include the replaced txid + for conflictTxid in conflicts { + // Try to get the replacement activity, or process it if it doesn't exist + var replacementActivity = try? await self.getOnchainActivityByTxId(txid: conflictTxid) + + if replacementActivity == nil, + let payments = await LightningService.shared.listPayments(), + let replacementPayment = payments.first(where: { payment in + if case let .onchain(paymentTxid, _) = payment.kind { + return paymentTxid == conflictTxid + } + return false + }) + { + Logger.info( + "Processing replacement transaction \(conflictTxid) that was already in payments list", + context: "CoreService.handleOnchainTransactionReplaced" + ) do { - let replacedTags = try await self.tags(forActivity: replacedActivity.id) - if !replacedTags.isEmpty { - try await self.appendTags(toActivity: activity.id, replacedTags) - } + try await self.processOnchainPayment(replacementPayment, transactionDetails: nil) + replacementActivity = try? await self.getOnchainActivityByTxId(txid: conflictTxid) } catch { Logger.error( - "Failed to copy tags from replaced transaction \(txid) to replacement transaction \(conflictTxid): \(error)", + "Failed to process replacement transaction \(conflictTxid): \(error)", context: "CoreService.handleOnchainTransactionReplaced" ) + continue } } - Logger.info( - "Updated replacement transaction \(conflictTxid) with boostTxId \(txid)", - context: "CoreService.handleOnchainTransactionReplaced" - ) + // Update the replacement transaction's boostTxIds to include the replaced txid + if var activity = replacementActivity, + !activity.boostTxIds.contains(txid) + { + activity.boostTxIds.append(txid) + activity.isBoosted = true + activity.contact = activity.contact ?? replacedActivity?.contact + activity.updatedAt = UInt64(Date().timeIntervalSince1970) + try await self.update(id: activity.id, activity: .onchain(activity)) + + // Move tags from the replaced transaction + if let replacedActivity { + do { + let replacedTags = try await self.tags(forActivity: replacedActivity.id) + if !replacedTags.isEmpty { + try await self.appendTags(toActivity: activity.id, replacedTags) + } + } catch { + Logger.error( + "Failed to copy tags from replaced transaction \(txid) to replacement transaction \(conflictTxid): \(error)", + context: "CoreService.handleOnchainTransactionReplaced" + ) + } + } + + Logger.info( + "Updated replacement transaction \(conflictTxid) with boostTxId \(txid)", + context: "CoreService.handleOnchainTransactionReplaced" + ) + } } - } - self.activitiesChangedSubject.send() + self.activitiesChangedSubject.send() + } } } @@ -940,22 +979,30 @@ class ActivityService { /// Handle a single payment event by processing the specific payment func handlePaymentEvent(paymentHash: String) async throws { - guard let payments = await LightningService.shared.listPayments() else { - Logger.warn("No payments available for hash \(paymentHash)", context: "CoreService.handlePaymentEvent") - return - } + try await withPaymentSync { [self] in + guard let payments = await LightningService.shared.listPayments() else { + Logger.warn("No payments available for hash \(paymentHash)", context: "CoreService.handlePaymentEvent") + return + } - try await ServiceQueue.background(.core) { - if let payment = payments.first(where: { $0.id == paymentHash }) { - try await self.processLightningPayment(payment) - } else { - Logger.info("Payment not found for hash \(paymentHash) - syncing all payments", context: "CoreService.handlePaymentEvent") - try await self.syncLdkNodePayments(payments) + try await ServiceQueue.background(.core) { + if let payment = payments.first(where: { $0.id == paymentHash }) { + try await self.processLightningPayment(payment) + } else { + Logger.info("Payment not found for hash \(paymentHash) - syncing all payments", context: "CoreService.handlePaymentEvent") + try await self.syncLdkNodePayments(payments) + } } } } private func processLightningPayment(_ payment: PaymentDetails) async throws { + try await withReceiptContacts(for: payment) { canAttribute in + try await self.storeLightningPayment(payment, canAttribute: canAttribute) + } + } + + private func storeLightningPayment(_ payment: PaymentDetails, canAttribute: @escaping ReceiptContactValidation) async throws { guard case let .bolt11(_, preimage, _, description, bolt11) = payment.kind else { return } // Skip pending inbound payments - just means they created an invoice @@ -1013,6 +1060,9 @@ class ActivityService { if existingActivity != nil { try await ServiceQueue.background(.core) { var updated = ln + if existingLightning?.contact == nil, !canAttribute() { + updated.contact = nil + } if case let .lightning(latest)? = try getActivityById(walletId: ln.walletId, activityId: payment.id), latest.contact != existingLightning?.contact { @@ -1025,7 +1075,14 @@ class ActivityService { self.activitiesChangedSubject.send() } } else { - try await upsert(.lightning(ln)) + try await ServiceQueue.background(.core) { + var updated = ln + if !canAttribute() || self.isContactDetached(activityId: payment.id, walletId: ln.walletId) { + updated.contact = nil + } + try upsertActivity(activity: .lightning(updated)) + self.activitiesChangedSubject.send() + } } } @@ -1038,48 +1095,50 @@ class ActivityService { /// Use for initial wallet load, manual refresh, or after operations that create new payments. /// Events handle individual payment updates, so this should not be called on every event. func syncLdkNodePayments(_ payments: [PaymentDetails]) async throws { - try await ServiceQueue.background(.core) { - var addedCount = 0 - var updatedCount = 0 - var latestCaughtError: Error? + try await withPaymentSync { [self] in + try await ServiceQueue.background(.core) { + var addedCount = 0 + var updatedCount = 0 + var latestCaughtError: Error? - for payment in payments { - if case let .onchain(txid, _) = payment.kind { - do { - let hadExistingActivity = try getActivityById(walletId: WalletScope.default, activityId: payment.id) != nil - try await self.processOnchainPayment(payment, transactionDetails: nil) - if hadExistingActivity { - updatedCount += 1 - } else { - addedCount += 1 + for payment in payments { + if case let .onchain(txid, _) = payment.kind { + do { + let hadExistingActivity = try getActivityById(walletId: WalletScope.default, activityId: payment.id) != nil + try await self.processOnchainPayment(payment, transactionDetails: nil) + if hadExistingActivity { + updatedCount += 1 + } else { + addedCount += 1 + } + } catch { + Logger.error("Error processing onchain payment \(txid): \(error)", context: "CoreService.syncLdkNodePayments") + latestCaughtError = error } - } catch { - Logger.error("Error processing onchain payment \(txid): \(error)", context: "CoreService.syncLdkNodePayments") - latestCaughtError = error - } - } else if case .bolt11 = payment.kind { - do { - let hadExistingActivity = try getActivityById(walletId: WalletScope.default, activityId: payment.id) != nil - try await self.processLightningPayment(payment) - if hadExistingActivity { - updatedCount += 1 - } else { - addedCount += 1 + } else if case .bolt11 = payment.kind { + do { + let hadExistingActivity = try getActivityById(walletId: WalletScope.default, activityId: payment.id) != nil + try await self.processLightningPayment(payment) + if hadExistingActivity { + updatedCount += 1 + } else { + addedCount += 1 + } + } catch { + Logger.error("Error processing lightning payment \(payment.id): \(error)", context: "CoreService.syncLdkNodePayments") + latestCaughtError = error } - } catch { - Logger.error("Error processing lightning payment \(payment.id): \(error)", context: "CoreService.syncLdkNodePayments") - latestCaughtError = error } } - } - // If any of the inserts failed, we want to throw the error up - if let error = latestCaughtError { - throw error - } + // If any of the inserts failed, we want to throw the error up + if let error = latestCaughtError { + throw error + } - Logger.info("Synced LDK payments - Added: \(addedCount) - Updated: \(updatedCount)", context: "CoreService") - self.activitiesChangedSubject.send() + Logger.info("Synced LDK payments - Added: \(addedCount) - Updated: \(updatedCount)", context: "CoreService") + self.activitiesChangedSubject.send() + } } } diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 46ba8c07f..dfcf51199 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -2050,7 +2050,17 @@ extension MigrationsService { } } - func restoreFromRNRemoteBackup(mnemonic: String, passphrase: String?) async throws { + func restoreFromRNRemoteBackup( + mnemonic: String, + passphrase: String?, + withWalletRestore: (_ operation: () async throws -> Void) async throws -> Void + ) async throws { + try await withWalletRestore { + try await restoreRNRemoteBackup(mnemonic: mnemonic, passphrase: passphrase) + } + } + + private func restoreRNRemoteBackup(mnemonic: String, passphrase: String?) async throws { let effectivePassphrase = normalizePassphrase(passphrase) try await RNBackupClient.shared.setup(mnemonic: mnemonic, passphrase: effectivePassphrase) diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index 1c04f6c52..fb999c73a 100644 --- a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -12,7 +12,7 @@ actor PrivatePaykitAddressReservationStore { walletBackupDataChangedSubject.eraseToAnyPublisher() } - private static let defaultsKey = "privatePaykitAddressReservations" + static let defaultsKey = "privatePaykitAddressReservations" private static let schemaVersion = 1 static let legacyPublicAddressesKey = "publicPaykitLegacyAddresses" @@ -34,6 +34,7 @@ actor PrivatePaykitAddressReservationStore { private let operations: AddressOperations private nonisolated let publicAssignmentRevision = OSAllocatedUnfairLock(initialState: UUID()) private(set) var attributionRevision: UInt64 = 0 + private var storedLedgerWasUnreadable = false private var ledger: Ledger { didSet { attributionRevision &+= 1 @@ -68,11 +69,12 @@ actor PrivatePaykitAddressReservationStore { self.operations = operations if let data = defaults.data(forKey: Self.defaultsKey), - let decoded = Self.decodeStoredLedger(data) + let decoded = Self.decodeStoredLedger(data), decoded.version == Self.schemaVersion { ledger = decoded } else { ledger = .empty + storedLedgerWasUnreadable = defaults.object(forKey: Self.defaultsKey) != nil } } @@ -93,6 +95,7 @@ actor PrivatePaykitAddressReservationStore { } func restoreBackup(_ highestReservedReceiveIndexByAddressType: [String: UInt32]?, legacyPublicAddresses: [String]? = nil) { + storedLedgerWasUnreadable = false let legacyAddresses = Set(legacyPublicAddressesSnapshot() ?? []).union(legacyPublicAddresses ?? []) defaults.set(legacyAddresses.sorted(), forKey: Self.legacyPublicAddressesKey) var ceilings = self.highestReservedReceiveIndexByAddressType() @@ -126,8 +129,34 @@ actor PrivatePaykitAddressReservationStore { } func contactPublicKeyForAttribution(forReservedAddress address: String) async throws -> String? { + return try await ProfileOperationAdmission.shared.withOperation(access: .local) { + try await contactPublicKey(for: address, assignments: contactAssignmentsForAttribution()) + } + } + + func contactsForReceipt( + _ contacts: PaykitReceivedPaymentContacts, + outputAddresses: [String], + expectedRevision: UInt64 + ) async throws -> PaykitReceivedPaymentContacts { + try ProfileOperationAdmission.shared.checkReceiptAttribution() + guard attributionRevision == expectedRevision else { throw ProfileRemovalStore.StoreError.changed } + // Keep all outputs on the same snapshot, including when profile cleanup runs during derivation. + let assignments = contactAssignmentsForAttribution() + // Pending cleanup may have erased a reservation that conflicts with a retained request. + if !contacts.isEmpty, assignments.isEmpty { + _ = try ProfileOperationAdmission.shared.snapshot() + } + let result = try await contacts.includingReservations(for: outputAddresses) { address in + try await self.contactPublicKey(for: address, assignments: assignments) + } + try ProfileOperationAdmission.shared.checkReceiptAttribution() + return result + } + + private func contactPublicKey(for address: String, assignments: [(publicKey: String, assignment: StoredAssignment)]) async throws -> String? { guard !address.isEmpty else { return nil } - for (publicKey, assignment) in contactAssignmentsForAttribution() { + for (publicKey, assignment) in assignments { guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), addressType.matchesAddressFormat(address, network: Env.network) else { continue } @@ -155,28 +184,30 @@ actor PrivatePaykitAddressReservationStore { } func currentOrRotatedAddress(for publicKey: String) async throws -> String { - let assignmentKey = try Self.normalizedPublicKey(publicKey) - if let existing = try await reservedAddressDetails(forAssignmentKey: assignmentKey) { - guard isAddressTypeMonitored(existing.addressType) else { - clearCurrentContactAssignment(assignmentKey: assignmentKey) - return try await allocateAddress(forAssignmentKey: assignmentKey) - } + return try await ProfileOperationAdmission.shared.withOperation { + let assignmentKey = try Self.normalizedPublicKey(publicKey) + if let existing = try await reservedAddressDetails(forAssignmentKey: assignmentKey) { + guard isAddressTypeMonitored(existing.addressType) else { + clearCurrentContactAssignment(assignmentKey: assignmentKey) + return try await allocateAddress(forAssignmentKey: assignmentKey) + } - do { - let isUsed = try await operations.isUsed(existing.address) - if !isUsed { - return existing.address + do { + let isUsed = try await operations.isUsed(existing.address) + if !isUsed { + return existing.address + } + } catch { + Logger.warn( + "Unable to verify private Paykit address usage; skipping private address publication: \(error)", + context: "PrivatePaykit" + ) + throw error } - } catch { - Logger.warn( - "Unable to verify private Paykit address usage; skipping private address publication: \(error)", - context: "PrivatePaykit" - ) - throw error } - } - return try await allocateAddress(forAssignmentKey: assignmentKey) + return try await allocateAddress(forAssignmentKey: assignmentKey) + } } func currentOrRotatedPublicAddress(for publicKey: String) async throws -> String { @@ -184,41 +215,46 @@ actor PrivatePaykitAddressReservationStore { } func currentOrRotatedPublicAddressSnapshot(for publicKey: String) async throws -> PublicAddressSnapshot { - let key = try Self.normalizedPublicKey(publicKey) - let revision = publicAssignmentRevision.withLock { $0 } - if let assignment = ledger.publicAssignments?[key], - let addressType = LDKNode.AddressType.from(string: assignment.addressType), - isAddressTypeMonitored(addressType) - { - let address = try await address(for: addressType, receiveIndex: assignment.receiveIndex) - if try await !operations.isUsed(address) { - return PublicAddressSnapshot(address: address, publicKey: key, revision: revision, revisionState: publicAssignmentRevision) + try await ProfileOperationAdmission.shared.withOperation { + let key = try Self.normalizedPublicKey(publicKey) + let revision = publicAssignmentRevision.withLock { $0 } + if let assignment = ledger.publicAssignments?[key], + let addressType = LDKNode.AddressType.from(string: assignment.addressType), + isAddressTypeMonitored(addressType) + { + let address = try await address(for: addressType, receiveIndex: assignment.receiveIndex) + if try await !operations.isUsed(address) { + return PublicAddressSnapshot(address: address, publicKey: key, revision: revision, revisionState: publicAssignmentRevision) + } } - } - let addressType = LDKNode.AddressType.fromStorage(defaults.string(forKey: "selectedAddressType")) - let receiveAddress = reusableOnchainAddress - try await prepareReusableReceive(addressType: addressType) - var info = try await nextAvailableAddressInfo(addressType: addressType) - if info.address == receiveAddress || info.address == reusableOnchainAddress { - try await reconcileAddressTypeWithLdk(addressType, highestReserved: info.index) - let previousIndex = info.index - info = try await nextAvailableAddressInfo(addressType: addressType) - guard info.index > previousIndex, info.address != receiveAddress, info.address != reusableOnchainAddress else { - throw AppError(message: "Unable to reserve public payment address", debugMessage: nil) + let addressType = LDKNode.AddressType.fromStorage(defaults.string(forKey: "selectedAddressType")) + let receiveAddress = reusableOnchainAddress + try await prepareReusableReceive(addressType: addressType) + var info = try await nextAvailableAddressInfo(addressType: addressType) + if info.address == receiveAddress || info.address == reusableOnchainAddress { + try await reconcileAddressTypeWithLdk(addressType, highestReserved: info.index) + let previousIndex = info.index + info = try await nextAvailableAddressInfo(addressType: addressType) + guard info.index > previousIndex, info.address != receiveAddress, info.address != reusableOnchainAddress else { + throw AppError(message: "Unable to reserve public payment address", debugMessage: nil) + } } + reserve(info.index, addressType: addressType) + // Keep a revealed index excluded even when removal revoked its publication owner. + persist() + markWalletBackupDataChanged() + try ProfileOperationAdmission.shared.checkCurrent() + var assignments = ledger.publicAssignments ?? [:] + assignments[key] = StoredAssignment(addressType: addressType.stringValue, receiveIndex: info.index) + ledger.publicAssignments = assignments + persist() + markWalletBackupDataChanged() + return PublicAddressSnapshot( + address: info.address, publicKey: key, + revision: publicAssignmentRevision.withLock { $0 }, revisionState: publicAssignmentRevision + ) } - try Task.checkCancellation() - reserve(info.index, addressType: addressType) - var assignments = ledger.publicAssignments ?? [:] - assignments[key] = StoredAssignment(addressType: addressType.stringValue, receiveIndex: info.index) - ledger.publicAssignments = assignments - persist() - markWalletBackupDataChanged() - return PublicAddressSnapshot( - address: info.address, publicKey: key, - revision: publicAssignmentRevision.withLock { $0 }, revisionState: publicAssignmentRevision - ) } func needsPublicAddressRotation(for publicKey: String) async throws -> Bool { @@ -381,12 +417,17 @@ actor PrivatePaykitAddressReservationStore { // MARK: - Cleanup func clear() { + storedLedgerWasUnreadable = false defaults.removeObject(forKey: Self.legacyPublicAddressesKey) ledger = .empty defaults.removeObject(forKey: Self.defaultsKey) markWalletBackupDataChanged() } + func validateWipedState() throws { + guard ledger == .empty, defaults.object(forKey: Self.defaultsKey) == nil else { throw ProfileRemovalStore.StoreError.changed } + } + func clearContactAssignments() { guard !ledger.contactAssignments.isEmpty || !ledger.contactAssignmentHistory.isEmpty else { return } ledger.contactAssignments = [:] @@ -394,6 +435,35 @@ actor PrivatePaykitAddressReservationStore { persist() } + @discardableResult + func clearContactAssignmentsForRemoval() throws -> Data { + guard !storedLedgerWasUnreadable else { throw ProfileRemovalStore.StoreError.unavailable } + if let stored = defaults.object(forKey: Self.defaultsKey) { + guard let data = stored as? Data, let decoded = Self.decodeStoredLedger(data), decoded == ledger else { + throw ProfileRemovalStore.StoreError.unavailable + } + } else if ledger != .empty { + throw ProfileRemovalStore.StoreError.unavailable + } + var cleared = ledger + cleared.contactAssignments = [:] + cleared.contactAssignmentHistory = [:] + let encoded = try JSONEncoder().encode(cleared) + defaults.set(encoded, forKey: Self.defaultsKey) + ledger = cleared + markWalletBackupDataChanged() + return encoded + } + + func validateRemovalState(_ encoded: Data) throws { + #if DEBUG + try ProfileRemovalTestFault.check("reservations:acknowledge") + #endif + guard Self.decodeStoredLedger(encoded) == ledger, defaults.data(forKey: Self.defaultsKey) == encoded else { + throw ProfileRemovalStore.StoreError.changed + } + } + func clearContactAssignment(publicKey: String) { clearContactAssignments(publicKeys: [publicKey]) } @@ -442,6 +512,10 @@ actor PrivatePaykitAddressReservationStore { reserve(addressInfo.index, addressType: addressType) + // Keep the already-revealed index excluded even if profile ownership ended during LDK allocation. + persist() + markWalletBackupDataChanged() + try ProfileOperationAdmission.shared.checkCurrent() let assignment = StoredAssignment( addressType: addressTypeKey, receiveIndex: addressInfo.index @@ -564,7 +638,7 @@ actor PrivatePaykitAddressReservationStore { // MARK: - Models - private struct Ledger: Codable { + private struct Ledger: Codable, Equatable { static let empty = Ledger( version: PrivatePaykitAddressReservationStore.schemaVersion, reservedReceiveIndexesByAddressType: [:], diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index eeb80d5b9..856708607 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -7,50 +7,54 @@ extension PrivatePaykitService { sdkService: PaykitSdkService = .shared, hasStoredIdentity: @Sendable () throws -> Bool = { try PubkyProfileManager.hasStoredIdentityForBackup() } ) async throws -> (state: String?, sdkVersion: UInt64) { - let initialSdkVersion = await sdkService.backupStateVersion - guard try await sdkService.currentPublicKey() != nil else { - guard try !hasStoredIdentity() else { throw PubkyServiceError.sessionNotActive } - return (nil, initialSdkVersion) - } - // Wallet backups may be required before broadcast; they cannot wait for payment to finish. - let exported = try await sdkService.exportBackupSnapshot(priority: .interactive) - let backup = Backup( - sdkState: exported.state, - consumedPrivatePaymentListVersions: state.contacts.compactMapValues { contactState in - contactState.consumedPrivatePaymentListVersion - }, - consumedPrivatePaymentListOwners: state.contacts.compactMapValues { contactState in - contactState.consumedPrivatePaymentListVersion == nil ? nil : contactState.consumedPrivatePaymentListOwner + return try await profileAdmission.withOperation { + let initialSdkVersion = await sdkService.backupStateVersion + guard try await sdkService.currentPublicKey() != nil else { + guard try !hasStoredIdentity() else { throw PubkyServiceError.sessionNotActive } + return (nil, initialSdkVersion) + } + // Wallet backups may be required before broadcast; they cannot wait for payment to finish. + let exported = try await sdkService.exportBackupSnapshot(priority: .interactive) + let backup = Backup( + sdkState: exported.state, + consumedPrivatePaymentListVersions: state.contacts.compactMapValues { contactState in + contactState.consumedPrivatePaymentListVersion + }, + consumedPrivatePaymentListOwners: state.contacts.compactMapValues { contactState in + contactState.consumedPrivatePaymentListVersion == nil ? nil : contactState.consumedPrivatePaymentListOwner + } + ) + let data = try JSONEncoder().encode(backup) + guard let encoded = String(data: data, encoding: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) } - ) - let data = try JSONEncoder().encode(backup) - guard let encoded = String(data: data, encoding: .utf8) else { - throw CocoaError(.fileWriteInapplicableStringEncoding) + return (encoded, exported.version) } - return (encoded, exported.version) } func restoreBackup(_ backup: String?) async throws { - invalidateContactPreparation() - unavailableLinkRetryAt.removeAll() - let decoded = try backup.map { try JSONDecoder().decode(Backup.self, from: Data($0.utf8)) } - if let decoded { - // Wallet restore must not rewind the identity's live state or Noise counters. - try Keychain.upsert(key: .paykitRecoveryBackup, data: Data(decoded.sdkState.utf8)) - } - privatePaymentListConsumptions.removeAll() - state = PrivatePaykitState(contacts: [:]) - knownSavedContactKeys.removeAll() - if let decoded { - for (publicKey, versions) in decoded.consumedPrivatePaymentListVersions { - state.contacts[publicKey, default: ContactState()].consumedPrivatePaymentListVersion = versions - state.contacts[publicKey]?.consumedPrivatePaymentListOwner = decoded.consumedPrivatePaymentListOwners?[publicKey] + return try await profileAdmission.withOperation { + invalidateContactPreparation() + unavailableLinkRetryAt.removeAll() + let decoded = try backup.map { try JSONDecoder().decode(Backup.self, from: Data($0.utf8)) } + if let decoded { + // Wallet restore must not rewind the identity's live state or Noise counters. + try Keychain.upsert(key: .paykitRecoveryBackup, data: Data(decoded.sdkState.utf8)) + } + privatePaymentListConsumptions.removeAll() + state = PrivatePaykitState(contacts: [:]) + knownSavedContactKeys.removeAll() + if let decoded { + for (publicKey, versions) in decoded.consumedPrivatePaymentListVersions { + state.contacts[publicKey, default: ContactState()].consumedPrivatePaymentListVersion = versions + state.contacts[publicKey]?.consumedPrivatePaymentListOwner = decoded.consumedPrivatePaymentListOwners?[publicKey] + } + } else { + await PaykitSdkService.shared.clearState() } - } else { - await PaykitSdkService.shared.clearState() + Self.setContactSharingCleanupPending(false) + Self.clearDeletedContactCleanupPending() + persistState(markWalletBackup: true) } - Self.setContactSharingCleanupPending(false) - Self.clearDeletedContactCleanupPending() - persistState(markWalletBackup: true) } } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 3056c4b0e..947714311 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -111,37 +111,40 @@ extension PrivatePaykitService { requireImmediatePublication: Bool = false, isSessionCurrent: (@MainActor () -> Bool)? = nil ) async -> Error? { - let generation = preparationGeneration - while true { - let revision = savedContactsRevision - if let isSessionCurrent, await !isSessionCurrent() { - return nil - } - guard !Task.isCancelled, generation == preparationGeneration else { return nil } - if revision == savedContactsRevision { - break + do { return try await profileAdmission.withOperation { + let generation = preparationGeneration + while true { + let revision = savedContactsRevision + if let isSessionCurrent, await !isSessionCurrent() { + return nil + } + guard !Task.isCancelled, generation == preparationGeneration else { return nil } + if revision == savedContactsRevision { + break + } } - } - if !requireImmediatePublication { - let keys = rememberSavedContacts(publicKeys, replacing: true) - scheduleContactPreparation(keys, wallet: wallet, isSessionCurrent: isSessionCurrent) - return nil - } - return await prepareSavedContacts( - publicKeys, - publicationUnavailableReason: privateEndpointPublicationUnavailabilityReason(wallet: wallet), - prepareLinks: { try? await self.prepareRelevantPrivateLinksIfAvailable($0, reason: "prepare") }, - publishEndpoints: { publicKeys in - await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() - return await self.syncLocalEndpointPublication( - for: publicKeys, - wallet: wallet, - reason: "prepare", - requireImmediatePublication: requireImmediatePublication, - isSessionCurrent: isSessionCurrent - ) + try profileAdmission.checkCurrent() + if !requireImmediatePublication { + let keys = rememberSavedContacts(publicKeys, replacing: true) + scheduleContactPreparation(keys, wallet: wallet, isSessionCurrent: isSessionCurrent) + return nil } - ) + return await prepareSavedContacts( + publicKeys, + publicationUnavailableReason: privateEndpointPublicationUnavailabilityReason(wallet: wallet), + prepareLinks: { try? await self.prepareRelevantPrivateLinksIfAvailable($0, reason: "prepare") }, + publishEndpoints: { publicKeys in + await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() + return await self.syncLocalEndpointPublication( + for: publicKeys, + wallet: wallet, + reason: "prepare", + requireImmediatePublication: requireImmediatePublication, + isSessionCurrent: isSessionCurrent + ) + } + ) + } } catch { return error } } func prepareSavedContacts( @@ -150,13 +153,15 @@ extension PrivatePaykitService { prepareLinks: ([String]) async -> Void, publishEndpoints: ([String]) async -> Error? ) async -> Error? { - let publicKeys = rememberSavedContacts(publicKeys, replacing: true) - if let reason = publicationUnavailableReason { - Logger.info("Deferring private Paykit endpoint publication during prepare: \(reason)", context: "PrivatePaykitService") - await prepareLinks(publicKeys) - return nil - } - return await publishEndpoints(publicKeys) + do { return try await profileAdmission.withOperation { + let publicKeys = rememberSavedContacts(publicKeys, replacing: true) + if let reason = publicationUnavailableReason { + Logger.info("Deferring private Paykit endpoint publication during prepare: \(reason)", context: "PrivatePaykitService") + await prepareLinks(publicKeys) + return nil + } + return await publishEndpoints(publicKeys) + } } catch { return error } } func refreshSavedContactEndpoints( @@ -229,18 +234,21 @@ extension PrivatePaykitService { requeueActive: Bool = false, operation: @escaping ([String], Bool) async -> Void ) { - guard !isDeletingProfile else { return } + guard !isDeletingProfile, (try? profileAdmission.snapshot()) != nil else { return } let keys = publicKeys.filter { requeueActive || forceRefreshLightning || !activePreparationKeys.contains($0) } guard !keys.isEmpty else { return } pendingPreparationKeys.formUnion(keys) pendingPreparationOperation = operation pendingForceRefreshLightning = pendingForceRefreshLightning || forceRefreshLightning guard preparationTask == nil, !pendingPreparationKeys.isEmpty else { return } - preparationTask = Task { + let owner = preparationGeneration + preparationTask = profileTask { [self] in defer { - activePreparationKeys.removeAll() - pendingPreparationOperation = nil - preparationTask = nil + if owner == preparationGeneration { + activePreparationKeys.removeAll() + pendingPreparationOperation = nil + preparationTask = nil + } } while !Task.isCancelled, !pendingPreparationKeys.isEmpty { guard await waitForBackgroundWork(generation: preparationGeneration) else { continue } @@ -259,6 +267,8 @@ extension PrivatePaykitService { func invalidateContactPreparation() { preparationGeneration += 1 + preparationTask?.cancel() + preparationTask = nil resumeBackgroundWorkWaiters() pendingPreparationKeys.removeAll() pendingPreparationOperation = nil @@ -318,13 +328,16 @@ extension PrivatePaykitService { waiters.forEach { $0.finish() } } - func beginProfileDeletion() { - isDeletingProfile = true + @discardableResult + func beginProfileDeletion(id: UUID = UUID()) -> UUID { + profileDeletionToken = id invalidateContactPreparation() + return id } - func endProfileDeletion() { - isDeletingProfile = false + func endProfileDeletion(id: UUID) { + guard profileDeletionToken == id else { return } + profileDeletionToken = nil } @discardableResult @@ -424,34 +437,37 @@ extension PrivatePaykitService { deferAppSync: Bool = false, operations: EndpointCleanupOperations ) async throws { - let revision = publicKeys != nil && isSessionCurrent != nil ? savedContactsRevision : nil - if let isSessionCurrent, await !isSessionCurrent() { - throw PubkyServiceError.sessionNotActive - } - let publicKeys = publicKeys.map { normalizedSavedContactKeys($0) } - guard publicKeys?.isEmpty != true else { return } - if publicKeys == nil { - invalidateContactPreparation() - } - let generation = preparationGeneration + try await profileAdmission.withOperation { + let revision = publicKeys != nil && isSessionCurrent != nil ? savedContactsRevision : nil + if let isSessionCurrent, await !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive + } + try profileAdmission.checkCurrent() + let publicKeys = publicKeys.map { normalizedSavedContactKeys($0) } + guard publicKeys?.isEmpty != true else { return } + if publicKeys == nil { + invalidateContactPreparation() + } + let generation = preparationGeneration - do { - try await withPublicationLock { - if let isSessionCurrent, await !isSessionCurrent() { - throw PubkyServiceError.sessionNotActive + do { + try await withPublicationLock { + if let isSessionCurrent, await !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive + } + try await removePublishedEndpointsLocked( + for: publicKeys, isSessionCurrent: isSessionCurrent, revision: revision, generation: generation, + deferAppSync: deferAppSync, operations: operations + ) } - try await removePublishedEndpointsLocked( - for: publicKeys, isSessionCurrent: isSessionCurrent, revision: revision, generation: generation, - deferAppSync: deferAppSync, operations: operations - ) - } - } catch { - if await isSessionCurrent?() != false, - revision == nil || (revision == savedContactsRevision && generation == preparationGeneration) - { - PublicPaykitService.setCleanupPending(true) + } catch { + if (try? profileAdmission.checkCurrent()) != nil, await isSessionCurrent?() != false, + revision == nil || (revision == savedContactsRevision && generation == preparationGeneration) + { + PublicPaykitService.setCleanupPending(true) + } + throw error } - throw error } } @@ -460,6 +476,7 @@ extension PrivatePaykitService { deferAppSync: Bool, operations: EndpointCleanupOperations ) async throws { let peers = try await operations.linkedPeers() + try profileAdmission.checkCurrent() let linkedPublicKeys = Set(peers .filter { $0.state == .linked || $0.state == .linking || $0.state == .recoveryRequired } .compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) @@ -485,16 +502,17 @@ extension PrivatePaykitService { if let revision, revision != savedContactsRevision || generation != preparationGeneration { throw PubkyServiceError.sessionNotActive } + try profileAdmission.checkCurrent() do { - if let report = try await operations.clearPaymentLists(cleanupKeys) { - logPrivatePaymentListDeliveryFailures(report, reason: "cleanup") - failedPublicKeys.formUnion((report.failedToQueue.map(\.counterparty) + report.failedToDeliver.map(\.counterparty)) - .compactMap { PubkyPublicKeyFormat.normalized($0) }) - clearedRetryKeys = report.cleared.compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) } - .filter { !failedPublicKeys.contains($0) } - if !failedPublicKeys.isEmpty { - firstError = PrivatePaykitError.privateUnavailable - } + guard let report = try await operations.clearPaymentLists(cleanupKeys) else { + throw PrivatePaykitError.privateUnavailable + } + logPrivatePaymentListDeliveryFailures(report, reason: "cleanup") + let acknowledged = Self.acknowledgedCleanupKeys(in: report, requestedKeys: cleanupKeys) + failedPublicKeys.formUnion(Set(cleanupKeys).subtracting(acknowledged)) + clearedRetryKeys = cleanupKeys.filter { acknowledged.contains($0) } + if !failedPublicKeys.isEmpty || !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { + firstError = PrivatePaykitError.privateUnavailable } } catch { if let isSessionCurrent, await !isSessionCurrent() { @@ -514,6 +532,7 @@ extension PrivatePaykitService { if !clearedRetryKeys.isEmpty { var pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) + try profileAdmission.checkCurrent() if !pendingRetryKeys.isEmpty { await operations.drainMessages(Array(pendingRetryKeys)) pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) @@ -546,6 +565,7 @@ extension PrivatePaykitService { } } + try profileAdmission.checkCurrent() let successfulPublicKeys = publicKeySet.subtracting(failedPublicKeys) if applyPublishedEndpointCleanupResults( successfulPublicKeys: successfulPublicKeys, @@ -571,129 +591,150 @@ extension PrivatePaykitService { } } + private static func acknowledgedCleanupKeys(in report: PrivatePaymentListDeliveryReport, requestedKeys: [String]) -> Set { + let failed = Set((report.failedToQueue.map(\.counterparty) + report.failedToDeliver.map(\.counterparty) + report.queued.map(\.counterparty)) + .compactMap { PubkyPublicKeyFormat.normalized($0) }) + return Set(requestedKeys.filter { key in + guard !failed.contains(key) else { return false } + let cleared = report.cleared.filter { PubkyPublicKeyFormat.normalized($0.counterparty) == key } + guard cleared.count == 1, let change = cleared.first, change.error == nil, + let messageID = change.outboundMessageId else { return false } + // Queue emptiness and a stored Sent row cannot establish delivery of this exact withdrawal. + return report.acknowledged.contains { + PubkyPublicKeyFormat.normalized($0.counterparty) == key && $0.outboundMessageId == messageID + } + }) + } + func removeSavedContact(publicKey: String) async { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } - savedContactsRevision += 1 - knownSavedContactKeys.remove(normalizedKey) - pendingMessageDrainRetries[normalizedKey] = nil - unavailableLinkRetryAt[normalizedKey] = nil - Self.markDeletedContactCleanupPending([normalizedKey]) - do { - try await removePublishedEndpoints(for: [normalizedKey]) - await clearContactState(publicKey: normalizedKey) - } catch { - Logger.warn( - "Failed to remove private Paykit endpoints for deleted contact \(PubkyPublicKeyFormat.redacted(normalizedKey)): \(error)", - context: "PrivatePaykit" - ) - } + await removeSavedContacts(publicKeys: [publicKey]) } func removeSavedContacts(publicKeys: [String]) async { - let normalizedKeys = normalizedSavedContactKeys(publicKeys) - savedContactsRevision += 1 - for publicKey in normalizedKeys { - knownSavedContactKeys.remove(publicKey) - pendingMessageDrainRetries[publicKey] = nil - unavailableLinkRetryAt[publicKey] = nil - } - if isDeletingProfile { - await clearContactStates(publicKeys: normalizedKeys) - return - } - Self.markDeletedContactCleanupPending(normalizedKeys) - do { - try await removePublishedEndpoints(for: normalizedKeys) - await clearContactStates(publicKeys: normalizedKeys) - } catch { - Logger.warn("Failed to remove private Paykit endpoints for deleted contacts: \(error)", context: "PrivatePaykit") + do { try await profileAdmission.withOperation { + try profileAdmission.checkCurrent() + let normalizedKeys = normalizedSavedContactKeys(publicKeys) + savedContactsRevision += 1 + for publicKey in normalizedKeys { + knownSavedContactKeys.remove(publicKey) + pendingMessageDrainRetries[publicKey] = nil + unavailableLinkRetryAt[publicKey] = nil + } + if isDeletingProfile { + try profileAdmission.checkCurrent() + await clearContactStates(publicKeys: normalizedKeys) + return + } + Self.markDeletedContactCleanupPending(normalizedKeys) + do { + try await removePublishedEndpoints(for: normalizedKeys) + try profileAdmission.checkCurrent() + await clearContactStates(publicKeys: normalizedKeys) + } catch { + Logger.warn("Failed to remove private Paykit endpoints for deleted contacts: \(error)", context: "PrivatePaykit") + } + } } catch { + Logger.debug("Contact cleanup stopped: \(error)", context: "PrivatePaykit") } } func pruneUnsavedContactState( savedPublicKeys publicKeys: [String], isSessionCurrent: (@MainActor () -> Bool)? = nil ) async { - let revision = savedContactsRevision - let generation = preparationGeneration - if let isSessionCurrent, await !isSessionCurrent() { - return - } - guard revision == savedContactsRevision, generation == preparationGeneration else { return } - let savedKeys = Set(rememberSavedContacts(publicKeys, replacing: true)) - let cleanupRevision = savedContactsRevision - - let staleKeys: Set = Set(state.contacts.compactMap { publicKey, contactState in - guard !savedKeys.contains(publicKey), contactState.hasContactOwnedCacheState else { return nil } - return publicKey - }) - let cleanupKeys = staleKeys.union(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys)) - guard !cleanupKeys.isEmpty else { return } + do { try await profileAdmission.withOperation { + let revision = savedContactsRevision + let generation = preparationGeneration + if let isSessionCurrent, await !isSessionCurrent() { + return + } + try profileAdmission.checkCurrent() + guard revision == savedContactsRevision, generation == preparationGeneration else { return } + let savedKeys = Set(rememberSavedContacts(publicKeys, replacing: true)) + let cleanupRevision = savedContactsRevision + + let staleKeys: Set = Set(state.contacts.compactMap { publicKey, contactState in + guard !savedKeys.contains(publicKey), contactState.hasContactOwnedCacheState else { return nil } + return publicKey + }) + let cleanupKeys = staleKeys.union(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys)) + guard !cleanupKeys.isEmpty else { return } - if isDeletingProfile { - await clearContactStates(publicKeys: Array(staleKeys)) - return - } + if isDeletingProfile { + try profileAdmission.checkCurrent() + await clearContactStates(publicKeys: Array(staleKeys)) + return + } - do { - try await removePublishedEndpoints(for: Array(cleanupKeys), isSessionCurrent: isSessionCurrent) - try await withPublicationLock { + do { + try await removePublishedEndpoints(for: Array(cleanupKeys), isSessionCurrent: isSessionCurrent) + try await withPublicationLock { + if let isSessionCurrent, await !isSessionCurrent() { + return + } + try profileAdmission.checkCurrent() + guard cleanupRevision == savedContactsRevision, generation == preparationGeneration else { return } + await clearContactStates(publicKeys: Array(staleKeys)) + } + } catch { if let isSessionCurrent, await !isSessionCurrent() { return } + try profileAdmission.checkCurrent() guard cleanupRevision == savedContactsRevision, generation == preparationGeneration else { return } - await clearContactStates(publicKeys: Array(staleKeys)) - } - } catch { - if let isSessionCurrent, await !isSessionCurrent() { - return + Logger.warn("Failed to prune private Paykit endpoints for unsaved contacts: \(error)", context: "PrivatePaykit") } - guard cleanupRevision == savedContactsRevision, generation == preparationGeneration else { return } - Logger.warn("Failed to prune private Paykit endpoints for unsaved contacts: \(error)", context: "PrivatePaykit") + } } catch { + Logger.debug("Contact cleanup stopped: \(error)", context: "PrivatePaykit") } } func retryPendingEndpointReconciliation(wallet: WalletViewModel, savedPublicKeys publicKeys: [String]) async { - guard !isDeletingProfile else { return } - let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) - let isFullCleanupPending = UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) - if isFullCleanupPending, - Self.fullCleanupReconciliationMode() == .restoreSavedContacts - { - let restoreKeys = savedKeys.union(knownSavedContactKeys) - guard !restoreKeys.isEmpty, await canPublishPrivateEndpoints(wallet: wallet) else { return } - - let publicKeys = rememberSavedContacts(Array(restoreKeys), replacing: true) - await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() - let error = await resumeEndpointPublication { - await self.refreshSavedContactEndpointsReturningError( - for: publicKeys, - wallet: wallet, - forceRefreshLightning: false, - requireImmediatePublication: true, - reason: "reconcile" - ) - } - if let error { - Logger.warn("Failed to reconcile private Paykit endpoints: \(error)", context: "PrivatePaykit") + do { try await profileAdmission.withOperation { + guard !isDeletingProfile else { return } + let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) + let isFullCleanupPending = UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) + if isFullCleanupPending, + Self.fullCleanupReconciliationMode() == .restoreSavedContacts + { + let restoreKeys = savedKeys.union(knownSavedContactKeys) + guard !restoreKeys.isEmpty, await canPublishPrivateEndpoints(wallet: wallet) else { return } + + try profileAdmission.checkCurrent() + let publicKeys = rememberSavedContacts(Array(restoreKeys), replacing: true) + await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() + let error = await resumeEndpointPublication { + await self.refreshSavedContactEndpointsReturningError( + for: publicKeys, + wallet: wallet, + forceRefreshLightning: false, + requireImmediatePublication: true, + reason: "reconcile" + ) + } + if let error { + Logger.warn("Failed to reconcile private Paykit endpoints: \(error)", context: "PrivatePaykit") + } + return } - return - } - let cleanupKeys = isFullCleanupPending - ? Set(knownSavedContactKeys).union(state.contacts.keys).union(Self.pendingDeletedContactCleanupKeys()) - : Set(pendingPrivateEndpointRemovalKeys(savedPublicKeys: publicKeys)) + let cleanupKeys = isFullCleanupPending + ? Set(knownSavedContactKeys).union(state.contacts.keys).union(Self.pendingDeletedContactCleanupKeys()) + : Set(pendingPrivateEndpointRemovalKeys(savedPublicKeys: publicKeys)) - guard isFullCleanupPending || !cleanupKeys.isEmpty else { return } + guard isFullCleanupPending || !cleanupKeys.isEmpty else { return } - do { - try await removePublishedEndpoints(for: isFullCleanupPending ? nil : Array(cleanupKeys)) - await clearContactStates(publicKeys: Array(cleanupKeys.subtracting(savedKeys))) - if isFullCleanupPending { - Self.setContactSharingCleanupPending(false) + do { + try await removePublishedEndpoints(for: isFullCleanupPending ? nil : Array(cleanupKeys)) + try profileAdmission.checkCurrent() + await clearContactStates(publicKeys: Array(cleanupKeys.subtracting(savedKeys))) + try profileAdmission.checkCurrent() + if isFullCleanupPending { + Self.setContactSharingCleanupPending(false) + } + } catch { + Logger.warn("Failed to retry private Paykit endpoint cleanup: \(error)", context: "PrivatePaykit") } - } catch { - Logger.warn("Failed to retry private Paykit endpoint cleanup: \(error)", context: "PrivatePaykit") - } + } } catch { Logger.debug("Endpoint reconciliation stopped: \(error)", context: "PrivatePaykit") } } func pendingPrivateEndpointRemovalKeys(savedPublicKeys publicKeys: [String]) -> [String] { @@ -1114,30 +1155,32 @@ extension PrivatePaykitService { } func startExplicitContactLink(publicKey: String, expectedIdentity: String?, wallet: WalletViewModel) { - let generation = preparationGeneration - let startedAt = messageRetryOperations.now() - guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey), - let expectedIdentity = expectedIdentity.flatMap(PubkyPublicKeyFormat.normalized), - PaykitFeatureFlags.isUIEnabled, PubkyProfileManager.hasLocalSecretKey(for: expectedIdentity), - !isDeletingProfile, !Task.isCancelled - else { return } - _ = rememberSavedContacts([publicKey], replacing: false) - scheduleExplicitContactLink(publicKey: publicKey, identity: expectedIdentity, startedAt: startedAt) - Task { - do { - guard await waitForBackgroundWork(generation: generation), - let identity = try await messageRetryOperations.currentPublicKey( - messageRetryOperations.now().timeIntervalSince(startedAt) < 20 ? .interactive : .background - ), - PubkyPublicKeyFormat.matches(identity, expectedIdentity), generation == preparationGeneration, - !Task.isCancelled, knownSavedContactKeys.contains(publicKey) - else { return } - scheduleContactPreparation([publicKey], wallet: wallet) - } catch { - Logger.warn( - "Failed to inspect private Paykit identity during contact preparation: \(PaykitResolutionFailureDiagnostics.reason(for: error))", - context: "PrivatePaykit" - ) + try? profileAdmission.withSynchronousOperation { + let generation = preparationGeneration + let startedAt = messageRetryOperations.now() + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey), + let expectedIdentity = expectedIdentity.flatMap(PubkyPublicKeyFormat.normalized), + PaykitFeatureFlags.isUIEnabled, PubkyProfileManager.hasLocalSecretKey(for: expectedIdentity), + !isDeletingProfile, !Task.isCancelled + else { return } + _ = rememberSavedContacts([publicKey], replacing: false) + scheduleExplicitContactLink(publicKey: publicKey, identity: expectedIdentity, startedAt: startedAt) + profileTask { [self] in + do { + guard await waitForBackgroundWork(generation: generation), + let identity = try await messageRetryOperations.currentPublicKey( + messageRetryOperations.now().timeIntervalSince(startedAt) < 20 ? .interactive : .background + ), + PubkyPublicKeyFormat.matches(identity, expectedIdentity), generation == preparationGeneration, + !Task.isCancelled, knownSavedContactKeys.contains(publicKey) + else { return } + scheduleContactPreparation([publicKey], wallet: wallet) + } catch { + Logger.warn( + "Failed to inspect private Paykit identity during contact preparation: \(PaykitResolutionFailureDiagnostics.reason(for: error))", + context: "PrivatePaykit" + ) + } } } } @@ -1165,7 +1208,7 @@ extension PrivatePaykitService { guard pendingMessageDrainRetryTask == nil else { return } pendingMessageDrainRetryGeneration += 1 let retryGeneration = pendingMessageDrainRetryGeneration - pendingMessageDrainRetryTask = Task { [reason, retryGeneration] in + pendingMessageDrainRetryTask = profileTask { [self, reason, retryGeneration] in var foregroundAttempts = 0 var schedulingSnapshot: PrivateMessageSchedulingSnapshot? while !Task.isCancelled, retryGeneration == pendingMessageDrainRetryGeneration { @@ -1203,7 +1246,9 @@ extension PrivatePaykitService { } } pendingMessageDrainRetrySleep = idle - await idle.value + await withTaskCancellationHandler { + await idle.value + } onCancel: { idle.cancel() } guard retryGeneration == pendingMessageDrainRetryGeneration else { return } pendingMessageDrainRetrySleep = nil continue @@ -1214,7 +1259,9 @@ extension PrivatePaykitService { let sleep = messageRetryOperations.sleep let timer = Task { _ = try? await sleep(UInt64(delay * 1_000_000_000)) } pendingMessageDrainRetrySleep = timer - await timer.value + await withTaskCancellationHandler { + await timer.value + } onCancel: { timer.cancel() } guard retryGeneration == pendingMessageDrainRetryGeneration else { return } pendingMessageDrainRetrySleep = nil continue diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 0beb64c7e..0d747d9f1 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -11,42 +11,51 @@ extension PrivatePaykitService { wallet: WalletViewModel, forceRefresh: Bool = false ) async throws -> StoredInvoice { - if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey) { - return invoice - } + return try await profileAdmission.withOperation { + if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey) { + return invoice + } - let bolt11 = try await createVariableInvoice(wallet) - guard case let .lightning(decodedInvoice) = try await decode(invoice: bolt11) else { - throw PublicPaykitError.invalidPayload - } - guard PublicPaykitService.hasLightningRouteHints(bolt11: bolt11) else { - throw PrivatePaykitError.routeHintsUnavailable - } + let bolt11 = try await createVariableInvoice(wallet) + guard case let .lightning(decodedInvoice) = try await decode(invoice: bolt11) else { + throw PublicPaykitError.invalidPayload + } + guard PublicPaykitService.hasLightningRouteHints(bolt11: bolt11) else { + throw PrivatePaykitError.routeHintsUnavailable + } - let invoice = StoredInvoice( - bolt11: bolt11, - paymentHash: decodedInvoice.paymentHash.hex, - expiresAt: Double(decodedInvoice.timestampSeconds + decodedInvoice.expirySeconds) - ) - setLocalInvoice(invoice, publicKey: publicKey) - persistState() - return invoice + let invoice = StoredInvoice( + bolt11: bolt11, + paymentHash: decodedInvoice.paymentHash.hex, + expiresAt: Double(decodedInvoice.timestampSeconds + decodedInvoice.expirySeconds) + ) + try profileAdmission.checkCurrent() + setLocalInvoice(invoice, publicKey: publicKey) + persistState() + return invoice + } } func rememberReceivedInvoicePaymentHash(_ paymentHash: String, publicKey: String) { - guard !paymentHash.isEmpty else { return } - - var contactState = state.contacts[publicKey, default: ContactState()] - guard !contactState.receivedInvoicePaymentHashes.contains(paymentHash) else { return } - - contactState.receivedInvoicePaymentHashes.append(paymentHash) - if contactState.receivedInvoicePaymentHashes.count > Self.maxReceivedInvoicePaymentHashesPerContact { - contactState.receivedInvoicePaymentHashes = Array( - contactState.receivedInvoicePaymentHashes.suffix(Self.maxReceivedInvoicePaymentHashesPerContact) - ) + do { + return try profileAdmission.withSynchronousOperation { + guard !paymentHash.isEmpty else { return } + + var contactState = state.contacts[publicKey, default: ContactState()] + guard !contactState.receivedInvoicePaymentHashes.contains(paymentHash) else { return } + + contactState.receivedInvoicePaymentHashes.append(paymentHash) + if contactState.receivedInvoicePaymentHashes.count > Self.maxReceivedInvoicePaymentHashesPerContact { + contactState.receivedInvoicePaymentHashes = Array( + contactState.receivedInvoicePaymentHashes.suffix(Self.maxReceivedInvoicePaymentHashesPerContact) + ) + } + state.contacts[publicKey] = contactState + persistState() + } + } catch { + Logger.debug("Private invoice attribution stopped: \(error)", context: "PrivatePaykit") } - state.contacts[publicKey] = contactState - persistState() } func reusablePrivateInvoice(for publicKey: String) async -> StoredInvoice? { @@ -99,6 +108,7 @@ extension PrivatePaykitService { func privateEndpointPublicationUnavailabilityReason( wallet: WalletViewModel, priority: PaykitSdkOperationLock.Priority ) async throws -> String? { + guard (try? profileAdmission.snapshot()) != nil else { return "profile recovery is pending" } guard PaykitFeatureFlags.isUIEnabled else { return "Paykit UI is disabled" } guard UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) else { return "private publication is disabled" } guard UIApplication.shared.applicationState == .active else { return "the app is not active" } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 9e7814989..d34023c3e 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -32,8 +32,16 @@ extension ContactPaymentContext { extension PrivatePaykitService { func contactPublicKey(forPrivateInvoicePaymentHash paymentHash: String) -> String? { - guard !paymentHash.isEmpty else { return nil } + guard !paymentHash.isEmpty, (try? profileAdmission.snapshot()) != nil else { return nil } + return storedInvoiceContact(paymentHash: paymentHash) + } + + func contactPublicKeyForReceipt(paymentHash: String) -> String? { + guard !paymentHash.isEmpty, (try? profileAdmission.checkReceiptAttribution()) != nil else { return nil } + return storedInvoiceContact(paymentHash: paymentHash) + } + private func storedInvoiceContact(paymentHash: String) -> String? { return state.contacts.first { _, contactState in contactState.localInvoice?.paymentHash == paymentHash || contactState.receivedInvoicePaymentHashes.contains(paymentHash) @@ -41,49 +49,75 @@ extension PrivatePaykitService { } func beginSavedContactPayment(to publicKey: String, wallet: WalletViewModel) async throws -> PublicPaykitPaymentLaunchResult { - guard let normalizedKey = knownSavedContact(publicKey) else { - return try await PublicPaykitService.beginPayment(to: publicKey) - } + return try await profileAdmission.withOperation { + guard let normalizedKey = knownSavedContact(publicKey) else { + return try await PublicPaykitService.beginPayment(to: publicKey) + } - if prePaymentPublicationKeys.insert(normalizedKey).inserted { - Task { - defer { prePaymentPublicationKeys.remove(normalizedKey) } - _ = await refreshSavedContactEndpointsReturningError( + try schedulePrePaymentPublication(for: normalizedKey) { + _ = await self.refreshSavedContactEndpointsReturningError( for: [normalizedKey], wallet: wallet, forceRefreshLightning: false, requireImmediatePublication: false ) } - } - var result = try await beginContactPayment(to: normalizedKey) - for delay in Self.privatePaymentResolutionRetryDelays { - guard case .waitingForUpdatedPaymentList = result else { return result } - try await Task.sleep(nanoseconds: delay) - result = try await beginContactPayment(to: normalizedKey) + var result = try await beginContactPayment(to: normalizedKey) + for delay in Self.privatePaymentResolutionRetryDelays { + guard case .waitingForUpdatedPaymentList = result else { return result } + try await Task.sleep(nanoseconds: delay) + result = try await beginContactPayment(to: normalizedKey) + } + return result } - return result } - func beginPaymentRequest(_ request: PaykitPaymentRequest) async throws -> PublicPaykitPaymentLaunchResult { - guard !request.isExpired(at: Date()) else { - throw PaykitPaymentRequestError.requestExpired + /// Retain the original profile owner while publication waits, and let removal cancel that wait before draining it. + @discardableResult + func schedulePrePaymentPublication(for publicKey: String, operation: @escaping () async -> Void) throws -> Task? { + guard prePaymentPublicationKeys.insert(publicKey).inserted else { return nil } + let admission = profileAdmission + let scope: ProfileOperationAdmission.Scope + do { scope = try admission.capture() } + catch { + prePaymentPublicationKeys.remove(publicKey) + throw error } - guard let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty) else { - throw PrivatePaykitError.invalidPublicKey + let id = UUID() + let task = Task { + defer { + prePaymentPublicationKeys.remove(publicKey) + admission.removeCancellationHandler(from: scope, id: id) + admission.release(scope) + } + do { try await admission.withScope(scope, revokeOnCancellation: false) { await operation() } } + catch { Logger.debug("Prepayment publication stopped: \(error)", context: "PrivatePaykit") } } + admission.onRevocation(of: scope, id: id) { task.cancel() } + return task + } - if let proof = try await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request), - let result = Self.retainedHardwarePaymentLaunchResult(proof: proof, request: request) - { - return result - } + func beginPaymentRequest(_ request: PaykitPaymentRequest) async throws -> PublicPaykitPaymentLaunchResult { + return try await profileAdmission.withOperation { + guard !request.isExpired(at: Date()) else { + throw PaykitPaymentRequestError.requestExpired + } + guard let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty) else { + throw PrivatePaykitError.invalidPublicKey + } - return try await beginContactPayment( - to: publicKey, - paymentRequest: request - ) + if let proof = try await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request), + let result = Self.retainedHardwarePaymentLaunchResult(proof: proof, request: request) + { + return result + } + + return try await beginContactPayment( + to: publicKey, + paymentRequest: request + ) + } } static func retainedHardwarePaymentLaunchResult( @@ -105,115 +139,119 @@ extension PrivatePaykitService { } func beginPaymentRequestWaitingForUpdatedList(_ request: PaykitPaymentRequest) async throws -> PublicPaykitPaymentLaunchResult { - var result = try await beginPaymentRequest(request) - for delay in Self.privatePaymentResolutionRetryDelays { - guard case .waitingForUpdatedPaymentList = result else { return result } - try await Task.sleep(nanoseconds: delay) - result = try await beginPaymentRequest(request) + return try await profileAdmission.withOperation { + var result = try await beginPaymentRequest(request) + for delay in Self.privatePaymentResolutionRetryDelays { + guard case .waitingForUpdatedPaymentList = result else { return result } + try await Task.sleep(nanoseconds: delay) + result = try await beginPaymentRequest(request) + } + return result } - return result } private func beginContactPayment( to publicKey: String, paymentRequest: PaykitPaymentRequest? = nil ) async throws -> PublicPaykitPaymentLaunchResult { - let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersion - let previousPaymentListVersion = consumedVersion.map(String.init) ?? "none" - do { - let prepared: PreparedPrivateContactPayment = if let paymentRequest { - try await PaykitSdkService.shared.prepareAndResolvePrivatePaymentRequest( - counterparty: publicKey, - paymentRequestId: paymentRequest.paymentRequestId, - afterPrivatePaymentListVersion: consumedVersion - ) - } else { - try await PaykitSdkService.shared.prepareAndResolvePrivateContactPayment( - counterparty: publicKey, - afterPrivatePaymentListVersion: consumedVersion + return try await profileAdmission.withOperation { + let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersion + let previousPaymentListVersion = consumedVersion.map(String.init) ?? "none" + do { + let prepared: PreparedPrivateContactPayment = if let paymentRequest { + try await PaykitSdkService.shared.prepareAndResolvePrivatePaymentRequest( + counterparty: publicKey, + paymentRequestId: paymentRequest.paymentRequestId, + afterPrivatePaymentListVersion: consumedVersion + ) + } else { + try await PaykitSdkService.shared.prepareAndResolvePrivateContactPayment( + counterparty: publicKey, + afterPrivatePaymentListVersion: consumedVersion + ) + } + let resolution = prepared.resolution + let linkState = try await currentLinkState( + publicKey: publicKey, + preparedState: prepared.linkReport?.state ) - } - let resolution = prepared.resolution - let linkState = try await currentLinkState( - publicKey: publicKey, - preparedState: prepared.linkReport?.state - ) - if paymentRequest == nil, canUsePublicPayment(linkState: linkState, resolution: resolution) { - return try await PublicPaykitService.beginPayment(to: publicKey) - } + if paymentRequest == nil, canUsePublicPayment(linkState: linkState, resolution: resolution) { + return try await PublicPaykitService.beginPayment(to: publicKey) + } - if paymentRequest != nil, - Self.paymentRequestNeedsPrivateLinkRecovery(resolutionState: resolution.state, linkState: linkState) - { - schedulePrivatePaymentRecovery(for: publicKey) - return .privateLinkPending - } + if paymentRequest != nil, + Self.paymentRequestNeedsPrivateLinkRecovery(resolutionState: resolution.state, linkState: linkState) + { + schedulePrivatePaymentRecovery(for: publicKey) + return .privateLinkPending + } - let result = await privatePaymentResult( - publicKey: publicKey, - paymentRequest: paymentRequest, - resolution: resolution, - validateEndpoints: { endpoints, allowUsedOnchainAddress in - await self.privatePayableEndpoints( - from: endpoints, - publicKey: publicKey, - allowUsedOnchainAddress: allowUsedOnchainAddress + let result = await privatePaymentResult( + publicKey: publicKey, + paymentRequest: paymentRequest, + resolution: resolution, + validateEndpoints: { endpoints, allowUsedOnchainAddress in + await self.privatePayableEndpoints( + from: endpoints, + publicKey: publicKey, + allowUsedOnchainAddress: allowUsedOnchainAddress + ) + } + ) + if case .opened = result { + return result + } + + if resolution.state == .recoveryPending { + schedulePrivatePaymentRecovery(for: publicKey) + } + + if resolution.status == .waitingForUpdatedPaymentList { + Logger.info( + "Waiting for a private Paykit payment list newer than \(previousPaymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey)); public resolution is disabled for this request", + context: "PrivatePaykit" ) + schedulePrivatePaymentRecovery(for: publicKey) + return .waitingForUpdatedPaymentList } - ) - if case .opened = result { - return result - } - if resolution.state == .recoveryPending { - schedulePrivatePaymentRecovery(for: publicKey) - } + return result + } catch { + if error is CancellationError || Task.isCancelled { + throw error + } - if resolution.status == .waitingForUpdatedPaymentList { - Logger.info( - "Waiting for a private Paykit payment list newer than \(previousPaymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey)); public resolution is disabled for this request", + Logger.warn( + "Failed to resolve Paykit contact payment for \(PubkyPublicKeyFormat.redacted(publicKey)): " + + "reason=\(PaykitResolutionFailureDiagnostics.reason(for: error))", context: "PrivatePaykit" ) - schedulePrivatePaymentRecovery(for: publicKey) - return .waitingForUpdatedPaymentList - } - - return result - } catch { - if error is CancellationError || Task.isCancelled { - throw error - } - Logger.warn( - "Failed to resolve Paykit contact payment for \(PubkyPublicKeyFormat.redacted(publicKey)): " + - "reason=\(PaykitResolutionFailureDiagnostics.reason(for: error))", - context: "PrivatePaykit" - ) - - if paymentRequest != nil, PaykitResolutionFailureDiagnostics.isRecoveryRequired(error) { - schedulePrivatePaymentRecovery(for: publicKey) - return .privateLinkPending - } - - let linkState: LinkedPeerState? - do { - linkState = try await currentLinkState(publicKey: publicKey) - } catch { if paymentRequest != nil, PaykitResolutionFailureDiagnostics.isRecoveryRequired(error) { schedulePrivatePaymentRecovery(for: publicKey) return .privateLinkPending } - throw error - } - if paymentRequest != nil, Self.paymentRequestNeedsPrivateLinkRecovery(linkState: linkState) { - schedulePrivatePaymentRecovery(for: publicKey) - return .privateLinkPending - } - guard paymentRequest == nil, canUsePublicPayment(linkState: linkState) else { - throw error + + let linkState: LinkedPeerState? + do { + linkState = try await currentLinkState(publicKey: publicKey) + } catch { + if paymentRequest != nil, PaykitResolutionFailureDiagnostics.isRecoveryRequired(error) { + schedulePrivatePaymentRecovery(for: publicKey) + return .privateLinkPending + } + throw error + } + if paymentRequest != nil, Self.paymentRequestNeedsPrivateLinkRecovery(linkState: linkState) { + schedulePrivatePaymentRecovery(for: publicKey) + return .privateLinkPending + } + guard paymentRequest == nil, canUsePublicPayment(linkState: linkState) else { + throw error + } + return try await PublicPaykitService.beginPayment(to: publicKey) } - return try await PublicPaykitService.beginPayment(to: publicKey) } } @@ -264,46 +302,49 @@ extension PrivatePaykitService { owner: String? = nil, expectedPreviousVersion: UInt64?? = nil ) throws { - guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { - throw PrivatePaykitError.invalidPublicKey - } - guard let paymentListVersion = context.paymentListVersion else { return } + return try profileAdmission.withSynchronousOperation { + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.invalidPublicKey + } + guard let paymentListVersion = context.paymentListVersion else { return } - var contactState = state.contacts[publicKey, default: ContactState()] - let consumedVersion = contactState.consumedPrivatePaymentListVersion - if let consumedVersion, - paymentListVersion <= consumedVersion - { - throw PrivatePaykitError.paymentListAlreadyConsumed - } - if let expectedPreviousVersion, expectedPreviousVersion != consumedVersion { - throw PrivatePaykitError.paymentListAlreadyConsumed - } + var contactState = state.contacts[publicKey, default: ContactState()] + let consumedVersion = contactState.consumedPrivatePaymentListVersion + if let consumedVersion, + paymentListVersion <= consumedVersion + { + throw PrivatePaykitError.paymentListAlreadyConsumed + } - let previousContactState = state.contacts[publicKey] - contactState.consumedPrivatePaymentListVersion = paymentListVersion - contactState.consumedPrivatePaymentListOwner = owner - contactState.cachedResolvedEndpoints.removeAll() - state.contacts[publicKey] = contactState - do { - try persistStateOrThrow(markWalletBackup: true) - } catch { - state.contacts[publicKey] = previousContactState - throw error - } + if let expectedPreviousVersion, expectedPreviousVersion != consumedVersion { + throw PrivatePaykitError.paymentListAlreadyConsumed + } - let consumptionKey = PrivatePaymentListConsumptionKey( - attemptId: attemptId, - publicKey: publicKey - ) - privatePaymentListConsumptions[consumptionKey] = PrivatePaymentListConsumption( - paymentListVersion: paymentListVersion, - previousPaymentListVersion: consumedVersion - ) - Logger.info( - "Consumed private Paykit payment list version \(paymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey))", - context: "PrivatePaykit" - ) + let previousContactState = state.contacts[publicKey] + contactState.consumedPrivatePaymentListVersion = paymentListVersion + contactState.consumedPrivatePaymentListOwner = owner + contactState.cachedResolvedEndpoints.removeAll() + state.contacts[publicKey] = contactState + do { + try persistStateOrThrow(markWalletBackup: true) + } catch { + state.contacts[publicKey] = previousContactState + throw error + } + + let consumptionKey = PrivatePaymentListConsumptionKey( + attemptId: attemptId, + publicKey: publicKey + ) + privatePaymentListConsumptions[consumptionKey] = PrivatePaymentListConsumption( + paymentListVersion: paymentListVersion, + previousPaymentListVersion: consumedVersion + ) + Logger.info( + "Consumed private Paykit payment list version \(paymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey))", + context: "PrivatePaykit" + ) + } } func resolvePrivatePaymentListConsumption( @@ -312,71 +353,75 @@ extension PrivatePaykitService { attemptId: UUID, outcome: PrivatePaymentListSendOutcome ) throws { - guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { - throw PrivatePaykitError.invalidPublicKey - } - guard let paymentListVersion = context.paymentListVersion else { return } + return try profileAdmission.withSynchronousOperation { + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.invalidPublicKey + } + guard let paymentListVersion = context.paymentListVersion else { return } - let consumptionKey = PrivatePaymentListConsumptionKey( - attemptId: attemptId, - publicKey: publicKey - ) - guard let consumption = privatePaymentListConsumptions[consumptionKey], - consumption.paymentListVersion == paymentListVersion - else { return } + let consumptionKey = PrivatePaymentListConsumptionKey( + attemptId: attemptId, + publicKey: publicKey + ) + guard let consumption = privatePaymentListConsumptions[consumptionKey], + consumption.paymentListVersion == paymentListVersion + else { return } - guard outcome == .definitePreBroadcastFailure else { - privatePaymentListConsumptions[consumptionKey] = nil - return - } + guard outcome == .definitePreBroadcastFailure else { + privatePaymentListConsumptions[consumptionKey] = nil + return + } - var contactState = state.contacts[publicKey, default: ContactState()] - guard contactState.consumedPrivatePaymentListVersion == paymentListVersion else { - privatePaymentListConsumptions[consumptionKey] = nil - return - } + var contactState = state.contacts[publicKey, default: ContactState()] + guard contactState.consumedPrivatePaymentListVersion == paymentListVersion else { + privatePaymentListConsumptions[consumptionKey] = nil + return + } - let previousContactState = state.contacts[publicKey] - contactState.consumedPrivatePaymentListVersion = consumption.previousPaymentListVersion - contactState.consumedPrivatePaymentListOwner = nil - state.contacts[publicKey] = contactState - do { - try persistStateOrThrow(markWalletBackup: true) - privatePaymentListConsumptions[consumptionKey] = nil - } catch { - state.contacts[publicKey] = previousContactState - throw error + let previousContactState = state.contacts[publicKey] + contactState.consumedPrivatePaymentListVersion = consumption.previousPaymentListVersion + contactState.consumedPrivatePaymentListOwner = nil + state.contacts[publicKey] = contactState + do { + try persistStateOrThrow(markWalletBackup: true) + privatePaymentListConsumptions[consumptionKey] = nil + } catch { + state.contacts[publicKey] = previousContactState + throw error + } + Logger.info( + "Released private Paykit payment list version \(paymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey))", + context: "PrivatePaykit" + ) } - Logger.info( - "Released private Paykit payment list version \(paymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey))", - context: "PrivatePaykit" - ) } /// Rolls back a list version a failed automatic payment consumed, which a restart may have left without its /// in-memory consumption record. A list another payment consumed, or one this attempt never consumed, is left alone. func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?, owner: String) throws { - guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { - throw PrivatePaykitError.invalidPublicKey - } - guard var contactState = state.contacts[publicKey], - contactState.consumedPrivatePaymentListVersion == version, - contactState.consumedPrivatePaymentListOwner == owner - else { return } - let previousContactState = state.contacts[publicKey] - contactState.consumedPrivatePaymentListVersion = previousVersion - contactState.consumedPrivatePaymentListOwner = nil - state.contacts[publicKey] = contactState - do { - try persistStateOrThrow(markWalletBackup: true) - } catch { - state.contacts[publicKey] = previousContactState - throw error + return try profileAdmission.withSynchronousOperation { + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.invalidPublicKey + } + guard var contactState = state.contacts[publicKey], + contactState.consumedPrivatePaymentListVersion == version, + contactState.consumedPrivatePaymentListOwner == owner + else { return } + let previousContactState = state.contacts[publicKey] + contactState.consumedPrivatePaymentListVersion = previousVersion + contactState.consumedPrivatePaymentListOwner = nil + state.contacts[publicKey] = contactState + do { + try persistStateOrThrow(markWalletBackup: true) + } catch { + state.contacts[publicKey] = previousContactState + throw error + } + Logger.info( + "Released private Paykit payment list version \(version) for \(PubkyPublicKeyFormat.redacted(publicKey)) after a failed automatic payment", + context: "PrivatePaykit" + ) } - Logger.info( - "Released private Paykit payment list version \(version) for \(PubkyPublicKeyFormat.redacted(publicKey)) after a failed automatic payment", - context: "PrivatePaykit" - ) } func consumedPaymentListVersion(publicKey: String, attemptId: UUID? = nil) -> UInt64? { @@ -389,40 +434,44 @@ extension PrivatePaykitService { /// Preserve the version captured by a retained signed receipt before it can be retried. func retainOriginalPaymentListVersion(publicKey: String, version: UInt64) throws { - guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } - var contact = state.contacts[key, default: ContactState()] - if let consumed = contact.consumedPrivatePaymentListVersion, consumed >= version { - return - } - let previous = state.contacts[key] - contact.consumedPrivatePaymentListVersion = version - contact.consumedPrivatePaymentListOwner = nil - contact.cachedResolvedEndpoints.removeAll() - state.contacts[key] = contact - do { - try persistStateOrThrow(markWalletBackup: true) - } catch { - state.contacts[key] = previous - throw error + return try profileAdmission.withSynchronousOperation { + guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } + var contact = state.contacts[key, default: ContactState()] + if let consumed = contact.consumedPrivatePaymentListVersion, consumed >= version { + return + } + let previous = state.contacts[key] + contact.consumedPrivatePaymentListVersion = version + contact.consumedPrivatePaymentListOwner = nil + contact.cachedResolvedEndpoints.removeAll() + state.contacts[key] = contact + do { + try persistStateOrThrow(markWalletBackup: true) + } catch { + state.contacts[key] = previous + throw error + } } } /// The caller must establish an exact receipt that definitely never reached native dispatch. func releaseUnsentPaymentListVersion(publicKey: String, version: UInt64, previousVersion: UInt64? = nil) throws { - guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } - // A list an automatic payment attempt consumed is that attempt's to give back. - guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version, - contact.consumedPrivatePaymentListOwner == nil - else { return } - let previous = contact - guard previousVersion.map({ $0 < version }) ?? true else { throw PaykitPaymentRequestError.requestUnavailable } - contact.consumedPrivatePaymentListVersion = previousVersion - state.contacts[key] = contact - do { - try persistStateOrThrow(markWalletBackup: true) - } catch { - state.contacts[key] = previous - throw error + return try profileAdmission.withSynchronousOperation { + guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } + // A list an automatic payment attempt consumed is that attempt's to give back. + guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version, + contact.consumedPrivatePaymentListOwner == nil + else { return } + let previous = contact + guard previousVersion.map({ $0 < version }) ?? true else { throw PaykitPaymentRequestError.requestUnavailable } + contact.consumedPrivatePaymentListVersion = previousVersion + state.contacts[key] = contact + do { + try persistStateOrThrow(markWalletBackup: true) + } catch { + state.contacts[key] = previous + throw error + } } } @@ -559,57 +608,59 @@ extension PrivatePaykitService { _ request: PaykitPaymentRequest, eligibleIdentifiers: [String] ) async throws -> PrivatePaykitAllowancePayment? { - guard !request.isExpired(at: Date()), - let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty) - else { return nil } - - let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersion - // Resolve through the request, as the manual flow does, so its required app and bound destination apply. - let prepared = try await PaykitSdkService.shared.prepareAndResolvePrivatePaymentRequest( - counterparty: publicKey, - paymentRequestId: request.paymentRequestId, - afterPrivatePaymentListVersion: consumedVersion - ) - if prepared.resolution.state == .recoveryPending || prepared.resolution.status == .waitingForUpdatedPaymentList { - // The last list was already paid from; a new one arrives once the payee sees that payment settle. - schedulePrivatePaymentRecovery(for: publicKey) - throw PaykitAllowanceError.paymentListPending - } - // Without a payment list version nothing limits how often the payee's endpoints can be paid, so the request asks. - guard let paymentListVersion = prepared.resolution.privatePaymentListVersion else { return nil } + return try await profileAdmission.withOperation { + guard !request.isExpired(at: Date()), + let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty) + else { return nil } + + let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersion + // Resolve through the request, as the manual flow does, so its required app and bound destination apply. + let prepared = try await PaykitSdkService.shared.prepareAndResolvePrivatePaymentRequest( + counterparty: publicKey, + paymentRequestId: request.paymentRequestId, + afterPrivatePaymentListVersion: consumedVersion + ) + if prepared.resolution.state == .recoveryPending || prepared.resolution.status == .waitingForUpdatedPaymentList { + // The last list was already paid from; a new one arrives once the payee sees that payment settle. + schedulePrivatePaymentRecovery(for: publicKey) + throw PaykitAllowanceError.paymentListPending + } + // Without a payment list version nothing limits how often the payee's endpoints can be paid, so the request asks. + guard let paymentListVersion = prepared.resolution.privatePaymentListVersion else { return nil } - let eligible = Set(eligibleIdentifiers).intersection(request.acceptedPaymentEndpointIdentifiers) - let candidates = resolvedEndpoints(from: prepared.resolution).filter { - eligible.contains($0.methodId.rawValue) && - ($0.methodId == .bitcoinLightningBolt11 || $0.methodId.onchainNetwork != nil) && $0.appId != nil - } - let payable = await privatePayableEndpoints(from: candidates, publicKey: publicKey) + let eligible = Set(eligibleIdentifiers).intersection(request.acceptedPaymentEndpointIdentifiers) + let candidates = resolvedEndpoints(from: prepared.resolution).filter { + eligible.contains($0.methodId.rawValue) && + ($0.methodId == .bitcoinLightningBolt11 || $0.methodId.onchainNetwork != nil) && $0.appId != nil + } + let payable = await privatePayableEndpoints(from: candidates, publicKey: publicKey) - for methodId in PublicPaykitService.MethodId.payablePreferenceOrder { - guard let endpoint = payable.first(where: { $0.methodId == methodId }), let appId = endpoint.appId else { continue } - let context = PrivatePaykitPaymentContext( - paymentAppsByEndpoint: [methodId.rawValue: appId], - paymentListVersion: paymentListVersion - ) - if methodId == .bitcoinLightningBolt11 { - guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value), - invoice.amountSatoshis == 0 || invoice.amountSatoshis == request.allowanceAmountSats - else { continue } + for methodId in PublicPaykitService.MethodId.payablePreferenceOrder { + guard let endpoint = payable.first(where: { $0.methodId == methodId }), let appId = endpoint.appId else { continue } + let context = PrivatePaykitPaymentContext( + paymentAppsByEndpoint: [methodId.rawValue: appId], + paymentListVersion: paymentListVersion + ) + if methodId == .bitcoinLightningBolt11 { + guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value), + invoice.amountSatoshis == 0 || invoice.amountSatoshis == request.allowanceAmountSats + else { continue } + return PrivatePaykitAllowancePayment( + endpoint: endpoint, + context: context, + lightningPaymentHash: invoice.paymentHash.hex, + lightningInvoiceHasAmount: invoice.amountSatoshis != 0 + ) + } return PrivatePaykitAllowancePayment( endpoint: endpoint, context: context, - lightningPaymentHash: invoice.paymentHash.hex, - lightningInvoiceHasAmount: invoice.amountSatoshis != 0 + lightningPaymentHash: nil, + lightningInvoiceHasAmount: false ) } - return PrivatePaykitAllowancePayment( - endpoint: endpoint, - context: context, - lightningPaymentHash: nil, - lightningInvoiceHasAmount: false - ) + return nil } - return nil } private func resolvedEndpoints(from resolution: PrivateContactPaymentResolution) -> [PublicPaykitService.Endpoint] { diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 5341fee19..fa0db63fc 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -3,16 +3,32 @@ import Foundation // MARK: - State extension PrivatePaykitService { - func closeAndClear() async { + @discardableResult + func closeAndClear() throws -> Data { + let emptyState = PrivatePaykitState(contacts: [:]) + let encoded = try JSONEncoder().encode(emptyState) invalidateContactPreparation() unavailableLinkRetryAt.removeAll() privatePaymentListConsumptions.removeAll() - state = PrivatePaykitState(contacts: [:]) + state = emptyState knownSavedContactKeys.removeAll() - await PaykitSdkService.shared.clearState() - persistState(markWalletBackup: true) + UserDefaults.standard.set(encoded, forKey: Self.cacheStateKey) + markWalletBackupDataChanged() Self.setContactSharingCleanupPending(false) Self.clearDeletedContactCleanupPending() + return encoded + } + + func validateWipedState() throws { + guard state.contacts.isEmpty, privatePaymentListConsumptions.isEmpty, + UserDefaults.standard.object(forKey: Self.cacheStateKey) == nil + else { throw ProfileRemovalStore.StoreError.changed } + } + + func validateRemovalState(_ encoded: Data) throws { + guard state.contacts.isEmpty, privatePaymentListConsumptions.isEmpty, + UserDefaults.standard.data(forKey: Self.cacheStateKey) == encoded + else { throw ProfileRemovalStore.StoreError.changed } } func clearContactState(publicKey: String) async { @@ -20,23 +36,26 @@ extension PrivatePaykitService { } func clearContactStates(publicKeys: [String]) async { - let keys = Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized)) - guard !keys.isEmpty else { return } - privatePaymentListConsumptions = privatePaymentListConsumptions.filter { !keys.contains($0.key.publicKey) } - for key in keys { - unavailableLinkRetryAt[key] = nil - let consumedVersion = state.contacts[key]?.consumedPrivatePaymentListVersion - if consumedVersion == nil { - state.contacts[key] = nil - } else { - var contactState = ContactState() - contactState.consumedPrivatePaymentListVersion = consumedVersion - contactState.consumedPrivatePaymentListOwner = state.contacts[key]?.consumedPrivatePaymentListOwner - state.contacts[key] = contactState + do { try await profileAdmission.withOperation { + let keys = Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized)) + guard !keys.isEmpty else { return } + await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(publicKeys: Array(keys)) + try profileAdmission.checkCurrent() + privatePaymentListConsumptions = privatePaymentListConsumptions.filter { !keys.contains($0.key.publicKey) } + for key in keys { + unavailableLinkRetryAt[key] = nil + let consumedVersion = state.contacts[key]?.consumedPrivatePaymentListVersion + if consumedVersion == nil { + state.contacts[key] = nil + } else { + var contactState = ContactState() + contactState.consumedPrivatePaymentListVersion = consumedVersion + contactState.consumedPrivatePaymentListOwner = state.contacts[key]?.consumedPrivatePaymentListOwner + state.contacts[key] = contactState + } } - } - await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(publicKeys: Array(keys)) - persistState(markWalletBackup: true) + persistState(markWalletBackup: true) + } } catch { Logger.debug("Contact state cleanup stopped: \(error)", context: "PrivatePaykit") } } func persistState(markWalletBackup: Bool = false) { diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 2107ff23b..b3219457d 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -87,7 +87,11 @@ actor PrivatePaykitService { var isBackgroundWorkPaused = false var messageSchedulingGeneration = 0 var backgroundWorkWaiters: [UUID: AsyncStream.Continuation] = [:] - var isDeletingProfile = false + var profileDeletionToken: UUID? + var isDeletingProfile: Bool { + profileDeletionToken != nil + } + var pendingForceRefreshLightning = false var unavailableLinkRetryAt: [String: Date] = [:] var pendingMessageDrainRetryTask: Task? @@ -104,19 +108,44 @@ actor PrivatePaykitService { var prePaymentPublicationKeys: Set = [] private let publicationLock = PrivatePaykitPublicationLock() let publicationOperations: EndpointPublicationOperations? + let profileAdmission: ProfileOperationAdmission init( publicationOperations: EndpointPublicationOperations? = nil, + profileAdmission: ProfileOperationAdmission = .shared, messageRetryOperations: PrivateMessageRetryOperations = .init() ) { self.publicationOperations = publicationOperations + self.profileAdmission = profileAdmission self.messageRetryOperations = messageRetryOperations state = UserDefaults.standard.data(forKey: Self.cacheStateKey) .flatMap { try? JSONDecoder().decode(PrivatePaykitState.self, from: $0) } ?? PrivatePaykitState(contacts: [:]) } + @discardableResult + func profileTask(_ operation: @escaping () async -> Void) -> Task? { + let admission = profileAdmission + guard let scope = try? admission.capture() else { return nil } + let id = UUID() + let task = Task { + defer { + admission.removeCancellationHandler(from: scope, id: id) + admission.release(scope) + } + do { try await admission.withScope(scope, revokeOnCancellation: false) { await operation() } } + catch { Logger.debug("Private profile work stopped: \(error)", context: "PrivatePaykit") } + } + admission.onRevocation(of: scope, id: id) { task.cancel() } + return task + } + func withPublicationLock(_ operation: () async throws -> T) async throws -> T { - try await publicationLock.withLock(operation) + try await profileAdmission.withOperation { + try await publicationLock.withLock { + try profileAdmission.checkCurrent() + return try await operation() + } + } } func markWalletBackupDataChanged() { diff --git a/Bitkit/Services/ProfileSessionRevocation.swift b/Bitkit/Services/ProfileSessionRevocation.swift new file mode 100644 index 000000000..7f6b362dd --- /dev/null +++ b/Bitkit/Services/ProfileSessionRevocation.swift @@ -0,0 +1,30 @@ +import Foundation + +final class ProfileSessionRevocation: @unchecked Sendable { + let validate: @Sendable () async throws -> Void + let execute: @Sendable () async throws -> Void + let acknowledged: @Sendable () async -> Bool + let clearCredentials: @Sendable () async throws -> Void + let revoke: @Sendable () -> Void + private let release: @Sendable () -> Void + + init(validate: @escaping @Sendable () async throws -> Void, + execute: @escaping @Sendable () async throws -> Void, + acknowledged: @escaping @Sendable () async -> Bool, + clearCredentials: @escaping @Sendable () async throws -> Void, + revoke: @escaping @Sendable () -> Void, + release: @escaping @Sendable () -> Void = {}) + { + self.validate = validate + self.execute = execute + self.acknowledged = acknowledged + self.clearCredentials = clearCredentials + self.revoke = revoke + self.release = release + } + + deinit { + revoke() + release() + } +} diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index e1b1882e4..0726e5c9b 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -33,6 +33,7 @@ enum PubkyServiceError: LocalizedError { struct PubkyRegisteredIdentity { let result: PubkySessionBootstrapResult let walletGeneration: Int + let profileGeneration: ProfileOperationAdmission.Generation } /// Which public read slots a read may use. Background work that reads for many contacts at once is `bulk`, so it can @@ -95,21 +96,29 @@ enum PubkyService { secretKeyHex: String, sdkService: PaykitSdkService = .shared ) async throws { - try await sdkService.republishIdentityBeforeApproval(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) - try Task.checkCancellation() - try await sdkService.approveAuth( - authUrl: authUrl, - expectedCapabilities: expectedCapabilities, - approvedClientID: approvedClientID, - secretKeyHex: secretKeyHex - ) + return try await sdkService.profileAdmission.withOperation { + try await sdkService.republishIdentityBeforeApproval(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try Task.checkCancellation() + try await sdkService.approveAuth( + authUrl: authUrl, + expectedCapabilities: expectedCapabilities, + approvedClientID: approvedClientID, + secretKeyHex: secretKeyHex + ) + } } static func approveRingAuth(authUrl: String, secretKeyHex: String, sdkService: PaykitSdkService = .shared) async throws { - try await sdkService.republishIdentityBeforeApproval(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) - try Task.checkCancellation() - try await ServiceQueue.background(.core) { - try await BitkitCore.approvePubkyAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) + return try await sdkService.profileAdmission.withOperation { + try await sdkService.republishIdentityBeforeApproval(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try Task.checkCancellation() + let scope = try sdkService.profileAdmission.capture() + defer { sdkService.profileAdmission.release(scope) } + try await ServiceQueue.background(.core) { + try await sdkService.profileAdmission.withScope(scope) { + try await BitkitCore.approvePubkyAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) + } + } } } @@ -121,23 +130,25 @@ enum PubkyService { secretKeyHex: String, sdkService: PaykitSdkService = .shared ) async throws { - try await sdkService.republishIdentityBeforeApproval(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) - try Task.checkCancellation() - let paykitKey = claim.includesPaykitAccess - ? try await sdkService.paykitKeyForAuthorization(secretKeyHex: secretKeyHex) - : nil - let payload = try claim.encode(accountPayload: accountPayload, paykitKey: paykitKey) - try await sdkService.approveAuthWithCompanionClaim( - authUrl: authUrl, - expectedCapabilities: PubkyAuthClaim.requiredCapabilities, - approvedClientID: approvedClientID, - secretKeyHex: secretKeyHex, - claim: Paykit.PubkyAuthCompanionClaim( - queryParameter: PubkyAuthClaim.queryParameter, - claimType: claim.rawValue, - unsignedPayload: payload + return try await sdkService.profileAdmission.withOperation { + try await sdkService.republishIdentityBeforeApproval(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try Task.checkCancellation() + let paykitKey = claim.includesPaykitAccess + ? try await sdkService.paykitKeyForAuthorization(secretKeyHex: secretKeyHex) + : nil + let payload = try claim.encode(accountPayload: accountPayload, paykitKey: paykitKey) + try await sdkService.approveAuthWithCompanionClaim( + authUrl: authUrl, + expectedCapabilities: PubkyAuthClaim.requiredCapabilities, + approvedClientID: approvedClientID, + secretKeyHex: secretKeyHex, + claim: Paykit.PubkyAuthCompanionClaim( + queryParameter: PubkyAuthClaim.queryParameter, + claimType: claim.rawValue, + unsignedPayload: payload + ) ) - ) + } } static func didDeliverCompanionClaim(error: Error) -> Bool { @@ -450,6 +461,7 @@ actor PaykitSdkService { struct BackupIdentity: Equatable { let runtimeGeneration: UInt64 let walletGeneration: Int + let profileGeneration: ProfileOperationAdmission.Generation } static let shared = PaykitSdkService() @@ -461,9 +473,10 @@ actor PaykitSdkService { walletBackupDataChangedSubject.eraseToAnyPublisher() } - private let sessionProvider = PaykitSdkSessionProvider() + private let sessionProvider: PaykitSdkSessionProvider private let paymentAdapter = PaykitSdkPaymentAdapter() - private let operationLock = PaykitSdkOperationLock() + private let operationLock: PaykitSdkOperationLock + nonisolated let profileAdmission: ProfileOperationAdmission private let publicReadSlots = PaykitPublicReadSlots() private let pubkyClientConfig = PaykitSdkService.makePubkyClientConfig(localTestnetHost: Env.pubkyLocalTestnetHost) private let sdkFactory: (() throws -> PaykitSdk)? @@ -477,23 +490,202 @@ actor PaykitSdkService { private var nextIdentityRepublishAt = Date.distantPast private var lastIdentityRepublishAt = Date.distantPast private var sdk: PaykitSdk? + private var publicSdk: PaykitSdk? private var cachedPaykitKey: (publicKey: String, generation: UInt64)? private var cachedBackupState: BackupStateSnapshot? private(set) var backupStateVersion: UInt64 = 0 private var runtimeGeneration: UInt64 = 0 + struct CredentialActivation: Sendable { + let publicKey: String + let previousPublicKey: String? + let removesOwnSharedRecord: Bool + } + + final class CredentialAttempt: @unchecked Sendable { + fileprivate let id = UUID() + fileprivate let scope: ProfileOperationAdmission.Scope + fileprivate let walletGeneration: Int + fileprivate let original: [Data?] + fileprivate var expected: [[Data?]] + fileprivate var consumed = false + fileprivate var activation: CredentialActivation? + + fileprivate init(scope: ProfileOperationAdmission.Scope, walletGeneration: Int, original: [Data?]) { + self.scope = scope + self.walletGeneration = walletGeneration + self.original = original + expected = [original] + } + } + + private var credentialAttempt: CredentialAttempt? + private static let credentialKeys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] + + func beginCredentialAttempt() async throws -> CredentialAttempt { + let scope = try profileAdmission.retainLocalCompletion() + var prepared: CredentialAttempt? + do { + return try await withLockedSdkOperation { + guard credentialAttempt == nil else { throw ProfileRemovalStore.StoreError.changed } + let attempt = try CredentialAttempt(scope: scope, walletGeneration: operationLock.walletGeneration(), + original: Self.credentialKeys.map { try Keychain.load(key: $0) }) + credentialAttempt = attempt + prepared = attempt + return attempt + } + } catch { + if credentialAttempt === prepared { + credentialAttempt = nil + } + profileAdmission.release(scope) + throw error + } + } + + #if DEBUG + private var beforeCredentialRollbackForTesting: (@Sendable () async -> Void)? + func setBeforeCredentialRollbackForTesting(_ operation: @escaping @Sendable () async -> Void) { + beforeCredentialRollbackForTesting = operation + } + #endif + + @discardableResult + func commitCredentialAttempt(_ attempt: CredentialAttempt) throws -> CredentialActivation? { + try Task.checkCancellation() + guard credentialAttempt === attempt, !attempt.consumed, profileAdmission.owns(attempt.scope) else { + throw ProfileRemovalStore.StoreError.changed + } + try profileAdmission.acknowledgeSessionTransition(attempt.scope) + attempt.consumed = true + credentialAttempt = nil + profileAdmission.release(attempt.scope) + return attempt.activation + } + + /// Called by an uncancelled cleanup worker with the preissued receipt; never constructs an SDK or signs out remotely. + func rollbackCredentialAttempt(_ attempt: CredentialAttempt) async throws { + guard credentialAttempt === attempt, !attempt.consumed else { throw ProfileRemovalStore.StoreError.changed } + attempt.consumed = true + defer { + credentialAttempt = nil + profileAdmission.release(attempt.scope) + } + do { + #if DEBUG + await beforeCredentialRollbackForTesting?() + #endif + try await operationLock.withLocalCompletion(scope: attempt.scope, generation: attempt.walletGeneration) { + defer { + sessionProvider.clearLiveSessionAccess() + resetRuntime() + markWalletBackupDataChanged() + } + let current = try Self.credentialKeys.map { try Keychain.load(key: $0) } + guard attempt.expected.contains(current) else { throw ProfileRemovalStore.StoreError.changed } + var firstError: Error? + for (key, value) in zip(Self.credentialKeys, attempt.original) { + do { + if let value { + try Keychain.upsert(key: key, data: value) + } else { + try Keychain.delete(key: key) + } + } catch { firstError = firstError ?? error } + } + if let firstError { + throw firstError + } + guard try Self.credentialKeys.map({ try Keychain.load(key: $0) }) == attempt.original else { + throw ProfileRemovalStore.StoreError.changed + } + } + } catch { + // Reopening identity admission with uncertain credentials would lose the local cleanup obligation. + do { try profileAdmission.recordLocalCompletionFailure(attempt.scope, id: attempt.id) } + catch { Logger.error("Unable to persist local credential recovery: \(error)", context: "PaykitSdkService") } + throw error + } + } + + private func withCredentialAttempt( + shouldCommit: (T) -> Bool = { _ in true }, _ operation: () async throws -> T + ) async throws -> T { + if let credentialAttempt { + guard profileAdmission.owns(credentialAttempt.scope) else { throw ProfileRemovalStore.StoreError.changed } + return try await operation() + } + let attempt: CredentialAttempt + do { attempt = try await beginCredentialAttempt() } + catch { + if PubkyCredentialLocalError.isLocalFailure(error) { + throw PubkyCredentialLocalError(underlyingError: error) + } + throw error + } + var finished = false + do { + let result = try await operation() + try profileAdmission.checkCurrent() + if shouldCommit(result) { + let activation = try commitCredentialAttempt(attempt) + finished = true + try await MainActor.run { + try self.profileAdmission.validateAcknowledgedLocalCompletion() + PubkyProfileManager.applyCommittedActivation(activation) + } + } else { + finished = true + try await Task.detached { try await self.rollbackCredentialAttempt(attempt) }.value + } + return result + } catch { + if !finished { + do { try await Task.detached { try await self.rollbackCredentialAttempt(attempt) }.value } + catch let cleanupError { throw PubkyCredentialLocalError(underlyingError: error, cleanupError: cleanupError) } + } + if PubkyCredentialLocalError.isLocalFailure(error) { + throw PubkyCredentialLocalError(underlyingError: error) + } + throw error + } + } + + private func recordCredentialWrite(key: KeychainEntryType, value: Data?, localCompletion: Bool = false) throws { + guard let attempt = credentialAttempt else { return } + guard !attempt.consumed, profileAdmission.owns(attempt.scope) else { throw ProfileRemovalStore.StoreError.changed } + if localCompletion { + try profileAdmission.validateLocalCompletion(attempt.scope) + } + var state = try Self.credentialKeys.map { try Keychain.load(key: $0) } + guard attempt.expected.contains(state), let index = Self.credentialKeys.firstIndex(where: { $0.storageKey == key.storageKey }) else { + throw ProfileRemovalStore.StoreError.changed + } + state[index] = value + if !attempt.expected.contains(state) { + attempt.expected.append(state) + } + } + init( + sessionProvider: PaykitSdkSessionProvider = PaykitSdkSessionProvider(), + profileAdmission: ProfileOperationAdmission = .shared, sdkFactory: (() throws -> PaykitSdk)? = nil, bootstrapFactory: @escaping BootstrapFactory = PubkySessionBootstrap.withPubkyClientConfig(clientId:pubkyClient:) ) { + self.sessionProvider = sessionProvider + self.profileAdmission = profileAdmission + operationLock = PaykitSdkOperationLock(profileAdmission: profileAdmission) self.sdkFactory = sdkFactory self.bootstrapFactory = bootstrapFactory } func initialize() async throws { - startIdentityRepublish() - try await operationLock.withLock { - try await initializeLocked() + return try await profileAdmission.withSessionTransition { + try await withLockedSdkOperation { + try await initializeLocked() + } + startIdentityRepublish() } } @@ -502,6 +694,7 @@ actor PaykitSdkService { var sdk = try handle() let status: IdentityStatus do { + try profileAdmission.checkCurrent() status = try await sdk.initialize() } catch { invalidatePaykitKeyIfNeeded(after: error) @@ -512,6 +705,7 @@ actor PaykitSdkService { resetRuntime() do { sdk = try handle() + try profileAdmission.checkCurrent() status = try await sdk.initialize() } catch { sessionProvider.resumeStoredSessionAccess() @@ -524,35 +718,91 @@ actor PaykitSdkService { /// Keep credential reads and fallback activation atomic with sign-out and identity changes. func restorePersistedSession() async throws -> PubkyProfileManager.SessionInitializationResult { - Task { await republishIdentityIfNeeded() } - return try await operationLock.withLock { - let savedSessionSecret = try Keychain.loadString(key: .paykitSession) - let storedSecretKeyHex = PubkyProfileManager.activeSecretKeyHex() - if savedSessionSecret == nil, storedSecretKeyHex == nil { - try await initializeLocked() + return try await profileAdmission.withSessionTransition { + try await withCredentialAttempt(shouldCommit: { + if case .restored = $0 { + return true + }; return false + }) { + let result = try await withLockedSdkOperation { + let savedSessionSecret = try Keychain.loadString(key: .paykitSession) + let storedSecretKeyHex = PubkyProfileManager.activeSecretKeyHex() + if savedSessionSecret == nil, storedSecretKeyHex == nil { + try await initializeLocked() + } + var requiredLocalError: Error? + let resolved = await PubkyProfileManager.resolveSessionInitialization( + savedSessionSecret: savedSessionSecret, + storedSecretKeyHex: storedSecretKeyHex, + importSession: { + do { return try await self.importSessionLocked(secret: $0).publicKey } + catch { + if PubkyCredentialLocalError.isLocalFailure(error) || error is CancellationError || error is ProfileRemovalStore + .StoreError + { + requiredLocalError = error + }; throw error + } + }, + signInWithSecretKey: { + if let requiredLocalError { + throw requiredLocalError + } + do { return try await self.signInLocked(secretKeyHex: $0).publicKey } + catch { + if PubkyCredentialLocalError.isLocalFailure(error) || error is CancellationError || error is ProfileRemovalStore + .StoreError + { + requiredLocalError = error + }; throw error + } + } + ) + if let requiredLocalError { + throw requiredLocalError + } + return resolved + } + if case .restored = result { + startIdentityRepublish() + } + return result } - return await PubkyProfileManager.resolveSessionInitialization( - savedSessionSecret: savedSessionSecret, - storedSecretKeyHex: storedSecretKeyHex, - importSession: { try await self.importSessionLocked(secret: $0).publicKey }, - signInWithSecretKey: { try await self.signInLocked(secretKeyHex: $0).publicKey } - ) } } /// Republishes without making the caller wait on the DHT. Only the public key string reaches the task, so it never /// keeps session access alive. func startIdentityRepublish(publicKey: String? = nil) { - Task { await republishIdentityIfNeeded(publicKey: publicKey) } + let admission = profileAdmission + try? admission.scheduleAfterTransition { scope in + let id = UUID() + let task = Task { + defer { + admission.removeCancellationHandler(from: scope, id: id) + admission.release(scope) + } + try? await admission.withScope(scope, revokeOnCancellation: false) { + await self.republishIdentityIfNeeded(publicKey: publicKey) + } + } + admission.onRevocation(of: scope, id: id) { task.cancel() } + } } func republishIdentityIfNeeded(publicKey: String? = nil, now: Date = Date(), timeout: Duration = .seconds(5)) async { guard !Task.isCancelled else { return } // Swift FFI may ignore cancellation; keep the in-flight guard until publication actually finishes. let (stream, continuation) = AsyncStream.makeStream() + guard let scope = try? profileAdmission.capture() else { return } let publication = Task { - await republishIdentity(publicKey: publicKey, now: now) - continuation.finish() + defer { + profileAdmission.release(scope) + continuation.finish() + } + try? await profileAdmission.withScope(scope, revokeOnCancellation: false) { + await republishIdentity(publicKey: publicKey, now: now) + } } defer { publication.cancel() } await waitForRepublish(stream, finishedBy: continuation, timeout: timeout) @@ -595,7 +845,9 @@ actor PaykitSdkService { /// Rebroadcasts the identity record when one exists. Returns false only when the network reports none. func hasIdentityRecord(publicKey: String) async throws -> Bool { - try await bootstrap().republishIdentity(publicKey: publicKey) + try await profileAdmission.withOperation { + try await bootstrap().republishIdentity(publicKey: publicKey) + } } private func republishIdentity(publicKey: String?, now: Date) async { @@ -609,6 +861,7 @@ actor PaykitSdkService { } do { + try profileAdmission.checkCurrent() let identity = try publicKey ?? sessionProvider.loadLocalSecretKey().map { try Paykit.pubkyPublicKeyFromSecret(localSecretKey: $0) } @@ -661,14 +914,19 @@ actor PaykitSdkService { } func importSession(secret: String) async throws -> PubkySessionBootstrapResult { - try await operationLock.withLock { - try await importSessionLocked(secret: secret) + return try await profileAdmission.withSessionTransition { + try await withCredentialAttempt { + try await withLockedSdkOperation { + try await importSessionLocked(secret: secret) + } + } } } private func importSessionLocked(secret: String) async throws -> PubkySessionBootstrapResult { let previousPublicKey = try await cachedIdentityPublicKey() let localSecret = try sessionProvider.loadLocalSecretKey() + try profileAdmission.checkCurrent() let result = try await bootstrap().importSession( sessionSecret: secret, localSecretKey: localSecret, @@ -680,17 +938,22 @@ actor PaykitSdkService { } func signUp(secretKeyHex: String, homeserverPublicKey: String, signupCode: String?) async throws -> PubkySessionBootstrapResult { - try await operationLock.withLock { - let previousPublicKey = try await cachedIdentityPublicKey() - let result = try await bootstrap().signUp( - localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), - homeserverPublicKey: homeserverPublicKey, - signupCode: signupCode, - requiredCapabilities: Self.requiredCapabilities() - ) - try await activateBootstrapResult(result, previousPublicKey: previousPublicKey) - markWalletBackupDataChanged() - return result + return try await profileAdmission.withSessionTransition { + try await withCredentialAttempt { + try await withLockedSdkOperation { + let previousPublicKey = try await cachedIdentityPublicKey() + try profileAdmission.checkCurrent() + let result = try await bootstrap().signUp( + localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + homeserverPublicKey: homeserverPublicKey, + signupCode: signupCode, + requiredCapabilities: Self.requiredCapabilities() + ) + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey) + markWalletBackupDataChanged() + return result + } + } } } @@ -699,7 +962,7 @@ actor PaykitSdkService { homeserverPublicKey: String, signupCode: String? ) async throws -> PubkyRegisteredIdentity { - try await operationLock.withLock { + try await withLockedSdkOperation { let generation = try operationLock.walletGeneration() let result = try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), @@ -707,26 +970,59 @@ actor PaykitSdkService { signupCode: signupCode, requiredCapabilities: Self.requiredCapabilities() ) - return PubkyRegisteredIdentity(result: result, walletGeneration: generation) + return try PubkyRegisteredIdentity( + result: result, + walletGeneration: generation, + profileGeneration: profileAdmission.operationGeneration() + ) } } + #if DEBUG + private var beforeRegistrationActivationForTesting: (@Sendable () async -> Void)? + + func setBeforeRegistrationActivationForTesting(_ operation: @escaping @Sendable () async -> Void) { + beforeRegistrationActivationForTesting = operation + } + #endif + func activateRegisteredIdentity(_ identity: PubkyRegisteredIdentity) async throws { - try await operationLock.withLock(generation: identity.walletGeneration) { - let previousPublicKey = try await cachedIdentityPublicKey() - try await activateBootstrapResult(identity.result, previousPublicKey: previousPublicKey) - markWalletBackupDataChanged() + guard try operationLock.walletGeneration() == identity.walletGeneration else { + throw PaykitError.Storage(code: "wallet_wipe_in_progress", context: "The registration belongs to an ended wallet") + } + try profileAdmission.validateOperationGeneration(identity.profileGeneration) + #if DEBUG + await beforeRegistrationActivationForTesting?() + #endif + return try await profileAdmission.withSessionTransition(expected: identity.profileGeneration) { + try await withCredentialAttempt { + guard try operationLock.walletGeneration() == identity.walletGeneration else { + throw PaykitError.Storage(code: "wallet_wipe_in_progress", context: "The registration belongs to an ended wallet") + } + try profileAdmission.validateOperationGeneration(identity.profileGeneration) + try await withLockedSdkOperation(generation: identity.walletGeneration) { + try profileAdmission.validateOperationGeneration(identity.profileGeneration) + let previousPublicKey = try await cachedIdentityPublicKey() + try await activateBootstrapResult(identity.result, previousPublicKey: previousPublicKey) + markWalletBackupDataChanged() + } + } } } func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { - try await operationLock.withLock { - try await signInLocked(secretKeyHex: secretKeyHex) + return try await profileAdmission.withSessionTransition { + try await withCredentialAttempt { + try await withLockedSdkOperation { + try await signInLocked(secretKeyHex: secretKeyHex) + } + } } } private func signInLocked(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { let previousPublicKey = try await cachedIdentityPublicKey() + try profileAdmission.checkCurrent() let result = try await bootstrap().signIn( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), requiredCapabilities: Self.requiredCapabilities() @@ -737,7 +1033,7 @@ actor PaykitSdkService { } func approveAuth(authUrl: String, expectedCapabilities: String, approvedClientID: String, secretKeyHex: String) async throws { - try await operationLock.withLock { + try await withLockedSdkOperation { try await approvalBootstrap(authUrl: authUrl, approvedClientID: approvedClientID).approveAuth( authUrl: authUrl, expectedCapabilities: expectedCapabilities, @@ -753,7 +1049,7 @@ actor PaykitSdkService { secretKeyHex: String, claim: Paykit.PubkyAuthCompanionClaim ) async throws { - try await operationLock.withLock { + try await withLockedSdkOperation { try await approvalBootstrap(authUrl: authUrl, approvedClientID: approvedClientID).approveAuthWithCompanionClaim( authUrl: authUrl, expectedCapabilities: expectedCapabilities, @@ -826,6 +1122,18 @@ actor PaykitSdkService { return record.uri } + func profileRemovalRecord(publicKey: String) async throws -> Paykit.PaykitProfileRecord? { + try await withPublicRead { try await $0.fetchPaykitProfile(publicKey: publicKey) } + } + + func deleteConfirmedProfile(expectedIdentity: String, expectedRevision: String) async throws { + try await withStateRevisionTracking { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + try await Self.requireNoActiveSubscriptions(in: sdk) + try await sdk.deletePaykitProfile(expectedRevision: expectedRevision) + } + } + func requireNoActiveSubscriptions(expectedIdentity: String) async throws { try await withStateRevisionTracking { sdk in try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) @@ -1034,6 +1342,7 @@ actor PaykitSdkService { guard await isSessionCurrent() else { throw PubkyServiceError.sessionNotActive } try Task.checkCancellation() } + try profileAdmission.checkCurrent() _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } } @@ -1092,12 +1401,6 @@ actor PaykitSdkService { !blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) } }.map { PrivatePaymentListReservationUpdateInput(counterparty: $0, reservations: []) } guard !updates.isEmpty else { return nil } - if let publicKey = try await sdk.identityStatus()?.publicKey, - let app = try await sdk.paykitAppRegistry(publicKey: publicKey)?.apps.first(where: { $0.appId == "bitkit" }), - !app.capabilities.privatePayments - { - return nil - } for update in updates where peers.contains(where: { $0.state == .recoveryRequired && PubkyPublicKeyFormat.matches($0.counterparty, update.counterparty) }) { @@ -1117,6 +1420,7 @@ actor PaykitSdkService { if let isSessionCurrent, await !isSessionCurrent() { throw PubkyServiceError.sessionNotActive } + try profileAdmission.checkCurrent() return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( updates: updates, clearUnlistedLinkedPeers: false @@ -1364,7 +1668,7 @@ actor PaykitSdkService { } func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] { - try await operationLock.withLock { + try await withLockedSdkOperation { try await handle().listAllowances(filter: filter) } } @@ -1413,7 +1717,7 @@ actor PaykitSdkService { } func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? { - try await operationLock.withLock { + try await withLockedSdkOperation { try await handle().allowanceAccountingState() } } @@ -1548,7 +1852,7 @@ actor PaykitSdkService { func resolvePublicContactPayment(counterparty: String) async throws -> PublicContactPaymentResolution { try await withPublicRead { sdk in let result = try await sdk.resolvePublicContactPayment(counterparty: counterparty, amount: nil) - guard self.sdk === sdk else { throw PubkyServiceError.identityChanged } + guard self.publicSdk === sdk else { throw PubkyServiceError.identityChanged } return result } } @@ -1558,56 +1862,146 @@ actor PaykitSdkService { } func backupIdentity() throws -> BackupIdentity { - try BackupIdentity(runtimeGeneration: runtimeGeneration, walletGeneration: operationLock.walletGeneration()) + try BackupIdentity( + runtimeGeneration: runtimeGeneration, + walletGeneration: operationLock.walletGeneration(), + profileGeneration: profileAdmission.snapshot() + ) } func validateBackupSnapshot(version: UInt64, identity: BackupIdentity) async throws { - try await operationLock.withLock(priority: .interactive) { + try await withLockedSdkOperation(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: BackupExport? = try await withSdk(priority: priority) { sdk in - guard sdk === instance, try operationLock.walletGeneration() == generation else { - throw PubkyServiceError.identityChanged + return try await profileAdmission.withOperation { + let generation = try operationLock.walletGeneration() + let instance = try handle() + while true { + if priority == .background { + try await profileAdmission.withDetached { try await PaykitPaymentActivity.shared.waitUntilIdle() } } - // Payment may have started while this export waited for the SDK lock. - if priority == .background, await PaykitPaymentActivity.shared.isActive { - return nil + 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 + } + let state = try await sdk.exportBackupString() + return BackupExport(state: state, version: backupStateVersion) + } + if let exported { + return exported } - let state = try await sdk.exportBackupString() - return BackupExport(state: state, version: backupStateVersion) - } - if let exported { - return exported } } } func signOut() async throws { - try await withStateRevisionTracking { sdk in - _ = try await sdk.signOut() + try await profileAdmission.withOperation { + try await withStateRevisionTracking { sdk in + _ = try await sdk.signOut() + } + resetRuntime() } - resetRuntime() } - func forgetSessionAccess() async throws { - try await operationLock.withLock { - defer { resetRuntime() } - _ = try await handle().forgetSessionAccess() + func captureProfileRevocation(expectedOwner: String) async throws -> ProfileSessionRevocation { + try await withLockedSdkOperation { + try await refreshPaykitKey() + let originalRuntime = runtimeGeneration + let originalSdk = try handle() + let access = try sessionProvider.captureOriginalAccess(expectedOwner: expectedOwner) + let logout = try await originalSdk.captureGrantLogout(expectedPublicKey: expectedOwner) + do { + try profileAdmission.checkCurrent() + let binding = logout.binding() + guard PubkyPublicKeyFormat.matches(binding.publicKey, expectedOwner), binding.clientId == Self.clientID, + !binding.grantId.isEmpty, !binding.homeserver.isEmpty else { throw ProfileRemovalStore.StoreError.changed } + let registration = try sessionProvider.registerGrantLogout(logout, access: access) + let validate: @Sendable () async throws -> Void = { [self] in + try await validateProfileRevocation(runtime: originalRuntime, sdk: originalSdk, access: access) + } + return ProfileSessionRevocation(validate: validate, execute: { [self] in + try await executeProfileRevocation(runtime: originalRuntime, sdk: originalSdk, access: access, + logout: logout, binding: binding) + }, acknowledged: { logout.acknowledged() == binding }, clearCredentials: { [self] in + try await clearRevokedProfileAccess(runtime: originalRuntime, sdk: originalSdk, access: access, + logout: logout, binding: binding) + }, revoke: { logout.revokeAdmission() }, release: { [sessionProvider] in + sessionProvider.unregisterGrantLogout(registration) + }) + } catch { + logout.revokeAdmission() + throw error + } + } + } + + private func validateProfileRevocation(runtime: UInt64, sdk originalSdk: PaykitSdk, + access: PaykitSdkSessionProvider.OriginalAccess) throws + { + try profileAdmission.checkCurrent() + guard runtimeGeneration == runtime, sdk === originalSdk else { throw ProfileRemovalStore.StoreError.changed } + try sessionProvider.validateOriginalAccess(access) + } + + private func executeProfileRevocation(runtime: UInt64, sdk originalSdk: PaykitSdk, + access: PaykitSdkSessionProvider.OriginalAccess, + logout: PubkyGrantLogout, binding: PubkyGrantLogoutReceipt) async throws + { + try await withLockedSdkOperation { + try validateProfileRevocation(runtime: runtime, sdk: originalSdk, access: access) + let attempt = try await logout.prepareAttempt() + try validateProfileRevocation(runtime: runtime, sdk: originalSdk, access: access) + let receipt = try await attempt.execute() + guard receipt == binding else { throw ProfileRemovalStore.StoreError.changed } + } + } + + private func clearRevokedProfileAccess(runtime: UInt64, sdk originalSdk: PaykitSdk, + access: PaykitSdkSessionProvider.OriginalAccess, + logout: PubkyGrantLogout, binding: PubkyGrantLogoutReceipt) async throws + { + try await withLockedSdkOperation(access: .local) { + guard runtimeGeneration == runtime, sdk === originalSdk, + logout.acknowledged() == binding else { throw ProfileRemovalStore.StoreError.changed } + try sessionProvider.clearOriginalAccess(access) + resetRuntime() markWalletBackupDataChanged() } } + func forgetSessionAccess() async throws { + try await withLockedSdkOperation(access: .local) { + // Local recovery must remain available when SDK construction or authentication is unavailable. + // A failed key deletion may still have removed the other credential, so invalidate snapshots either way. + defer { + resetRuntime() + markWalletBackupDataChanged() + } + try sessionProvider.clearSessionAccess() + } + } + func withWalletWipe(_ operation: () async throws -> T) async throws -> T { + if let current = ProfileOperationAdmission.current, profileAdmission.isWalletWipe(current) { + return try await withSdkWipe(operation) + } + let scope = try profileAdmission.reserve(kind: .walletWipe, id: UUID()) + defer { profileAdmission.release(scope) } + return try await profileAdmission.withScope(scope, access: .control) { + try await profileAdmission.drain(before: scope) + return try await withSdkWipe(operation) + } + } + + private func withSdkWipe(_ operation: () async throws -> T) async throws -> T { try await operationLock.withWalletWipe { resetRuntime() defer { @@ -1620,7 +2014,7 @@ actor PaykitSdkService { func clearState() async { do { - try await operationLock.withLock { + try await withLockedSdkOperation(access: .local) { resetRuntime() markWalletBackupDataChanged() } @@ -1650,6 +2044,7 @@ actor PaykitSdkService { } private func handle() throws -> PaykitSdk { + try profileAdmission.checkCurrent() if let sdk { return sdk } @@ -1664,20 +2059,50 @@ actor PaykitSdkService { return created } + /// Construction is synchronous on this actor and never joins the authenticated operation queue. + private func publicHandle() throws -> PaykitSdk { + let generation = try operationLock.publicReadGeneration() + if let publicSdk { + return publicSdk + } + let created = try sdkFactory?() ?? PaykitSdk.withPaymentAdapterAndPubkySharedStateAndClientConfig( + sessionProvider: PaykitSdkSessionProvider(publicOnly: true), + paymentAdapter: paymentAdapter, + config: Self.config(), + pubkyClient: pubkyClientConfig + ) + guard try operationLock.publicReadGeneration() == generation else { throw PubkyServiceError.identityChanged } + publicSdk = created + return created + } + private func withPublicRead( priority: PaykitPublicReadPriority = .interactive, _ read: (PaykitSdk) async throws -> T ) async throws -> T { try await operationLock.withoutLock { - let instance: PaykitSdk = if let sdk { - sdk - } else { - try await operationLock.withCancellableLock { try handle() } - } + let instance = try publicHandle() return try await publicReadSlots.withSlot(priority) { try await read(instance) } } } + private func withLockedSdkOperation( + priority: PaykitSdkOperationLock.Priority = .ordered, + generation: Int? = nil, + access: ProfileOperationAdmission.Access = .authenticated, + _ operation: () async throws -> T + ) async throws -> T { + try await operationLock.withLock(priority: priority, generation: generation, access: access) { + let scope = ProfileOperationAdmission.current + sessionProvider.setAdmissionCheck { + guard let scope else { throw ProfileRemovalStore.StoreError.changed } + try self.profileAdmission.validate(scope, access: access) + } + defer { sessionProvider.setAdmissionCheck(nil) } + return try await operation() + } + } + private func withSdk( priority: PaykitSdkOperationLock.Priority = .ordered, deferDuringPayment: Bool = false, @@ -1690,61 +2115,65 @@ actor PaykitSdkService { let clock = ContinuousClock() let started = clock.now #endif - let generation = deferDuringPayment ? try operationLock.walletGeneration() : nil - let instance = deferDuringPayment ? try handle() : nil - while true { - if deferDuringPayment { - try await PaykitPaymentActivity.shared.waitUntilIdle() - } - let result: T? = try await operationLock.withLock(priority: priority) { - #if DEBUG - let admitted = clock.now - defer { - Logger.debug( - "Finished Paykit operation \(operationName) trace=\(trace) queue=\(started.duration(to: admitted)) work=\(admitted.duration(to: clock.now))", - context: "PaykitSdkService" - ) - } - #endif - if let instance { - guard sdk === instance, try operationLock.walletGeneration() == generation else { - throw PubkyServiceError.identityChanged - } - // Release the SDK lock before waiting if payment started while this operation was queued. - guard await !PaykitPaymentActivity.shared.isActive else { return nil } + return try await profileAdmission.withOperation { + let generation = deferDuringPayment ? try operationLock.walletGeneration() : nil + let instance = deferDuringPayment ? try handle() : nil + while true { + if deferDuringPayment { + try await profileAdmission.withDetached { try await PaykitPaymentActivity.shared.waitUntilIdle() } } - try await beforeOperation() - do { - return try await withSdkErrorHandling { - #if DEBUG - let keyStarted = clock.now - #endif - try await refreshPaykitKey() - #if DEBUG + let result: T? = try await withLockedSdkOperation(priority: priority) { + #if DEBUG + let admitted = clock.now + defer { Logger.debug( - "Prepared Paykit key trace=\(trace) duration=\(keyStarted.duration(to: clock.now))", + "Finished Paykit operation \(operationName) trace=\(trace) queue=\(started.duration(to: admitted)) work=\(admitted.duration(to: clock.now))", context: "PaykitSdkService" ) - #endif - try await beforeOperation() - return try await .some(operation(handle())) + } + #endif + if let instance { + guard sdk === instance, try operationLock.walletGeneration() == generation else { + throw PubkyServiceError.identityChanged + } + // Release the SDK lock before waiting if payment started while this operation was queued. + guard await !PaykitPaymentActivity.shared.isActive else { return nil } + } + try await beforeOperation() + do { + return try await withSdkErrorHandling { + #if DEBUG + let keyStarted = clock.now + #endif + try await refreshPaykitKey() + #if DEBUG + Logger.debug( + "Prepared Paykit key trace=\(trace) duration=\(keyStarted.duration(to: clock.now))", + context: "PaykitSdkService" + ) + #endif + try await beforeOperation() + return try await .some(operation(handle())) + } + } catch OperationDeferral.paymentActive { + return nil } - } catch OperationDeferral.paymentActive { - return nil } - } - if let result { - return result + if let result { + return result + } } } } private func readRetryingSharedStateBusy(_ read: () async throws -> T) async throws -> T { + try profileAdmission.checkCurrent() do { return try await read() } catch PaykitError.SharedStateBusy { // Acquisition failed before the read ran. Retry only this read, never a payment or uncertain write. try await Task.sleep(for: .milliseconds(250)) + try profileAdmission.checkCurrent() return try await read() } } @@ -1769,6 +2198,7 @@ actor PaykitSdkService { if priority == .background, await PaykitPaymentActivity.shared.shouldDeferRefresh { throw PaykitPaymentRequestError.operationInProgress } + try profileAdmission.checkCurrent() } private func withStateRevisionTracking( @@ -1789,6 +2219,7 @@ actor PaykitSdkService { try Task.checkCancellation() } try await admit() + try profileAdmission.checkCurrent() }) { try await operation(sdk) } } } @@ -1885,6 +2316,7 @@ actor PaykitSdkService { private func resetRuntime() { runtimeGeneration &+= 1 sdk = nil + publicSdk = nil cachedPaykitKey = nil cachedBackupState = nil } @@ -1910,12 +2342,13 @@ actor PaykitSdkService { cachedPaykitKey = nil cachedBackupState = nil let key = try await paykitKey(for: root) + try profileAdmission.checkCurrent() sessionProvider.setPaykitIdentitySecretKey(key) cachedPaykitKey = (publicKey, key.keyGeneration()) } func paykitKeyForAuthorization(secretKeyHex: String) async throws -> PaykitIdentitySecretKey { - try await operationLock.withLock { + try await withLockedSdkOperation { try await withSdkErrorHandling { try await paykitKey(for: Self.localSecretKey(fromHex: secretKeyHex)) } @@ -1925,6 +2358,7 @@ actor PaykitSdkService { private func paykitKey(for root: PubkyLocalSecretKey) async throws -> PaykitIdentitySecretKey { let publicKey = try Paykit.pubkyPublicKeyFromSecret(localSecretKey: root) let registry = try await handle().paykitAppRegistry(publicKey: publicKey) + try profileAdmission.checkCurrent() let generation = registry?.keyGeneration ?? 1 let saved = try Keychain.load(key: .paykitKeyGeneration(publicKey: publicKey)) .map { try JSONDecoder().decode(UInt64.self, from: $0) } @@ -1938,12 +2372,15 @@ actor PaykitSdkService { } private func persistSessionAccess(_ access: PubkySessionAccess) throws { + try profileAdmission.checkCurrent() guard let sessionData = access.exportSessionSecret().data(using: .utf8) else { throw KeychainError.failedToSave } + try recordCredentialWrite(key: .paykitSession, value: sessionData) try Keychain.upsert(key: .paykitSession, data: sessionData) guard AdoptedPubkyReference.current == nil, let localSecret = access.exportLocalSecretKey() else { + try recordCredentialWrite(key: .pubkySecretKey, value: nil) try Keychain.delete(key: .pubkySecretKey) return } @@ -1951,6 +2388,7 @@ actor PaykitSdkService { guard let secretData = Self.secretKeyHex(from: localSecret).data(using: .utf8) else { throw KeychainError.failedToSave } + try recordCredentialWrite(key: .pubkySecretKey, value: secretData) try Keychain.upsert(key: .pubkySecretKey, data: secretData) } @@ -1968,14 +2406,17 @@ actor PaykitSdkService { resetRuntime() try await refreshPaykitKey() sdk = try handle() + try profileAdmission.checkCurrent() status = try await sdk.initialize() if result.sessionAccess.exportLocalSecretKey() != nil { + try profileAdmission.checkCurrent() _ = try await sdk.publishPaykitNoiseKeyAuthorization() } } catch { var rollbackError: Error? for (key, value) in zip(keys, previousValues) { do { + try recordCredentialWrite(key: key, value: value, localCompletion: true) if let value { try Keychain.upsert(key: key, data: value) } else { @@ -1987,11 +2428,27 @@ actor PaykitSdkService { } sessionProvider.clearLiveSessionAccess() resetRuntime() - throw rollbackError ?? error + if let rollbackError { + Logger.error("Credential activation rollback failed: \(rollbackError)", context: "PaykitSdkService") + } + markWalletBackupDataChanged() + if let rollbackError { + throw PubkyCredentialLocalError(underlyingError: error, cleanupError: rollbackError) + } + throw error } - await PubkyProfileManager.activateCachedIdentity(publicKey: result.publicKey, previousPublicKey: previousPublicKey) - if AdoptedPubkyReference.current != nil || result.sessionAccess.exportLocalSecretKey() == nil { - SharedPubkyKeychain.removeAllOwn() + try profileAdmission.checkCurrent() + let activation = CredentialActivation(publicKey: result.publicKey, previousPublicKey: previousPublicKey, + removesOwnSharedRecord: AdoptedPubkyReference.current != nil || result.sessionAccess + .exportLocalSecretKey() == nil) + if let credentialAttempt { + guard !credentialAttempt.consumed, profileAdmission.owns(credentialAttempt.scope) else { throw ProfileRemovalStore.StoreError.changed } + credentialAttempt.activation = activation + } else { + await PubkyProfileManager.activateCachedIdentity(publicKey: result.publicKey, previousPublicKey: previousPublicKey) + if activation.removesOwnSharedRecord { + SharedPubkyKeychain.removeAllOwn() + } } await publishAppIfLiveSessionAvailable(using: sdk, status: status) startIdentityRepublish(publicKey: result.publicKey) @@ -2002,6 +2459,7 @@ actor PaykitSdkService { var capabilities = appCapabilities(for: status) guard capabilities.privatePayments else { return } capabilities.privatePayments = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) + try profileAdmission.checkCurrent() _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } catch { invalidatePaykitKeyIfNeeded(after: error) @@ -2033,6 +2491,9 @@ actor PaykitSdkService { /// check and the guarded read or write. private nonisolated static func requireSignedInIdentity(_ expectedIdentity: String, in sdk: PaykitSdk) async throws { let signedInIdentity = try await sdk.identityStatus()?.publicKey + if let scope = ProfileOperationAdmission.current { + try scope.admission.validate(scope) + } guard PubkyPublicKeyFormat.matches(signedInIdentity, expectedIdentity) else { throw PubkyServiceError.identityChanged } @@ -2106,6 +2567,7 @@ final class PaykitSdkOperationLock: @unchecked Sendable { } } + private let profileAdmission: ProfileOperationAdmission private let maxInteractiveBypasses = 3 private let lock = NSLock() private var isLocked = false @@ -2119,34 +2581,61 @@ final class PaykitSdkOperationLock: @unchecked Sendable { #if DEBUG private let beforeAcquireForTesting: (@Sendable () async -> Void)? - init(beforeAcquireForTesting: (@Sendable () async -> Void)? = nil) { + init(profileAdmission: ProfileOperationAdmission = .shared, beforeAcquireForTesting: (@Sendable () async -> Void)? = nil) { + self.profileAdmission = profileAdmission self.beforeAcquireForTesting = beforeAcquireForTesting } + #else + init(profileAdmission: ProfileOperationAdmission = .shared) { + self.profileAdmission = profileAdmission + } #endif func withLock( priority: Priority = .ordered, generation expectedGeneration: Int? = nil, + access: ProfileOperationAdmission.Access = .authenticated, _ operation: () async throws -> T ) async throws -> T { - if ownsWipe() { + return try await profileAdmission.withOperation(access: access) { + if ownsWipe() { + return try await operation() + } + let admittedGeneration = try admit() + #if DEBUG + await beforeAcquireForTesting?() + #endif + guard await acquire(priority: priority, generation: admittedGeneration) else { throw wipeError() } + defer { release() } + try validate(expectedGeneration ?? admittedGeneration) + try profileAdmission.checkCurrent(access: access) + try Task.checkCancellation() return try await operation() } - let admittedGeneration = try admit() - #if DEBUG - await beforeAcquireForTesting?() - #endif - guard await acquire(priority: priority, generation: admittedGeneration) else { throw wipeError() } + } + + func withLocalCompletion(scope: ProfileOperationAdmission.Scope, generation expected: Int, _ operation: () throws -> T) async throws -> T { + try profileAdmission.validateLocalCompletion(scope) + guard await acquire(priority: .ordered, generation: expected, isLocalCompletion: true) else { throw wipeError() } defer { release() } - try validate(expectedGeneration ?? admittedGeneration) - try Task.checkCancellation() - return try await operation() + try profileAdmission.validateLocalCompletion(scope) + try lock.withLock { + guard !isWiping, generation == expected else { throw wipeError() } + } + return try operation() } func walletGeneration() throws -> Int { try admit() } + func publicReadGeneration() throws -> Int { + if ownsWipe() { + return lock.withLock { generation } + } + return try admit() + } + /// Runs `operation` without the lock but under the wallet wipe admission of `withLock`: it is rejected while a wipe /// is in progress. A wipe cannot drain work that does not hold the lock, so a result that a wipe overtakes is /// discarded and the caller gets the wipe error instead. @@ -2182,14 +2671,14 @@ final class PaykitSdkOperationLock: @unchecked Sendable { private func admit() throws -> Int { lock.lock() defer { lock.unlock() } - guard !isWiping else { throw wipeError() } + guard !isWiping, !profileAdmission.isWalletWiping else { throw wipeError() } return generation } private func validate(_ admittedGeneration: Int) throws { lock.lock() defer { lock.unlock() } - guard !isWiping, generation == admittedGeneration else { throw wipeError() } + guard !isWiping, !profileAdmission.isWalletWiping, generation == admittedGeneration else { throw wipeError() } } private func beginWipe() throws -> UUID { @@ -2231,11 +2720,11 @@ final class PaykitSdkOperationLock: @unchecked Sendable { return try await operation() } - private func acquire(priority: Priority, generation expectedGeneration: Int? = nil) async -> Bool { + private func acquire(priority: Priority, generation expectedGeneration: Int? = nil, isLocalCompletion: Bool = false) async -> Bool { await withCheckedContinuation { continuation in lock.lock() // Recheck while inserting: an old outer owner must not queue behind a wipe draining it. - if let expectedGeneration, isWiping || generation != expectedGeneration { + if let expectedGeneration, isWiping || (!isLocalCompletion && profileAdmission.isWalletWiping) || generation != expectedGeneration { lock.unlock() continuation.resume(returning: false) } else if isLocked { @@ -2257,7 +2746,7 @@ final class PaykitSdkOperationLock: @unchecked Sendable { if Task.isCancelled { lock.unlock() continuation.resume(throwing: CancellationError()) - } else if isWiping || generation != admittedGeneration { + } else if isWiping || profileAdmission.isWalletWiping || generation != admittedGeneration { lock.unlock() continuation.resume(throwing: wipeError()) } else if isLocked { @@ -2440,21 +2929,55 @@ extension PublicPaykitService.Endpoint { final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendable { private let lock = NSLock() private let loadSessionSecret: () throws -> String? + private let loadLocalCredential: () throws -> Data? + private let loadAdoptedReference: () throws -> AdoptedReference? + private let loadAdoptedCredential: (AdoptedReference?) throws -> AdoptedCredential? private let deleteKeychainValue: (KeychainEntryType) throws -> Void private var liveSessionAccess: PubkySessionAccess? private var paykitIdentitySecretKey: PaykitIdentitySecretKey? + private var accessRevision = UUID() + private var grantRevision = UUID() + private var grantAdmissions: [UUID: PubkyGrantLogout] = [:] private var isStoredSessionAccessSuspended = false + private let publicOnly: Bool + private var admissionCheck: (() throws -> Void)? init( + publicOnly: Bool = false, loadSessionSecret: @escaping () throws -> String? = { try Keychain.loadString(key: .paykitSession) }, + loadLocalCredential: @escaping () throws -> Data? = { try Keychain.load(key: .pubkySecretKey) }, + loadAdoptedReference: @escaping () throws -> AdoptedReference? = { try AdoptedReference.load() }, + loadAdoptedCredential: @escaping (AdoptedReference?) throws -> AdoptedCredential? = { try AdoptedCredential.load(reference: $0) }, deleteKeychainValue: @escaping (KeychainEntryType) throws -> Void = { try Keychain.delete(key: $0) } ) { + self.publicOnly = publicOnly self.loadSessionSecret = loadSessionSecret + self.loadLocalCredential = loadLocalCredential + self.loadAdoptedReference = loadAdoptedReference + self.loadAdoptedCredential = loadAdoptedCredential self.deleteKeychainValue = deleteKeychainValue } + func setAdmissionCheck(_ check: (() throws -> Void)?) { + lock.withLock { admissionCheck = check } + } + + private func checkAdmission() throws { + let check = lock.withLock { admissionCheck } + try check?() + } + + private func validateAdmission() throws { + do { try checkAdmission() } + catch { + // UniFFI callback errors must belong to its declared error type. + throw PaykitError.Storage(code: "profile_operation_unavailable", context: "The original profile operation is no longer admitted") + } + } + func setLiveSessionAccess(_ access: PubkySessionAccess) { lock.lock() + invalidateGrantAdmissions() liveSessionAccess = access paykitIdentitySecretKey = access.exportPaykitIdentitySecretKey() lock.unlock() @@ -2462,13 +2985,16 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab func clearLiveSessionAccess() { lock.lock() + invalidateGrantAdmissions() liveSessionAccess = nil paykitIdentitySecretKey = nil lock.unlock() } func loadSessionAccess() throws -> PubkySessionAccess? { - try paykitStorageCallback(code: "session_load_failed") { + guard !publicOnly else { return nil } + try validateAdmission() + return try paykitStorageCallback(code: "session_load_failed") { lock.lock() defer { lock.unlock() } @@ -2485,9 +3011,10 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab let access = try PubkySessionAccess( clientId: PaykitSdkService.clientID, sessionSecret: sessionSecret, - localSecretKey: loadLocalSecretKey(), + localSecretKey: readLocalSecretKey(), paykitIdentitySecretKey: paykitIdentitySecretKey ) + invalidateGrantAdmissions() liveSessionAccess = access return access } @@ -2504,6 +3031,7 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab func suspendStoredSessionAccess() { lock.lock() + invalidateGrantAdmissions() liveSessionAccess = nil isStoredSessionAccessSuspended = true lock.unlock() @@ -2516,6 +3044,10 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab } func clearSessionAccess() throws { + guard !publicOnly else { + throw PaykitError.Storage(code: "profile_operation_unavailable", context: "Public reads cannot clear credentials") + } + try validateAdmission() try paykitStorageCallback(code: "session_clear_failed") { clearLiveSessionAccess() try PubkySessionAccessTeardown.clear(deleteKeychainValue: deleteKeychainValue) @@ -2523,6 +3055,12 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab } func loadLocalSecretKey() throws -> PubkyLocalSecretKey? { + guard !publicOnly else { return nil } + try validateAdmission() + return try readLocalSecretKey() + } + + private func readLocalSecretKey() throws -> PubkyLocalSecretKey? { guard let secretKeyHex = PubkyProfileManager.activeSecretKeyHex() else { return nil } @@ -2533,10 +3071,215 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab func setPaykitIdentitySecretKey(_ key: PaykitIdentitySecretKey) { lock.lock() defer { lock.unlock() } - if paykitIdentitySecretKey?.keyGeneration() != key.keyGeneration() { + invalidateGrantAdmissions() + liveSessionAccess = liveSessionAccess?.withPaykitIdentitySecretKey(key: key) + paykitIdentitySecretKey = key + } + + private func invalidateGrantAdmissions() { + accessRevision = UUID() + revokeRemoteCaptures() + } + + private func revokeRemoteCaptures() { + grantRevision = UUID() + let admissions = Array(grantAdmissions.values) + grantAdmissions.removeAll() + // Native revocation only changes its cancellation flag; it does not call this provider. + admissions.forEach { $0.revokeAdmission() } + } + + struct AdoptedReference: Equatable, Sendable { + let sourceApp: String + let publicKey: String + + static func load(defaults: UserDefaults = .standard) throws -> AdoptedReference? { + let source = defaults.object(forKey: AdoptedPubkyReference.sourceAppKey) + let key = defaults.object(forKey: AdoptedPubkyReference.pubkyKey) + guard source != nil || key != nil else { return nil } + guard let source = source as? String, !source.isEmpty, let key = key as? String, !key.isEmpty + else { throw ProfileRemovalStore.StoreError.changed } + return AdoptedReference(sourceApp: source, publicKey: key) + } + } + + struct AdoptedCredential: Equatable, Sendable { + let sourceApp: String + let publicKey: String + let secret: String + var reference: AdoptedReference { + AdoptedReference(sourceApp: sourceApp, publicKey: publicKey) + } + + static func load(defaults: UserDefaults = .standard, + readSecret: (String, String) throws -> String? = { try SharedPubkyKeychain.readSecret(sourceApp: $0, pubky: $1) }) throws + -> AdoptedCredential? + { + try load(reference: AdoptedReference.load(defaults: defaults), readSecret: readSecret) + } + + static func load(reference: AdoptedReference?, + readSecret: (String, String) throws -> String? = { try SharedPubkyKeychain.readSecret(sourceApp: $0, pubky: $1) }) throws + -> AdoptedCredential? + { + guard let reference else { return nil } + guard let secret = try readSecret(reference.sourceApp, reference.publicKey) else { throw ProfileRemovalStore.StoreError.changed } + return AdoptedCredential(sourceApp: reference.sourceApp, publicKey: reference.publicKey, secret: secret) + } + } + + final class OriginalAccess: @unchecked Sendable { + fileprivate var revision: UUID + fileprivate let grantRevision: UUID + fileprivate var session: String? + fileprivate var localCredential: Data? + fileprivate let adoptedCredential: AdoptedCredential? + + fileprivate init(revision: UUID, grantRevision: UUID, session: String, localCredential: Data?, adoptedCredential: AdoptedCredential?) { + self.revision = revision + self.grantRevision = grantRevision + self.session = session + self.localCredential = localCredential + self.adoptedCredential = adoptedCredential + } + } + + func captureOriginalAccess(expectedOwner: String) throws -> OriginalAccess { + try validateAdmission() + return try lock.withLock { + guard !publicOnly, !isStoredSessionAccessSuspended, let secret = try loadSessionSecret(), !secret.isEmpty, + liveSessionAccess?.exportSessionSecret() == secret else { throw ProfileRemovalStore.StoreError.changed } + let local = try loadLocalCredential() + let reference = try loadAdoptedReference() + let adopted = try loadAdoptedCredential(reference) + guard reference == adopted?.reference else { throw ProfileRemovalStore.StoreError.changed } + let selectedSecret: String? + if let local, !local.isEmpty { + guard let value = String(data: local, encoding: .utf8), !value.isEmpty else { throw ProfileRemovalStore.StoreError.changed } + selectedSecret = value + } else { + selectedSecret = adopted?.secret + } + if let selectedSecret { + let owner = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: selectedSecret) + guard PubkyPublicKeyFormat.matches(owner, expectedOwner) else { throw ProfileRemovalStore.StoreError.changed } + } + return OriginalAccess(revision: accessRevision, grantRevision: grantRevision, session: secret, + localCredential: local, adoptedCredential: adopted) + } + } + + func validateOriginalAccess(_ access: OriginalAccess) throws { + try validateOriginalAdmission(access) + try lock.withLock { try requireOriginalAccess(access) } + } + + private func validateOriginalAdmission(_ access: OriginalAccess) throws { + do { try checkAdmission() } + catch { + if !(error is CancellationError) { + lock.withLock { invalidateOriginalGrant(access) } + } + throw error + } + } + + /// Called with the provider lock held. An old failure cannot retire captures from a newer epoch. + private func invalidateOriginalAccess(_ access: OriginalAccess) { + if accessRevision == access.revision { + invalidateGrantAdmissions() + } + } + + private func invalidateOriginalGrant(_ access: OriginalAccess) { + if accessRevision == access.revision, grantRevision == access.grantRevision { + revokeRemoteCaptures() + } + } + + private func requireOriginalAccess(_ access: OriginalAccess) throws { + try requireOwnedOriginalAccess(access) + do { + guard grantRevision == access.grantRevision else { throw ProfileRemovalStore.StoreError.changed } + let adopted = try loadAdoptedCredential(access.adoptedCredential?.reference) + guard adopted?.reference == access.adoptedCredential?.reference else { + invalidateOriginalAccess(access) + throw ProfileRemovalStore.StoreError.changed + } + guard adopted == access.adoptedCredential else { throw ProfileRemovalStore.StoreError.changed } + } catch { + if !(error is CancellationError) { + invalidateOriginalGrant(access) + } + throw error + } + } + + private func requireOwnedOriginalAccess(_ access: OriginalAccess) throws { + do { + guard !publicOnly, !isStoredSessionAccessSuspended, accessRevision == access.revision, + try loadSessionSecret() == access.session, try loadLocalCredential() == access.localCredential, + try loadAdoptedReference() == access.adoptedCredential?.reference else { throw ProfileRemovalStore.StoreError.changed } + } catch { + if !(error is CancellationError) { + invalidateOriginalAccess(access) + } + throw error + } + } + + func registerGrantLogout(_ logout: PubkyGrantLogout, access: OriginalAccess) throws -> UUID { + try lock.withLock { + try requireOriginalAccess(access) + let id = UUID() + grantAdmissions[id] = logout + return id + } + } + + func unregisterGrantLogout(_ id: UUID) { + _ = lock.withLock { grantAdmissions.removeValue(forKey: id) } + } + + func clearOriginalAccess(_ access: OriginalAccess) throws { + try validateOriginalAdmission(access) + try lock.withLock { + try requireOwnedOriginalAccess(access) + invalidateGrantAdmissions() + access.revision = accessRevision liveSessionAccess = nil + paykitIdentitySecretKey = nil + do { try deleteKeychainValue(.paykitSession) } + catch { + access.session = failedEraseValue(access, expected: access.session, load: loadSessionSecret) + throw error + } + access.session = nil + try requireOwnedOriginalAccess(access) + do { try deleteKeychainValue(.pubkySecretKey) } + catch { + access.localCredential = failedEraseValue(access, expected: access.localCredential, load: loadLocalCredential) + throw error + } + access.localCredential = nil + try requireOwnedOriginalAccess(access) + } + } + + private func failedEraseValue(_ access: OriginalAccess, expected: T?, load: () throws -> T?) -> T? { + do { + let observed = try load() + guard observed == nil || observed == expected else { + invalidateOriginalAccess(access) + return expected + } + return observed + } catch { + if !(error is CancellationError) { + invalidateOriginalAccess(access) + } + return expected } - paykitIdentitySecretKey = key } } @@ -2619,3 +3362,18 @@ func paykitStorageCallback(code: String, operation: () throws -> T) throws -> throw PaykitError.Storage(code: code, context: "Platform Paykit storage operation failed") } } + +struct PubkyCredentialLocalError: Error { + let underlyingError: Error + var cleanupError: Error? + + static func isLocalFailure(_ error: Error) -> Bool { + if error is PubkyCredentialLocalError || error is KeychainError { + return true + } + if case let PaykitError.Storage(code, _) = error { + return ["session_load_failed", "session_clear_failed", "profile_operation_unavailable"].contains(code) + } + return false + } +} diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 71ec66fa3..233f0cccb 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -141,6 +141,13 @@ enum PublicPaykitService { } } + static func clearRemovedProfileReconciliation() { + reconciliationLock.withLock { + UserDefaults.standard.set(false, forKey: cleanupPendingKey) + UserDefaults.standard.removeObject(forKey: reconciliationRevisionKey) + } + } + static func queueEndpointReconciliation() { reconciliationLock.withLock { UserDefaults.standard.set(UUID().uuidString, forKey: reconciliationRevisionKey) @@ -357,46 +364,51 @@ enum PublicPaykitService { syncApp: (() async throws -> Void)? = nil, applyEndpoints: (([Endpoint]) async throws -> Void)? = nil ) async throws { - let session = wallet.publicPaykitSession - if publish { - wallet.preparePendingPublicPaykitInvoiceRefresh() - } - let pendingRevision = reconciliationRevision - var desiredSnapshot: ( - endpoints: [Endpoint], - invoiceRevision: UUID, - publicAddress: PrivatePaykitAddressReservationStore.PublicAddressSnapshot? - )? - let apply = applyEndpoints ?? { try await applyPublishedEndpointsLocked($0, isSessionCurrent: isSessionCurrent) } - try await syncPublishedEndpoints( - publish: publish, - isSessionCurrent: isSessionCurrent, - buildEndpoints: { - let snapshot = try await buildWalletEndpoints(wallet: wallet, session: session, refreshIfNeeded: true, requireEndpoint: true) - desiredSnapshot = snapshot - return snapshot.endpoints - }, - syncApp: syncApp ?? { try await syncPaykitApp(priority: appSyncPriority, isSessionCurrent: isSessionCurrent) }, - applyEndpoints: { endpoints in - if publish { - markPublicationPending(wallet: wallet, session: session, isSessionCurrent: isSessionCurrent) - } - try await apply(endpoints) - }, - onPublished: { - if let desiredSnapshot { - acknowledgePublishedEndpoints( - wallet: wallet, - endpoints: desiredSnapshot.endpoints, - invoiceRevision: desiredSnapshot.invoiceRevision, - reconciliationRevision: pendingRevision, - publicAddress: desiredSnapshot.publicAddress, - session: session, - isSessionCurrent: isSessionCurrent - ) - } + try await ProfileOperationAdmission.shared.withOperation { + if let isSessionCurrent, !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive } - ) + let session = wallet.publicPaykitSession + if publish { + wallet.preparePendingPublicPaykitInvoiceRefresh() + } + let pendingRevision = reconciliationRevision + var desiredSnapshot: ( + endpoints: [Endpoint], + invoiceRevision: UUID, + publicAddress: PrivatePaykitAddressReservationStore.PublicAddressSnapshot? + )? + let apply = applyEndpoints ?? { try await applyPublishedEndpointsLocked($0, isSessionCurrent: isSessionCurrent) } + try await syncPublishedEndpoints( + publish: publish, + isSessionCurrent: isSessionCurrent, + buildEndpoints: { + let snapshot = try await buildWalletEndpoints(wallet: wallet, session: session, refreshIfNeeded: true, requireEndpoint: true) + desiredSnapshot = snapshot + return snapshot.endpoints + }, + syncApp: syncApp ?? { try await syncPaykitApp(priority: appSyncPriority, isSessionCurrent: isSessionCurrent) }, + applyEndpoints: { endpoints in + if publish { + markPublicationPending(wallet: wallet, session: session, isSessionCurrent: isSessionCurrent) + } + try await apply(endpoints) + }, + onPublished: { + if let desiredSnapshot { + acknowledgePublishedEndpoints( + wallet: wallet, + endpoints: desiredSnapshot.endpoints, + invoiceRevision: desiredSnapshot.invoiceRevision, + reconciliationRevision: pendingRevision, + publicAddress: desiredSnapshot.publicAddress, + session: session, + isSessionCurrent: isSessionCurrent + ) + } + } + ) + } } @MainActor @@ -408,44 +420,52 @@ enum PublicPaykitService { applyEndpoints: ([Endpoint]) async throws -> Void, onPublished: () -> Void = {} ) async throws { - let desiredEndpoints: Result<[Endpoint], Error> - do { - desiredEndpoints = try await .success(publish ? buildEndpoints() : []) - } catch { - desiredEndpoints = .failure(error) - } - try await withEndpointLock(unlessSessionEnded: isSessionCurrent) { - guard publish else { - var firstError: Error? - do { - try await applyEndpoints([]) - } catch { - firstError = firstError ?? error + try await ProfileOperationAdmission.shared.withOperation { + if let isSessionCurrent, !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive + } + let desiredEndpoints: Result<[Endpoint], Error> + do { + desiredEndpoints = try await .success(publish ? buildEndpoints() : []) + } catch { + desiredEndpoints = .failure(error) + } + try await withEndpointLock(unlessSessionEnded: isSessionCurrent) { + guard publish else { + var firstError: Error? + do { + try await applyEndpoints([]) + } catch { + firstError = firstError ?? error + } + try ProfileOperationAdmission.shared.checkCurrent() + if let isSessionCurrent, !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive + } + do { + try await syncApp() + } catch { + firstError = firstError ?? error + } + if let firstError { + throw firstError + } + return } + + // Persist retry work before a remote mutation can commit without an acknowledgement. + setCleanupPending(true) + try await syncApp() + try ProfileOperationAdmission.shared.checkCurrent() if let isSessionCurrent, !isSessionCurrent() { throw PubkyServiceError.sessionNotActive } - do { - try await syncApp() - } catch { - firstError = firstError ?? error - } - if let firstError { - throw firstError - } - return - } - - // Persist retry work before a remote mutation can commit without an acknowledgement. - setCleanupPending(true) - try await syncApp() - if let isSessionCurrent, !isSessionCurrent() { - throw PubkyServiceError.sessionNotActive + // Keep the App Registry current even when wallet endpoints are unavailable. + try await applyEndpoints(desiredEndpoints.get()) + try ProfileOperationAdmission.shared.checkCurrent() + // A later writer must not begin before this publication's acknowledgement. + onPublished() } - // Keep the App Registry current even when wallet endpoints are unavailable. - try await applyEndpoints(desiredEndpoints.get()) - // A later writer must not begin before this publication's acknowledgement. - onPublished() } } @@ -456,37 +476,42 @@ enum PublicPaykitService { syncApp: (() async throws -> Void)? = nil, applyEndpoints: (([Endpoint]) async throws -> Void)? = nil ) async throws { - let session = wallet.publicPaykitSession - wallet.preparePendingPublicPaykitInvoiceRefresh() - let pendingRevision = reconciliationRevision - let (desiredEndpoints, invoiceRevision, publicAddress) = try await buildWalletEndpoints( - wallet: wallet, - session: session, - refreshIfNeeded: false, - requireEndpoint: false - ) - let apply = applyEndpoints ?? { try await applyPublishedEndpointsLocked($0, isSessionCurrent: isSessionCurrent) } - try await syncPublishedEndpoints( - publish: true, - isSessionCurrent: isSessionCurrent, - buildEndpoints: { desiredEndpoints }, - syncApp: syncApp ?? { try await syncPaykitApp(isSessionCurrent: isSessionCurrent) }, - applyEndpoints: { endpoints in - markPublicationPending(wallet: wallet, session: session, isSessionCurrent: isSessionCurrent) - try await apply(endpoints) - }, - onPublished: { - acknowledgePublishedEndpoints( - wallet: wallet, - endpoints: desiredEndpoints, - invoiceRevision: invoiceRevision, - reconciliationRevision: pendingRevision, - publicAddress: publicAddress, - session: session, - isSessionCurrent: isSessionCurrent - ) + try await ProfileOperationAdmission.shared.withOperation { + if let isSessionCurrent, !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive } - ) + let session = wallet.publicPaykitSession + wallet.preparePendingPublicPaykitInvoiceRefresh() + let pendingRevision = reconciliationRevision + let (desiredEndpoints, invoiceRevision, publicAddress) = try await buildWalletEndpoints( + wallet: wallet, + session: session, + refreshIfNeeded: false, + requireEndpoint: false + ) + let apply = applyEndpoints ?? { try await applyPublishedEndpointsLocked($0, isSessionCurrent: isSessionCurrent) } + try await syncPublishedEndpoints( + publish: true, + isSessionCurrent: isSessionCurrent, + buildEndpoints: { desiredEndpoints }, + syncApp: syncApp ?? { try await syncPaykitApp(isSessionCurrent: isSessionCurrent) }, + applyEndpoints: { endpoints in + markPublicationPending(wallet: wallet, session: session, isSessionCurrent: isSessionCurrent) + try await apply(endpoints) + }, + onPublished: { + acknowledgePublishedEndpoints( + wallet: wallet, + endpoints: desiredEndpoints, + invoiceRevision: invoiceRevision, + reconciliationRevision: pendingRevision, + publicAddress: publicAddress, + session: session, + isSessionCurrent: isSessionCurrent + ) + } + ) + } } @MainActor @@ -496,22 +521,24 @@ enum PublicPaykitService { syncApp: (() async throws -> Void)? = nil, applyEndpoints: (([Endpoint]) async throws -> Void)? = nil ) async throws { - guard isCleanupPending, !Task.isCancelled, isSessionCurrent() else { return } - let mode = pendingReconciliationMode() - let isCurrent = { @MainActor in - !Task.isCancelled && isSessionCurrent() && pendingReconciliationMode() == mode - } - switch mode { - case .publishEndpoints: - try await syncCurrentPublishedEndpoints( - wallet: wallet, isSessionCurrent: isCurrent, syncApp: syncApp, applyEndpoints: applyEndpoints - ) - case .removePublishedState: - try await syncPublishedEndpoints( - wallet: wallet, publish: false, isSessionCurrent: isCurrent, syncApp: syncApp, applyEndpoints: applyEndpoints - ) - guard isCurrent() else { return } - setCleanupPending(false) + try await ProfileOperationAdmission.shared.withOperation { + guard isCleanupPending, !Task.isCancelled, isSessionCurrent() else { return } + let mode = pendingReconciliationMode() + let isCurrent = { @MainActor in + !Task.isCancelled && isSessionCurrent() && pendingReconciliationMode() == mode + } + switch mode { + case .publishEndpoints: + try await syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: isCurrent, syncApp: syncApp, applyEndpoints: applyEndpoints + ) + case .removePublishedState: + try await syncPublishedEndpoints( + wallet: wallet, publish: false, isSessionCurrent: isCurrent, syncApp: syncApp, applyEndpoints: applyEndpoints + ) + guard isCurrent() else { return } + setCleanupPending(false) + } } } @@ -677,11 +704,14 @@ enum PublicPaykitService { unlessSessionEnded isSessionCurrent: (@MainActor () -> Bool)? = nil, _ operation: () async throws -> T ) async throws -> T { - try await endpointLock.withLock { - if let isSessionCurrent, await !isSessionCurrent() { - throw PubkyServiceError.sessionNotActive + try await ProfileOperationAdmission.shared.withOperation { + try await endpointLock.withLock { + try ProfileOperationAdmission.shared.checkCurrent() + if let isSessionCurrent, await !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive + } + return try await operation() } - return try await operation() } } diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index f309a6f01..fc58a5c22 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -12,32 +12,83 @@ enum AppReset { app: AppViewModel, wallet: WalletViewModel, session: SessionManager, - toastType: Toast.ToastType = .success + toastType: Toast.ToastType = .success, + expectedPendingID: UUID? = nil ) async throws { - try await PaykitSdkService.shared.withWalletWipe { + try await withWipeAdmission(expectedPendingID: expectedPendingID) { permitsRemoteCleanup in + BackupService.shared.setWiping(true) + defer { BackupService.shared.setWiping(false) } try await app.withMigrationSyncPausedForWipe { await SettingsViewModel.shared.beginServerSettingsWipe() defer { SettingsViewModel.shared.endServerSettingsWipe() } - try await wipeLocal(app: app, wallet: wallet, session: session, toastType: toastType) + try await wipeLocal(app: app, wallet: wallet, permitsRemoteCleanup: permitsRemoteCleanup) } } + // Required cleanup and the exact marker are acknowledged before any success or replacement tree. + session.skipSplashOnce = true + session.bump() + app.toast(type: toastType, title: t("security__wiped_title"), description: t("security__wiped_message")) + } + + @MainActor + static func withWipeAdmission( + admission: ProfileOperationAdmission = .shared, + sdkService: PaykitSdkService = .shared, + expectedPendingID: UUID? = nil, + pause: ((UUID) async -> Void)? = nil, + resume: ((UUID) async -> Void)? = nil, + cleanup: (Bool) async throws -> Void + ) async throws { + let id = UUID() + let scope = try admission.reserve(kind: .walletWipe, id: id, requiredPendingID: expectedPendingID) + defer { admission.release(scope) } + if let pause { + await pause(id) + } else { + await PrivatePaykitService.shared.beginProfileDeletion(id: id) + } + @MainActor func resumeWork() async { + if let resume { + await resume(id) + } else { + await PrivatePaykitService.shared.endProfileDeletion(id: id) + } + } + var completed = false + do { + try await admission.withScope(scope, access: .control) { + try await admission.drain(before: scope) + try admission.persist(scope) + let permitsRemoteCleanup = try admission.permitsRemoteCleanup(scope) + try await sdkService.withWalletWipe { + try await cleanup(permitsRemoteCleanup) + } + try admission.complete(scope) + completed = true + } + } catch { + await resumeWork() + // Cancellation immediately after acknowledged completion cannot turn it into pending work. + if completed { + return + } + throw error + } + await resumeWork() } @MainActor private static func wipeLocal( app: AppViewModel, wallet: WalletViewModel, - session: SessionManager, - toastType: Toast.ToastType + permitsRemoteCleanup: Bool ) async throws { - await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe") - await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "AppReset.wipe") - - // Set wiping flag to prevent backups during wipe operations - BackupService.shared.setWiping(true) - defer { - BackupService.shared.setWiping(false) + if permitsRemoteCleanup { + await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe") + await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "AppReset.wipe") } + guard let scope = ProfileOperationAdmission.current else { throw ProfileRemovalStore.StoreError.changed } + try ProfileOperationAdmission.shared.requireLocalOnly(scope) // Stop backup observers and reset VSS client await BackupService.shared.stopObservingBackups() @@ -48,9 +99,11 @@ enum AppReset { // Before the paired devices are wiped, so a reconnect still running cannot save one back. await hardwareWallets?.resetForWipe() - var usdtCleanupError: Error? - do { try await wallet.usdtWallet.wipe() } - catch { usdtCleanupError = error } + try await wallet.usdtWallet.wipe() + + // Credentials must be durably absent before any consumed-payment watermark is cleared. + try Keychain.wipeEntireKeychain() + try SharedPubkyKeychain.removeAllOwnOrThrow() // Stop node and wipe LDK persistence via the wallet API. try await wallet.wipe() @@ -60,15 +113,12 @@ enum AppReset { Logger.info("Core DB wipe: \(coreWipeResult)") // Clear any live Pubky runtime state and cached profile images. - await PubkyProfileManager.clearLocalState() await PrivatePaykitAddressReservationStore.shared.clear() - - // Wipe keychain - try Keychain.wipeEntireKeychain() + try await PubkyProfileManager.clearLocalState() PaykitUsdtPaymentService.shared.resetForWipe() // Delete installation marker so next install can detect orphaned keychain - try? InstallationMarker.delete() + try InstallationMarker.delete() // Wipe user defaults if let bundleID = Bundle.main.bundleIdentifier { @@ -87,21 +137,14 @@ enum AppReset { try wipeLogs() } - // Recreate the entire app state tree to guarantee clean defaults for all @StateObject VMs. - // Avoid showing splash during when app is reset - session.skipSplashOnce = true - session.bump() - - if let usdtCleanupError { - throw usdtCleanupError - } - - // Show toast - app.toast( - type: toastType, - title: t("security__wiped_title"), - description: t("security__wiped_message") - ) + guard let domain = Bundle.main.bundleIdentifier, + let expected = UserDefaults.standard.persistentDomain(forName: domain), + UserDefaults.standard.synchronize(), + let persisted = UserDefaults.standard.persistentDomain(forName: domain), + NSDictionary(dictionary: expected).isEqual(to: persisted) + else { throw ProfileRemovalStore.StoreError.unavailable } + try await PrivatePaykitService.shared.validateWipedState() + try await PrivatePaykitAddressReservationStore.shared.validateWipedState() } private static func wipeLogs() throws { diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index d1b717a35..66e3a0823 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -153,6 +153,9 @@ class Keychain { } class func delete(key: KeychainEntryType) throws { + #if DEBUG + try ProfileRemovalTestFault.check("\(key.storageKey):delete") + #endif let query = [ kSecClass as String: kSecClassGenericPassword as String, kSecAttrAccount as String: account(for: key), @@ -245,52 +248,94 @@ class Keychain { return nil } - class func getAllKeyChainStorageKeys() -> [String] { + class func getAllKeyChainStorageKeys( + copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemCopyMatching + ) throws -> [String] { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecReturnData as String: kCFBooleanTrue!, kSecReturnAttributes as String: kCFBooleanTrue!, - kSecReturnRef as String: kCFBooleanTrue!, kSecMatchLimit as String: kSecMatchLimitAll, kSecAttrAccessGroup as String: Env.keychainGroup, ] - - var result: AnyObject? - let lastResultCode = withUnsafeMutablePointer(to: &result) { - SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + var result: CFTypeRef? + let status = copyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return [] } - - var storageKeys = [String]() - if lastResultCode == noErr { - let array = result as? [[String: Any]] - for item in array! { - if let key = item[kSecAttrAccount as String] as? String { - storageKeys.append(key) - } + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + Logger.error("Failed to enumerate keychain accounts: \(status)", context: "Keychain") + throw KeychainError.failedToLoad + } + return try items.map { item in + guard let account = item[kSecAttrAccount as String] as? String else { + throw KeychainError.failedToLoad } + return account } - - return storageKeys } - class func wipeEntireKeychain() throws { - let keys = getAllKeyChainStorageKeys() + class func wipeEntireKeychain( + copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemCopyMatching, + delete: (CFDictionary) -> OSStatus = SecItemDelete + ) throws { + let keys = try getAllKeyChainStorageKeys(copyMatching: copyMatching) for key in keys { - // A test run must only ever clear its own namespaced entries. Deleting an un-prefixed - // account here is what destroys the wallet on the simulator the suite runs against. + // Tests may erase only their own namespace, never a simulator's wallet or Ring credentials. if Env.isUnitTest, !key.hasPrefix(unitTestAccountPrefix) { continue } - - let query = [ - kSecClass as String: kSecClassGenericPassword as String, + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecAttrAccessGroup as String: Env.keychainGroup, - ] as [String: Any] + ] + let status = delete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + Logger.error("Failed to delete a keychain account: \(status)", context: "Keychain") + throw KeychainError.failedToDelete + } + } + } +} + +#if DEBUG + /// Opt-in failures at real local teardown boundaries on a disposable regtest simulator. + enum ProfileRemovalTestFault { + static let allowed: Set = [ + "paykit_session:delete", "pubky_secret_key:delete", + "shared-own:enumerate", "shared-own:delete", + "preferences:flush", "reservations:acknowledge", "marker:persist", "marker:complete", + ] - SecItemDelete(query as CFDictionary) + private static func selected() -> String? { + #if targetEnvironment(simulator) + guard Env.isE2E, Env.network == .regtest, !Env.isUnitTest, + ProcessInfo.processInfo.arguments.contains("-E2E_PROFILE_REMOVAL_FAULTS") else { return nil } + let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("profile-removal-test-fault") + guard let file = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? file.close() } + guard let data = try? file.read(upToCount: 129), data.count <= 128, + let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + allowed.contains(value) else { return nil } + return value + #else + return nil + #endif + } - Logger.info("Deleted \(key) from keychain") + static func check(_ point: String) throws { + try check(point, selected: selected()) + } + + static func check(_ point: String, selected: String?) throws { + guard allowed.contains(point), selected == point else { return } + Logger.warn("Injected local profile cleanup failure: \(point)", context: "ProfileRemovalTestFault") + throw NSError( + domain: "ProfileRemovalTestFault", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Local profile cleanup storage unavailable (\(point))"] + ) } } -} +#endif diff --git a/Bitkit/Utilities/ProfileOperationAdmission.swift b/Bitkit/Utilities/ProfileOperationAdmission.swift new file mode 100644 index 000000000..3bacfb570 --- /dev/null +++ b/Bitkit/Utilities/ProfileOperationAdmission.swift @@ -0,0 +1,769 @@ +import Foundation + +/// Tracks complete profile operations, including their local tails, without holding a mutex across suspension. +/// A persisted removal record is an obligation; only a live reservation can authorize remote cleanup. +final class ProfileOperationAdmission: @unchecked Sendable { + enum Access { + case authenticated + case local + case control + } + + enum Kind { + case ordinary + case sessionTransition + case removal + case localRecovery + case walletWipe + } + + struct Scope: Sendable { + let admission: ProfileOperationAdmission + fileprivate let id: UUID + } + + struct Generation: Equatable, Sendable { + fileprivate let live: UUID + fileprivate let stored: UUID + } + + private struct WalletOperationScope: Sendable { + let admission: ProfileOperationAdmission + let id: UUID + let generation: UUID + } + + private struct Entry { + let generation: Generation + let kind: Kind + var pendingID: UUID? + var requiredPendingID: UUID? + var holders = 1 + var revoked = false + var localOnly = false + var backgroundInitialization = false + var acknowledged = false + var afterTransition: [@Sendable (Scope) -> Void] = [] + var cancellationHandlers: [UUID: @Sendable () -> Void] = [:] + } + + static let shared = ProfileOperationAdmission(store: .shared) + static let didReleaseOperation = Notification.Name("ProfileOperationAdmissionDidReleaseOperation") + @TaskLocal static var current: Scope? + @TaskLocal private static var currentWalletOperation: WalletOperationScope? + + private let lock = NSLock() + private let store: ProfileRemovalStore + private var generation = UUID() + private var entries: [UUID: Entry] = [:] + private var exclusive: UUID? + private var localFailureID: UUID? + private var drainWaiters: [(UUID, CheckedContinuation)] = [] + private var walletGeneration = UUID() + private var walletOperations: [UUID: Bool] = [:] + + init(store: ProfileRemovalStore) { + self.store = store + } + + /// Receipt associations belong to the wallet history, not the live profile. Wipe drains their database writes. + func withReceiptAttribution(_ operation: (@escaping @Sendable () -> Bool) async throws -> Void) async throws { + try await withWalletOperation(operation) + } + + func withWalletRestore(_ operation: () async throws -> T) async throws -> T { + try await withWalletOperation { isCurrent in + let result = try await operation() + try Task.checkCancellation() + guard isCurrent() else { throw ProfileRemovalStore.StoreError.changed } + return result + } + } + + private func withWalletOperation(_ operation: (@escaping @Sendable () -> Bool) async throws -> T) async throws -> T { + try Task.checkCancellation() + let receipt = try captureWalletOperation() + defer { + _ = lock.withLock { walletOperations.removeValue(forKey: receipt.id) } + resumeDrains() + } + return try await withTaskCancellationHandler { + try await Self.$currentWalletOperation.withValue(receipt) { + try Task.checkCancellation() + return try await operation { (try? self.validateWalletOperation(receipt)) != nil } + } + } onCancel: { + self.lock.withLock { + if self.walletOperations[receipt.id] != nil { + self.walletOperations[receipt.id] = false + } + } + } + } + + func checkReceiptAttribution() throws { + try Task.checkCancellation() + guard let receipt = Self.currentWalletOperation, receipt.admission === self else { throw ProfileRemovalStore.StoreError.changed } + try validateWalletOperation(receipt) + } + + private func captureWalletOperation() throws -> WalletOperationScope { + let expected = lock.withLock { walletGeneration } + guard try store.pending()?.scope != .walletWipe else { throw ProfileRemovalStore.StoreError.pending } + return try lock.withLock { + guard localFailureID == nil, walletGeneration == expected, + exclusive.flatMap({ entries[$0]?.kind }) != .walletWipe else { throw ProfileRemovalStore.StoreError.changed } + let receipt = WalletOperationScope(admission: self, id: UUID(), generation: expected) + walletOperations[receipt.id] = true + return receipt + } + } + + private func validateWalletOperation(_ receipt: WalletOperationScope) throws { + guard try store.pending()?.scope != .walletWipe else { throw ProfileRemovalStore.StoreError.pending } + try lock.withLock { + guard receipt.admission === self, walletOperations[receipt.id] == true, receipt.generation == walletGeneration, + localFailureID == nil, exclusive.flatMap({ entries[$0]?.kind }) != .walletWipe + else { throw ProfileRemovalStore.StoreError.changed } + } + } + + func snapshot() throws -> Generation { + let live = lock.withLock { generation } + let stored = try store.requireNoPending() + return try lock.withLock { + guard localFailureID == nil, exclusive == nil, !entries.values.contains(where: \.revoked), + live == generation else { throw ProfileRemovalStore.StoreError.changed } + return Generation(live: live, stored: stored) + } + } + + func validate(_ expected: Generation) throws { + try store.validate(generation: expected.stored) + try lock.withLock { + guard localFailureID == nil, exclusive == nil, !entries.values.contains(where: \.revoked), + generation == expected.live else { throw ProfileRemovalStore.StoreError.changed } + } + } + + /// Capture before launching detached or queued work. Release only when its actual local/native tail has returned. + func capture(access: Access = .authenticated) throws -> Scope { + if let current = Self.current, current.admission === self { + try validate(current, access: access) + return try lock.withLock { + guard var entry = entries[current.id], !entry.revoked else { throw ProfileRemovalStore.StoreError.changed } + entry.holders += 1 + entries[current.id] = entry + return current + } + } + let expected = try snapshot() + return try lock.withLock { + guard localFailureID == nil, exclusive == nil, generation == expected.live else { throw ProfileRemovalStore.StoreError.changed } + let scope = Scope(admission: self, id: UUID()) + entries[scope.id] = Entry(generation: expected, kind: .ordinary, pendingID: nil) + return scope + } + } + + func validate(_ scope: Scope, access: Access = .authenticated) throws { + guard scope.admission === self else { throw ProfileRemovalStore.StoreError.changed } + let entry = try lock.withLock { + guard let entry = entries[scope.id], !entry.revoked, + localFailureID == nil || (entry.kind != .ordinary && entry.kind != .sessionTransition), + entry.kind == .ordinary ? exclusive == nil && entry.generation.live == generation : exclusive == scope.id, + access != .authenticated || (!entry.localOnly && entry.kind != .localRecovery) + else { throw ProfileRemovalStore.StoreError.changed } + return entry + } + if entry.kind == .ordinary || entry.kind == .sessionTransition { + try store.validate(generation: entry.generation.stored) + if entry.kind == .sessionTransition, access != .control { + try lock.withLock { + guard !entries.keys.contains(where: { $0 != scope.id }) else { throw ProfileRemovalStore.StoreError.changed } + } + } + } else { + let pending = try store.pending() + if let required = entry.requiredPendingID { + guard pending?.id == required, pending?.scope == .walletWipe else { throw ProfileRemovalStore.StoreError.changed } + } + if access != .control { + guard let pending, pending.id == entry.pendingID else { throw ProfileRemovalStore.StoreError.pending } + } + if let pending { + guard entry.kind == .walletWipe || pending.id == entry.pendingID, + entry.kind == .walletWipe || pending.scope == .profile else { throw ProfileRemovalStore.StoreError.changed } + } + } + // File access is outside the admission mutex. A reservation may have changed while it ran. + try lock.withLock { + guard let current = entries[scope.id], !current.revoked, + localFailureID == nil || (current.kind != .ordinary && current.kind != .sessionTransition), + current.kind == .ordinary ? exclusive == nil && current.generation.live == generation : exclusive == scope.id + else { throw ProfileRemovalStore.StoreError.changed } + } + } + + func checkCurrent(access: Access = .authenticated) throws { + try Task.checkCancellation() + guard let scope = Self.current else { throw ProfileRemovalStore.StoreError.changed } + try validate(scope, access: access) + } + + func withSynchronousOperation(access: Access = .authenticated, _ operation: () throws -> T) throws -> T { + let scope = try capture(access: access) + defer { release(scope) } + return try Self.$current.withValue(scope) { + try checkCurrent(access: access) + let result = try operation() + try validate(scope, access: access) + return result + } + } + + func withOperation(access: Access = .authenticated, _ operation: () async throws -> T) async throws -> T { + try Task.checkCancellation() + let ownsScope = Self.current?.admission !== self + let scope = try capture(access: access) + defer { release(scope) } + return try await withScope(scope, access: access, revokeOnCancellation: ownsScope, operation) + } + + /// Explicit handoff preserves admission across Task.detached and ServiceQueue, which do not inherit task locals. + func withScope(_ scope: Scope, access: Access = .authenticated, revokeOnCancellation: Bool = true, + _ operation: () async throws -> T) async throws -> T + { + try await withTaskCancellationHandler { + do { + try Task.checkCancellation() + try validate(scope, access: access) + let result = try await Self.$current.withValue(scope) { try await operation() } + if !isAcknowledged(scope) { + try Task.checkCancellation() + } + try validate(scope, access: access) + return result + } catch ProfileRemovalStore.StoreError.changed where Task.isCancelled && !isAcknowledged(scope) { + // Original cancellation can revoke admission between the task check and a storage read. + throw CancellationError() + } + } onCancel: { + if revokeOnCancellation { + self.revokeForCancellation(scope) + } + } + } + + /// Cancellation requests stop queued/waiting work, but the lease remains held until the worker actually returns. + func withDetached(access: Access = .authenticated, _ operation: @escaping @Sendable () async throws -> T) async throws -> T { + try Task.checkCancellation() + let ownsScope = Self.current?.admission !== self + let scope = try capture(access: access) + let worker = Task.detached { + try await self.withScope(scope, access: access, revokeOnCancellation: false, operation) + } + let handlerID = UUID() + let cancelled = lock.withLock { + guard entries[scope.id]?.revoked == false else { return true } + entries[scope.id]?.cancellationHandlers[handlerID] = { worker.cancel() } + return false + } + if cancelled { + worker.cancel() + } + defer { + _ = lock.withLock { entries[scope.id]?.cancellationHandlers.removeValue(forKey: handlerID) } + release(scope) + } + return try await withTaskCancellationHandler { + let result = try await worker.value + try Task.checkCancellation() + try validate(scope, access: access) + return result + } onCancel: { + worker.cancel() + if ownsScope { + self.revoke(scope) + } + } + } + + /// Identity replacement owns its full native and local tail. It never creates a removal obligation. + func reserveSessionTransition(expected: Generation? = nil, backgroundInitialization: Bool = false) throws -> Scope { + if let current = Self.current, current.admission === self { + let isTransition = lock.withLock { entries[current.id]?.kind == .sessionTransition } + guard isTransition else { throw ProfileRemovalStore.StoreError.changed } + try validate(current) + if let expected { + try validateOperationGeneration(expected) + } + return try capture() + } + // An explicit replacement may retire startup recovery, but never another interactive transition. + let live = lock.withLock { generation } + let stored = try store.requireNoPending() + let expected = expected ?? Generation(live: live, stored: stored) + try store.validate(generation: expected.stored) + let scope = try lock.withLock { + let mayReplaceStartup = !backgroundInitialization && exclusive.flatMap { entries[$0]?.backgroundInitialization } == true + guard localFailureID == nil, exclusive == nil || mayReplaceStartup, + generation == expected.live else { throw ProfileRemovalStore.StoreError.changed } + for key in entries.keys { + entries[key]?.revoked = true + } + let scope = Scope(admission: self, id: UUID()) + entries[scope.id] = Entry( + generation: expected, + kind: .sessionTransition, + pendingID: nil, + backgroundInitialization: backgroundInitialization + ) + exclusive = scope.id + return scope + } + cancelRevokedOperations() + resumeDrains() + return scope + } + + func withSessionTransition(expected: Generation? = nil, _ operation: () async throws -> T) async throws -> T { + try Task.checkCancellation() + let ownsScope = Self.current?.admission !== self + let scope = try reserveSessionTransition(expected: expected) + defer { release(scope) } + do { + return try await withScope(scope, access: .control, revokeOnCancellation: ownsScope) { + try await drain(before: scope) + try validate(scope) + return try await operation() + } + } catch { + if ownsScope { + revoke(scope) + } + throw error + } + } + + /// Linearizes completed identity creation with original cancellation. Wipe revocation remains unconditional. + func acknowledgeSessionTransition(_ scope: Scope) throws { + try validate(scope) + try lock.withLock { + try Task.checkCancellation() + guard scope.admission === self, exclusive == scope.id, var entry = entries[scope.id], + entry.kind == .sessionTransition, !entry.revoked, localFailureID == nil, + !entries.keys.contains(where: { $0 != scope.id }) else { throw ProfileRemovalStore.StoreError.changed } + entry.acknowledged = true + entries[scope.id] = entry + } + } + + func validateAcknowledgedCompletion() throws { + guard let scope = Self.current, isAcknowledged(scope) else { throw ProfileRemovalStore.StoreError.changed } + try validate(scope) + } + + /// Mandatory Swift state finishes under the retained original owner while a successor drains it. + /// The outer scope still rejects stale UI completion; this grants no further native or remote work. + func validateAcknowledgedLocalCompletion() throws { + guard let scope = Self.current, isAcknowledged(scope) else { throw ProfileRemovalStore.StoreError.changed } + try validateLocalCompletion(scope) + } + + func validateCommittedOrCurrentLocalCompletion() throws { + guard let scope = Self.current else { throw ProfileRemovalStore.StoreError.changed } + if isAcknowledged(scope) { + try validateAcknowledgedLocalCompletion() + } else { + try checkCurrent() + } + } + + private func isAcknowledged(_ scope: Scope) -> Bool { + lock.withLock { scope.admission === self && entries[scope.id]?.acknowledged == true } + } + + private func revokeForCancellation(_ scope: Scope) { + guard scope.admission === self else { return } + lock.withLock { + guard entries[scope.id]?.acknowledged != true else { return } + entries[scope.id]?.revoked = true + } + cancelRevokedOperations() + resumeDrains() + } + + /// Only a retained original transition can complete its Swift credential writes after cancellation. + /// This does not authorize SDK authentication or any remote operation. + func retainLocalCompletion() throws -> Scope { + guard let scope = Self.current, scope.admission === self else { throw ProfileRemovalStore.StoreError.changed } + try validate(scope) + return try lock.withLock { + guard entries[scope.id]?.kind == .sessionTransition else { throw ProfileRemovalStore.StoreError.changed } + entries[scope.id]?.holders += 1 + return scope + } + } + + func validateLocalCompletion(_ scope: Scope) throws { + try lock.withLock { + guard scope.admission === self, let entry = entries[scope.id], entry.kind == .sessionTransition else { + throw ProfileRemovalStore.StoreError.changed + } + } + } + + /// A cancelled ordinary operation may restore its own local preference before releasing its drain. + /// This grants no SDK work and cannot cross a reserved identity transition or recovery obligation. + func validateCancelledPreference(_ scope: Scope) throws { + let stored = try lock.withLock { + guard scope.admission === self, let entry = entries[scope.id], entry.kind == .ordinary, + exclusive == nil, localFailureID == nil, entry.generation.live == generation + else { + throw ProfileRemovalStore.StoreError.changed + } + return entry.generation.stored + } + try store.validate(generation: stored) + try lock.withLock { + guard entries[scope.id] != nil, exclusive == nil, localFailureID == nil, + entries[scope.id]?.generation.live == generation else { throw ProfileRemovalStore.StoreError.changed } + } + } + + func owns(_ scope: Scope) -> Bool { + Self.current?.admission === self && Self.current?.id == scope.id + } + + var hasUnpersistedLocalFailure: Bool { + lock.withLock { localFailureID != nil } + } + + /// Only complete backup capture may retry with a fresh owner after transient admission contention. + /// A durable or unreadable recovery obligation remains closed until explicit local completion. + func isTransientBackupCaptureFailure(_ error: Error) -> Bool { + var underlying = error + for _ in 0 ..< 8 { + guard let wrapped = underlying as? AppError, let original = wrapped.underlyingError else { break } + underlying = original + } + guard let admissionError = underlying as? ProfileRemovalStore.StoreError, case .changed = admissionError, + !hasUnpersistedLocalFailure, (try? store.requireNoPending()) != nil else { return false } + return true + } + + func recordLocalCompletionFailure(_ scope: Scope, id: UUID) throws { + try validateLocalCompletion(scope) + lock.withLock { localFailureID = id } + try persistLocalFailure() + } + + /// Retrying this local record write never retries the failed remote operation. + func persistLocalFailure(recoverMalformed: Bool = false) throws { + guard let id = lock.withLock({ localFailureID }) else { return } + if recoverMalformed { + try store.recoverMalformedRecord(id: id) + } + if try store.pending() == nil { + try store.begin(id: id) + } + guard try store.pending() != nil else { throw ProfileRemovalStore.StoreError.unavailable } + lock.withLock { + if localFailureID == id { + localFailureID = nil + } + } + } + + /// Optional work starts under the committed owner, never by recapturing whichever identity is current later. + /// The callback owns the supplied scope and must release it after its actual tail returns. + func scheduleAfterTransition(_ operation: @escaping @Sendable (Scope) -> Void) throws { + if let current = Self.current, current.admission === self { + try validate(current) + let deferred = try lock.withLock { + guard var entry = entries[current.id], !entry.revoked else { throw ProfileRemovalStore.StoreError.changed } + if entry.kind == .sessionTransition { + entry.afterTransition.append(operation) + entries[current.id] = entry + return true + } + return false + } + if deferred { + return + } + } + let scope = try capture() + Self.$current.withValue(nil) { operation(scope) } + } + + /// Registration/activation may share a transition. Stable backup snapshots must still reject it. + func operationGeneration() throws -> Generation { + if let current = Self.current, current.admission === self, + let entry = lock.withLock({ entries[current.id] }), entry.kind == .sessionTransition + { + try validate(current) + return entry.generation + } + return try snapshot() + } + + func validateOperationGeneration(_ expected: Generation) throws { + guard try operationGeneration() == expected else { throw ProfileRemovalStore.StoreError.changed } + } + + /// Transfers the still-retained startup owner to a distinct local recovery reservation without reopening admission. + func handoffToLocalRecovery(from scope: Scope, id: UUID) throws -> Scope { + try validate(scope) + let next = try lock.withLock { + guard exclusive == scope.id, let previous = entries[scope.id], previous.kind == .sessionTransition, + !previous.revoked, !previous.acknowledged, localFailureID == nil else { throw ProfileRemovalStore.StoreError.changed } + entries[scope.id]?.revoked = true + generation = UUID() + localFailureID = id + let next = Scope(admission: self, id: UUID()) + entries[next.id] = Entry(generation: Generation(live: generation, stored: previous.generation.stored), + kind: .localRecovery, pendingID: id, localOnly: true) + exclusive = next.id + return next + } + cancelRevokedOperations() + resumeDrains() + return next + } + + /// Closes admission synchronously. Drain before acquiring an SDK or publication lock. + func reserve(kind: Kind, id: UUID, expected: Generation? = nil, requiredPendingID: UUID? = nil) throws -> Scope { + precondition(kind != .ordinary && kind != .sessionTransition) + if let current = Self.current, current.admission === self { + throw ProfileRemovalStore.StoreError.changed + } + let pending = try store.pending() + if let requiredPendingID { + guard kind == .walletWipe, pending?.id == requiredPendingID, pending?.scope == .walletWipe else { + throw ProfileRemovalStore.StoreError.changed + } + } + if let expected { + try validate(expected) + } + if kind == .removal { + guard expected != nil, pending == nil else { throw ProfileRemovalStore.StoreError.pending } + } else if let pending { + guard kind == .walletWipe || (pending.id == id && pending.scope == .profile) else { + throw ProfileRemovalStore.StoreError.changed + } + } else if kind == .localRecovery { + guard expected != nil else { throw ProfileRemovalStore.StoreError.changed } + } + let stored = pending == nil ? try store.requireNoPending() : UUID() + let scope = try lock.withLock { + guard exclusive.flatMap({ entries[$0]?.kind }) != .walletWipe, + kind == .walletWipe || exclusive == nil else { throw ProfileRemovalStore.StoreError.pending } + if let expected, expected.live != generation { + throw ProfileRemovalStore.StoreError.changed + } + if kind == .walletWipe { + walletGeneration = UUID() + } + for key in entries.keys { + entries[key]?.revoked = true + } + generation = UUID() + let scope = Scope(admission: self, id: UUID()) + entries[scope.id] = Entry( + generation: Generation(live: generation, stored: stored), kind: kind, + pendingID: kind == .walletWipe ? pending?.id ?? localFailureID ?? id : id, + requiredPendingID: requiredPendingID, + localOnly: pending != nil || localFailureID != nil + ) + exclusive = scope.id + return scope + } + cancelRevokedOperations() + resumeDrains() + return scope + } + + func drain(before scope: Scope) async throws { + try validate(scope, access: .control) + await withCheckedContinuation { continuation in + let ready = lock.withLock { + if entries[scope.id]?.revoked == false, hasOutstandingWork(before: scope.id) { + drainWaiters.append((scope.id, continuation)) + return false + } + return true + } + if ready { + continuation.resume() + } + } + try Task.checkCancellation() + try validate(scope, access: .control) + } + + func persist(_ scope: Scope) throws { + try validate(scope, access: .control) + let entry = try lock.withLock { + guard let entry = entries[scope.id], entry.kind != .ordinary, entry.kind != .sessionTransition, + !hasOutstandingWork(before: scope.id) else { throw ProfileRemovalStore.StoreError.changed } + return entry + } + let pending = try store.pending() + let failureID = lock.withLock { localFailureID } + let id = entry.kind == .walletWipe ? (pending?.id ?? failureID ?? entry.pendingID) : entry.pendingID + guard let id else { throw ProfileRemovalStore.StoreError.changed } + lock.withLock { + entries[scope.id]?.pendingID = id + if pending != nil || localFailureID != nil { + entries[scope.id]?.localOnly = true + } + } + if let required = entry.requiredPendingID { + try store.begin(id: id, scope: .walletWipe, requiredExistingID: required) + } else if pending == nil { + // A prior acknowledged completion may remove its record while the wipe drains that owner. + // Only the new explicit wipe can start from this post-drain clean generation. + let expected = entry.kind == .walletWipe ? try store.requireNoPending() : entry.generation.stored + try store.begin(id: id, scope: entry.kind == .walletWipe ? .walletWipe : .profile, expectedGeneration: expected) + } else { + try store.begin(id: id, scope: entry.kind == .walletWipe ? .walletWipe : .profile) + } + try validate(scope, access: .control) + lock.withLock { + if localFailureID == id { + localFailureID = nil + } + } + } + + func permitsRemoteCleanup(_ scope: Scope) throws -> Bool { + try validate(scope, access: .local) + return try lock.withLock { + guard let entry = entries[scope.id], entry.kind == .removal || entry.kind == .localRecovery || entry.kind == .walletWipe else { + throw ProfileRemovalStore.StoreError.changed + } + return !entry.localOnly && entry.kind != .localRecovery + } + } + + func requireLocalOnly(_ scope: Scope) throws { + try validate(scope, access: .local) + try lock.withLock { + guard let entry = entries[scope.id], entry.kind != .ordinary, entry.kind != .sessionTransition else { + throw ProfileRemovalStore.StoreError.changed + } + entries[scope.id]?.localOnly = true + } + } + + @discardableResult + func complete(_ scope: Scope) throws -> Generation { + try requireLocalOnly(scope) + let entry = lock.withLock { entries[scope.id] } + guard let entry, let id = entry.pendingID else { throw ProfileRemovalStore.StoreError.changed } + try store.complete(id: id, scope: entry.kind == .walletWipe ? .walletWipe : .profile) + lock.withLock { localFailureID = nil } + return try Generation(live: entry.generation.live, stored: store.requireNoPending()) + } + + var isWalletWiping: Bool { + lock.withLock { exclusive.flatMap { entries[$0]?.kind } == .walletWipe } + } + + func isWalletWipe(_ scope: Scope) -> Bool { + guard scope.admission === self else { return false } + return lock.withLock { exclusive == scope.id && entries[scope.id]?.kind == .walletWipe && entries[scope.id]?.revoked == false } + } + + func onRevocation(of scope: Scope, id: UUID, cancel: @escaping @Sendable () -> Void) { + let revoked = lock.withLock { + guard scope.admission === self, entries[scope.id]?.revoked == false else { return true } + entries[scope.id]?.cancellationHandlers[id] = cancel + return false + } + if revoked { + cancel() + } + } + + func removeCancellationHandler(from scope: Scope, id: UUID) { + _ = lock.withLock { entries[scope.id]?.cancellationHandlers.removeValue(forKey: id) } + } + + func revoke(_ scope: Scope) { + guard scope.admission === self else { return } + lock.withLock { entries[scope.id]?.revoked = true } + cancelRevokedOperations() + resumeDrains() + } + + func release(_ scope: Scope) { + guard scope.admission === self else { return } + var scheduled: [(@Sendable (Scope) -> Void, Scope)] = [] + var released = false + lock.withLock { + guard var entry = entries[scope.id] else { return } + entry.holders -= 1 + if entry.holders == 0 { + released = true + entries.removeValue(forKey: scope.id) + if exclusive == scope.id { + if entry.kind == .sessionTransition { + generation = UUID() + if !entry.revoked { + for operation in entry.afterTransition { + let next = Scope(admission: self, id: UUID()) + entries[next.id] = Entry(generation: Generation(live: generation, stored: entry.generation.stored), kind: .ordinary) + scheduled.append((operation, next)) + } + } + } + exclusive = nil + } + } else { + entries[scope.id] = entry + } + } + resumeDrains() + Self.$current.withValue(nil) { + for (operation, scope) in scheduled { + operation(scope) + } + } + if released { + NotificationCenter.default.post(name: Self.didReleaseOperation, object: self) + } + } + + private func cancelRevokedOperations() { + let handlers: [@Sendable () -> Void] = lock.withLock { + var handlers: [@Sendable () -> Void] = [] + for id in entries.keys where entries[id]?.revoked == true { + handlers.append(contentsOf: entries[id]?.cancellationHandlers.values.map { $0 } ?? []) + entries[id]?.cancellationHandlers.removeAll() + } + return handlers + } + handlers.forEach { $0() } + } + + private func resumeDrains() { + let ready: [CheckedContinuation] = lock.withLock { + var ready: [CheckedContinuation] = [] + drainWaiters.removeAll { id, continuation in + guard entries[id]?.revoked != false || !hasOutstandingWork(before: id) else { return false } + ready.append(continuation) + return true + } + return ready + } + ready.forEach { $0.resume() } + } + + private func hasOutstandingWork(before id: UUID) -> Bool { + entries.keys.contains(where: { $0 != id }) || (entries[id]?.kind == .walletWipe && !walletOperations.isEmpty) + } +} diff --git a/Bitkit/Utilities/ProfileRemovalLease.swift b/Bitkit/Utilities/ProfileRemovalLease.swift new file mode 100644 index 000000000..60a7062d4 --- /dev/null +++ b/Bitkit/Utilities/ProfileRemovalLease.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Keeps a live removal intent between confirmations while active work retains its own drain holder. +final class ProfileRemovalLease: @unchecked Sendable { + let scope: ProfileOperationAdmission.Scope + private let lock = NSLock() + private let revokeRemoteAdmission: @Sendable () -> Void + private let handlerID = UUID() + private var retired = false + private var active = false + + init(scope: ProfileOperationAdmission.Scope, revokeRemoteAdmission: @escaping @Sendable () -> Void) { + self.scope = scope + self.revokeRemoteAdmission = revokeRemoteAdmission + scope.admission.onRevocation(of: scope, id: handlerID) { [weak self] in self?.retire() } + } + + deinit { retire() } + + var canResume: Bool { + lock.withLock { !retired && !active } && (try? scope.admission.validate(scope, access: .control)) != nil + } + + func retire() { + let releaseIdle = lock.withLock { + guard !retired else { return false } + retired = true + return true + } + guard releaseIdle else { return } + revokeRemoteAdmission() + scope.admission.removeCancellationHandler(from: scope, id: handlerID) + scope.admission.revoke(scope) + scope.admission.release(scope) + } + + func withOperation(_ operation: () async throws -> T) async throws -> T { + try lock.withLock { + guard !retired, !active else { throw ProfileRemovalStore.StoreError.changed } + active = true + } + defer { lock.withLock { active = false } } + let work = try ProfileOperationAdmission.$current.withValue(scope) { + try scope.admission.capture(access: .control) + } + defer { scope.admission.release(work) } + return try await scope.admission.withScope(work, access: .control, operation) + } +} diff --git a/Bitkit/Utilities/ProfileRemovalStore.swift b/Bitkit/Utilities/ProfileRemovalStore.swift new file mode 100644 index 000000000..437f5efb9 --- /dev/null +++ b/Bitkit/Utilities/ProfileRemovalStore.swift @@ -0,0 +1,155 @@ +import Foundation + +/// A local cleanup obligation, independent of credentials and preferences that cleanup removes. +/// It never establishes that a remote mutation succeeded or authorizes replaying one. +final class ProfileRemovalStore: @unchecked Sendable { + enum Scope: String, Codable { + case profile + case walletWipe + } + + struct Pending: Codable, Equatable { + let version: Int + let id: UUID + let scope: Scope + } + + enum StoreError: Error { + case unavailable + case pending + case changed + } + + static let didChange = Notification.Name("ProfileRemovalStoreDidChange") + + static let shared = ProfileRemovalStore( + url: Env.appStorageUrl.appendingPathComponent(Env.networkName) + .appendingPathComponent("wallet0/profile-removal-v1.json") + ) + + private let lock = NSLock() + private let url: URL + private let write: (Data, URL) throws -> Void + private let remove: (URL) throws -> Void + private var generation = UUID() + private var observed: Pending? + + init( + url: URL, + write: @escaping (Data, URL) throws -> Void = { data, url in + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + }, + remove: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) } + ) { + self.url = url + self.write = write + self.remove = remove + } + + func pending() throws -> Pending? { + try lock.withLock { try readLocked() } + } + + @discardableResult + func requireNoPending() throws -> UUID { + try lock.withLock { + guard try readLocked() == nil else { throw StoreError.pending } + return generation + } + } + + func validate(generation expected: UUID) throws { + try lock.withLock { + guard try readLocked() == nil, generation == expected else { throw StoreError.changed } + } + } + + func begin(id: UUID, scope: Scope = .profile, expectedGeneration: UUID? = nil, requiredExistingID: UUID? = nil) throws { + try lock.withLock { + let current = try readLocked() + if let requiredExistingID { + guard current?.id == requiredExistingID, current?.scope == scope else { throw StoreError.changed } + } + if let expectedGeneration { + guard current == nil, generation == expectedGeneration else { throw StoreError.changed } + } + guard current == nil || current?.id == id else { throw StoreError.changed } + // A partially completed wallet wipe must not be downgraded to profile-only recovery. + guard current?.scope != .walletWipe || scope == .walletWipe else { throw StoreError.changed } + let next = Pending(version: 1, id: id, scope: scope) + if current == next { + return + } + #if DEBUG + if self === Self.shared { + try ProfileRemovalTestFault.check("marker:persist") + } + #endif + try write(JSONEncoder().encode(next), url) + observed = next + generation = UUID() + } + if self === Self.shared { + NotificationCenter.default.post(name: Self.didChange, object: self) + } + } + + /// Call only after every required credential and local-state write has been acknowledged. + func complete(id: UUID, scope: Scope = .profile) throws { + try lock.withLock { + guard let current = try readLocked(), current.id == id, current.scope == scope else { + throw StoreError.changed + } + #if DEBUG + if self === Self.shared { + try ProfileRemovalTestFault.check("marker:complete") + } + #endif + try remove(url) + observed = nil + generation = UUID() + } + if self === Self.shared { + NotificationCenter.default.post(name: Self.didChange, object: self) + } + } + + /// An explicit local recovery can repair only this version's identifiable damaged record. + /// Unknown formats and unreadable storage remain untouched. + func recoverMalformedRecord(id: UUID = UUID()) throws { + try lock.withLock { + do { _ = try readLocked(); return } catch {} + let bytes = try Data(contentsOf: url) + guard let object = try JSONSerialization.jsonObject(with: bytes) as? [String: Any], + object["version"] as? Int == 1, + let rawScope = object["scope"] as? String, let scope = Scope(rawValue: rawScope) + else { throw StoreError.unavailable } + guard try Data(contentsOf: url) == bytes else { throw StoreError.changed } + let next = Pending(version: 1, id: id, scope: scope) + try write(JSONEncoder().encode(next), url) + guard try readLocked() == next else { throw StoreError.unavailable } + } + if self === Self.shared { + NotificationCenter.default.post(name: Self.didChange, object: self) + } + } + + private func readLocked() throws -> Pending? { + var value: Pending? + do { + let data = try Data(contentsOf: url) + value = try JSONDecoder().decode(Pending.self, from: data) + guard value?.version == 1 else { throw StoreError.unavailable } + } catch let error as CocoaError where error.code == .fileReadNoSuchFile { + value = nil + } catch { + throw StoreError.unavailable + } + if value != observed { + generation = UUID() + observed = value + } + return value + } +} diff --git a/Bitkit/Utilities/SharedPubkyKeychain.swift b/Bitkit/Utilities/SharedPubkyKeychain.swift index 704c3e509..4d75360a3 100644 --- a/Bitkit/Utilities/SharedPubkyKeychain.swift +++ b/Bitkit/Utilities/SharedPubkyKeychain.swift @@ -135,12 +135,46 @@ enum SharedPubkyKeychain { SecItemDelete(query as CFDictionary) } } + + /// Required local teardown must acknowledge enumeration and every owned-record deletion. + static func removeAllOwnOrThrow( + copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemCopyMatching, + delete: (CFDictionary) -> OSStatus = SecItemDelete + ) throws { + #if DEBUG + try ProfileRemovalTestFault.check("shared-own:enumerate") + #endif + var query = baseQuery + query[kSecReturnAttributes as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitAll + var result: CFTypeRef? + let status = copyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return + } + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + throw KeychainError.failedToLoad + } + let accounts = try items.map { item in + guard let account = item[kSecAttrAccount as String] as? String else { throw KeychainError.failedToLoad } + return account + } + for account in accounts where account.hasPrefix("\(ownSourceApp):") { + #if DEBUG + try ProfileRemovalTestFault.check("shared-own:delete") + #endif + var item = baseQuery + item[kSecAttrAccount as String] = account + let result = delete(item as CFDictionary) + guard result == errSecSuccess || result == errSecItemNotFound else { throw KeychainError.failedToDelete } + } + } } /// The pubky Bitkit adopted from another app. Only the reference is stored; the secret stays in its owner's store. enum AdoptedPubkyReference { - private static let sourceAppKey = "adoptedPubkySourceApp" - private static let pubkyKey = "adoptedPubkyPubky" + static let sourceAppKey = "adoptedPubkySourceApp" + static let pubkyKey = "adoptedPubkyPubky" static var current: (sourceApp: String, pubky: String)? { get { diff --git a/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift b/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift index 3e1334282..2cd1f17b2 100644 --- a/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift +++ b/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift @@ -27,18 +27,27 @@ .accessibilityIdentifier("ContactImportFixtureLeave") NavigationStack(path: $navigation.path) { - ContactImportOverviewView(profile: manager.fixtureProfile, contacts: manager.fixtureContacts) - .navigationDestination(for: Route.self) { route in - switch route { - case .contactImportSelect: - ContactImportSelectView(contacts: manager.fixtureContacts) - case .payContacts: - CaptionText(manager.savedKeys.joined(separator: ",")) - .accessibilityIdentifier("ContactImportFixtureCompleted") - default: - EmptyView() - } + ContactImportOverviewView( + profile: manager.fixtureProfile, + contacts: manager.fixtureContacts, + importGeneration: manager.importGeneration, + owner: profile.removalPresentationOwner + ) + .navigationDestination(for: Route.self) { route in + switch route { + case .contactImportSelect: + ContactImportSelectView( + contacts: manager.fixtureContacts, + importGeneration: manager.importGeneration, + owner: profile.removalPresentationOwner + ) + case .payContacts: + CaptionText(manager.savedKeys.joined(separator: ",")) + .accessibilityIdentifier("ContactImportFixtureCompleted") + default: + EmptyView() } + } } } .environmentObject(manager as ContactsManager) diff --git a/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift b/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift index fbf817523..bce23efe0 100644 --- a/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift +++ b/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift @@ -2,21 +2,28 @@ import SwiftUI struct ProfileRecoveryUITestFixture: View { - @StateObject private var profile = ProfileRecoveryTestManager() + @StateObject private var profile: ProfileRecoveryTestManager + @StateObject private var local: ProfileRecoveryLocalState @StateObject private var app = AppViewModel() @StateObject private var navigation = NavigationViewModel() @StateObject private var contacts = ContactsManager() @State private var clipboard = "" + init() { + let local = ProfileRecoveryLocalState() + _local = StateObject(wrappedValue: local) + _profile = StateObject(wrappedValue: ProfileRecoveryTestManager(local: local)) + } + var body: some View { VStack(spacing: 8) { HStack { Button("Restore") { profile.restoreSession() } .accessibilityIdentifier("ProfileFixtureRestore") .accessibilityValue(profile.currentSession == nil ? "Unavailable" : "Restored") - Button("Fail") { profile.finish(success: false) } + Button("Fail") { finish(success: false) } .accessibilityIdentifier("ProfileFixtureFail") - Button("Load") { profile.finish(success: true) } + Button("Load") { finish(success: true) } .accessibilityIdentifier("ProfileFixtureLoad") Button("Clipboard") { clipboard = UIPasteboard.general.string ?? "" } .accessibilityIdentifier("ProfileFixtureClipboard") @@ -28,17 +35,35 @@ } CaptionText(clipboard) .accessibilityIdentifier("ProfileFixtureClipboardValue") - CaptionText(profile.isLoadingProfile ? "Loading" : profile.isDisconnectPending ? "Disconnecting" : "Idle") + CaptionText(profile.isLoadingProfile ? "Loading" : local.isPending ? "Disconnecting" : "Idle") .accessibilityIdentifier("ProfileFixtureStatus") .accessibilityValue(profile.events.joined(separator: "\n")) + CaptionText( + "Credentials: \(local.credentialsPresent ? "present" : "cleared"), watermark: \(local.watermark), remote: \(local.remoteCalls)" + ) + .accessibilityIdentifier("ProfileFixtureLocalState") + CaptionText(profile.pendingProfileRemovalID?.uuidString ?? "None") + .accessibilityIdentifier("ProfileFixturePendingID") NavigationStack(path: $navigation.path) { - ProfileDestinationView(hasSeenIntro: true) - .navigationDestination(for: Route.self) { route in - if route == .editProfile { - CaptionText("Edit profile") - .accessibilityIdentifier("ProfileFixtureEditing") - } + Group { + if !local.credentialsPresent, profile.removalRecovery == .ready { + CaptionText("Waiting for profile navigation") + .accessibilityIdentifier("ProfileFixtureAwaitingCompletion") + } else { + ProfileDestinationView(hasSeenIntro: true) } + } + .navigationDestination(for: Route.self) { route in + if route == .editProfile { + CaptionText("Edit profile") + .accessibilityIdentifier("ProfileFixtureEditing") + } else if route == .pubkyChoice || route == .profileIntro, !local.credentialsPresent, + profile.removalRecovery == .ready + { + CaptionText("Local cleanup complete") + .accessibilityIdentifier("ProfileFixtureCompleted") + } + } } } .environmentObject(profile as PubkyProfileManager) @@ -47,6 +72,43 @@ .environmentObject(contacts) .preferredColorScheme(.dark) } + + private func finish(success: Bool) { + if local.isPending { + local.finish(success: success) + } else { + profile.finish(success: success) + } + } + } + + @MainActor + private final class ProfileRecoveryLocalState: ObservableObject { + @Published private(set) var isPending = false + @Published private(set) var credentialsPresent = true + @Published private(set) var watermark = 42 + @Published private(set) var remoteCalls = 0 + private var pendingResult: CheckedContinuation? + + func complete() async throws { + isPending = true + defer { isPending = false } + let success = await withCheckedContinuation { pendingResult = $0 } + guard success else { throw KeychainError.failedToDelete } + credentialsPresent = false + watermark = 0 + } + + func finish(success: Bool) { + let result = pendingResult + pendingResult = nil + result?.resume(returning: success) + } + + func rejectRemote() throws { + remoteCalls += 1 + throw PubkyServiceError.sessionNotActive + } } @MainActor @@ -56,23 +118,42 @@ imageUrl: nil, links: [], tags: ["public-tag"], status: nil ) - @Published private(set) var isDisconnectPending = false + private let local: ProfileRecoveryLocalState @Published private(set) var events: [String] = [] private var nextOperation = 0 private var pendingOperation: String? private var hasLoaded = false private var pendingResult: CheckedContinuation? + init(local: ProfileRecoveryLocalState) { + self.local = local + let store = ProfileRemovalStore(url: FileManager.default.temporaryDirectory + .appendingPathComponent("profile-ui-\(UUID().uuidString)/pending.json")) + let services = RemovalServices( + readProfile: { _ in try await local.rejectRemote(); return nil }, + deleteProfile: { _, _ in try await local.rejectRemote() }, + withdrawPrivate: { try local.rejectRemote() }, + withdrawPublic: { try local.rejectRemote() }, + syncApp: { try local.rejectRemote() }, + validateSubscriptions: { _ in try local.rejectRemote() }, + captureRevocation: { _ in try local.rejectRemote(); throw PubkyServiceError.sessionNotActive }, + completeLocal: { try await local.complete() }, + completeRevokedLocal: { _ in try local.rejectRemote() }, + pause: { _ in }, resume: { _ in } + ) + super.init(removalServices: services, removalStore: store, removalAdmission: ProfileOperationAdmission(store: store)) + } + override var hasExistingIdentity: Bool { - true + local.credentialsPresent } override var profileForDisplay: PubkyProfile? { - profile ?? Self.savedProfile + local.credentialsPresent ? profile ?? Self.savedProfile : nil } override var publicKeyForDisplay: String? { - Self.savedProfile.publicKey + local.credentialsPresent ? Self.savedProfile.publicKey : nil } override func restoreSessionIfNeeded( @@ -101,21 +182,8 @@ record("\(operation) ended") } - override func signOut() async throws { - let operation = beginOperation("disconnect") - isDisconnectPending = true - _ = await withCheckedContinuation { - record("\(operation) installed; previous=\(pendingOperation ?? "none")") - pendingOperation = operation - pendingResult = $0 - } - record("\(operation) resumed") - isDisconnectPending = false - record("\(operation) ended") - throw PubkyServiceError.sessionNotActive - } - func restoreSession() { + guard removalRecovery == .ready else { return } record("restore action") publicKey = Self.savedProfile.publicKey authState = .authenticated diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 1a303bb6c..72bfcaad7 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -226,17 +226,19 @@ class ActivityListViewModel: ObservableObject { isSyncingLdkNodePayments = true defer { isSyncingLdkNodePayments = false } - guard let ldkPayments = await listPayments() else { return } - try await coreService.activity.syncLdkNodePayments(ldkPayments) - await syncState() + try await coreService.activity.withPaymentSync { @MainActor [self] in + guard let ldkPayments = await listPayments() else { return } + try await coreService.activity.syncLdkNodePayments(ldkPayments) + await syncState() - // This ensures pending transfers are marked as settled when channels become ready - do { - try await transferService.syncTransferStates() - Logger.debug("Transfer states synced after LDK payments sync", context: "ActivityListViewModel") - } catch { - Logger.error("Failed to sync transfer states after LDK payments sync", context: error.localizedDescription) - // Don't throw - we don't want to fail the entire sync if transfer sync fails + // This ensures pending transfers are marked as settled when channels become ready + do { + try await transferService.syncTransferStates() + Logger.debug("Transfer states synced after LDK payments sync", context: "ActivityListViewModel") + } catch { + Logger.error("Failed to sync transfer states after LDK payments sync", context: error.localizedDescription) + // Don't throw - we don't want to fail the entire sync if transfer sync fails + } } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 97ee650ac..546be1951 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1352,65 +1352,73 @@ class WalletViewModel: ObservableObject { includeOnchain: Bool = true, includeLightning: Bool = true ) async throws -> PublicPaykitEndpoints { - guard isPaykitUIActive else { - return PublicPaykitEndpoints(onchainSnapshot: nil, bolt11: "") - } - - let initialInvoiceRevision = publicPaykitInvoiceRevision - let session = pubkyProfile?.currentSession - let wasSharing = sharesPublicPaykitEndpoints - let publicOnchainSnapshot: PrivatePaykitAddressReservationStore.PublicAddressSnapshot? - if includeOnchain { - guard let session else { throw PubkyServiceError.sessionNotActive } - publicOnchainSnapshot = try await publicPaykitOnchainAddressSnapshot(for: session.publicKey) - // A reservation committed before cancellation still needs publication or reconciliation. - if isPaykitUIActive, sharesPublicPaykitEndpoints, pubkyProfile?.currentSession == session { - PublicPaykitService.setCleanupPending(true) + try await ProfileOperationAdmission.shared.withOperation { + guard isPaykitUIActive else { + return PublicPaykitEndpoints(onchainSnapshot: nil, bolt11: "") } - } else { - publicOnchainSnapshot = nil - } - for attempt in 0 ..< 2 { - let invoiceRevision = attempt == 0 ? initialInvoiceRevision : publicPaykitInvoiceRevision - let canCommitInvoice = { () async throws -> Bool in - if attempt > 0 { - guard await !PrivatePaykitService.shared.isDeletingProfile else { throw PubkyServiceError.sessionNotActive } + let initialInvoiceRevision = publicPaykitInvoiceRevision + let session = pubkyProfile?.currentSession + let wasSharing = sharesPublicPaykitEndpoints + let publicOnchainSnapshot: PrivatePaykitAddressReservationStore.PublicAddressSnapshot? + if includeOnchain { + guard let session else { throw PubkyServiceError.sessionNotActive } + publicOnchainSnapshot = try await publicPaykitOnchainAddressSnapshot(for: session.publicKey) + // A reservation committed before cancellation still needs publication or reconciliation. + if let scope = ProfileOperationAdmission.current, + (try? ProfileOperationAdmission.shared.validate(scope)) != nil || + (try? ProfileOperationAdmission.shared.validateCancelledPreference(scope)) != nil, + isPaykitUIActive, sharesPublicPaykitEndpoints, pubkyProfile?.currentSession == session + { + PublicPaykitService.setCleanupPending(true) } - guard !Task.isCancelled, self.isPaykitUIActive, - attempt == 0 || self.pubkyProfile?.isDeletingProfile != true, - self.pubkyProfile?.currentSession == session && self.sharesPublicPaykitEndpoints == wasSharing - else { throw PubkyServiceError.sessionNotActive } - return self.publicPaykitInvoiceRevision == invoiceRevision - } - if attempt > 0 { - guard try await canCommitInvoice() else { continue } + try ProfileOperationAdmission.shared.checkCurrent() + } else { + publicOnchainSnapshot = nil } - if includeLightning, hasUsableChannels { - let hasReusableInvoice = await hasReusablePublicPaykitInvoice() - guard try await canCommitInvoice() else { continue } - let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice - if shouldRefreshBolt11 { - do { - guard try await refreshPublicPaykitBolt11(canCommitInvoice: canCommitInvoice) else { continue } - } catch let error as PublicPaykitError { - guard case .routeHintsUnavailable = error else { - throw error + + for attempt in 0 ..< 2 { + let invoiceRevision = attempt == 0 ? initialInvoiceRevision : publicPaykitInvoiceRevision + let canCommitInvoice = { () async throws -> Bool in + try ProfileOperationAdmission.shared.checkCurrent() + if attempt > 0 { + guard await !PrivatePaykitService.shared.isDeletingProfile else { throw PubkyServiceError.sessionNotActive } + } + guard !Task.isCancelled, self.isPaykitUIActive, + attempt == 0 || self.pubkyProfile?.isDeletingProfile != true, + self.pubkyProfile?.currentSession == session && self.sharesPublicPaykitEndpoints == wasSharing + else { throw PubkyServiceError.sessionNotActive } + return self.publicPaykitInvoiceRevision == invoiceRevision + } + if attempt > 0 { + guard try await canCommitInvoice() else { continue } + } + if includeLightning, hasUsableChannels { + let hasReusableInvoice = await hasReusablePublicPaykitInvoice() + guard try await canCommitInvoice() else { continue } + let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice + if shouldRefreshBolt11 { + do { + guard try await refreshPublicPaykitBolt11(canCommitInvoice: canCommitInvoice) else { continue } + } catch let error as PublicPaykitError { + guard case .routeHintsUnavailable = error else { + throw error + } + Logger.warn( + "Public Paykit Lightning invoice has no route hints; publishing on-chain endpoint only", + context: "WalletViewModel" + ) } - Logger.warn( - "Public Paykit Lightning invoice has no route hints; publishing on-chain endpoint only", - context: "WalletViewModel" - ) } + } else if includeLightning { + guard try await canCommitInvoice() else { continue } + clearPublicPaykitBolt11() } - } else if includeLightning { - guard try await canCommitInvoice() else { continue } - clearPublicPaykitBolt11() - } - return PublicPaykitEndpoints(onchainSnapshot: publicOnchainSnapshot, bolt11: includeLightning ? publicPaykitBolt11 : "") + return PublicPaykitEndpoints(onchainSnapshot: publicOnchainSnapshot, bolt11: includeLightning ? publicPaykitBolt11 : "") + } + throw PublicPaykitError.publicationFailed } - throw PublicPaykitError.publicationFailed } func publicPaykitOnchainAddressSnapshot(for publicKey: String) async throws -> PrivatePaykitAddressReservationStore.PublicAddressSnapshot { @@ -1422,42 +1430,49 @@ class WalletViewModel: ObservableObject { } func refreshPublicPaykitAddressIfNeeded(isSessionCurrent: (@MainActor () -> Bool)? = nil) async { - let isSessionCurrent = isSessionCurrent ?? publicPaykitPublicationAdmission() - let canPublish = { - !Task.isCancelled && isSessionCurrent() && self.isPaykitUIActive && self.sharesPublicPaykitEndpoints && - self.pubkyProfile?.isDeletingProfile != true && PublicPaykitService.isOnchainPaymentOptionEnabled() - } - guard canPublish(), let session = pubkyProfile?.automaticPaykitSession, - await !PrivatePaykitService.shared.isDeletingProfile - else { return } - do { - guard try await needsPublicPaykitAddressRotation(for: session.publicKey), - canPublish(), pubkyProfile?.automaticPaykitSession == session, + try? await ProfileOperationAdmission.shared.withOperation { + let isSessionCurrent = isSessionCurrent ?? publicPaykitPublicationAdmission() + let canPublish = { + !Task.isCancelled && isSessionCurrent() && self.isPaykitUIActive && self.sharesPublicPaykitEndpoints && + self.pubkyProfile?.isDeletingProfile != true && PublicPaykitService.isOnchainPaymentOptionEnabled() + } + guard canPublish(), let session = pubkyProfile?.automaticPaykitSession, await !PrivatePaykitService.shared.isDeletingProfile else { return } - PublicPaykitService.setCleanupPending(true) - try await syncReceivePublicPaykitEndpoints(isSessionCurrent: canPublish) - } catch { - guard canPublish(), pubkyProfile?.automaticPaykitSession == session else { return } - PublicPaykitService.setCleanupPending(true) - Logger.warn("Failed to refresh public Paykit address: \(error)", context: "WalletViewModel") + do { + guard try await needsPublicPaykitAddressRotation(for: session.publicKey), + canPublish(), pubkyProfile?.automaticPaykitSession == session, + await !PrivatePaykitService.shared.isDeletingProfile + else { return } + try ProfileOperationAdmission.shared.checkCurrent() + PublicPaykitService.setCleanupPending(true) + try await syncReceivePublicPaykitEndpoints(isSessionCurrent: canPublish) + } catch { + guard (try? ProfileOperationAdmission.shared.checkCurrent()) != nil, + canPublish(), pubkyProfile?.automaticPaykitSession == session else { return } + PublicPaykitService.setCleanupPending(true) + Logger.warn("Failed to refresh public Paykit address: \(error)", context: "WalletViewModel") + } } } func refreshPublicPaykitEndpointsOnForeground(isSessionCurrent: @escaping @MainActor () -> Bool) async { - guard !Task.isCancelled, isSessionCurrent(), isPaykitUIActive, sharesPublicPaykitEndpoints else { return } - do { - try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self, isSessionCurrent: { - !Task.isCancelled && isSessionCurrent() && self.isPaykitUIActive && self.sharesPublicPaykitEndpoints - }) - } catch is CancellationError { - return - } catch PubkyServiceError.sessionNotActive { - return - } catch { + try? await ProfileOperationAdmission.shared.withOperation { guard !Task.isCancelled, isSessionCurrent(), isPaykitUIActive, sharesPublicPaykitEndpoints else { return } - PublicPaykitService.setCleanupPending(true) - Logger.warn("Failed to refresh public Paykit endpoints on foreground: \(error)", context: "WalletViewModel") + do { + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self, isSessionCurrent: { + !Task.isCancelled && isSessionCurrent() && self.isPaykitUIActive && self.sharesPublicPaykitEndpoints + }) + } catch is CancellationError { + return + } catch PubkyServiceError.sessionNotActive { + return + } catch { + guard (try? ProfileOperationAdmission.shared.checkCurrent()) != nil, + !Task.isCancelled, isSessionCurrent(), isPaykitUIActive, sharesPublicPaykitEndpoints else { return } + PublicPaykitService.setCleanupPending(true) + Logger.warn("Failed to refresh public Paykit endpoints on foreground: \(error)", context: "WalletViewModel") + } } } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index bc0fbaa03..085927158 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -14,6 +14,7 @@ struct AddContactView: View { let publicKey: String var loadPaymentEndpoint: (String) async throws -> Bool = PublicPaykitService.hasPayablePublicEndpoint + @State private var screenSession: PubkyProfileManager.SignedInSession? @State private var fetchedProfile: PubkyProfile? @State private var isLoading = true @State private var isSaving = false @@ -54,6 +55,9 @@ struct AddContactView: View { .background(Color.customBlack) .navigationBarHidden(true) .task { + if screenSession == nil { + screenSession = pubkyProfile.currentSession + } await loadProfile() } .task(id: fetchedProfile?.publicKey) { [profileKey = fetchedProfile?.publicKey] in @@ -222,8 +226,13 @@ struct AddContactView: View { errorMessage = t("contacts__add_error_existing") canRetryError = false isLoading = false - if let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) { - await contactsManager.refreshContactLink(publicKey: normalizedKey, wallet: wallet) + if let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), let session = screenSession, + pubkyProfile.currentSession == session + { + try? await ProfileOperationAdmission.shared.withOperation { + try pubkyProfile.requireCurrentSession(session) + await contactsManager.refreshContactLink(publicKey: normalizedKey, wallet: wallet) + } } return case let .valid(normalizedKey): @@ -242,6 +251,9 @@ struct AddContactView: View { private func saveContact() async { guard !isSaving else { return } + let admission = ProfileOperationAdmission.shared + guard let session = screenSession, pubkyProfile.currentSession == session, let scope = try? admission.capture() else { return } + defer { admission.release(scope) } isSaving = true defer { isSaving = false } @@ -251,15 +263,20 @@ struct AddContactView: View { return } - let identity = pubkyProfile.publicKey - try await contactsManager.addContact( - publicKey: normalizedPublicKey, - existingProfile: fetchedProfile, - ownPublicKey: identity - ) - await PrivatePaykitService.shared.startExplicitContactLink(publicKey: normalizedPublicKey, expectedIdentity: identity, wallet: wallet) - navigation.path = [.contacts, .contactSaved(publicKey: normalizedPublicKey)] + let identity = session.publicKey + try await admission.withScope(scope) { + try await contactsManager.addContact( + publicKey: normalizedPublicKey, + existingProfile: fetchedProfile, + ownPublicKey: identity + ) + await PrivatePaykitService.shared.startExplicitContactLink(publicKey: normalizedPublicKey, expectedIdentity: identity, wallet: wallet) + try admission.checkCurrent() + guard pubkyProfile.currentSession == session else { return } + navigation.path = [.contacts, .contactSaved(publicKey: normalizedPublicKey)] + } } catch { + guard pubkyProfile.currentSession == session, (try? admission.validate(scope)) != nil, !Task.isCancelled else { return } Logger.error("Failed to save contact: \(error)", context: "AddContactView") app.toast(type: .error, title: t("contacts__add_error"), description: error.localizedDescription) } diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 2f53f01ea..617189d2d 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -17,11 +17,14 @@ struct ContactDetailView: View { let publicKey: String var showsDeleteAction = false + @State private var screenSession: PubkyProfileManager.SignedInSession? @State private var profile: PubkyProfile? @State private var isLoading = true @State private var showAddTagSheet = false @State private var hasResolvedContactFromContacts = false @State private var showDeleteConfirmation = false + @State private var deleteSession: PubkyProfileManager.SignedInSession? + @State private var deleteGeneration: ProfileOperationAdmission.Generation? @State private var isPayLoading = false @State private var payTask: Task? @@ -42,6 +45,9 @@ struct ContactDetailView: View { .background(Color.customBlack) .navigationBarHidden(true) .task { + if screenSession == nil { + screenSession = pubkyProfile.currentSession + } if let cached = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) { profile = cached.profile hasResolvedContactFromContacts = true @@ -73,7 +79,8 @@ struct ContactDetailView: View { isPresented: $showDeleteConfirmation ) { Button(t("contacts__delete_confirm"), role: .destructive) { - Task { await deleteContact() } + guard let session = deleteSession, let generation = deleteGeneration else { return } + Task { await deleteContact(session: session, generation: generation) } } Button(t("common__dialog_cancel"), role: .cancel) {} } message: { @@ -154,7 +161,7 @@ struct ContactDetailView: View { accessibilityLabel: t(showsDeleteAction ? "common__delete" : "common__edit") ) { if showsDeleteAction { - showDeleteConfirmation = true + prepareDelete() } else { navigation.navigate(.editContact(publicKey: publicKey)) } @@ -239,7 +246,7 @@ struct ContactDetailView: View { /// lookup of a profile the row is still waiting for. The save is dropped quietly once this Pubky session ends. private func updateTags(_ transform: @escaping ([String]) -> [String]) { let pubkyProfile = pubkyProfile - guard let current = profile, let session = pubkyProfile.currentSession else { return } + guard let current = profile, let session = screenSession, pubkyProfile.currentSession == session else { return } profile = current.withTags(transform(current.tags)) let change = contactsManager.updateContactTags( publicKey: publicKey, @@ -252,15 +259,29 @@ struct ContactDetailView: View { do { try await change.value } catch { + guard pubkyProfile.currentSession == session, !Task.isCancelled else { return } Logger.error("Failed to persist contact tags: \(error)", context: "ContactDetailView") app.toast(type: .error, title: t("contacts__error_saving")) } } } - private func deleteContact() async { + private func prepareDelete() { + guard let session = screenSession, pubkyProfile.currentSession == session, + let generation = try? ProfileOperationAdmission.shared.snapshot() else { return } + deleteSession = session + deleteGeneration = generation + showDeleteConfirmation = true + } + + private func deleteContact(session: PubkyProfileManager.SignedInSession, generation: ProfileOperationAdmission.Generation) async { + let admission = ProfileOperationAdmission.shared + guard pubkyProfile.currentSession == session, (try? admission.validate(generation)) != nil, + let scope = try? admission.capture() else { return } + defer { admission.release(scope) } do { - try await contactsManager.removeContact(publicKey: publicKey) + try await admission.withScope(scope) { try await contactsManager.removeContact(publicKey: publicKey) } + guard pubkyProfile.currentSession == session, (try? admission.validate(scope)) != nil, !Task.isCancelled else { return } app.toast( type: .success, title: t("contacts__delete_success"), @@ -270,11 +291,13 @@ struct ContactDetailView: View { navigation.returnToContactsAfterRemoving(publicKey: publicKey) } } catch let PubkyServiceError.activeSubscription(endsAt) { + guard pubkyProfile.currentSession == session, (try? admission.validate(scope)) != nil, !Task.isCancelled else { return } let description = endsAt.map { t("subscriptions__expires_date", variables: ["date": $0.formatted(date: .long, time: .omitted)]) } app.toast(type: .error, title: t("contacts__delete_active_subscription"), description: description) } catch { + guard pubkyProfile.currentSession == session, (try? admission.validate(scope)) != nil, !Task.isCancelled else { return } Logger.error("Failed to delete contact: \(error)", context: "ContactDetailView") app.toast(type: .error, title: t("contacts__delete_error")) } diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index 809650c5e..15cdc73e4 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -3,6 +3,8 @@ import SwiftUI struct ContactImportOverviewView: View { let profile: PubkyProfile let contacts: [PubkyContact] + let importGeneration: UUID + let owner: PubkyProfileManager.RemovalPresentationOwner @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @@ -152,6 +154,8 @@ struct ContactImportOverviewView: View { private var buttonBar: some View { HStack(spacing: 16) { CustomButton(title: t("contacts__import_select"), variant: .secondary) { + guard contactsManager.importGeneration == importGeneration, + pubkyProfile.ownsRemovalPresentation(owner) else { return } navigation.navigate(.contactImportSelect) } .disabled(isImporting) @@ -170,16 +174,27 @@ struct ContactImportOverviewView: View { // MARK: - Actions private func importAllContacts() async { - guard !isImporting else { return } + let admission = ProfileOperationAdmission.shared + guard !isImporting, contactsManager.importGeneration == importGeneration, + pubkyProfile.ownsRemovalPresentation(owner), let scope = try? admission.capture() else { return } + defer { admission.release(scope) } isImporting = true defer { isImporting = false } - + let selected = contacts do { - try await contactsManager.importContacts(contacts: contacts) - if contactsManager.completePendingImport() { - navigation.path = [.payContacts] + try await admission.withScope(scope) { + if !selected.isEmpty { + try await contactsManager.importContacts(contacts: selected) + } + try admission.checkCurrent() + guard pubkyProfile.ownsRemovalPresentation(owner) else { return } + if contactsManager.completePendingImport(expectedGeneration: importGeneration) { + navigation.path = [.payContacts] + } } } catch { + guard !Task.isCancelled, (try? admission.validate(scope)) != nil, + contactsManager.importGeneration == importGeneration, pubkyProfile.ownsRemovalPresentation(owner) else { return } app.toast(type: .error, title: t("contacts__import_error")) } } @@ -214,11 +229,16 @@ private extension View { ] NavigationStack { - ContactImportOverviewView(profile: profile, contacts: contacts) - .environmentObject(AppViewModel()) - .environmentObject(NavigationViewModel()) - .environmentObject(ContactsManager()) - .environmentObject(PubkyProfileManager()) + ContactImportOverviewView( + profile: profile, + contacts: contacts, + importGeneration: UUID(), + owner: PubkyProfileManager().removalPresentationOwner + ) + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(PubkyProfileManager()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Contacts/ContactImportSelectView.swift b/Bitkit/Views/Contacts/ContactImportSelectView.swift index d8832e95d..eb5140de8 100644 --- a/Bitkit/Views/Contacts/ContactImportSelectView.swift +++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift @@ -2,10 +2,13 @@ import SwiftUI struct ContactImportSelectView: View { let contacts: [PubkyContact] + let importGeneration: UUID + let owner: PubkyProfileManager.RemovalPresentationOwner @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var contactsManager: ContactsManager + @EnvironmentObject var pubkyProfile: PubkyProfileManager @State private var selectedKeys: Set = [] @State private var isImporting = false @@ -168,24 +171,27 @@ struct ContactImportSelectView: View { // MARK: - Actions private func importSelectedContacts() async { - guard !isImporting else { return } - let selected = contacts.filter { selectedKeys.contains($0.publicKey) } - - guard !selected.isEmpty else { - contactsManager.clearPendingImport() - navigation.path = [.payContacts] - return - } - + let admission = ProfileOperationAdmission.shared + guard !isImporting, contactsManager.importGeneration == importGeneration, + pubkyProfile.ownsRemovalPresentation(owner), let scope = try? admission.capture() else { return } + defer { admission.release(scope) } isImporting = true defer { isImporting = false } - + let selected = contacts.filter { selectedKeys.contains($0.publicKey) } do { - try await contactsManager.importContacts(contacts: selected) - if contactsManager.completePendingImport() { - navigation.path = [.payContacts] + try await admission.withScope(scope) { + if !selected.isEmpty { + try await contactsManager.importContacts(contacts: selected) + } + try admission.checkCurrent() + guard pubkyProfile.ownsRemovalPresentation(owner) else { return } + if contactsManager.completePendingImport(expectedGeneration: importGeneration) { + navigation.path = [.payContacts] + } } } catch { + guard !Task.isCancelled, (try? admission.validate(scope)) != nil, + contactsManager.importGeneration == importGeneration, pubkyProfile.ownsRemovalPresentation(owner) else { return } app.toast(type: .error, title: t("contacts__import_error")) } } @@ -211,10 +217,11 @@ struct ContactImportSelectView: View { ] NavigationStack { - ContactImportSelectView(contacts: contacts) + ContactImportSelectView(contacts: contacts, importGeneration: UUID(), owner: PubkyProfileManager().removalPresentationOwner) .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(ContactsManager()) + .environmentObject(PubkyProfileManager()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 5f5603824..9154db6c9 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -9,9 +9,12 @@ struct EditContactView: View { let publicKey: String + @State private var screenSession: PubkyProfileManager.SignedInSession? @State private var form = ContactEditForm() @State private var isSaving = false @State private var showDeleteConfirmation = false + @State private var deleteSession: PubkyProfileManager.SignedInSession? + @State private var deleteGeneration: ProfileOperationAdmission.Generation? @State private var selectedPhotoItem: PhotosPickerItem? @State private var avatarImage: UIImage? @@ -32,7 +35,7 @@ struct EditContactView: View { deleteActionStyle: .buttonWithIcon(), onSave: { await saveContact() }, onCancel: { navigation.navigateBack() }, - onDelete: { showDeleteConfirmation = true } + onDelete: { prepareDelete() } ) { avatarSection } @@ -41,11 +44,15 @@ struct EditContactView: View { .background(Color.customBlack) .navigationBarHidden(true) .task { + if screenSession == nil { + screenSession = pubkyProfile.currentSession + } await loadContactData() } .alert(t("contacts__delete_title", variables: ["name": form.name]), isPresented: $showDeleteConfirmation) { Button(t("contacts__delete_confirm"), role: .destructive) { - Task { await deleteContact() } + guard let session = deleteSession, let generation = deleteGeneration else { return } + Task { await deleteContact(session: session, generation: generation) } } Button(t("common__dialog_cancel"), role: .cancel) {} } message: { @@ -118,9 +125,22 @@ struct EditContactView: View { // MARK: - Delete - private func deleteContact() async { + private func prepareDelete() { + guard let session = screenSession, pubkyProfile.currentSession == session, + let generation = try? ProfileOperationAdmission.shared.snapshot() else { return } + deleteSession = session + deleteGeneration = generation + showDeleteConfirmation = true + } + + private func deleteContact(session: PubkyProfileManager.SignedInSession, generation: ProfileOperationAdmission.Generation) async { + let admission = ProfileOperationAdmission.shared + guard pubkyProfile.currentSession == session, (try? admission.validate(generation)) != nil, + let scope = try? admission.capture() else { return } + defer { admission.release(scope) } do { - try await contactsManager.removeContact(publicKey: publicKey) + try await admission.withScope(scope) { try await contactsManager.removeContact(publicKey: publicKey) } + guard pubkyProfile.currentSession == session, (try? admission.validate(scope)) != nil, !Task.isCancelled else { return } app.toast( type: .success, title: t("contacts__delete_success"), @@ -130,11 +150,13 @@ struct EditContactView: View { navigation.returnToContactsAfterRemoving(publicKey: publicKey) } } catch let PubkyServiceError.activeSubscription(endsAt) { + guard pubkyProfile.currentSession == session, (try? admission.validate(scope)) != nil, !Task.isCancelled else { return } let description = endsAt.map { t("subscriptions__expires_date", variables: ["date": $0.formatted(date: .long, time: .omitted)]) } app.toast(type: .error, title: t("contacts__delete_active_subscription"), description: description) } catch { + guard pubkyProfile.currentSession == session, (try? admission.validate(scope)) != nil, !Task.isCancelled else { return } Logger.error("Failed to delete contact: \(error)", context: "EditContactView") app.toast(type: .error, title: t("contacts__delete_error")) } @@ -147,13 +169,16 @@ struct EditContactView: View { /// and the contact save each write nothing once another identity is signed in. private func saveContact() async { let pubkyProfile = pubkyProfile - guard !form.trimmedName.isEmpty, let session = pubkyProfile.currentSession else { return } + guard !form.trimmedName.isEmpty, let session = screenSession, pubkyProfile.currentSession == session else { return } + let admission = ProfileOperationAdmission.shared + guard let scope = try? admission.capture() else { return } + defer { admission.release(scope) } isSaving = true defer { isSaving = false } do { - let savedProfile = try await contactsManager.saveContactEdit( + let savedProfile = try await admission.withScope(scope) { try await contactsManager.saveContactEdit( publicKey: publicKey, expectedIdentity: session.publicKey, isSessionCurrent: { pubkyProfile.currentSession == session } @@ -165,8 +190,8 @@ struct EditContactView: View { form.imageUrl } return form.edit(imageUrl: uploadedImageUrl) - } - guard let savedProfile else { return } + } } + guard let savedProfile, pubkyProfile.currentSession == session, (try? admission.validate(scope)) != nil, !Task.isCancelled else { return } form.imageUrl = savedProfile.imageUrl app.toast( type: .success, @@ -175,6 +200,7 @@ struct EditContactView: View { ) navigation.navigateBack() } catch { + guard pubkyProfile.currentSession == session, (try? admission.validate(scope)) != nil, !Task.isCancelled else { return } Logger.error("Failed to save contact: \(error)", context: "EditContactView") app.toast(type: .error, title: t("contacts__edit_error")) } diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index 42119d49f..fb641c444 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -6,6 +6,9 @@ struct CreateProfileView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager + @State private var screenOwner: PubkyProfileManager.RemovalPresentationOwner? + @State private var screenGeneration: ProfileOperationAdmission.Generation? + @State private var loadGeneration = 0 @State private var derivedPublicKey: String = "" @State private var username: String = "" @State private var isLoading = false @@ -34,8 +37,18 @@ struct CreateProfileView: View { .ignoresSafeArea(.keyboard, edges: .bottom) .background(Color.customBlack) .navigationBarHidden(true) - .task { - await loadInitialData() + .task(id: loadGeneration) { await loadInitialData() } + .onChange(of: pubkyProfile.isAdoptingRingIdentity || pubkyProfile.isRestoringSession) { _, busy in + if !busy, screenGeneration == nil { + loadGeneration += 1 + } + } + .onReceive(NotificationCenter.default.publisher(for: ProfileOperationAdmission.didReleaseOperation, + object: ProfileOperationAdmission.shared).receive(on: RunLoop.main)) + { _ in + if screenGeneration == nil { + loadGeneration += 1 + } } } @@ -157,51 +170,51 @@ struct CreateProfileView: View { // MARK: - Data Loading private func loadInitialData() async { + let admission = ProfileOperationAdmission.shared + if screenOwner == nil { + screenOwner = pubkyProfile.removalPresentationOwner + } + guard let screenOwner, pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } + if screenGeneration == nil { + guard let generation = try? admission.snapshot() else { return } + screenGeneration = generation + } + guard let screenGeneration, + (try? admission.validate(screenGeneration)) != nil else { return } isLoading = true defer { isLoading = false } - do { - let publicKey = try await pubkyProfile.activePublicKey() - derivedPublicKey = publicKey - - // Restore existing profile if one is found on the network - if let remote = await remoteProfile(publicKey: publicKey) { - username = remote.name + try await admission.withOperation { + let publicKey = try await pubkyProfile.activePublicKey() + try admission.checkCurrent() + guard pubkyProfile.ownsRemovalPresentation(screenOwner) else { throw PubkyServiceError.identityChanged } + let remote = try await remoteProfile(publicKey: publicKey) + try admission.checkCurrent() + guard pubkyProfile.ownsRemovalPresentation(screenOwner) else { throw PubkyServiceError.identityChanged } + derivedPublicKey = publicKey + remoteLookupFailed = false existingProfile = remote - isRestoring = true + isRestoring = remote != nil + if let remote { + username = remote.name + } } - } catch is CancellationError { - return } catch { - Logger.error("Failed to derive pubky keys: \(error)", context: "CreateProfileView") + guard !Task.isCancelled, pubkyProfile.ownsRemovalPresentation(screenOwner), + (try? admission.validate(screenGeneration)) != nil else { return } + remoteLookupFailed = true + Logger.error("Failed to load the profile: \(error)", context: "CreateProfileView") app.toast(type: .error, title: t("profile__create_error_title"), description: error.localizedDescription) - navigation.navigateBack() } } - /// With a session or a stored key the pubky has signed up before, so a failed lookup is not treated as - /// "no profile": saving then could replace an existing profile with an empty one. - private func remoteProfile(publicKey: String) async -> PubkyProfile? { + /// A failed existing-profile lookup must not become permission to overwrite it with empty fields. + private func remoteProfile(publicKey: String) async throws -> PubkyProfile? { guard pubkyProfile.publicKey != nil || PubkyProfileManager.hasLocalSecretKey(for: publicKey) else { return await pubkyProfile.fetchRemoteProfile(publicKey: publicKey) } - - do { - let profile = try await PubkyProfileManager.resolveRemoteProfile(publicKey: publicKey) - remoteLookupFailed = false - return profile - } catch PubkyServiceError.profileNotFound { - remoteLookupFailed = false - return nil - } catch is CancellationError { - remoteLookupFailed = true - return nil - } catch { - Logger.warn("Failed to look up the existing profile: \(error)", context: "CreateProfileView") - remoteLookupFailed = true - app.toast(type: .error, title: t("profile__create_error_title"), description: error.localizedDescription) - return nil - } + do { return try await PubkyProfileManager.resolveRemoteProfile(publicKey: publicKey) } + catch PubkyServiceError.profileNotFound { return nil } } // MARK: - Save Profile @@ -213,26 +226,39 @@ struct CreateProfileView: View { } let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return } + guard !trimmedName.isEmpty, let screenOwner, let screenGeneration, + pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } + let admission = ProfileOperationAdmission.shared isSaving = true defer { isSaving = false } do { - try await pubkyProfile.createIdentity( - name: trimmedName, - bio: existingProfile?.bio ?? "", - links: existingProfile?.links ?? [], - tags: existingProfile?.tags ?? [], - existingImageUrl: existingProfile?.imageUrl, - avatarImage: avatarImage - ) - navigation.navigate(.payContacts) - } catch is CancellationError { - return + try await admission.withSessionTransition(expected: screenGeneration) { + guard pubkyProfile.ownsRemovalPresentation(screenOwner) else { throw PubkyServiceError.identityChanged } + do { + try await pubkyProfile.createIdentity( + name: trimmedName, + bio: existingProfile?.bio ?? "", + links: existingProfile?.links ?? [], + tags: existingProfile?.tags ?? [], + existingImageUrl: existingProfile?.imageUrl, + avatarImage: avatarImage + ) + try admission.checkCurrent() + navigation.navigate(.payContacts) + } catch { + guard !Task.isCancelled, (try? admission.checkCurrent()) != nil else { throw error } + try pubkyProfile.schedulePresentationContext { owner, generation in + self.screenOwner = owner + self.screenGeneration = generation + } + Logger.error("Failed to save profile: \(error)", context: "CreateProfileView") + app.toast(type: .error, title: t("profile__create_error_title"), description: error.localizedDescription) + } + } } catch { - Logger.error("Failed to save profile: \(error)", context: "CreateProfileView") - app.toast(type: .error, title: t("profile__create_error_title"), description: error.localizedDescription) + // A retired screen cannot present results for its replacement identity. } } } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index d85df61f5..2b381ab2a 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -7,21 +7,39 @@ struct EditProfileView: View { @EnvironmentObject var pubkyProfile: PubkyProfileManager @EnvironmentObject var contactsManager: ContactsManager + @State private var screenOwner: PubkyProfileManager.RemovalPresentationOwner? @State private var username: String = "" @State private var bio: String = "" @State private var links: [ProfileLinkInput] = [] @State private var tags: [String] = [] @State private var isSaving = false @State private var isDeleting = false - @State private var showDeleteConfirmation = false - @State private var showDeleteFailureOptions = false + @State private var showRemovalConfirmation = false + @State private var removalConfirmation: PubkyProfileManager.RemovalConfirmation? + @State private var confirmationOwner: PubkyProfileManager.RemovalPresentationOwner? + @State private var localDisconnectOwner: PubkyProfileManager.RemovalPresentationOwner? + @State private var preparationTask: Task? @State private var selectedPhotoItem: PhotosPickerItem? @State private var avatarImage: UIImage? var body: some View { - let copy = pubkyProfile.isRingAdoptedIdentity ? RemovalCopy.unlink : RemovalCopy.delete + let action = removalConfirmation?.action + let isUnlink = action == .unlink || (action != .deleteProfile && pubkyProfile.isRingAdoptedIdentity) + let copy = isUnlink ? RemovalCopy.unlink : RemovalCopy.delete - ZStack { + Group { + if pubkyProfile.removalRecovery != .ready, !isDeleting { + ProfileRemovalRecoveryView() + } else { + editContent(copy: copy) + } + } + } + + private func editContent(copy: RemovalCopy) -> some View { + let isLocalDisconnect = removalConfirmation?.action == .disconnectLocally + + return ZStack { ProfileEditFormView( navigationTitle: t("profile__edit_nav_title"), name: $username, @@ -38,10 +56,28 @@ struct EditProfileView: View { deleteActionStyle: .buttonWithIcon(icon: copy.icon, isSystemIcon: copy.isSystemIcon), onSave: { await saveProfile() }, onCancel: { navigation.navigateBack() }, - onDelete: { showDeleteConfirmation = true } + onDelete: { + guard let owner = screenOwner, pubkyProfile.ownsRemovalPresentation(owner) else { return } + preparationTask = Task { + await prepareRemoval(action: pubkyProfile.isRingAdoptedIdentity ? .unlink : .deleteProfile, owner: owner) + } + } ) { avatarPicker } + .safeAreaInset(edge: .bottom, spacing: 16) { + if let owner = localDisconnectOwner, pubkyProfile.ownsRemovalPresentation(owner) { + VStack(spacing: 12) { + CaptionMText(t("profile__disconnect_local_description"), textColor: .white64) + CustomButton(title: t("profile__sign_out"), variant: .secondary) { + preparationTask = Task { await prepareRemoval(action: .disconnectLocally, owner: owner) } + } + .accessibilityIdentifier("ProfileLocalDisconnect") + } + .padding(.horizontal, 16) + .background(Color.customBlack) + } + } if isDeleting { Color.customBlack.opacity(0.72) @@ -58,26 +94,23 @@ struct EditProfileView: View { .navigationBarHidden(true) .disabled(isDeleting) .task { + if screenOwner == nil { + screenOwner = pubkyProfile.removalPresentationOwner + } loadProfileData() } - .alert(copy.title, isPresented: $showDeleteConfirmation) { - Button(copy.confirm, role: .destructive) { - Task { await deleteProfile() } - } - Button(t("common__dialog_cancel"), role: .cancel) {} - } message: { - Text(copy.description) + .onDisappear { + preparationTask?.cancel() + pubkyProfile.cancelRemovalConfirmation(removalConfirmation) } - .alert(copy.errorTitle, isPresented: $showDeleteFailureOptions) { - Button(t("common__retry")) { - Task { await deleteProfile() } - } - Button(t("profile__sign_out"), role: .destructive) { - Task { await disconnectAfterFailedDelete() } + .alert(isLocalDisconnect ? t("profile__sign_out_title") : copy.title, isPresented: $showRemovalConfirmation) { + Button(isLocalDisconnect ? t("profile__sign_out") : copy.confirm, role: .destructive) { + Task { await confirmRemoval() } } - Button(t("common__dialog_cancel"), role: .cancel) {} + .accessibilityIdentifier(isLocalDisconnect ? "ProfileLocalDisconnectConfirm" : "ProfileRemovalConfirm") + Button(t("common__dialog_cancel"), role: .cancel) { pubkyProfile.cancelRemovalConfirmation(removalConfirmation) } } message: { - Text(copy.errorDescription) + Text(isLocalDisconnect ? t("profile__disconnect_local_description") : copy.description) } } @@ -147,68 +180,58 @@ struct EditProfileView: View { // MARK: - Delete Profile - private func deleteProfile() async { - guard !isDeleting else { return } - guard let session = pubkyProfile.currentSession else { - showDeleteFailureOptions = true - return - } - + private func prepareRemoval(action: PubkyProfileManager.RemovalAction, + owner presentationOwner: PubkyProfileManager.RemovalPresentationOwner) async + { + guard !isDeleting, pubkyProfile.ownsRemovalPresentation(presentationOwner) else { return } isDeleting = true defer { isDeleting = false } do { - try await pubkyProfile.performProfileDeletion { - do { - try await performDeleteProfile(session: session) - } catch let PubkyServiceError.activeSubscription(endsAt) { - guard !Task.isCancelled, pubkyProfile.currentSession == session else { return } - let description = endsAt.map { - t("subscriptions__expires_date", variables: ["date": $0.formatted(date: .long, time: .omitted)]) - } - app.toast(type: .error, title: t("profile__delete_active_subscription"), description: description) - } catch { - guard !Task.isCancelled, pubkyProfile.publicKey == session.publicKey else { return } - Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") - showDeleteFailureOptions = true - } - } + removalConfirmation = try await pubkyProfile.prepareRemoval( + action: action, contacts: contactsManager, expectedOwner: presentationOwner + ) + confirmationOwner = presentationOwner + showRemovalConfirmation = true } catch { - guard !Task.isCancelled, pubkyProfile.currentSession == session else { return } - Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") - showDeleteFailureOptions = true + guard !Task.isCancelled, pubkyProfile.ownsRemovalPresentation(presentationOwner) else { return } + showRemovalError(error, action: action, owner: presentationOwner) } } - private func performDeleteProfile(session: PubkyProfileManager.SignedInSession) async throws { - try await Self.performDeleteProfile(session: session, pubkyProfile: pubkyProfile, contactsManager: contactsManager) { - try await PubkyProfileManager.removePrivatePaykitEndpoints( - context: "EditProfileView.deleteProfile", deferAppSync: true, - isSessionCurrent: { pubkyProfile.currentSession == session } - ) + private func confirmRemoval() async { + guard !isDeleting, let confirmation = removalConfirmation, let presentationOwner = confirmationOwner else { return } + isDeleting = true + defer { isDeleting = false } + do { + let result = try await pubkyProfile.confirmRemoval(confirmation) + if pubkyProfile.ownsRemovalCompletion(result) { + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } + } catch { + pubkyProfile.refreshRemovalRecovery() + guard !Task.isCancelled, pubkyProfile.ownsRemovalFailure(confirmation, owner: presentationOwner) else { return } + if pubkyProfile.pendingProfileRemovalID != confirmation.id { + showRemovalError(error, action: confirmation.action, owner: presentationOwner) + } } - navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] } - static func performDeleteProfile( - session: PubkyProfileManager.SignedInSession, - pubkyProfile: PubkyProfileManager, - contactsManager: ContactsManager, - removePrivateEndpoints: () async throws -> Void - ) async throws { - try await pubkyProfile.validateProfileDeletion(for: session) - try await removePrivateEndpoints() - try pubkyProfile.requireCurrentSession(session) - try await contactsManager.deleteAllContacts() - try pubkyProfile.requireCurrentSession(session) - try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session) - } - - private func disconnectAfterFailedDelete() async { - do { - try await pubkyProfile.signOut() - navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] - } catch { - app.toast(type: .error, title: t("profile__sign_out_title"), description: error.localizedDescription) + private func showRemovalError(_ error: Error, action: PubkyProfileManager.RemovalAction, + owner: PubkyProfileManager.RemovalPresentationOwner) + { + if case let PubkyServiceError.activeSubscription(endsAt) = error { + localDisconnectOwner = owner + let description = endsAt.map { + t("subscriptions__expires_date", variables: ["date": $0.formatted(date: .long, time: .omitted)]) + } + app.toast(type: .error, title: t("profile__delete_active_subscription"), description: description) + } else { + let title: String = switch action { + case .deleteProfile: RemovalCopy.delete.title + case .unlink: RemovalCopy.unlink.title + case .disconnect, .disconnectLocally: t("profile__sign_out_title") + } + app.toast(type: .error, title: title, description: error.localizedDescription) } } @@ -218,23 +241,31 @@ struct EditProfileView: View { /// or the profile publishes, it stops quietly, with no toast and no navigation. private func saveProfile() async { let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return } + guard !trimmedName.isEmpty, let screenOwner, pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } + let admission = ProfileOperationAdmission.shared + guard let scope = try? admission.capture() else { return } + defer { admission.release(scope) } isSaving = true defer { isSaving = false } do { - let isSaved = try await pubkyProfile.saveProfile( - name: trimmedName, - bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), - links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, - tags: tags, - avatarImage: avatarImage - ) - guard isSaved else { return } - app.toast(type: .success, title: t("profile__edit_saved"), accessibilityIdentifier: "ProfileUpdatedToast") - navigation.navigateBack() + try await admission.withScope(scope) { + let isSaved = try await pubkyProfile.saveProfile( + name: trimmedName, + bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), + links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, + tags: tags, + avatarImage: avatarImage + ) + try admission.checkCurrent() + guard isSaved, pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } + app.toast(type: .success, title: t("profile__edit_saved"), accessibilityIdentifier: "ProfileUpdatedToast") + navigation.navigateBack() + } } catch { + guard !Task.isCancelled, (try? admission.validate(scope)) != nil, + pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } Logger.error("Failed to save profile: \(error)", context: "EditProfileView") app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) } @@ -249,8 +280,6 @@ private struct RemovalCopy { let title: String let confirm: String let description: String - let errorTitle: String - let errorDescription: String static var delete: RemovalCopy { RemovalCopy( @@ -260,9 +289,7 @@ private struct RemovalCopy { isSystemIcon: false, title: t("profile__delete_title"), confirm: t("profile__delete_confirm"), - description: t("profile__delete_description"), - errorTitle: t("profile__delete_error_title"), - errorDescription: t("profile__delete_error_description") + description: t("profile__delete_description") ) } @@ -274,9 +301,7 @@ private struct RemovalCopy { isSystemIcon: true, title: t("profile__unlink_title"), confirm: t("profile__unlink_confirm"), - description: t("profile__unlink_description"), - errorTitle: t("profile__unlink_error_title"), - errorDescription: t("profile__unlink_error_description") + description: t("profile__unlink_description") ) } } diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 161c9e2ab..9c1edd8ea 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -8,6 +8,7 @@ struct PayContactsView: View { @EnvironmentObject var wallet: WalletViewModel @State private var isSaving = false + @State private var screenOwner: PubkyProfileManager.RemovalPresentationOwner? /// Continue's enable runs in its own task, so it can finish after the user left Pay Contacts, or after a Pubky sign-out /// or a newer contact payments change stopped it. Like an import that finishes after the user left it, it opens @@ -61,23 +62,35 @@ struct PayContactsView: View { .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) + .onAppear { + if screenOwner == nil { + screenOwner = pubkyProfile.removalPresentationOwner + } + } } private func continueFlow() async { + let admission = ProfileOperationAdmission.shared + guard let screenOwner, pubkyProfile.ownsRemovalPresentation(screenOwner), let scope = try? admission.capture() else { return } + defer { admission.release(scope) } isSaving = true defer { isSaving = false } - do { + do { try await admission.withScope(scope) { let isApplied = try await ContactPaymentsService.setEnabled( true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: .live(wallet: wallet) ) + try admission.checkCurrent() + guard pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } if let destination = Self.destinationAfterEnable(isApplied: isApplied, currentRoute: navigation.currentRoute) { navigation.path = [destination] } - } catch { + } } catch { + guard !Task.isCancelled, (try? admission.validate(scope)) != nil, + pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } Logger.error("Failed to enable contact payments: \(error)", context: "PayContactsView") app.toast( type: .error, diff --git a/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift b/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift new file mode 100644 index 000000000..ce149ae69 --- /dev/null +++ b/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift @@ -0,0 +1,131 @@ +import SwiftUI + +struct ProfileRemovalRecoveryView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager + @EnvironmentObject private var wallet: WalletViewModel + @EnvironmentObject private var session: SessionManager + @State private var isWorking = false + @State private var confirmWipe = false + @State private var confirmDisconnect = false + @State private var confirmRemoteRetry = false + @State private var remoteRetry: PubkyProfileManager.RemoteRemovalConfirmation? + @State private var disconnectID: UUID? + @State private var wipeID: UUID? + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + NavigationBar(title: t("profile__recovery_title")) + if pubkyProfile.isDeletingProfile { + BodyMText(t("profile__recovery_working")) + ProgressView() + } else { + switch pubkyProfile.removalRecovery { + case let .pending(record) where record.scope == .profile: + BodyMText(t("profile__recovery_pending")) + if pubkyProfile.canRetryRemoteRemoval(id: record.id) { + CustomButton(title: t("common__retry")) { + Task { await prepareRetry(id: record.id) } + } + .accessibilityIdentifier("ProfileRecoveryRetryDisconnect") + } + CustomButton(title: t("profile__sign_out")) { + disconnectID = record.id; confirmDisconnect = true + } + .accessibilityIdentifier("ProfileRecoveryDisconnect") + case let .pending(record): + BodyMText(t("profile__recovery_wipe")) + CustomButton(title: t("security__reset_button_reset")) { wipeID = record.id; confirmWipe = true } + .accessibilityIdentifier("ProfileRecoveryWipe") + case .unavailable: + BodyMText(t("profile__recovery_unavailable")) + CustomButton(title: t("common__retry")) { pubkyProfile.retryLocalRecoveryRecord() } + .accessibilityIdentifier("ProfileRecoveryReadAgain") + case .ready: + EmptyView() + } + } + if isWorking { + ProgressView() + } + Spacer() + } + .padding(.horizontal, 16) + .background(Color.customBlack) + .navigationBarHidden(true) + .disabled(isWorking || pubkyProfile.isDeletingProfile) + .onDisappear { pubkyProfile.cancelRemoteRemovalRetry(remoteRetry) } + .alert(t("profile__sign_out_title"), isPresented: $confirmRemoteRetry) { + Button(t("common__retry"), role: .destructive) { + if let remoteRetry { + Task { await retry(remoteRetry) } + } + } + Button(t("common__dialog_cancel"), role: .cancel) { + pubkyProfile.cancelRemoteRemovalRetry(remoteRetry) + remoteRetry = nil + } + } message: { Text(t("profile__sign_out_description")) } + .alert(t("profile__sign_out_title"), isPresented: $confirmDisconnect) { + Button(t("profile__sign_out"), role: .destructive) { + if let disconnectID { + Task { await disconnect(id: disconnectID) } + } + } + Button(t("common__dialog_cancel"), role: .cancel) { disconnectID = nil } + } message: { Text(t("profile__recovery_pending")) } + .alert(t("security__reset_dialog_title"), isPresented: $confirmWipe) { + Button(t("security__reset_confirm"), role: .destructive) { + Task { await finishWipe() } + } + Button(t("common__dialog_cancel"), role: .cancel) {} + } message: { Text(t("security__reset_dialog_desc")) } + } + + private func prepareRetry(id: UUID) async { + isWorking = true + defer { isWorking = false } + do { + remoteRetry = try await pubkyProfile.prepareRemoteRemovalRetry(id: id) + confirmRemoteRetry = true + } catch { show(error, expectedID: id, expectedScope: .profile) } + } + + private func retry(_ confirmation: PubkyProfileManager.RemoteRemovalConfirmation) async { + isWorking = true + defer { isWorking = false } + do { + let completion = try await pubkyProfile.confirmRemoteRemovalRetry(confirmation) + if pubkyProfile.ownsRemovalCompletion(completion) { + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } + } catch { show(error, expectedID: confirmation.removalID, expectedScope: .profile) } + } + + private func disconnect(id: UUID) async { + isWorking = true + defer { isWorking = false } + do { + let completion = try await pubkyProfile.finishPendingRemoval(id: id) + if pubkyProfile.ownsRemovalCompletion(completion) { + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } + } catch { show(error, expectedID: id, expectedScope: .profile) } + } + + private func finishWipe() async { + guard let wipeID else { return } + isWorking = true + defer { isWorking = false } + do { + try await AppReset.wipe(app: app, wallet: wallet, session: session, expectedPendingID: wipeID) + } catch { show(error, expectedID: wipeID, expectedScope: .walletWipe) } + } + + private func show(_ error: Error, expectedID: UUID, expectedScope: ProfileRemovalStore.Scope) { + pubkyProfile.refreshRemovalRecovery() + guard !Task.isCancelled, pubkyProfile.ownsRecoveryFailure(id: expectedID, scope: expectedScope) else { return } + app.toast(type: .error, title: t("profile__recovery_title"), description: error.localizedDescription) + } +} diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 9b3c4100b..0b8a6bc6e 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -18,13 +18,17 @@ struct ProfileDestinationView: View { } var body: some View { - switch Self.destination(for: pubkyProfile, hasSeenIntro: hasSeenIntro) { - case .profile: - ProfileView() - case .pubkyChoice: - PubkyChoiceView() - default: - ProfileIntroView() + if pubkyProfile.removalRecovery != .ready { + ProfileRemovalRecoveryView() + } else { + switch Self.destination(for: pubkyProfile, hasSeenIntro: hasSeenIntro) { + case .profile: + ProfileView() + case .pubkyChoice: + PubkyChoiceView() + default: + ProfileIntroView() + } } } } @@ -34,8 +38,12 @@ struct ProfileView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager + @State private var screenOwner: PubkyProfileManager.RemovalPresentationOwner? @State private var isRefreshing = true @State private var showSignOutConfirmation = false + @State private var signOutConfirmation: PubkyProfileManager.RemovalConfirmation? + @State private var signOutOwner: PubkyProfileManager.RemovalPresentationOwner? + @State private var signOutPreparation: Task? @State private var showAddTagSheet = false @State private var isUpdatingTags = false @State private var isSigningOut = false @@ -48,7 +56,9 @@ struct ProfileView: View { var body: some View { Group { - if let profile = pubkyProfile.profileForDisplay { + if pubkyProfile.removalRecovery != .ready, !isSigningOut { + ProfileRemovalRecoveryView() + } else if let profile = pubkyProfile.profileForDisplay { profileContent(profile) } else if pubkyProfile.isLoadingProfile || !pubkyProfile.isAuthenticated, let cachedProfile = pubkyProfile.cachedProfilePreview { cachedProfileContent(cachedProfile) @@ -78,8 +88,15 @@ struct ProfileView: View { .background(Color.customBlack) .navigationBarHidden(true) .task { + if screenOwner == nil { + screenOwner = pubkyProfile.removalPresentationOwner + } await refreshProfile() } + .onDisappear { + signOutPreparation?.cancel() + pubkyProfile.cancelRemovalConfirmation(signOutConfirmation) + } .alert( t("profile__sign_out_title"), isPresented: $showSignOutConfirmation @@ -87,7 +104,7 @@ struct ProfileView: View { Button(t("profile__sign_out"), role: .destructive) { Task { await performSignOut() } } - Button(t("common__dialog_cancel"), role: .cancel) {} + Button(t("common__dialog_cancel"), role: .cancel) { pubkyProfile.cancelRemovalConfirmation(signOutConfirmation) } } message: { Text(t("profile__sign_out_description")) } @@ -268,20 +285,28 @@ struct ProfileView: View { } private func updateTags(_ tags: [String], profile: PubkyProfile) { - guard canEditProfile, !isUpdatingTags else { return } + guard canEditProfile, !isUpdatingTags, let screenOwner, + pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } + let admission = ProfileOperationAdmission.shared + guard let scope = try? admission.capture() else { return } isUpdatingTags = true Task { - defer { isUpdatingTags = false } + defer { isUpdatingTags = false; admission.release(scope) } do { - try await pubkyProfile.saveProfile( - name: profile.name, - bio: profile.bio, - links: profile.links, - tags: tags - ) + try await admission.withScope(scope) { + guard pubkyProfile.ownsRemovalPresentation(screenOwner) else { throw PubkyServiceError.identityChanged } + try await pubkyProfile.saveProfile( + name: profile.name, + bio: profile.bio, + links: profile.links, + tags: tags + ) + } } catch { + guard !Task.isCancelled, (try? admission.validate(scope)) != nil, + pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } Logger.error("Failed to update profile tags: \(error)", context: "ProfileView") app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) } @@ -374,7 +399,17 @@ struct ProfileView: View { private var signOutButton: some View { Button(t("profile__sign_out")) { - showSignOutConfirmation = true + guard let owner = screenOwner, pubkyProfile.ownsRemovalPresentation(owner) else { return } + signOutPreparation = Task { + do { + signOutConfirmation = try await pubkyProfile.prepareRemoval(action: .disconnect, expectedOwner: owner) + signOutOwner = owner + showSignOutConfirmation = true + } catch { + guard !Task.isCancelled, pubkyProfile.ownsRemovalPresentation(owner) else { return } + app.toast(type: .error, title: t("profile__sign_out_title"), description: error.localizedDescription) + } + } } .font(Fonts.regular(size: 17)) .foregroundColor(.white64) @@ -383,13 +418,16 @@ struct ProfileView: View { } private func performSignOut() async { + guard let owner = signOutOwner, let confirmation = signOutConfirmation else { return } isSigningOut = true + defer { isSigningOut = false } do { - try await pubkyProfile.signOut() + _ = try await pubkyProfile.confirmRemoval(confirmation) } catch { + pubkyProfile.refreshRemovalRecovery() + guard !Task.isCancelled, pubkyProfile.ownsRemovalFailure(confirmation, owner: owner) else { return } app.toast(type: .error, title: t("profile__sign_out_title"), description: error.localizedDescription) } - isSigningOut = false } private func shareProfile() { diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index ad1c8d25f..f1713ef56 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -7,6 +7,8 @@ struct PubkyChoiceView: View { @EnvironmentObject var contactsManager: ContactsManager @Environment(\.scenePhase) private var scenePhase + @State private var screenOwner: PubkyProfileManager.RemovalPresentationOwner? + @State private var screenGeneration: ProfileOperationAdmission.Generation? @State private var ringPubkys: [String] = [] @State private var ringProfiles: [String: PubkyProfile] = [:] @State private var didLoad = false @@ -52,12 +54,17 @@ struct PubkyChoiceView: View { pubkyProfile: PubkyProfileManager, reloadRows: () -> Void ) async throws -> PubkyProfile? { - pubkyProfile.cancelRingIdentityLookups(except: pubky) - do { - return try await pubkyProfile.adoptRingIdentity(pubky: pubky) - } catch { - reloadRows() - throw error + let admission = ProfileOperationAdmission.shared + return try await admission.withSessionTransition { + pubkyProfile.cancelRingIdentityLookups(except: pubky) + do { + return try await pubkyProfile.adoptRingIdentity(pubky: pubky) + } catch { + if !Task.isCancelled, (try? admission.checkCurrent()) != nil { + reloadRows() + } + throw error + } } } @@ -69,15 +76,24 @@ struct PubkyChoiceView: View { pubkyProfile: PubkyProfileManager, contactsManager: ContactsManager ) async -> Route? { - let destination = await contactsManager.destinationAfterAuthentication( - profile: pubkyProfile.profile, - publicKey: adopted.publicKey - ) - guard !pubkyProfile.isProfileSetupPending else { - contactsManager.clearPendingImport() - return nil - } - return destination + let owner = pubkyProfile.removalPresentationOwner + guard PubkyPublicKeyFormat.matches(pubkyProfile.publicKey, adopted.publicKey), + let scope = try? ProfileOperationAdmission.shared.capture() else { return nil } + let admission = ProfileOperationAdmission.shared + defer { admission.release(scope) } + do { return try await admission.withScope(scope) { + let destination = await contactsManager.destinationAfterAuthentication( + profile: pubkyProfile.profile, + publicKey: adopted.publicKey + ) + try admission.checkCurrent() + guard pubkyProfile.ownsRemovalPresentation(owner) else { return nil } + guard !pubkyProfile.isProfileSetupPending else { + contactsManager.clearPendingImport() + return nil + } + return destination + } } catch { return nil } } var body: some View { @@ -106,6 +122,18 @@ struct PubkyChoiceView: View { .background(Color.customBlack) .navigationBarHidden(true) .task(id: ringLoadGeneration) { await loadIdentities() } + .onChange(of: pubkyProfile.isAdoptingRingIdentity || pubkyProfile.isRestoringSession) { _, busy in + if !busy, screenGeneration == nil { + reloadIdentities() + } + } + .onReceive(NotificationCenter.default.publisher(for: ProfileOperationAdmission.didReleaseOperation, + object: ProfileOperationAdmission.shared).receive(on: RunLoop.main)) + { _ in + if screenGeneration == nil { + reloadIdentities() + } + } .onChange(of: scenePhase) { _, newPhase in guard newPhase == .active else { return } pubkyProfile.forgetRingIdentityMisses() @@ -186,28 +214,41 @@ struct PubkyChoiceView: View { } private func startAdopting(_ pubky: String) { - guard adoptingPubky == nil else { return } + guard adoptingPubky == nil, let screenOwner, let screenGeneration, + pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } adoptingPubky = pubky - Task { await adopt(pubky) } + Task { await adopt(pubky, owner: screenOwner, generation: screenGeneration) } } - private func adopt(_ pubky: String) async { + private func adopt(_ pubky: String, owner: PubkyProfileManager.RemovalPresentationOwner, + generation: ProfileOperationAdmission.Generation) async + { defer { adoptingPubky = nil } - do { - guard let adopted = try await Self.adoptRingIdentity(pubky, pubkyProfile: pubkyProfile, reloadRows: reloadIdentities) else { - navigation.navigate(.createProfile) - return - } + let admission = ProfileOperationAdmission.shared + do { try await admission.withSessionTransition(expected: generation) { + guard pubkyProfile.ownsRemovalPresentation(owner) else { throw PubkyServiceError.identityChanged } + do { + guard let adopted = try await Self.adoptRingIdentity(pubky, pubkyProfile: pubkyProfile, reloadRows: reloadIdentities) else { + try admission.checkCurrent() + navigation.navigate(.createProfile) + return + } - if let destination = await Self.destinationAfterAdoption(of: adopted, pubkyProfile: pubkyProfile, contactsManager: contactsManager) { - navigation.path = [destination] + if let destination = await Self.destinationAfterAdoption(of: adopted, pubkyProfile: pubkyProfile, contactsManager: contactsManager) { + try admission.checkCurrent() + navigation.path = [destination] + } + } catch { + guard !Task.isCancelled, (try? admission.checkCurrent()) != nil else { throw error } + try pubkyProfile.schedulePresentationContext { owner, generation in + screenOwner = owner + screenGeneration = generation + reloadIdentities() + } + app.toast(type: .error, title: t("profile__adopt_error_title"), description: error.localizedDescription) } - } catch is CancellationError { - return - } catch { - app.toast(type: .error, title: t("profile__adopt_error_title"), description: error.localizedDescription) - } + } } catch {} } private func reloadIdentities() { @@ -215,6 +256,14 @@ struct PubkyChoiceView: View { } private func loadIdentities() async { + if screenOwner == nil { + screenOwner = pubkyProfile.removalPresentationOwner + } + guard let screenOwner, pubkyProfile.ownsRemovalPresentation(screenOwner) else { return } + if screenGeneration == nil { + guard let generation = try? ProfileOperationAdmission.shared.snapshot() else { return } + screenGeneration = generation + } ringPubkys = SharedPubkyKeychain.listRingIdentities() didLoad = true await pubkyProfile.loadRingIdentityProfiles(ringPubkys) diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 6e0562b15..063555456 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -106,9 +106,7 @@ struct DevSettingsView: View { if enabled { showPaykitWarning = true } else { - Task { - await disablePaykitUI() - } + startDisablingPaykitUI() } } ), @@ -241,23 +239,26 @@ struct DevSettingsView: View { } @MainActor - private func disablePaykitUI() async { - do { - guard try await ContactPaymentsService.disablePaykitUI(pubkyProfile: pubkyProfile, operations: .live(wallet: wallet)) else { return } - } catch is CancellationError { - return - } catch { - Logger.warn("Failed to remove Paykit endpoints after disabling Paykit UI: \(error)", context: "DevSettingsView") - app.toast( - type: .error, - title: "Paykit UI disabled", - description: error.localizedDescription, - accessibilityIdentifier: "PaykitUiDisabledToast" - ) - return + private func startDisablingPaykitUI() { + let admission = ProfileOperationAdmission.shared + guard let scope = try? admission.capture() else { return } + let owner = pubkyProfile.removalPresentationOwner + Task { + defer { admission.release(scope) } + do { try await admission.withScope(scope) { + guard pubkyProfile.ownsRemovalPresentation(owner) else { throw PubkyServiceError.identityChanged } + guard try await ContactPaymentsService.disablePaykitUI(pubkyProfile: pubkyProfile, operations: .live(wallet: wallet)) else { return } + try admission.checkCurrent() + guard pubkyProfile.ownsRemovalPresentation(owner) else { return } + app.toast(type: .success, title: "Paykit UI disabled", accessibilityIdentifier: "PaykitUiDisabledToast") + } } catch { + guard !Task.isCancelled, (try? admission.validate(scope)) != nil, + pubkyProfile.ownsRemovalPresentation(owner) else { return } + Logger.warn("Failed to remove Paykit endpoints after disabling Paykit UI: \(error)", context: "DevSettingsView") + app.toast(type: .error, title: "Paykit UI disabled", description: error.localizedDescription, + accessibilityIdentifier: "PaykitUiDisabledToast") + } } - - app.toast(type: .success, title: "Paykit UI disabled", accessibilityIdentifier: "PaykitUiDisabledToast") } } diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index 3e5845ab8..eaad50639 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -39,7 +39,7 @@ struct GeneralSettingsView: View { (hasConfirmedContactPaymentsPreference && sharesPrivatePaykitEndpoints) }, set: { enabled in - Task { await updateContactPayments(enabled) } + startUpdatingContactPayments(enabled) } ) } @@ -47,7 +47,7 @@ struct GeneralSettingsView: View { private var publicPaymentsToggle: Binding { Binding( get: { pendingPublicPaymentsValue ?? sharesPublicPaykitEndpoints }, - set: { enabled in Task { await updatePublicPayments(enabled) } } + set: { enabled in startUpdatingPublicPayments(enabled) } ) } @@ -198,7 +198,7 @@ struct GeneralSettingsView: View { // Leaving the screen cancels `.task`; its own task lets the first contacts load and the enable still finish // unless the Pubky session ends or a newer contact payments change starts first. - Task { await updateContactPayments(true) } + startUpdatingContactPayments(true) } } @@ -226,21 +226,35 @@ struct GeneralSettingsView: View { } } - private func updatePublicPayments(_ enabled: Bool) async { - guard !isUpdatingPublicPayments else { return } + private func startUpdatingPublicPayments(_ enabled: Bool) { + let admission = ProfileOperationAdmission.shared + guard !isUpdatingPublicPayments, let scope = try? admission.capture() else { return } + let owner = pubkyProfile.removalPresentationOwner isUpdatingPublicPayments = true + Task { + defer { admission.release(scope) } + await updatePublicPayments(enabled, scope: scope, owner: owner) + } + } + + private func updatePublicPayments(_ enabled: Bool, scope: ProfileOperationAdmission.Scope, + owner: PubkyProfileManager.RemovalPresentationOwner) async + { + let admission = ProfileOperationAdmission.shared pendingPublicPaymentsValue = enabled defer { pendingPublicPaymentsValue = nil isUpdatingPublicPayments = false } - do { + do { try await admission.withScope(scope) { + guard pubkyProfile.ownsRemovalPresentation(owner) else { throw PubkyServiceError.identityChanged } try await ContactPaymentsService.setPublicEnabled( enabled, pubkyProfile: pubkyProfile, operations: .live(wallet: wallet) ) - } catch is CancellationError { + } } catch is CancellationError { return } catch { + guard (try? admission.validate(scope)) != nil, pubkyProfile.ownsRemovalPresentation(owner) else { return } Logger.error("Failed to update public payments: \(error)", context: "GeneralSettingsView") app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) } @@ -264,26 +278,39 @@ struct GeneralSettingsView: View { } } - private func updateContactPayments(_ enabled: Bool) async { - guard !isUpdatingContactPayments else { return } - + private func startUpdatingContactPayments(_ enabled: Bool) { + let admission = ProfileOperationAdmission.shared + guard !isUpdatingContactPayments, let scope = try? admission.capture() else { return } + let owner = pubkyProfile.removalPresentationOwner isUpdatingContactPayments = true + Task { + defer { admission.release(scope) } + await updateContactPayments(enabled, scope: scope, owner: owner) + } + } + + private func updateContactPayments(_ enabled: Bool, scope: ProfileOperationAdmission.Scope, + owner: PubkyProfileManager.RemovalPresentationOwner) async + { + let admission = ProfileOperationAdmission.shared pendingContactPaymentsValue = enabled defer { pendingContactPaymentsValue = nil isUpdatingContactPayments = false } - do { + do { try await admission.withScope(scope) { + guard pubkyProfile.ownsRemovalPresentation(owner) else { throw PubkyServiceError.identityChanged } try await ContactPaymentsService.setEnabled( enabled, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: .live(wallet: wallet) ) - } catch is CancellationError { + } } catch is CancellationError { return } catch { + guard (try? admission.validate(scope)) != nil, pubkyProfile.ownsRemovalPresentation(owner) else { return } Logger.error("Failed to update contact payments: \(error)", context: "GeneralSettingsView") app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) } diff --git a/BitkitTests/ActivityListSyncTests.swift b/BitkitTests/ActivityListSyncTests.swift index 466980a2b..55d59f386 100644 --- a/BitkitTests/ActivityListSyncTests.swift +++ b/BitkitTests/ActivityListSyncTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import BitkitCore +import LDKNode import XCTest final class ActivityListSyncTests: XCTestCase { @@ -22,6 +23,57 @@ final class ActivityListSyncTests: XCTestCase { try await super.tearDown() } + @MainActor + func testWipeDrainsPaymentEnumerationBeforeClearingTheOriginalWallet() async throws { + let admission = ProfileOperationAdmission(store: ProfileRemovalStore(url: database.appendingPathComponent("pending.json"))) + let activity = Bitkit.ActivityService(coreService: .shared) + activity.setPrivatePaykitContactResolvers(invoice: nil, onchainAddresses: nil, + receiptOperation: { try await admission.withReceiptAttribution($0) }) + let originalActivity = Bitkit.CoreService.shared.activity + Bitkit.CoreService.shared.activity = activity + defer { Bitkit.CoreService.shared.activity = originalActivity } + let entered = expectation(description: "Payment enumeration suspended") + var continuation: CheckedContinuation? + let hash = String(repeating: "a", count: 64) + let payment = LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: nil), + amountMsat: 5_000_000, feePaidMsat: 0, direction: .inbound, status: .succeeded, + latestUpdateTimestamp: UInt64(Date().timeIntervalSince1970) + ) + let model = Bitkit.ActivityListViewModel(transferService: Bitkit.TransferService( + lightningService: .shared + ), listPayments: { + await withCheckedContinuation { + continuation = $0 + entered.fulfill() + } + return [payment] + }) + let syncing = Task { try await model.syncLdkNodePayments() } + await fulfillment(of: [entered], timeout: 3) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(wipe) } + XCTAssertThrowsError(try admission.persist(wipe), "Payment enumeration still owns its receipt work") + let wiping = Task { + try await admission.drain(before: wipe) + try admission.persist(wipe) + try await activity.removeAll() + } + continuation?.resume() + do { + try await syncing.value + XCTFail("Old payment enumeration entered storage after wipe reservation") + } catch is ProfileRemovalStore.StoreError {} + try await wiping.value + try admission.complete(wipe) + admission.release(wipe) + let rows = try await activity.get(walletId: WalletScope.default) + XCTAssertTrue(rows.isEmpty) + try await activity.syncLdkNodePayments([payment]) + let replacementRows = try await activity.get(walletId: WalletScope.default) + XCTAssertEqual(replacementRows.count, 1, "Fresh replacement sync must be admitted after wipe releases") + } + @MainActor func testOverlappingSyncsDoNotLoadPaymentsAndUnavailableNodeAllowsRetry() async throws { let started = expectation(description: "First payment list suspended") diff --git a/BitkitTests/BackupServiceTests.swift b/BitkitTests/BackupServiceTests.swift index 58f1511ba..94040bc80 100644 --- a/BitkitTests/BackupServiceTests.swift +++ b/BitkitTests/BackupServiceTests.swift @@ -6,6 +6,195 @@ import XCTest @MainActor final class BackupServiceTests: XCTestCase { + func testPendingRestoreRetriesRejectWipeBeforeReadingOrChangingBackupState() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + for state in ["reserved", "persisted", "unreadable"] { + for pendingReadFails in [false, true] { + let marker = directory.appendingPathComponent(UUID().uuidString) + let admission = ProfileOperationAdmission(store: ProfileRemovalStore(url: marker)) + let scope = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(scope) } + if state != "reserved" { + try admission.persist(scope) + admission.release(scope) + } + if state == "unreadable" { + try Data("unreadable".utf8).write(to: marker) + } + let suite = "RestoreWipe.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var reads = 0 + let gate = WalletBackupRestoreGate(load: { + reads += 1 + if pendingReadFails { + throw KeychainError.failedToLoad + } + return Data("retained-wallet".utf8) + }, store: { _ in XCTFail("Rejected restore changed its pending payload") }, + clear: { XCTFail("Rejected restore cleared its pending gate") }) + let service = BackupService(defaults: defaults, profileAdmission: admission, walletRestoreGate: gate) + XCTAssertTrue(service.hasPendingWalletRestore()) + XCTAssertEqual(reads, 1, "The pending restore decision must exercise its keychain read") + let readsBeforeRetry = reads + for legacyRecovery in [false, true] { + do { + if legacyRecovery { + try await service.restoreUsdtRecoveryAfterLegacyMigration() + } else { + try await service.performFullRestoreFromLatestBackup() + } + XCTFail("Restore started while wallet admission was closed: \(state)") + } catch is ProfileRemovalStore.StoreError {} + } + do { + try await Bitkit.MigrationsService.shared.restoreFromRNRemoteBackup(mnemonic: "unused", passphrase: nil) { + try await admission.withWalletRestore($0) + } + XCTFail("Legacy restore started while wallet admission was closed") + } catch is ProfileRemovalStore.StoreError {} + XCTAssertEqual(reads, readsBeforeRetry, "Admission must precede restore payload access") + XCTAssertTrue(service.hasPendingWalletRestore()) + } + } + } + + func testWipeDrainsRestoreTailAndRejectsItsCompletionBeforeReplacement() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + for cancelled in [false, true] { + let marker = directory.appendingPathComponent(UUID().uuidString) + let storage = directory.appendingPathComponent(UUID().uuidString) + let admission = ProfileOperationAdmission(store: ProfileRemovalStore(url: marker)) + let entered = expectation(description: "Restore reached its local write") + let gate = BackupDelayGate() + var pending: Data? + let restoreGate = WalletBackupRestoreGate(load: { pending }, store: { pending = $0 }, clear: { pending = nil }) + var didStart = false + let restoring = Task { + try await admission.withWalletRestore { + _ = try await restoreGate.performRestore { _ in + try restoreGate.retain(Data("retained-wallet".utf8)) + try await admission.withSessionTransition { + try admission.checkCurrent() + } + XCTAssertNoThrow(try admission.snapshot(), "Wallet restore must not hold profile exclusivity") + await gate.wait(started: entered) + try Data("original-wallet".utf8).write(to: storage) + return true + } + } + didStart = true + } + await fulfillment(of: [entered], timeout: 3) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(wipe) } + if cancelled { + restoring.cancel() + } + XCTAssertThrowsError(try admission.persist(wipe), "Restore still owns its actual write tail") + let wiping = Task { + try await admission.drain(before: wipe) + try admission.persist(wipe) + try FileManager.default.removeItem(at: storage) + } + await gate.release() + do { + try await restoring.value + XCTFail("Invalidated restore reported success") + } catch is CancellationError { + XCTAssertTrue(cancelled) + } catch is ProfileRemovalStore.StoreError { + XCTAssertFalse(cancelled) + } + try await wiping.value + XCTAssertFalse(didStart) + XCTAssertFalse(FileManager.default.fileExists(atPath: storage.path)) + XCTAssertNil(pending) + try admission.complete(wipe) + admission.release(wipe) + try await admission.withWalletRestore { + _ = try await restoreGate.performRestore { _ in + try Data("replacement-wallet".utf8).write(to: storage) + return true + } + } + XCTAssertEqual(try Data(contentsOf: storage), Data("replacement-wallet".utf8)) + XCTAssertNoThrow(try restoreGate.requireReplacementBackupAllowed()) + } + } + + func testMetadataLocalFailureRetainsWalletGateAndRemoteAuthFailureCanComplete() async throws { + for localFailure in [true, false] { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let service = BackupService(defaults: defaults) + service.markBackupRequired(category: .metadata, schedule: false) + let before = service.getBackupStatus(category: .metadata) + var pending: Data? + let gate = WalletBackupRestoreGate(load: { pending }, store: { pending = $0 }, clear: { pending = nil }) + let retainedWallet = Data("complete-wallet-sentinel".utf8) + var credentialKeys = Set([KeychainEntryType.paykitSession.storageKey, KeychainEntryType.pubkySecretKey.storageKey]) + var overrides = 0 + var continued = false + do { + _ = try await gate.performRestore { _ in + try gate.retain(retainedWallet) + try await service.performRestore(category: .metadata, retainedData: Data("metadata".utf8)) { _ in + try await BackupService.restoreProfileMetadata { + try await PubkyProfileManager.restoreSessionBackupState(nil, + loadKeychainString: { _ in nil }, + persistKeychainString: { _, _ in + XCTFail("No new key for absent profile") + }, + deleteKeychainValue: { key in + if localFailure, + key.storageKey == KeychainEntryType.pubkySecretKey + .storageKey + { + throw KeychainError.failedToDelete + } + credentialKeys.remove(key.storageKey) + }, removeOwnSharedRecords: {}, forgetSessionAccess: {}, + signInWithSecretKey: { _ in + XCTFail("No authentication for absent profile"); return "unused" + }) + throw PubkyServiceError.authFailed("Remote authorization unavailable after local cleanup") + } installOverrides: { overrides += 1 } + } + continued = true + return true + } + XCTAssertFalse(localFailure) + } catch is ProfileSessionRestoreLocalError { XCTAssertTrue(localFailure) } + if localFailure { + XCTAssertEqual(credentialKeys, [KeychainEntryType.pubkySecretKey.storageKey]) + XCTAssertEqual(overrides, 0) + XCTAssertFalse(continued) + XCTAssertEqual(service.getBackupStatus(category: .metadata), before) + XCTAssertEqual(pending, retainedWallet) + XCTAssertThrowsError(try gate.requireReplacementBackupAllowed()) + for completionStart in [false, true] { + XCTAssertTrue(try WalletBackupRestoreGate.blocksWalletStart(isRestoreRunning: false, + hasPendingRestore: gate.hasPendingRestore(), + isRestoreCompletionStart: completionStart)) + } + } else { + XCTAssertTrue(credentialKeys.isEmpty) + XCTAssertEqual(overrides, 1) + XCTAssertTrue(continued) + XCTAssertFalse(service.getBackupStatus(category: .metadata).isRequired) + XCTAssertNil(pending) + } + service.setWiping(true) + await service.stopObservingBackups() + } + } + func testReleasedVssConnectionErrorIsTemporaryWithoutBroadUnknownRetry() { XCTAssertTrue(BackupService.isTemporaryBackupFailure( VssError.UnknownError(errorDetails: "error sending request for url (http://127.0.0.1:1/putObjects)") @@ -618,6 +807,47 @@ final class BackupServiceTests: XCTestCase { XCTAssertFalse(service.getBackupStatus(category: .usdt).isRequired) } + func testTransientProfileAdmissionRetriesOnlyCompleteBackupCapture() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let gate = ProfileOperationAdmission(store: store) + XCTAssertNoThrow(try store.requireNoPending()) + XCTAssertFalse(gate.hasUnpersistedLocalFailure) + XCTAssertTrue(gate.isTransientBackupCaptureFailure(ProfileRemovalStore.StoreError.changed), "Raw transient admission") + XCTAssertTrue( + gate.isTransientBackupCaptureFailure(Bitkit.AppError(error: ProfileRemovalStore.StoreError.changed)), + "Wrapped transient admission" + ) + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var captures = 0 + var delays: [UInt64] = [] + let completed = expectation(description: "Complete wallet capture resumed after transition") + let service = BackupService(defaults: defaults, profileAdmission: gate, backupData: { _ in + captures += 1 + if captures == 1 { + throw Bitkit.AppError(error: ProfileRemovalStore.StoreError.changed) + } + return Data("complete-wallet".utf8) + }, uploadBackup: { _, data in XCTAssertEqual(data, Data("complete-wallet".utf8)) }, + waitForBackupDelay: {}, waitForTemporaryFailureDelay: { delays.append($0) }) + let listener = service.backupStatusesPublisher + .filter { $0[.wallet].map { !$0.isRequired && !$0.running } == true } + .first().sink { _ in completed.fulfill() } + service.markBackupRequired(category: .wallet) + await fulfillment(of: [completed], timeout: 3) + listener.cancel() + await service.stopObservingBackups() + XCTAssertEqual(captures, 2) + XCTAssertEqual(delays, [10_000_000_000]) + XCTAssertFalse(gate.isTransientBackupCaptureFailure(ProfileRemovalStore.StoreError.pending)) + XCTAssertFalse(gate.isTransientBackupCaptureFailure(ProfileRemovalStore.StoreError.unavailable)) + try store.begin(id: UUID()) + XCTAssertFalse(gate.isTransientBackupCaptureFailure(Bitkit.AppError(error: ProfileRemovalStore.StoreError.changed))) + } + func testTemporaryFailureRemainsPendingAcrossServiceRestart() async throws { let suite = "BackupServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 0be8dfc8b..674fda3ad 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -210,6 +210,39 @@ final class ContactPaymentsServiceTests: XCTestCase { } } + func testPublicEnableCancellationRetainsRecoveryWithoutReplayingPublication() async throws { + try await withIsolatedDefaultsAsync { defaults in + let profile = signedInProfile(ownerKey: "owner") + let publicationStarted = expectation(description: "Public publication is pending") + let (wait, release) = AsyncStream.makeStream() + defer { release.finish() } + let operations = OperationsSpy() + operations.syncPublicEndpoints = { _, _ in + publicationStarted.fulfill() + for await _ in wait {} + } + let enable = Task { + try await ContactPaymentsService.setPublicEnabled( + true, pubkyProfile: profile, operations: operations.makeOperations(defaults: defaults), defaults: defaults + ) + } + await fulfillment(of: [publicationStarted], timeout: 2) + enable.cancel() + release.finish() + do { + _ = try await enable.value + XCTFail("Cancelled enable must not report that the preference was applied") + } catch { + XCTAssertTrue(error is CancellationError) + } + XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey)) + XCTAssertEqual(operations.publicPublicationValues, [true]) + XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + XCTAssertTrue(operations.privateCapabilityValues.isEmpty) + XCTAssertTrue(operations.privatePublications.isEmpty) + } + } + func testEnableCancelledAfterCapabilitySyncDoesNotReportApplied() async throws { try await withIsolatedDefaultsAsync { defaults in let contacts = ContactsManager() @@ -245,6 +278,10 @@ final class ContactPaymentsServiceTests: XCTestCase { } XCTAssertTrue(operations.privatePublications.isEmpty) XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) + XCTAssertEqual(operations.privateCapabilityValues, [true], "Cancellation must not replay remote publication or withdrawal") + XCTAssertTrue(operations.publicPublicationValues.isEmpty) + XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) } } @@ -663,15 +700,16 @@ final class ContactPaymentsServiceTests: XCTestCase { func testForegroundReconciliationRetainsRegistryRepairDuringDeletionOrCancellation() async { snapshotAppDefaultsDomain() for interruption in ["deletion", "deletionAfterPrivate", "cancelAfterPrivate"] { + let pause = UUID() PublicPaykitService.setCleanupPending(true) if interruption == "deletion" { - await PrivatePaykitService.shared.beginProfileDeletion() + await PrivatePaykitService.shared.beginProfileDeletion(id: pause) } var registryUpdates = 0 let reconciliation = Task { await ContactPaymentsService.reconcilePendingEndpoints(reconcilePrivate: { if interruption == "deletionAfterPrivate" { - await PrivatePaykitService.shared.beginProfileDeletion() + await PrivatePaykitService.shared.beginProfileDeletion(id: pause) } if interruption == "cancelAfterPrivate" { withUnsafeCurrentTask { $0?.cancel() } @@ -682,7 +720,7 @@ final class ContactPaymentsServiceTests: XCTestCase { } } await reconciliation.value - await PrivatePaykitService.shared.endProfileDeletion() + await PrivatePaykitService.shared.endProfileDeletion(id: pause) XCTAssertEqual(registryUpdates, 0, interruption) XCTAssertTrue(PublicPaykitService.isCleanupPending, interruption) @@ -926,131 +964,111 @@ final class ContactPaymentsServiceTests: XCTestCase { } } - /// A publication already holding its lock can finish; sign-out must remove it afterward and retain failed cleanup. + /// Removal drains already-admitted publication, retains uncertain withdrawal, and requires explicit local recovery. func testContactPaymentsEnableStopsWhenPubkySignsOutDuringPublication() async throws { - enum HeldPublication { - case capabilityBeforeLock, privateBeforeLock, capabilityHoldingLock - } - let cases: [(name: String, held: HeldPublication, calls: [String], writes: [String], keepsFlags: Bool)] = [ - ( - "capability sync waiting for its lock", .capabilityBeforeLock, - ["capability:true"], ["private:removed", "public:removal failed"], false - ), - ( - "private publication waiting for its lock", .privateBeforeLock, - ["capability:true", "private:publish"], ["capability:enabled", "private:removed", "public:removal failed"], false - ), - ( - "capability sync holding its lock while sign-out fails", .capabilityHoldingLock, - ["capability:true"], ["private:removal failed", "capability:enabled"], true - ), - ] - let ownerKey = try useLocalPubkySecretKey() - let record = contactRecord(publicKey: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") - let endpoint = PublicPaykitService.Endpoint( - methodId: .regtestOnchainP2wpkh, value: "bcrt1qendpoint", min: nil, max: nil, rawPayload: #"{"value":"bcrt1qendpoint"}"# - ) - - for testCase in cases { - let defaults = UserDefaults.standard - for key in [ - ContactPaymentsService.confirmedPreferenceKey, PublicPaykitService.publishingEnabledKey, - PrivatePaykitService.publishingEnabledKey, PublicPaykitService.cleanupPendingKey, - PrivatePaykitService.cleanupPendingKey, PrivatePaykitService.cacheStateKey, - ] { - defaults.removeObject(forKey: key) - } - let pubkyProfile = signedInProfile(ownerKey: ownerKey) - let contactsManager = ContactsManager(contactRecords: { [record] }) - let privatePaykit = PrivatePaykitService() - let writes = WriteLog() - let publicationOperations = PrivatePaykitService.EndpointPublicationOperations( - currentPublicKey: { ownerKey }, - ensureLink: { _ in .linked }, - buildEndpoints: { _ in [endpoint] }, - syncPaymentLists: { _ in - writes.append("private:published") - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) - } - ) - let reachedHeldPoint = expectation(description: "\(testCase.name): publication reached its held point") - let (gate, release) = AsyncStream.makeStream() - let held = testCase.held - let operations = OperationsSpy() - operations.preparePrivateEndpoints = { contactPublicKeys, requireImmediatePublication, isSessionCurrent in - if held == .privateBeforeLock { - reachedHeldPoint.fulfill() - for await _ in gate {} + for heldStage in ["capability-before-lock", "private-before-lock", "capability-holding-lock"] { + for removalFailsPrivately in [false, true] { + snapshotAppDefaultsDomain() + let defaults = UserDefaults.standard + for key in [ContactPaymentsService.confirmedPreferenceKey, PublicPaykitService.publishingEnabledKey, + PrivatePaykitService.publishingEnabledKey, PublicPaykitService.cleanupPendingKey, + PrivatePaykitService.cleanupPendingKey] + { + defaults.removeObject(forKey: key) } - _ = await privatePaykit.rememberSavedContacts(contactPublicKeys, replacing: true) - return await privatePaykit.syncLocalEndpointPublication( - for: contactPublicKeys, - reason: "test", - requireImmediatePublication: requireImmediatePublication, - isSessionCurrent: isSessionCurrent, - operations: publicationOperations + let ownerKey = "pubky" + String(repeating: "y", count: 52) + let started = expectation(description: "Original publication is held") + let paused = expectation(description: "Removal closes admission before draining publication") + let (wait, release) = AsyncStream.makeStream() + let native = Task.detached { for await _ in wait {} } + defer { release.finish() } + let writes = WriteLog() + let privatePaykit = PrivatePaykitService() + var completeLocalCount = 0 + var pauseCount = 0 + let services = PubkyProfileManager.RemovalServices( + readProfile: { _ in XCTFail("Disconnect does not read a deletion snapshot"); return nil }, + deleteProfile: { _, _ in XCTFail("Disconnect must not delete the remote profile") }, + withdrawPrivate: { + try await privatePaykit.withPublicationLock { + writes.append("private:remove") + if removalFailsPrivately { + throw TestError.operationFailed + } + } + }, + withdrawPublic: { + try await PublicPaykitService.withEndpointLock { + writes.append("public:remove"); throw TestError.operationFailed + } + }, + syncApp: { XCTFail("Failed endpoint withdrawal cannot update the registry") }, + validateSubscriptions: { _ in XCTFail("Disconnect does not remove contacts") }, + captureRevocation: { _ in + ProfileRevocationTestDouble(execute: { XCTFail("Failed endpoint withdrawal cannot revoke the session") }).handle() + }, + completeLocal: { completeLocalCount += 1 }, + completeRevokedLocal: { _ in XCTFail("Failed endpoint withdrawal cannot finish remote cleanup") }, + pause: { + _ in pauseCount += 1; if pauseCount == 1 { + paused.fulfill() + } + }, resume: { _ in } ) - } - operations.syncPrivateCapability = { publish, isSessionCurrent in - guard publish else { return } - if held == .capabilityBeforeLock { - reachedHeldPoint.fulfill() - for await _ in gate {} - } - try await PublicPaykitService.withEndpointLock(unlessSessionEnded: isSessionCurrent) { - if held == .capabilityHoldingLock { - reachedHeldPoint.fulfill() - for await _ in gate {} + let manager = PubkyProfileManager(removalServices: services) + manager.publicKey = ownerKey + manager.authState = .authenticated + let contacts = ContactsManager(contactRecords: { [] }) + let operations = OperationsSpy() + operations.syncPrivateCapability = { publish, isCurrent in + guard publish else { return } + if heldStage == "capability-before-lock" { + started.fulfill(); await native.value } - writes.append("capability:enabled") - } - } - let enable = Task { - try await ContactPaymentsService.setEnabled( - true, - pubkyProfile: pubkyProfile, - contactsManager: contactsManager, - operations: operations.makeOperations(), - defaults: defaults - ) - } - await fulfillment(of: [reachedHeldPoint], timeout: 2) - - let privateRemovalFails = held == .capabilityHoldingLock - do { - try await pubkyProfile.signOut(performSessionCleanup: { @MainActor in - try await privatePaykit.withPublicationLock { - guard !privateRemovalFails else { - writes.append("private:removal failed") - throw TestError.operationFailed + try await PublicPaykitService.withEndpointLock(unlessSessionEnded: isCurrent) { + if heldStage == "capability-holding-lock" { + started.fulfill(); await native.value } - writes.append("private:removed") + writes.append("capability:publish") } - do { - try await PublicPaykitService.withEndpointLock { - writes.append("public:removal failed") - throw TestError.operationFailed - } - } catch { - PublicPaykitService.setCleanupPending(true) + } + operations.preparePrivateEndpoints = { _, _, _ in + if heldStage == "private-before-lock" { + started.fulfill(); await native.value } - }) - XCTAssertFalse(privateRemovalFails, testCase.name) - } catch { - XCTAssertTrue(privateRemovalFails, testCase.name) + do { try ProfileOperationAdmission.shared.checkCurrent(); writes.append("private:publish"); return nil } + catch { return error } + } + let enabling = Task { + try await ContactPaymentsService.setEnabled(true, pubkyProfile: manager, contactsManager: contacts, + operations: operations.makeOperations(), defaults: defaults, + privatePaymentAccess: { _ in true }) + } + await fulfillment(of: [started], timeout: 2) + let confirmation = try await manager.prepareRemoval(action: .disconnect) + let removing = Task { try await manager.confirmRemoval(confirmation) } + await fulfillment(of: [paused], timeout: 2) + XCTAssertEqual(completeLocalCount, 0) + XCTAssertThrowsError(try ProfileOperationAdmission.shared.capture()) + release.finish() + switch await enabling.result { + case let .success(enabled): XCTAssertFalse(enabled) + case let .failure(error): XCTAssertTrue(error is CancellationError || error is ProfileRemovalStore.StoreError) + } + do { _ = try await removing.value; XCTFail("Required withdrawal failure must remain uncertain") } + catch TestError.operationFailed {} + XCTAssertEqual(manager.pendingProfileRemovalID, confirmation.id) + XCTAssertEqual(manager.publicKey, ownerKey) + XCTAssertEqual(completeLocalCount, 0) + let expected = (heldStage != "capability-before-lock" ? ["capability:publish"] : []) + ["private:remove"] + + (removalFailsPrivately ? [] : ["public:remove"]) + XCTAssertEqual(writes.entries, expected) + _ = try await manager.finishPendingRemoval(id: confirmation.id) + XCTAssertEqual(completeLocalCount, 1) + XCTAssertNil(manager.publicKey) + XCTAssertNil(manager.pendingProfileRemovalID) + XCTAssertEqual(writes.entries, expected, "Restart recovery makes no remote mutation") } - release.finish() - let result = await enable.result - - XCTAssertFalse(try result.get(), testCase.name) - XCTAssertEqual(operations.calls, testCase.calls, testCase.name) - XCTAssertEqual(writes.entries, testCase.writes, testCase.name) - XCTAssertEqual(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), testCase.keepsFlags, testCase.name) - XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), testCase.name) - XCTAssertEqual(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), testCase.keepsFlags, testCase.name) - XCTAssertTrue(operations.publicCleanupValues.isEmpty, testCase.name) - XCTAssertEqual(operations.privateCleanupValues, held == .privateBeforeLock ? [false] : [], testCase.name) - XCTAssertTrue(PublicPaykitService.isCleanupPending, testCase.name) } } @@ -1194,8 +1212,9 @@ final class ContactPaymentsServiceTests: XCTestCase { let contactsManager = ContactsManager(contactRecords: { [record] }) let endpoints = LockedEndpointWrites() let reachedHeldPoint = expectation(description: "\(testCase.name): removal reached its held point") - let enableRequested = expectation(description: "\(testCase.name): enable requested") + let enableAdmitted = expectation(description: "\(testCase.name): enable owns the sharing change") let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } let held = testCase.held let operations = OperationsSpy() operations.removePrivateEndpoints = { isChangeCurrent in @@ -1229,13 +1248,17 @@ final class ContactPaymentsServiceTests: XCTestCase { await fulfillment(of: [reachedHeldPoint], timeout: 2) let enable = Task { - enableRequested.fulfill() return try await ContactPaymentsService.setEnabled( true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), - defaults: defaults + defaults: defaults, + privatePaymentAccess: { publicKey in + let hasAccess = try PubkyProfileManager.hasPrivatePaymentAccess(for: publicKey) + enableAdmitted.fulfill() + return hasAccess + } ) } - await fulfillment(of: [enableRequested], timeout: 2) + await fulfillment(of: [enableAdmitted], timeout: 2) release.finish() let disableResult = await disable.result let enableResult = await enable.result @@ -1465,7 +1488,7 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey)) XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) } - XCTAssertEqual(endpoints.entries, cancel ? ["public:published", "public:removed"] : []) + XCTAssertTrue(endpoints.entries.isEmpty) XCTAssertTrue(operations.privateCapabilityValues.isEmpty) } } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index bc50bf841..179cfcdb5 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -12,6 +12,73 @@ final class ContactsManagerTests: XCTestCase { UserDefaults.standard.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) } + func testRemovalDrainsOriginalContactMutationBeforeLocalCleanup() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let gate = ProfileOperationAdmission(store: ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"))) + let entered = expectation(description: "Original native contact removal entered") + let (nativeWait, releaseNative) = AsyncStream.makeStream() + let native = Task.detached { for await _ in nativeWait {} } + defer { releaseNative.finish() } + let manager = ContactsManager(removeContactRecord: { _ in + XCTAssertNotNil(ProfileOperationAdmission.current, "Detached native work retains its original owner") + entered.fulfill() + await native.value + }, forgetRemovedContacts: { _ in XCTFail("Revoked contact mutation cannot clear private state") }, profileAdmission: gate) + let profile = PubkyProfile(publicKey: contactProfileKey, name: "Original", bio: "", imageUrl: nil, links: [], tags: [], status: nil) + manager.contacts = [PubkyContact(publicKey: contactProfileKey, profile: profile)] + let removal = Task { try await manager.removeContact(publicKey: contactProfileKey) } + await fulfillment(of: [entered], timeout: 2) + let wipe = try gate.reserve(kind: .walletWipe, id: UUID()) + defer { gate.release(wipe) } + var drained = false + let drain = Task { try await gate.drain(before: wipe); drained = true } + await Task.yield() + XCTAssertFalse(drained) + releaseNative.finish() + do { try await removal.value; XCTFail("Revoked mutation must not install its local completion") } + catch is CancellationError {} catch ProfileRemovalStore.StoreError.changed {} + try await drain.value + XCTAssertTrue(drained) + XCTAssertEqual(manager.contacts.map(\.publicKey), [contactProfileKey]) + } + + func testHeldDiscoveryCannotRepopulateAnEndedImportOrClearItsReplacement() async { + for endsWithError in [false, true] { + for replace in [false, true] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let admission = ProfileOperationAdmission(store: ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"))) + let started = expectation(description: "Original follow lookup is held") + let (wait, release) = AsyncStream.makeStream() + let native = Task.detached { for await _ in wait {} } + defer { release.finish() } + let friend = makeContact(publicKey: contactProfileKey) + let manager = ContactsManager(profileAdmission: admission) + let discovery = Task { + await manager.discoverRemoteContacts(publicKey: "original", fetchContactKeys: { _ in + started.fulfill() + await native.value + if endsWithError { + throw URLError(.networkConnectionLost) + } + return [friend.publicKey] + }, resolveProfile: { _ in friend.profile }) + } + await fulfillment(of: [started], timeout: 2) + manager.reset() + if replace { + await manager.discoverRemoteContacts(publicKey: "replacement", fetchContactKeys: { _ in [friend.publicKey] }, + resolveProfile: { _ in friend.profile }) + } + release.finish() + await discovery.value + XCTAssertEqual(manager.pendingImportContacts.map(\.publicKey), replace ? [friend.publicKey] : []) + XCTAssertNil(manager.pendingImportProfile) + } + } + } + func testImportRequiresSessionForSharedContactStorage() async throws { let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let originals = try keys.map { try Keychain.load(key: $0) } @@ -2181,14 +2248,25 @@ final class ContactsManagerTests: XCTestCase { let manager = ContactsManager() manager.pendingImportProfile = makeProfile(publicKey: "pubky-owner") manager.pendingImportContacts = [makeContact(publicKey: contactProfileKey)] - XCTAssertTrue(manager.completePendingImport(), "An import that finishes on the import screens opens Pay Contacts") + XCTAssertTrue( + manager.completePendingImport(expectedGeneration: manager.importGeneration), + "An import that finishes on the import screens opens Pay Contacts" + ) XCTAssertFalse(manager.hasPendingImport) manager.pendingImportProfile = makeProfile(publicKey: "pubky-owner") manager.pendingImportContacts = [makeContact(publicKey: contactProfileKey)] XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .settings)) + let oldGeneration = manager.importGeneration manager.clearPendingImport() - XCTAssertFalse(manager.completePendingImport(), "An import that finishes after the user left must not pull them away") + XCTAssertFalse( + manager.completePendingImport(expectedGeneration: oldGeneration), + "An import that finishes after the user left must not pull them away" + ) + manager.pendingImportProfile = makeProfile(publicKey: "pubky-replacement") + manager.pendingImportContacts = [makeContact(publicKey: contactProfileKey)] + XCTAssertFalse(manager.completePendingImport(expectedGeneration: oldGeneration), "Old completion cannot consume the replacement import") + XCTAssertTrue(manager.hasPendingImport) } /// Removing contacts left the running profile refresh looking them up, so it kept issuing reads for deleted contacts. diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index f11ea1c79..67d234fce 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -23,7 +23,7 @@ final class HwFundingSignerTests: XCTestCase { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", - proposalAppId: "bitkit", + proposalAppId: "bitkit", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -39,7 +39,7 @@ final class HwFundingSignerTests: XCTestCase { metadata: PrivateJsonObject(text: "{}") ), acceptedEventId: nil, - acceptedOutboundStatus: nil, + acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/KeychainTests.swift b/BitkitTests/KeychainTests.swift index f296a119b..0ca2390ce 100644 --- a/BitkitTests/KeychainTests.swift +++ b/BitkitTests/KeychainTests.swift @@ -160,4 +160,62 @@ final class KeychainTests: XCTestCase { "wipeEntireKeychain deleted an account outside the unit-test namespace" ) } + + func testWipeRejectsEnumerationFailureBeforeAnyDeletion() { + var deletions = 0 + XCTAssertThrowsError(try Keychain.wipeEntireKeychain( + copyMatching: { _, _ in errSecInteractionNotAllowed }, + delete: { _ in deletions += 1; return errSecSuccess } + )) + XCTAssertEqual(deletions, 0) + } + + func testWipeRejectsMalformedEnumeration() { + for result: CFTypeRef in ["invalid" as CFString, [["unexpected": "value"]] as CFArray] { + var deletions = 0 + XCTAssertThrowsError(try Keychain.wipeEntireKeychain( + copyMatching: { _, output in output?.pointee = result; return errSecSuccess }, + delete: { _ in deletions += 1; return errSecSuccess } + )) + XCTAssertEqual(deletions, 0) + } + } + + func testWipeDoesNotAcknowledgeFailedDeletionAndCanResume() throws { + let accounts = [Keychain.account(for: .paykitSession), Keychain.account(for: .pubkySecretKey)] + var remaining = Set(accounts) + var refuseDeletion = true + let read: (CFDictionary, UnsafeMutablePointer?) -> OSStatus = { query, output in + XCTAssertNil((query as NSDictionary)[kSecReturnData]) + output?.pointee = remaining.sorted().map { [kSecAttrAccount as String: $0] } as CFArray + return errSecSuccess + } + let delete: (CFDictionary) -> OSStatus = { query in + let account = (query as NSDictionary)[kSecAttrAccount] as! String + if refuseDeletion, account == accounts[1] { + return errSecInteractionNotAllowed + } + remaining.remove(account) + return errSecSuccess + } + XCTAssertThrowsError(try Keychain.wipeEntireKeychain(copyMatching: read, delete: delete)) + XCTAssertTrue(remaining.contains(accounts[1])) + refuseDeletion = false + try Keychain.wipeEntireKeychain(copyMatching: read, delete: delete) + XCTAssertTrue(remaining.isEmpty) + } + + func testWipeAcceptsAcknowledgedAbsence() throws { + try Keychain.wipeEntireKeychain( + copyMatching: { _, _ in errSecItemNotFound }, + delete: { _ in XCTFail("Empty keychain must not invoke deletion"); return errSecSuccess } + ) + try Keychain.wipeEntireKeychain( + copyMatching: { _, output in + output?.pointee = [[kSecAttrAccount as String: Keychain.account(for: .paykitSession)]] as CFArray + return errSecSuccess + }, + delete: { _ in errSecItemNotFound } + ) + } } diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index e63f1ffbc..c859bafb1 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -713,7 +713,7 @@ enum PaykitAllowanceFixtures { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", - proposalAppId: "bitkit", + proposalAppId: "bitkit", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: Paykit.PaymentRequestTerms( @@ -729,7 +729,7 @@ enum PaykitAllowanceFixtures { metadata: Paykit.PrivateJsonObject(text: "{}") ), acceptedEventId: nil, - acceptedOutboundStatus: nil, + acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 4639062c7..63195dd53 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -77,15 +77,22 @@ final class PaykitContactLifecycleTests: XCTestCase { counterparty: sdk.publicKey, paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: testCase.role, state: .activeRecurring, proposalStreamItemId: nil, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: nil, proposalAppId: testCase.appId, + proposalEventId: nil, proposalAppId: testCase.appId, proposalRecordedAt: nil, payerAppId: testCase.role == .payer ? testCase.appId : nil, executionClaimAppId: nil, - terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, + terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: nil, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: nil, invalidReason: nil )] let service = PaykitSdkService(sdkFactory: { sdk }) + do { + try await service.requireNoActiveSubscriptions(expectedIdentity: sdk.publicKey) + XCTFail("Preflight must include subscriptions from every app and role") + } catch let PubkyServiceError.activeSubscription(endsAt) { + XCTAssertEqual(endsAt, testCase.endsAt.flatMap(PaykitPaymentRequest.parseDate)) + } + XCTAssertTrue(sdk.events.isEmpty) do { _ = try await service.removeContact(publicKey: sdk.publicKey) XCTFail("Expected active subscription to prevent deletion") @@ -101,6 +108,7 @@ final class PaykitContactLifecycleTests: XCTestCase { } else { sdk.requests[0].state = .canceled } + try await service.requireNoActiveSubscriptions(expectedIdentity: sdk.publicKey) _ = try await service.removeContact(publicKey: sdk.publicKey) XCTAssertNil(sdk.record) _ = try await sdk.saveContact(update: ContactUpdate(publicKey: sdk.publicKey, label: "Contact")) @@ -128,8 +136,8 @@ final class PaykitContactLifecycleTests: XCTestCase { counterparty: publicKey, paymentRequestId: UUID().uuidString, localRole: .payer, state: .activeRecurring, proposalStreamItemId: nil, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: nil, proposalAppId: "paykit-server", payerAppId: "paykit-server", executionClaimAppId: nil, - terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, + proposalEventId: nil, proposalAppId: "paykit-server", proposalRecordedAt: nil, payerAppId: "paykit-server", executionClaimAppId: nil, + terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: nil, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: nil, invalidReason: nil @@ -344,16 +352,18 @@ final class PaykitContactLifecycleTests: XCTestCase { XCTAssertTrue(sdk.withdrawals.isEmpty) } - func testDisabledPrivateCapabilityDoesNotQueueWithdrawal() async throws { + func testDisabledPrivateCapabilityStillAllowsEmptyWithdrawal() async throws { let sdk = ContactLifecycleSdk(noPointer: .init()) sdk.capabilities.privatePayments = false let service = PaykitSdkService(sdkFactory: { sdk }) let report = try await service.clearPrivatePaymentLists(to: [sdk.publicKey]) - XCTAssertNil(report) - XCTAssertTrue(sdk.events.isEmpty) + XCTAssertEqual(report?.cleared.map(\.counterparty), [sdk.publicKey]) + XCTAssertEqual(sdk.withdrawals.count, 1) + XCTAssertTrue(sdk.withdrawals.first?.allSatisfy(\.reservations.isEmpty) == true) XCTAssertFalse(sdk.capabilities.privatePayments) + XCTAssertEqual(sdk.appPublications, 0) } func testCleanupPendingDoesNotEnablePrivateCapability() async throws { @@ -368,6 +378,129 @@ final class PaykitContactLifecycleTests: XCTestCase { XCTAssertFalse(sdk.capabilities.privatePayments) } + func testProfileRemovalWithdrawsLinkedListsAfterSharingIsDisabled() async throws { + snapshotAppDefaultsDomain() + for acknowledges in [true, false] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let admission = ProfileOperationAdmission(store: store) + let scope = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + defer { admission.release(scope) } + try admission.persist(scope) + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.capabilities.privatePayments = false + sdk.acknowledgesWithdrawal = acknowledges + let service = PaykitSdkService(profileAdmission: admission, sdkFactory: { sdk }) + let privateService = PrivatePaykitService(profileAdmission: admission) + let operations = PrivatePaykitService.EndpointCleanupOperations( + linkedPeers: { try await service.linkedPeers() }, + clearPaymentLists: { try await service.clearPrivatePaymentLists(to: $0) }, + drainMessages: { _ in XCTFail("Withdrawal must not replay") }, pendingDrainKeys: { _ in [] }, + syncApp: { XCTFail("Profile removal defers registry synchronization") } + ) + do { + try await admission.withScope(scope) { + try await privateService.removePublishedEndpoints(deferAppSync: true, operations: operations) + } + XCTAssertTrue(acknowledges, "An empty queue without the exact withdrawal acknowledgement must fail") + } catch PrivatePaykitError.privateUnavailable { + XCTAssertFalse(acknowledges, "Disabled sharing must not prevent an acknowledged profile withdrawal") + } + XCTAssertEqual(sdk.withdrawals.count, 1) + XCTAssertEqual(sdk.withdrawals.first?.map(\.counterparty), [sdk.publicKey]) + XCTAssertTrue(sdk.withdrawals.first?.allSatisfy(\.reservations.isEmpty) == true) + XCTAssertFalse(sdk.capabilities.privatePayments) + XCTAssertEqual(sdk.appPublications, 0) + XCTAssertNotNil(sdk.record) + XCTAssertEqual(sdk.peers.first?.state, .linked) + } + } + + func testDisabledSharingCleanupRequiresAcknowledgementWithAndWithoutPublishedState() async throws { + snapshotAppDefaultsDomain() + for hasPublishedList in [false, true] { + for acknowledges in [false, true] { + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.deletedContactCleanupKeysKey) + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.capabilities.privatePayments = false + sdk.acknowledgesWithdrawal = acknowledges + let service = PaykitSdkService(sdkFactory: { sdk }) + let privateService = PrivatePaykitService() + await privateService.setTestPublishedList(hasPublishedList, for: sdk.publicKey) + PrivatePaykitService.markDeletedContactCleanupPending([sdk.publicKey]) + var registryUpdates = 0 + do { + try await privateService.removePublishedEndpoints(for: [sdk.publicKey], operations: .init( + linkedPeers: { try await service.linkedPeers() }, + clearPaymentLists: { try await service.clearPrivatePaymentLists(to: $0) }, + drainMessages: { _ in XCTFail("Withdrawal must not replay") }, pendingDrainKeys: { _ in [] }, + syncApp: { registryUpdates += 1 } + )) + XCTAssertTrue(acknowledges) + } catch PrivatePaykitError.privateUnavailable { + XCTAssertFalse(acknowledges) + } + XCTAssertEqual(sdk.withdrawals.count, 1) + XCTAssertTrue(sdk.withdrawals.first?.allSatisfy(\.reservations.isEmpty) == true) + XCTAssertFalse(sdk.capabilities.privatePayments) + XCTAssertEqual(sdk.appPublications, 0) + XCTAssertEqual(registryUpdates, acknowledges ? 1 : 0) + XCTAssertEqual(PrivatePaykitService.pendingDeletedContactCleanupKeys().contains(sdk.publicKey), !acknowledges) + let retained = await privateService.testHasPublishedList(for: sdk.publicKey) + XCTAssertEqual(retained, hasPublishedList && !acknowledges) + XCTAssertEqual(sdk.peers.first?.state, .linked) + } + } + } + + func testDisabledSharingWithdrawalRejectsUnavailableRemoteAdmission() async throws { + for mode in ["revoked", "local-only", "local-recovery", "record-only"] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let admission = ProfileOperationAdmission(store: store) + let id = UUID() + let scope = try admission.reserve(kind: mode == "local-recovery" ? .localRecovery : .removal, + id: id, expected: admission.snapshot()) + try admission.persist(scope) + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.capabilities.privatePayments = false + if mode == "local-only" { + try admission.requireLocalOnly(scope) + } + let service = PaykitSdkService(profileAdmission: admission, sdkFactory: { sdk }) + if mode == "record-only" { + admission.release(scope) + } + defer { + if mode != "record-only" { + admission.release(scope) + } + } + do { + if mode == "record-only" { + _ = try await service.clearPrivatePaymentLists(to: [sdk.publicKey]) + } else { + _ = try await admission.withScope(scope, access: .local) { + try await service.clearPrivatePaymentLists(to: [sdk.publicKey], isSessionCurrent: { + if mode == "revoked" { + admission.revoke(scope) + } + return true + }) + } + } + XCTFail("\(mode) must not authorize withdrawal") + } catch is ProfileRemovalStore.StoreError {} + XCTAssertTrue(sdk.withdrawals.isEmpty) + XCTAssertEqual(sdk.appPublications, 0) + XCTAssertFalse(sdk.capabilities.privatePayments) + XCTAssertEqual(try store.pending()?.id, id) + } + } + func testWithdrawalBatchesPreflightAndDelegatesRepeatedEmptyLists() async throws { let sdk = ContactLifecycleSdk(noPointer: .init()) let other = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -386,8 +519,8 @@ final class PaykitContactLifecycleTests: XCTestCase { } XCTAssertEqual(sdk.peerReads, 3) - XCTAssertEqual(sdk.identityReads, 3) - XCTAssertEqual(sdk.registryReads, 3) + XCTAssertEqual(sdk.identityReads, 0) + XCTAssertEqual(sdk.registryReads, 0) XCTAssertEqual(sdk.withdrawals.count, 3) XCTAssertTrue(sdk.withdrawals.allSatisfy { updates in updates.map(\.counterparty) == [sdk.publicKey, other] && updates.allSatisfy(\.reservations.isEmpty) @@ -418,6 +551,18 @@ final class PaykitContactLifecycleTests: XCTestCase { } } +private extension PrivatePaykitService { + func setTestPublishedList(_ published: Bool, for publicKey: String) { + var contact = ContactState() + contact.hasPublishedPrivatePaymentList = published + state.contacts[publicKey] = contact + } + + func testHasPublishedList(for publicKey: String) -> Bool { + state.contacts[publicKey]?.hasPublishedPrivatePaymentList == true + } +} + private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" var events: [String] = [] @@ -436,6 +581,8 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { var registryReads = 0 var beforeLinkedPeers: (() async -> Void)? var withdrawals = [[PrivatePaymentListReservationUpdateInput]]() + var acknowledgesWithdrawal = false + var appPublications = 0 var capabilities = PaykitAppCapabilities(privatePayments: true, paymentRequests: true, receipts: false, outgoingPayments: true) lazy var record: ContactRecord? = ContactRecord( publicKey: publicKey, label: "Contact", profile: nil, @@ -472,6 +619,7 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { } override func publishPaykitApp(displayName _: String, capabilities: PaykitAppCapabilities) async throws -> PaykitAppRegistry { + appPublications += 1 self.capabilities = capabilities return registry } @@ -527,6 +675,8 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { queued: [], cleared: updates.filter { update in !failed.contains { $0.counterparty == update.counterparty } } .map { .init(counterparty: $0.counterparty, outboundMessageId: 1, error: nil) }, + acknowledged: acknowledgesWithdrawal ? updates.filter { update in !failed.contains { $0.counterparty == update.counterparty } } + .map { .init(counterparty: $0.counterparty, outboundMessageId: 1) } : [], failedToQueue: failed.map { .init(counterparty: $0.counterparty, outboundMessageId: nil, error: nil) }, failedToDeliver: [] ) @@ -540,7 +690,7 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { if failWithdrawal { throw PubkyServiceError.sessionNotActive } - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } override func blockPeer(counterparty: String) async throws -> LinkedPeerRecord { diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift index 0754b5bfc..028f33596 100644 --- a/BitkitTests/PaykitIssuerInteropTests.swift +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -99,7 +99,7 @@ final class PaykitIssuerInteropTests: XCTestCase { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "71300000-0000-4000-8000-000000000002", - proposalAppId: "bitkit", + proposalAppId: "bitkit", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -115,7 +115,7 @@ final class PaykitIssuerInteropTests: XCTestCase { metadata: PrivateJsonObject(text: #"{"order":"713"}"#) ), acceptedEventId: nil, - acceptedOutboundStatus: nil, + acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index e7fdcb7c2..86379650a 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -3421,7 +3421,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", - proposalAppId: "bitkit", + proposalAppId: "bitkit", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -3437,7 +3437,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { metadata: PrivateJsonObject(text: "{}") ), acceptedEventId: nil, - acceptedOutboundStatus: nil, + acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 669b7d23c..8c86c4843 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -10254,7 +10254,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { proposalOutboundMessageId: proposalOutboundMessageId, proposalOutboundStatus: proposalOutboundStatus, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", - proposalAppId: proposalAppId, + proposalAppId: proposalAppId, proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -10270,7 +10270,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { metadata: PrivateJsonObject(text: metadata) ), acceptedEventId: acceptedEventId, - acceptedOutboundStatus: nil, + acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: rejectedEventId, rejectedOutboundStatus: nil, canceledEventId: canceledEventId, diff --git a/BitkitTests/PaykitPublicReadLaneTests.swift b/BitkitTests/PaykitPublicReadLaneTests.swift index ef9bee796..efbeb44cc 100644 --- a/BitkitTests/PaykitPublicReadLaneTests.swift +++ b/BitkitTests/PaykitPublicReadLaneTests.swift @@ -48,7 +48,7 @@ final class PaykitPublicReadLaneTests: XCTestCase { } } - func testPublicReadWaitingToBuildAnSdkWhenAWipeStartsFailsWithoutBuildingOne() async throws { + func testColdPublicReadCompletesWhileApprovalWaitsAndIsRebuiltAfterWipe() async throws { let sdk = WipeRaceSdk(noPointer: .init()) await sdk.gate.open() let builds = BuildCount() @@ -65,25 +65,32 @@ final class PaykitPublicReadLaneTests: XCTestCase { } try await bootstrap.started.waitForEntries(count: 1) let read = Task { try await service.fetchPubkyFollows(publicKey: "follower") } - try await Task.sleep(for: .milliseconds(50)) + let beforeWipe = try await read.value + XCTAssertEqual(beforeWipe, ["follow"]) + XCTAssertEqual(builds.value, 1, "Cold public construction does not queue behind authentication") let wipe = Task { try await service.withWalletWipe { builds.value } } try await Task.sleep(for: .milliseconds(50)) await bootstrap.gate.open() - try await approval.value - await assertWipeError(read.result, "A read queued to build an SDK must not build or read once a wipe starts") + do { try await approval.value; XCTFail("The old authorization must not complete across wipe admission") } + catch is CancellationError {} catch ProfileRemovalStore.StoreError.changed {} let buildsByWipe = try await wipe.value - XCTAssertEqual(buildsByWipe, 0) - XCTAssertEqual(builds.value, 0, "Only work under the lock may build an SDK, and none was admitted") + XCTAssertEqual(buildsByWipe, 1) + let afterWipe = try await service.fetchPubkyFollows(publicKey: "after") + XCTAssertEqual(afterWipe, ["follow"]) + XCTAssertEqual(builds.value, 2, "Wipe clears the independently constructed public handle") let reads = await sdk.log.entries - XCTAssertEqual(reads, []) + XCTAssertEqual(reads, ["follows:follower", "follows:after"]) } func testPublicReadsDuringAWipeAreRejectedExceptForTheWipeItself() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let admission = ProfileOperationAdmission(store: ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"))) let sdk = WipeRaceSdk(noPointer: .init()) await sdk.gate.open() let builds = BuildCount() - let service = PaykitSdkService(sdkFactory: { + let service = PaykitSdkService(profileAdmission: admission, sdkFactory: { builds.increment() return sdk }) @@ -91,7 +98,10 @@ final class PaykitPublicReadLaneTests: XCTestCase { let (cleanupStarted, startCleanup) = AsyncStream.makeStream() let (wipeGate, releaseWipe) = AsyncStream.makeStream() let wipe = Task { + defer { startCleanup.finish() } try await service.withWalletWipe { + let scope = try XCTUnwrap(ProfileOperationAdmission.current) + try admission.persist(scope) _ = try await service.contactRecords() let follows = try await service.fetchPubkyFollows(publicKey: "follower") XCTAssertEqual(follows, ["follow"], "The wipe's own public reads must still run") @@ -99,6 +109,7 @@ final class PaykitPublicReadLaneTests: XCTestCase { for await _ in wipeGate { break } + try admission.complete(scope) } } for await _ in cleanupStarted { @@ -119,13 +130,13 @@ final class PaykitPublicReadLaneTests: XCTestCase { } let reads = await sdk.log.entries XCTAssertEqual(reads, ["follows:follower"], "Only the wipe's own read may reach the wiped wallet's SDK") - XCTAssertEqual(builds.value, 2) + XCTAssertEqual(builds.value, 3, "Authenticated and credential-free public handles are built separately") releaseWipe.yield() try await wipe.value let follows = try await service.fetchPubkyFollows(publicKey: "follower") XCTAssertEqual(follows, ["follow"]) - XCTAssertEqual(builds.value, 3, "A read after the wipe builds a fresh SDK from the cleared state") + XCTAssertEqual(builds.value, 4, "A read after the wipe builds a fresh SDK from the cleared state") } func testPublicReadThatAWipeOvertakesFailsWithoutDelayingTheWipe() async throws { diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift index 2d5ed36a5..a9f7b40a1 100644 --- a/BitkitTests/PaykitReceivedPaymentContactsTests.swift +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -53,6 +53,48 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { } } + func testRetainedRequestCannotHideAReservationClearedDuringRemoval() async throws { + let suite = "ReceiptRemovalConflict.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let ledger: [String: Any] = [ + "version": 1, "reservedReceiveIndexesByAddressType": ["nativeSegwit": [3]], + "contactAssignments": [bob: ["addressType": "nativeSegwit", "receiveIndex": 3]], + "contactAssignmentHistory": [:], "restoredReservedReceiveIndexCeilingsByAddressType": [:], + ] + try defaults.set(JSONSerialization.data(withJSONObject: ledger), forKey: PrivatePaykitAddressReservationStore.defaultsKey) + let reservations = PrivatePaykitAddressReservationStore(defaults: defaults) + let requests = try contacts(address: receivingAddress, counterparty: alice) + let outputs = [receivingAddress, otherAddress] + let before = await requests.includingReservations(for: outputs) { $0 == self.otherAddress ? self.bob : nil } + XCTAssertNil(before.contact(receivingAddress: receivingAddress, outputAddresses: outputs)) + let previousRevision = await reservations.attributionRevision + let admission = ProfileOperationAdmission.shared + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + defer { + _ = try? admission.complete(removal) + admission.release(removal) + } + try admission.persist(removal) + _ = try await reservations.clearContactAssignmentsForRemoval() + let clearedRevision = await reservations.attributionRevision + try await admission.withReceiptAttribution { _ in + let combined = try? await reservations.contactsForReceipt(requests, outputAddresses: outputs, expectedRevision: clearedRevision) + XCTAssertNil(combined?.contact(receivingAddress: self.receivingAddress, outputAddresses: outputs), + "A retained request must not become an unambiguous match when cleanup erases its conflict") + } + try admission.complete(removal) + admission.release(removal) + try await admission.withReceiptAttribution { canAttribute in + XCTAssertTrue(canAttribute()) + let stale = try? await reservations.contactsForReceipt(requests, outputAddresses: outputs, expectedRevision: previousRevision) + XCTAssertNil(stale, "Request and reservation snapshots must describe the same ledger revision") + let current = try await reservations.contactsForReceipt(requests, outputAddresses: outputs, expectedRevision: clearedRevision) + XCTAssertEqual(current.contact(receivingAddress: self.receivingAddress, outputAddresses: outputs), self.alice, + "A request-only receipt remains attributable when the profile is available") + } + } + func testSameContactAcrossRequestAndReservationsIsUnambiguous() async throws { try await assertAttribution( contacts: contacts(address: otherAddress, counterparty: alice), @@ -303,7 +345,7 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { try PaymentRequestRecord( counterparty: counterparty, paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: .payee, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "paykit-server", payerAppId: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "paykit-server", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), @@ -311,7 +353,7 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { paymentEndpoints: [identifier: PublicPaykitService.serializePayload(value: address)], requiredAppId: nil, conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), - acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, + acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: 1, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: "2027-01-15T08:00:00Z", invalidReason: nil ) diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 88d2858a9..cdfcc6725 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -3,6 +3,48 @@ import Paykit import XCTest final class PaykitSdkClientConfigTests: XCTestCase { + func testSignupDoesNotEnterNativeMutationAfterHeldIdentityReadIsRevoked() async throws { + for cancel in [true, false] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let admission = ProfileOperationAdmission(store: store) + let sdk = CacheActivationSdk(noPointer: .init()) + let bootstrap = CacheActivationBootstrap(noPointer: .init()) + let service = PaykitSdkService(profileAdmission: admission, sdkFactory: { sdk }) { _, _ in bootstrap } + let originalCredentials = try [KeychainEntryType.paykitSession, .pubkySecretKey].map { try Keychain.load(key: $0) } + let started = expectation(description: "Native identity read is held") + let (stream, release) = AsyncStream.makeStream() + defer { release.finish() } + let held = Task.detached { for await _ in stream {} } + sdk.readIdentity = { started.fulfill(); await held.value } + let signingUp = Task { + try await service.signUp(secretKeyHex: String(repeating: "01", count: 32), homeserverPublicKey: "test", signupCode: nil) + } + await fulfillment(of: [started], timeout: 2) + let wipe: ProfileOperationAdmission.Scope? + if cancel { + signingUp.cancel(); wipe = nil + } else { + wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + } + release.finish() + do { _ = try await signingUp.value; XCTFail("Revoked signup must fail") } + catch { XCTAssertTrue(error is CancellationError || error is ProfileRemovalStore.StoreError) } + XCTAssertEqual(bootstrap.signUpCalls, 0) + XCTAssertEqual(try [KeychainEntryType.paykitSession, .pubkySecretKey].map { try Keychain.load(key: $0) }, originalCredentials) + XCTAssertTrue(sdk.activationEvents.isEmpty) + if let wipe { + try await admission.withScope(wipe, access: .control) { + try await admission.drain(before: wipe) + try await service.withWalletWipe {} + } + admission.release(wipe) + } + XCTAssertNoThrow(try admission.snapshot()) + } + } + private let externalAuthURL = "pubkyauth://signin_grant?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/" + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + @@ -67,6 +109,39 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertNotEqual(identity.walletGeneration, freshIdentity.walletGeneration) } + func testRegisteredIdentityCannotCrossRemovalBetweenPreflightAndNativeAdmission() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let admission = ProfileOperationAdmission(store: store) + let bootstrap = CacheActivationBootstrap(noPointer: .init()) + let service = PaykitSdkService(profileAdmission: admission, sdkFactory: { + XCTFail("Stale registered identity reached native activation") + throw PubkyServiceError.sessionNotActive + }) { _, _ in bootstrap } + let identity = try await service.registerIdentity( + secretKeyHex: String(repeating: "01", count: 32), homeserverPublicKey: "test", signupCode: nil + ) + let preflight = expectation(description: "Original registration preflight completed") + let (stream, release) = AsyncStream.makeStream() + defer { release.finish() } + await service.setBeforeRegistrationActivationForTesting { + preflight.fulfill() + for await _ in stream {} + } + let activation = Task { try await service.activateRegisteredIdentity(identity) } + await fulfillment(of: [preflight], timeout: 2) + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + try admission.persist(removal) + try admission.complete(removal) + admission.release(removal) + release.finish() + do { + try await activation.value + XCTFail("Stale registration activated") + } catch ProfileRemovalStore.StoreError.changed {} + } + func testPaykitKeyForAuthorizationPreservesPerIdentityGenerationHighWater() async throws { let secrets = [String(repeating: "11", count: 32), String(repeating: "12", count: 32)] let roots = try secrets.map { try PaykitSdkService.localSecretKey(fromHex: $0) } @@ -453,7 +528,11 @@ final class PaykitSdkClientConfigTests: XCTestCase { session.localSecretKey = try PaykitSdkService.localSecretKey(fromHex: secret) let result = PubkySessionBootstrapResult(sessionAccess: session, publicKey: publicKey, capability: .privateLinkCapable) - try await service.activateRegisteredIdentity(.init(result: result, walletGeneration: 0)) + try await service.activateRegisteredIdentity(.init( + result: result, + walletGeneration: 0, + profileGeneration: service.profileAdmission.snapshot() + )) await manager.initialize { .restored(publicKey: "pubky\(originalKey)") } XCTAssertEqual(manager.publicKey, "pubky\(originalKey)") @@ -546,7 +625,8 @@ final class PaykitSdkClientConfigTests: XCTestCase { do { try await service.activateRegisteredIdentity(.init( - result: .init(sessionAccess: session, publicKey: newKey, capability: .privateLinkCapable), walletGeneration: 0 + result: .init(sessionAccess: session, publicKey: newKey, capability: .privateLinkCapable), walletGeneration: 0, + profileGeneration: service.profileAdmission.snapshot() )) XCTFail("Expected activation to fail after staging the new session") } catch { @@ -613,7 +693,11 @@ final class PaykitSdkClientConfigTests: XCTestCase { let result = PubkySessionBootstrapResult(sessionAccess: session, publicKey: publicKey, capability: .privateLinkCapable) let activation = Task { - try await service.activateRegisteredIdentity(.init(result: result, walletGeneration: 0)) + try await service.activateRegisteredIdentity(.init( + result: result, + walletGeneration: 0, + profileGeneration: service.profileAdmission.snapshot() + )) activationReturned.fulfill() } defer { activation.cancel() } @@ -707,7 +791,11 @@ final class PaykitSdkClientConfigTests: XCTestCase { let forgotEarly = expectation(description: "forget cannot interleave with recovery") forgotEarly.isInverted = true sdk.onForget = { forgotEarly.fulfill() } - let service = PaykitSdkService(sdkFactory: { sdk }, bootstrapFactory: { _, _ in bootstrap }) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let gate = ProfileOperationAdmission(store: store) + let service = PaykitSdkService(profileAdmission: gate, sdkFactory: { sdk }, bootstrapFactory: { _, _ in bootstrap }) let recovery = Task { try await service.restorePersistedSession() } await fulfillment(of: [started], timeout: 2) let forget = Task { try await service.forgetSessionAccess() } @@ -715,7 +803,18 @@ final class PaykitSdkClientConfigTests: XCTestCase { sdk.onForget = {} continuation.finish() let result = try await recovery.value - try await forget.value + do { + try await forget.value + XCTFail("An unbound forget must not cross an active restoration") + } catch ProfileRemovalStore.StoreError.changed {} + let scope = try gate.reserve(kind: .localRecovery, id: UUID(), expected: gate.snapshot()) + do { try await gate.withScope(scope, access: .control) { + try await gate.drain(before: scope) + try gate.persist(scope) + try await service.forgetSessionAccess() + try gate.complete(scope) + } } catch { gate.release(scope); throw error } + gate.release(scope) XCTAssertEqual(result, .restored(publicKey: bootstrap.result.publicKey)) XCTAssertEqual(sdk.initializationCalls, 1) XCTAssertNil(try Keychain.load(key: .paykitSession)) @@ -724,6 +823,250 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertEqual(afterForget, .noSession) } + @MainActor + func testUncommittedCredentialActivationDoesNotReplaceCachedIdentity() async throws { + snapshotAppDefaultsDomain() + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] + let saved = try keys.map { try Keychain.load(key: $0) } + defer { for (key, value) in zip(keys, saved) { + if let value { + try? Keychain.upsert(key: key, data: value) + } else { + try? Keychain.delete(key: key) + } + } } + for key in keys { + try Keychain.delete(key: key) + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let gate = ProfileOperationAdmission(store: ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"))) + let sdk = CacheActivationSdk(noPointer: .init()) + let service = PaykitSdkService(profileAdmission: gate, sdkFactory: { sdk }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } + let defaults = UserDefaults.standard + defaults.set("previous-owner", forKey: "pubky_profile_identity") + defaults.set("Previous name", forKey: "pubky_profile_name") + defaults.set("previous-avatar", forKey: "pubky_profile_image_uri") + try await gate.withSessionTransition { + let attempt = try await service.beginCredentialAttempt() + let result = PubkySessionBootstrapResult(sessionAccess: CacheActivationSession(noPointer: .init()), + publicKey: "pubky_test", capability: .privateLinkCapable) + try await service.activateRegisteredIdentity(.init(result: result, walletGeneration: 0, + profileGeneration: gate.operationGeneration())) + XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "new-session") + XCTAssertEqual(defaults.string(forKey: "pubky_profile_identity"), "previous-owner") + XCTAssertEqual(defaults.string(forKey: "pubky_profile_name"), "Previous name") + try await service.rollbackCredentialAttempt(attempt) + } + XCTAssertNil(try Keychain.load(key: .paykitSession)) + XCTAssertEqual(defaults.string(forKey: "pubky_profile_identity"), "previous-owner") + XCTAssertEqual(defaults.string(forKey: "pubky_profile_name"), "Previous name") + XCTAssertEqual(defaults.string(forKey: "pubky_profile_image_uri"), "previous-avatar") + } + + @MainActor + func testAcknowledgedAdoptionFinishesLocalStateBeforeCanceledWipeDrains() async throws { + snapshotAppDefaultsDomain() + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] + let saved = try keys.map { try Keychain.load(key: $0) } + defer { for (key, value) in zip(keys, saved) { + if let value { + try? Keychain.upsert(key: key, data: value) + } else { + try? Keychain.delete(key: key) + } + } } + try Keychain.upsert(key: .paykitSession, data: Data("previous-session".utf8)) + try Keychain.delete(key: .pubkySecretKey) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let admission = ProfileOperationAdmission(store: store) + let owner = "pubky" + String(repeating: "y", count: 52) + AdoptedPubkyReference.current = ("ring", "previous-reference") + let manager = PubkyProfileManager(removalStore: store, removalAdmission: admission) + manager.publicKey = owner + manager.authState = .authenticated + UserDefaults.standard.set(owner, forKey: "pubky_profile_identity") + let native = CacheActivationSdk(noPointer: .init()) + let sdk = PaykitSdkService(profileAdmission: admission, sdkFactory: { native }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } + let committed = expectation(description: "Credentials committed before manager installation") + let (wait, release) = AsyncStream.makeStream() + defer { release.finish() } + manager.beforeCommittedIdentityInstallForTesting = { committed.fulfill(); for await _ in wait {} } + let next = try PubkyProfileManager.publicKeyFromSecretKey(String(repeating: "02", count: 32)) + let adoption = Task { + try await manager.adoptRingIdentity(pubky: next, loadSecret: { _, _ in String(repeating: "02", count: 32) }, + signIn: { _ in + let result = PubkySessionBootstrapResult( + sessionAccess: CacheActivationSession(noPointer: .init()), + publicKey: next, + capability: .privateLinkCapable + ) + try await sdk.activateRegisteredIdentity(.init(result: result, walletGeneration: 0, + profileGeneration: admission + .operationGeneration())) + }, fetchProfile: { key in + PubkyProfile(publicKey: key, name: "Next owner", bio: "", imageUrl: nil, links: [], status: nil) + }, sdkService: sdk) + } + await fulfillment(of: [committed], timeout: 2) + XCTAssertEqual(manager.publicKey, owner) + XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "new-session") + XCTAssertEqual(UserDefaults.standard.string(forKey: "pubky_profile_identity"), owner) + let wiping = Task { + try await AppReset.withWipeAdmission(admission: admission, sdkService: sdk, pause: { _ in }, resume: { _ in }) { _ in + XCTFail("Canceled wipe cannot reach cleanup") + } + } + for _ in 0 ..< 100 where !admission.isWalletWiping { + await Task.yield() + } + XCTAssertTrue(admission.isWalletWiping) + wiping.cancel() + do { try await wiping.value; XCTFail("Canceled wipe completed") } catch is CancellationError {} + release.finish() + do { _ = try await adoption.value; XCTFail("Superseded adoption cannot navigate") } + catch ProfileRemovalStore.StoreError.changed {} catch is CancellationError {} + XCTAssertEqual(manager.publicKey, next) + XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "new-session") + XCTAssertNil(try Keychain.load(key: .pubkySecretKey), "Ring owns the secret") + XCTAssertEqual(UserDefaults.standard.string(forKey: "pubky_profile_identity"), next) + XCTAssertEqual(manager.profile?.name, "Next owner") + XCTAssertEqual(AdoptedPubkyReference.current?.pubky, next) + XCTAssertNil(try store.pending()) + XCTAssertNoThrow(try admission.snapshot()) + } + + @MainActor + func testCancelledNewSessionCleanupDrainsBeforeWalletWipe() async throws { + try await checkCancelledNewSessionCleanup(cancelWipe: false) + } + + @MainActor + func testCanceledWipeCannotStrandOriginalCredentialCleanup() async throws { + try await checkCancelledNewSessionCleanup(cancelWipe: true) + } + + @MainActor + private func checkCancelledNewSessionCleanup(cancelWipe: Bool) async throws { + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] + let saved = try keys.map { try Keychain.load(key: $0) } + defer { for (key, value) in zip(keys, saved) { + if let value { + try? Keychain.upsert(key: key, data: value) + } else { + try? Keychain.delete(key: key) + } + } } + for key in keys { + try Keychain.delete(key: key) + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let gate = ProfileOperationAdmission(store: store) + let sdk = CacheActivationSdk(noPointer: .init()) + let service = PaykitSdkService(profileAdmission: gate, sdkFactory: { sdk }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } + let installed = expectation(description: "New session installed before profile publication") + let cleaning = expectation(description: "Original local cleanup entered") + let (profileWait, releaseProfile) = AsyncStream.makeStream() + let (cleanupWait, releaseCleanup) = AsyncStream.makeStream() + defer { releaseProfile.finish(); releaseCleanup.finish() } + await service.setBeforeCredentialRollbackForTesting { + cleaning.fulfill() + for await _ in cleanupWait {} + } + let operation = Task { + try await gate.withSessionTransition { + let attempt = try await service.beginCredentialAttempt() + do { + let result = PubkySessionBootstrapResult(sessionAccess: CacheActivationSession(noPointer: .init()), + publicKey: "pubky_test", capability: .privateLinkCapable) + try await service.activateRegisteredIdentity(.init(result: result, walletGeneration: 0, + profileGeneration: gate.operationGeneration())) + installed.fulfill() + for await _ in profileWait {} + try Task.checkCancellation() + try await service.commitCredentialAttempt(attempt) + } catch { + try await Task.detached { try await service.rollbackCredentialAttempt(attempt) }.value + throw error + } + } + } + await fulfillment(of: [installed], timeout: 2) + operation.cancel() + await fulfillment(of: [cleaning], timeout: 2) + XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "new-session") + var wiped = false + let wipe = Task { + try await AppReset.withWipeAdmission(admission: gate, sdkService: service, pause: { _ in }, resume: { _ in }) { _ in + wiped = true + XCTAssertNil(try Keychain.load(key: .paykitSession)) + } + } + for _ in 0 ..< 100 where !gate.isWalletWiping { + await Task.yield() + } + XCTAssertTrue(gate.isWalletWiping) + XCTAssertFalse(wiped) + if cancelWipe { + wipe.cancel() + do { try await wipe.value; XCTFail("Canceled wipe completed") } catch is CancellationError {} + catch { XCTFail("Wipe cancellation returned \(String(reflecting: type(of: error))): \(error)") } + XCTAssertFalse(gate.isWalletWiping) + XCTAssertThrowsError(try gate.snapshot(), "Canceled successor must not reopen ordinary admission ahead of old cleanup") + let unexpected = try? gate.capture() + if let unexpected { + gate.release(unexpected) + } + XCTAssertNil(unexpected) + } + releaseCleanup.finish() + do { try await operation.value; XCTFail("Canceled caller reported success") } + catch is CancellationError {} + catch { XCTFail("Original local cleanup failed: \(error)") } + if !cancelWipe { + try await wipe.value + } + XCTAssertEqual(wiped, !cancelWipe) + XCTAssertNil(try Keychain.load(key: .paykitSession)) + XCTAssertNil(try Keychain.load(key: .pubkySecretKey)) + XCTAssertNil(try store.pending()) + XCTAssertNoThrow(try gate.snapshot()) + } + + @MainActor + func testCredentialCleanupDoesNotEraseUnrecognizedBytesAndRetainsObligation() async throws { + let key = KeychainEntryType.paykitSession + let saved = try Keychain.load(key: key) + defer { + if let saved { + try? Keychain.upsert(key: key, data: saved) + } else { + try? Keychain.delete(key: key) + } + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let gate = ProfileOperationAdmission(store: store) + let service = PaykitSdkService(profileAdmission: gate, sdkFactory: { + XCTFail("Local credential cleanup must not construct SDK") + throw ProfileRemovalStore.StoreError.unavailable + }) + do { try await gate.withSessionTransition { + let attempt = try await service.beginCredentialAttempt() + try Keychain.upsert(key: key, data: Data("unrecognized-session".utf8)) + do { try await service.rollbackCredentialAttempt(attempt); XCTFail("Unexpected credential change was accepted") } + catch ProfileRemovalStore.StoreError.changed {} + XCTAssertEqual(try Keychain.loadString(key: key), "unrecognized-session") + XCTAssertNotNil(try store.pending()) + }; XCTFail("Pending cleanup must keep identity admission closed") } catch ProfileRemovalStore.StoreError.changed {} + XCTAssertThrowsError(try gate.snapshot()) + } + func testClientIDUsesBitkitOwnedDomain() { let expectedClientID = Env.network == .bitcoin ? "bitkit.to" : "staging.bitkit.to" @@ -822,6 +1165,7 @@ private final class UnavailableProfileManager: PubkyProfileManager { } private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { + var readIdentity: (() async -> Void)? var handshakeState: LinkedPeerState = .linking var handshakeAdvanceSteps: [UInt32] = [] var previousKey: String? @@ -859,7 +1203,8 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { } override func identityStatus() async throws -> IdentityStatus? { - IdentityStatus(publicKey: previousKey, capability: capability) + await readIdentity?() + return IdentityStatus(publicKey: previousKey, capability: capability) } override func paykitAppRegistry(publicKey: String) async throws -> PaykitAppRegistry? { @@ -926,6 +1271,7 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { } private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked Sendable { + var signUpCalls = 0 var republishedKeys: [String] = [] var republishOperation: () async -> Bool = { true } @@ -935,7 +1281,8 @@ private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked signupCode _: String?, requiredCapabilities _: String ) async throws -> PubkySessionBootstrapResult { - PubkySessionBootstrapResult( + signUpCalls += 1 + return PubkySessionBootstrapResult( sessionAccess: CacheActivationSession(noPointer: .init()), publicKey: "pubky_test", capability: .privateLinkCapable @@ -950,6 +1297,7 @@ private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked private final class CacheActivationSession: PubkySessionAccess, @unchecked Sendable { var localSecretKey: PubkyLocalSecretKey? + var paykitKey: PaykitIdentitySecretKey? override func exportSessionSecret() -> String { "new-session" @@ -960,7 +1308,14 @@ private final class CacheActivationSession: PubkySessionAccess, @unchecked Senda } override func exportPaykitIdentitySecretKey() -> PaykitIdentitySecretKey? { - nil + paykitKey + } + + override func withPaykitIdentitySecretKey(key: PaykitIdentitySecretKey) -> PubkySessionAccess { + let attached = CacheActivationSession(noPointer: .init()) + attached.localSecretKey = localSecretKey + attached.paykitKey = key + return attached } } diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index 56d261eed..2a7542fc4 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -115,6 +115,66 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { } } + func testRevokedProfileReadsDoNotReenterSdkAfterContention() async throws { + var operations: [(PaykitSdkService) async throws -> Void] = [ + { _ = try await $0.readPaymentRequests(counterparty: contactKey, expectedIdentity: nil) }, + ] + for priority in [PaykitSdkOperationLock.Priority.ordered, .background] { + operations += [ + { _ = try await $0.identityStatus(priority: priority) }, + { _ = try await $0.linkedPeers(priority: priority) }, + { _ = try await $0.privateMessageSchedulingSnapshot(priority: priority) }, + ] + } + for kind in [ProfileOperationAdmission.Kind.removal, .walletWipe] { + for operation in operations { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let admission = ProfileOperationAdmission(store: store) + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.readErrors = [sharedStateBusy] + await sdk.readGate.close() + sdk.paymentRequestRead = { _, _ in + await sdk.reads.record() + await sdk.readGate.wait() + throw sharedStateBusy + } + sdk.schedulingRead = { + await sdk.reads.record() + await sdk.readGate.wait() + throw sharedStateBusy + } + let service = PaykitSdkService(profileAdmission: admission, sdkFactory: { sdk }) + let read = Task { try await operation(service) } + try await sdk.reads.waitForEntries(count: 1) + let reservation = try admission.reserve(kind: kind, id: UUID(), expected: admission.snapshot()) + defer { admission.release(reservation) } + let completedDrains = IdentityCheckCallLog() + let drain = Task { + try await admission.drain(before: reservation) + await completedDrains.record() + } + await Task.yield() + let prematureDrains = await completedDrains.count + XCTAssertEqual(prematureDrains, 0, "The original native tail still owns admission") + await sdk.readGate.open() + let result = await read.result + try await drain.value + XCTAssertFalse(read.isCancelled, "Revocation must be checked even without task cancellation") + XCTAssertThrowsError(try result.get()) { error in + guard case ProfileRemovalStore.StoreError.changed = error else { + return XCTFail("Expected revoked profile admission, got \(error)") + } + } + let attempts = await sdk.reads.count + XCTAssertEqual(attempts, 1, "A revoked caller must not enter the SDK again") + XCTAssertTrue(sdk.writes.isEmpty) + XCTAssertNil(try store.pending()) + } + } + } + func testPaymentRequestReadRetainsItsRuntimeThroughContentionAndFiltersRecords() async throws { let sdk = IdentitySwitchingSdk(noPointer: .init()) let freshSdk = IdentitySwitchingSdk(noPointer: .init()) @@ -245,9 +305,13 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { let service = PaykitSdkService(sdkFactory: { sdk }) let read = Task { try await service.readPaymentRequests(counterparty: contactKey, expectedIdentity: nil) } try await reads.waitForEntries(count: 1) + let admission = service.profileAdmission + let contactsScope = try admission.capture() + defer { admission.release(contactsScope) } read.cancel() - let contacts = Task { try await service.contactRecords() } + let contacts = Task { try await admission.withScope(contactsScope) { try await service.contactRecords() } } if holdNativeRead { + XCTAssertThrowsError(try admission.capture(), "Cancelled native work must drain before new admission") try await Task.sleep(for: .milliseconds(50)) let overlappingReads = await sdk.contactRecordsStarted.count XCTAssertEqual(overlappingReads, 0) @@ -260,6 +324,9 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { XCTAssertEqual(attempts, 1) let contactReads = await sdk.contactRecordsStarted.count XCTAssertEqual(contactReads, 1) + _ = try await service.contactRecords() + let freshReads = await sdk.contactRecordsStarted.count + XCTAssertEqual(freshReads, 2) } } @@ -292,10 +359,9 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { try await wipe.value let rejectedResult = await read.result XCTAssertThrowsError(try rejectedResult.get()) { error in - guard case let PaykitError.Storage(code, _) = error else { - return XCTFail("Expected the wallet wipe error, got \(error)") + guard case ProfileRemovalStore.StoreError.changed = error else { + return XCTFail("Expected closed profile admission during wallet wipe, got \(error)") } - XCTAssertEqual(code, "wallet_wipe_in_progress") } XCTAssertEqual(rejectedReads, 0) let result = try await service.readPaymentRequests(counterparty: contactKey, expectedIdentity: nil) @@ -358,17 +424,25 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { await sdk.observationGate.close() let observation = Task { try await service.observeState(priority: .background, expectedIdentity: identityA) } try await sdk.observationStarted.waitForEntries(count: 1) + let admission = service.profileAdmission + let contactsScope = try admission.capture() + defer { admission.release(contactsScope) } observation.cancel() - let contacts = Task { try await service.contactRecords() } + XCTAssertThrowsError(try admission.capture(), "New work is rejected until the cancelled native tail drains") + let contacts = Task { try await admission.withScope(contactsScope) { try await service.contactRecords() } } try await Task.sleep(for: .milliseconds(50)) let overlappingReads = await sdk.contactRecordsStarted.count await sdk.observationGate.open() let result = await observation.result - _ = try await contacts.value + let contactsResult = await contacts.result + XCTAssertNoThrow(try contactsResult.get()) XCTAssertEqual(overlappingReads, 0) XCTAssertThrowsError(try result.get()) { XCTAssertTrue($0 is CancellationError) } let completedReads = await sdk.contactRecordsStarted.count XCTAssertEqual(completedReads, 1) + _ = try await service.contactRecords() + let freshReads = await sdk.contactRecordsStarted.count + XCTAssertEqual(freshReads, 2) } func testContactSaveForAnIdentityThatIsNoLongerSignedInWritesNothing() async throws { @@ -480,10 +554,19 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { sdk.rawPaymentRequests = { [request] } let service = PaykitSdkService(sdkFactory: { sdk }) - do { - try await service.deletePaykitProfile(expectedIdentity: identityA) - XCTFail("An active subscription must prevent profile deletion even without a contact") - } catch PubkyServiceError.activeSubscription {} + let operations: [() async throws -> Void] = [ + { try await service.requireNoActiveSubscriptions(expectedIdentity: identityA) }, + { _ = try await service.removeContactsForProfile(publicKeys: []) }, + { _ = try await service.removeContactsForProfile(publicKeys: [identityB]) }, + { try await service.deletePaykitProfile(expectedIdentity: identityA) }, + { try await service.deleteConfirmedProfile(expectedIdentity: identityA, expectedRevision: "confirmed") }, + ] + for operation in operations { + do { + try await operation() + XCTFail("An active subscription must prevent profile cleanup even without its contact") + } catch PubkyServiceError.activeSubscription {} + } XCTAssertTrue(sdk.writes.isEmpty) } } @@ -506,6 +589,7 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { { _ = try await service.removeContactsForProfile(publicKeys: [identityB]) }, { _ = try await service.removeContactsForProfile(publicKeys: []) }, { try await service.deletePaykitProfile(expectedIdentity: identityA) }, + { try await service.deleteConfirmedProfile(expectedIdentity: identityA, expectedRevision: "confirmed") }, ] for operation in operations { do { @@ -516,7 +600,7 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { XCTAssertEqual(actualEnd, endsAt.flatMap(PaykitPaymentRequest.parseDate)) } } - XCTAssertEqual(sdk.writes, blocked ? [] : ["remove-contacts:1", "delete-profile:test"]) + XCTAssertEqual(sdk.writes, blocked ? [] : ["remove-contacts:1", "delete-profile:test", "delete-profile:confirmed"]) } } @@ -564,6 +648,7 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { { try await service.requireNoActiveSubscriptions(expectedIdentity: identityA) }, { _ = try await service.removeContactsForProfile(publicKeys: [contactKey]) }, { try await service.deletePaykitProfile(expectedIdentity: identityA) }, + { try await service.deleteConfirmedProfile(expectedIdentity: identityA, expectedRevision: "confirmed") }, ] for operation in operations { do { @@ -909,12 +994,19 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { try await sdk.sharingStarted.waitForEntries(count: 1) isCurrent = false cleanup.cancel() - let signOut = Task { try await service.signOut() } + let signOut = Task { + try await ProfileOperationAdmission.shared.withSessionTransition { + try await service.signOut() + } + } try await Task.sleep(for: .milliseconds(50)) XCTAssertEqual(sdk.identity, identityA) XCTAssertTrue(sdk.writes.isEmpty) await sdk.sharingGate.open() - _ = await cleanup.result + do { + try await cleanup.value + XCTFail("Cancelled sharing must not return success") + } catch is CancellationError {} try await signOut.value XCTAssertEqual(sdk.writes, ["registry"]) XCTAssertEqual(sdk.identitiesAtWrites, [identityA]) @@ -939,11 +1031,18 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { try await sdk.sharingStarted.waitForEntries(count: 1) isCurrent = false write.cancel() - let signOut = Task { try await service.signOut() } + let signOut = Task { + try await ProfileOperationAdmission.shared.withSessionTransition { + try await service.signOut() + } + } try await Task.sleep(for: .milliseconds(50)) XCTAssertEqual(sdk.identity, identityA) await sdk.sharingGate.open() - _ = await write.result + do { + try await write.value + XCTFail("Cancelled sharing must not return success") + } catch is CancellationError {} try await signOut.value XCTAssertEqual(sdk.writes, [registry ? "registry" : "endpoints"]) XCTAssertEqual(sdk.identitiesAtWrites, [identityA]) @@ -1015,7 +1114,7 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { localRole: .payee, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "71300000-0000-4000-8000-000000000002", - proposalAppId: appId, payerAppId: nil, executionClaimAppId: nil, + proposalAppId: appId, proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), @@ -1023,7 +1122,7 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { paymentEndpoints: nil, requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), - acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: 42, @@ -1109,6 +1208,7 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { private var pendingReadErrors: [PaykitError] = [] private var linkedPeerState: LinkedPeerState? = .linked let reads = IdentityCheckCallLog() + let readGate = IdentityCheckGate() var paymentRequestRead: (String, String?) async throws -> PaymentRequestRead = { _, _ in PaymentRequestRead(identityStatus: nil, paymentRequests: []) } @@ -1204,6 +1304,7 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { override func identityStatus() async throws -> IdentityStatus? { await reads.record() + await readGate.wait() await identityStatusGate.wait() let error = lock.withLock { pendingReadErrors.isEmpty ? nil : pendingReadErrors.removeFirst() } if let error { @@ -1217,6 +1318,7 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { await linkedPeersStarted.record() await linkedPeersGate.wait() await reads.record() + await readGate.wait() let error = lock.withLock { pendingReadErrors.isEmpty ? nil : pendingReadErrors.removeFirst() } if let error { throw error diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index d3754e8bc..a08a80b5e 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -190,6 +190,14 @@ final class PaykitSdkOperationLockTests: XCTestCase { XCTAssertNil(identity, "Wipe must close admission before receive resumes") } let authority = wipe == nil ? Task { try await service.contactRecords() } : nil + if boundary == "cancellation" { + do { + _ = try await authority?.value + XCTFail("New ordinary work entered before the cancelled receive finished") + } catch ProfileRemovalStore.StoreError.changed {} + let heldEvents = await recorder.events + XCTAssertEqual(heldEvents, ["receive"]) + } gate.open() do { _ = try await refresh.value @@ -203,8 +211,12 @@ final class PaykitSdkOperationLockTests: XCTestCase { XCTAssertEqual(code, "wallet_wipe_in_progress") } try await wipe?.value - _ = try await authority?.value await fulfillment(of: [received], timeout: 2) + if boundary == "cancellation" { + _ = try await service.contactRecords() + } else { + _ = try await authority?.value + } let events = await recorder.events let version = await service.backupStateVersion XCTAssertEqual(events, boundary == "wipe" ? ["receive"] : ["receive", "authority"]) @@ -474,24 +486,34 @@ final class PaykitSdkOperationLockTests: XCTestCase { if !cancelBeforeAdmission { await fulfillment(of: [started], timeout: 2) } + let admission = service.profileAdmission + let nextScope = try admission.capture() + defer { admission.release(nextScope) } proof.cancel() - let next = Task { _ = try await service.contactRecords() } + let next = Task { try await admission.withScope(nextScope) { _ = try await service.contactRecords() } } try await Task.sleep(for: .milliseconds(50)) let heldEvents = await recorder.events XCTAssertEqual(heldEvents, [cancelBeforeAdmission ? "active" : "proof"]) gate.open() try await active?.value - do { - _ = try await proof.value + switch await proof.result { + case .success: XCTFail("Canceled proof operation must not succeed") - } catch is CancellationError { - XCTAssertTrue(cancelBeforeAdmission) - } catch PaymentMutationReached.reached { - XCTAssertFalse(cancelBeforeAdmission) + case let .failure(error): + if cancelBeforeAdmission { + XCTAssertTrue(error is CancellationError, "Unadmitted proof returned \(error)") + } else { + guard case PaymentMutationReached.reached = error else { + return XCTFail("Admitted proof must preserve its native outcome, got \(error)") + } + } } try await next.value let events = await recorder.events XCTAssertEqual(events, [cancelBeforeAdmission ? "active" : "proof", "next"]) + _ = try await service.contactRecords() + let freshEvents = await recorder.events + XCTAssertEqual(freshEvents, events + ["next"]) } } @@ -762,7 +784,7 @@ final class PaykitSdkOperationLockTests: XCTestCase { _ = try await delivery.value XCTAssertEqual(change, "resume") } catch is CancellationError { - XCTAssertEqual(change, "cancel") + XCTAssertTrue(change == "cancel" || change == "wipe") } catch PubkyServiceError.identityChanged { XCTAssertTrue(change == "reset" || change == "wipe") } @@ -797,7 +819,7 @@ final class PaykitSdkOperationLockTests: XCTestCase { _ = try await delivery.value XCTFail("Deferred outbound delivery must not survive \(change)") } catch is CancellationError { - XCTAssertEqual(change, "cancel") + XCTAssertTrue(change == "cancel" || change == "wipe") } catch PubkyServiceError.identityChanged { XCTAssertNotEqual(change, "cancel") } @@ -880,7 +902,7 @@ final class PaykitSdkOperationLockTests: XCTestCase { _ = try await export.value XCTFail("Deferred backup must not survive \(change)") } catch is CancellationError { - XCTAssertEqual(change, "cancel") + XCTAssertTrue(change == "cancel" || change == "wipe") } catch PubkyServiceError.identityChanged { XCTAssertNotEqual(change, "cancel") } @@ -945,6 +967,24 @@ final class PaykitSdkOperationLockTests: XCTestCase { XCTAssertEqual(lock.waiterCountForTesting, count) } + func testColdPublicCapabilityReadDoesNotActivatePendingProfileCredentials() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let admission = ProfileOperationAdmission(store: store) + let pendingID = UUID() + let scope = try admission.reserve(kind: .removal, id: pendingID, expected: admission.snapshot()) + try admission.persist(scope) + admission.release(scope) + let sdk = PublicReadSdk(noPointer: .init()) + sdk.lockedRead = { XCTFail("Public preview must not read authenticated contacts") } + let service = PaykitSdkService(profileAdmission: admission, sdkFactory: { sdk }) + let result = try await service.canReceivePaymentRequests(publicKey: "peer") + XCTAssertFalse(result) + XCTAssertEqual(try store.pending()?.id, pendingID) + XCTAssertThrowsError(try admission.capture()) + } + func testPublicCapabilityReadDoesNotWaitForSerializedSdkWork() async throws { let (gate, release) = AsyncStream.makeStream() defer { release.finish() } @@ -1296,7 +1336,7 @@ private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { updates _: [PrivatePaymentListReservationUpdateInput], clearUnlistedLinkedPeers _: Bool ) async throws -> PrivatePaymentListDeliveryReport { await intake() - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } override func submitPaymentProof( diff --git a/BitkitTests/PaykitStorageCallbackTests.swift b/BitkitTests/PaykitStorageCallbackTests.swift index abe2707bd..d7ea2645e 100644 --- a/BitkitTests/PaykitStorageCallbackTests.swift +++ b/BitkitTests/PaykitStorageCallbackTests.swift @@ -23,6 +23,63 @@ final class PaykitStorageCallbackTests: XCTestCase { } } + private enum SetupFailure: Error { case unavailable } + + func testLocalForgetDoesNotConstructAnSdkOrLoadRetainedSession() async throws { + var deleted: [Bitkit.KeychainEntryType] = [] + var constructions = 0 + let provider = PaykitSdkSessionProvider( + loadSessionSecret: { + XCTFail("Local cleanup must not load the retained session") + throw KeychainError.failedToLoad + }, + deleteKeychainValue: { deleted.append($0) } + ) + let service = PaykitSdkService(sessionProvider: provider, sdkFactory: { + constructions += 1 + throw SetupFailure.unavailable + }) + try await service.forgetSessionAccess() + XCTAssertEqual(constructions, 0) + XCTAssertEqual(deleted.map(\.storageKey), ["paykit_session", "pubky_secret_key"]) + } + + func testLocalForgetPreservesCredentialFailureAndAttemptsBothLocalKeys() async throws { + var deleted: [Bitkit.KeychainEntryType] = [] + let provider = PaykitSdkSessionProvider(deleteKeychainValue: { + deleted.append($0) + if case .paykitSession = $0 { + throw KeychainError.failedToDelete + } + }) + let service = PaykitSdkService(sessionProvider: provider, sdkFactory: { throw SetupFailure.unavailable }) + let version = await service.backupStateVersion + do { + try await service.forgetSessionAccess() + XCTFail("Credential failure must remain visible to local completion") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "session_clear_failed") + } + XCTAssertEqual(deleted.map(\.storageKey), ["paykit_session", "pubky_secret_key"]) + let after = await service.backupStateVersion + XCTAssertGreaterThan(after, version) + } + + func testNativeProviderReturnsTypedAdmissionFailureWithoutAnUnexpectedCallbackError() async throws { + let provider = PaykitSdkSessionProvider(loadSessionSecret: { nil }) + let sdk = try PaykitSdk.withPubkySharedState(sessionProvider: provider, config: Paykit.defaultConfig(appId: "bitkit")) + provider.setAdmissionCheck { throw ProfileRemovalStore.StoreError.changed } + do { + _ = try await sdk.identityStatus() + XCTFail("Revoked admission must not load credentials") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "profile_operation_unavailable") + } + provider.setAdmissionCheck(nil) + let identity = try await sdk.identityStatus() + XCTAssertNil(identity) + } + func testNativeSdkCanRetryAfterProductionSessionLoadFailure() async throws { let sessionStorage = SessionStorage() let provider = PaykitSdkSessionProvider( diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 0dfe59635..eca92843c 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -284,6 +284,7 @@ final class PaymentNavigationHelperTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -300,6 +301,7 @@ final class PaymentNavigationHelperTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index f3350230b..e64cb2e57 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -364,7 +364,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("Failed links must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: { [] } ) ) @@ -1130,7 +1130,7 @@ final class PrivatePaykitServiceTests: XCTestCase { } syncedUpdates = updates published.fulfill() - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) @@ -1174,7 +1174,7 @@ final class PrivatePaykitServiceTests: XCTestCase { currentPublicKey: { "pubkylocal" }, ensureLink: { linked.append($0); return .linked }, buildEndpoints: { _ in [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) var sessionCurrent = true var prune: Task? @@ -1208,7 +1208,11 @@ final class PrivatePaykitServiceTests: XCTestCase { let error = await preparation.value await prune?.value try await service.awaitContactPreparation() - XCTAssertNil(error, change) + if change == "cancelled" { + XCTAssertTrue(error is CancellationError) + } else { + XCTAssertNil(error, change) + } XCTAssertNotNil(prune, change) XCTAssertEqual(linked, change == "current" ? [key] : [], change) await service.invalidateContactPreparation() @@ -1235,7 +1239,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in XCTFail("Ended sessions must not build endpoints"); return [] }, syncPaymentLists: { _ in XCTFail("Ended sessions must not publish") - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) @@ -1277,7 +1281,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in [endpoint] }, syncPaymentLists: { updates in published += updates.map(\.counterparty) - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) @@ -1699,7 +1703,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in [] }, syncPaymentLists: { updates in published += updates.map(\.counterparty) - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) _ = await service.rememberSavedContacts([publicKey], replacing: true) @@ -1745,7 +1749,7 @@ final class PrivatePaykitServiceTests: XCTestCase { publishing.fulfill() for await _ in resume {} } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) let wallet = WalletViewModel() @@ -2252,7 +2256,7 @@ final class PrivatePaykitServiceTests: XCTestCase { admitted.fulfill() for await _ in finish {} XCTAssertFalse(Task.isCancelled) - return nil + return Self.acknowledgedWithdrawal([key]) }, drainMessages: { _ in }, pendingDrainKeys: { _ in [] }, syncApp: {} ) let cleanup = Task { @@ -2278,7 +2282,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(retained?.hasPublishedPrivatePaymentList == true) } try await service.removePublishedEndpoints(isSessionCurrent: { true }, operations: .init( - linkedPeers: { [] }, clearPaymentLists: { _ in nil }, + linkedPeers: { [] }, clearPaymentLists: { keys in Self.acknowledgedWithdrawal(keys) }, drainMessages: { _ in }, pendingDrainKeys: { _ in [] }, syncApp: {} )) let afterSharingOff = await service.testContactState(publicKey: key) @@ -2302,7 +2306,7 @@ final class PrivatePaykitServiceTests: XCTestCase { let operations = PrivatePaykitService.EndpointCleanupOperations( linkedPeers: { [] }, clearPaymentLists: { _ in await MainActor.run { withdrawalCompleted = true } - return nil + return Self.acknowledgedWithdrawal([key]) }, drainMessages: { _ in }, pendingDrainKeys: { _ in [] }, syncApp: {} ) do { @@ -2327,6 +2331,101 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(retained?.hasPublishedPrivatePaymentList == true) } + private static func acknowledgedWithdrawal(_ keys: [String]) -> PrivatePaymentListDeliveryReport { + .init( + queued: [], cleared: keys.map { .init(counterparty: $0, outboundMessageId: 1, error: nil) }, + acknowledged: keys.map { .init(counterparty: $0, outboundMessageId: 1) }, failedToQueue: [], failedToDeliver: [] + ) + } + + func testCleanupRequiresPositiveAcknowledgementForEachExactWithdrawal() async throws { + let keys = ["pubky" + String(repeating: "y", count: 52), "pubky" + String(repeating: "o", count: 52)] + let complete = PrivatePaymentListDeliveryReport( + queued: [], cleared: keys.enumerated().map { .init(counterparty: $0.element, outboundMessageId: UInt64($0.offset + 41), error: nil) }, + acknowledged: keys.enumerated().map { .init(counterparty: $0.element, outboundMessageId: UInt64($0.offset + 41)) }, + failedToQueue: [], failedToDeliver: [] + ) + var cases: [(String, PrivatePaymentListDeliveryReport?, Set)] = [("complete", complete, Set(keys)), ("no report", nil, [])] + func add(_ name: String, successful: Set = [], change: (inout PrivatePaymentListDeliveryReport) -> Void) { + var report = complete + change(&report) + cases.append((name, report, successful)) + } + add("no positive witness") { $0.acknowledged = [] } + add("no clear") { $0.cleared = [] } + add("no outbound IDs") { for index in $0.cleared.indices { + $0.cleared[index].outboundMessageId = nil + } } + add("wrong message IDs") { for index in $0.acknowledged.indices { + $0.acknowledged[index].outboundMessageId = 99 + } } + add("crossed peers") { $0.acknowledged.swapAt(0, 1); for index in keys.indices { + $0.acknowledged[index].counterparty = keys[index] + } } + add("missing peer", successful: [keys[0]]) { $0.cleared.removeLast(); $0.acknowledged.removeLast() } + add("missing witness", successful: [keys[0]]) { $0.acknowledged.removeLast() } + add("ambiguous clear", successful: [keys[1]]) { $0.cleared.append($0.cleared[0]) } + add("conflicting publication", successful: [keys[1]]) { $0.queued = [$0.cleared[0]] } + add("queue failure", successful: [keys[1]]) { $0.failedToQueue = [$0.cleared[0]] } + add("reservation failure", successful: [keys[1]]) { + $0.failedToDeliver = [.init(counterparty: keys[0], outboundMessageId: 41, reservationId: "reservation", + error: CleanupDeliveryError(noPointer: .init()))] + } + add("clear error", successful: [keys[1]]) { $0.cleared[0].error = CleanupDeliveryError(noPointer: .init()) } + add("normalized peers", successful: Set(keys)) { + for index in $0.acknowledged.indices { + $0.acknowledged[index].counterparty = String(keys[index].dropFirst(5)) + } + } + + for (name, report, successful) in cases { + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + for key in keys { + var contact = PrivatePaykitService.ContactState() + contact.hasPublishedPrivatePaymentList = true + await service.setTestContactState(contact, publicKey: key) + } + PrivatePaykitService.markDeletedContactCleanupPending(keys) + var withdrawals = 0 + var registryUpdates = 0 + do { + try await service.removePublishedEndpoints(for: keys, operations: .init( + linkedPeers: { [] }, clearPaymentLists: { requested in + XCTAssertEqual(Set(requested), Set(keys), name) + withdrawals += 1 + return report + }, drainMessages: { _ in XCTFail("Empty queue needs no drain") }, + pendingDrainKeys: { _ in [] }, syncApp: { registryUpdates += 1 } + )) + XCTAssertEqual(successful, Set(keys), name) + } catch PrivatePaykitError.privateUnavailable { + XCTAssertNotEqual(successful, Set(keys), name) + } + XCTAssertEqual(withdrawals, 1, "Never replay to manufacture a receipt: \(name)") + XCTAssertEqual(registryUpdates, successful == Set(keys) ? 1 : 0, name) + for key in keys { + let contact = await service.testContactState(publicKey: key) + XCTAssertEqual(contact?.hasPublishedPrivatePaymentList == true, !successful.contains(key), name) + XCTAssertEqual(PrivatePaykitService.pendingDeletedContactCleanupKeys().contains(key), !successful.contains(key), name) + } + } + } + + func testCleanupWithAnExplicitlyEmptyPeerSetNeedsNoDeliveryReceipt() async throws { + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.deletedContactCleanupKeysKey) + let service = PrivatePaykitService() + var registryUpdates = 0 + try await service.removePublishedEndpoints(operations: .init( + linkedPeers: { [] }, clearPaymentLists: { _ in XCTFail("No withdrawal was intended"); return nil }, + drainMessages: { _ in XCTFail("No messages were intended") }, + pendingDrainKeys: { _ in XCTFail("No messages were intended"); return [] }, + syncApp: { registryUpdates += 1 } + )) + XCTAssertEqual(registryUpdates, 1) + } + func testDeferredCleanupLeavesDurableRegistryReconciliation() async throws { let key = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" for sharingEnabled in [false, true] { @@ -2348,7 +2447,7 @@ final class PrivatePaykitServiceTests: XCTestCase { withdrawals += 1 return .init( queued: [], cleared: [.init(counterparty: key, outboundMessageId: 1, error: nil)], - failedToQueue: [], failedToDeliver: [] + acknowledged: [.init(counterparty: key, outboundMessageId: 1)], failedToQueue: [], failedToDeliver: [] ) }, drainMessages: { _ in XCTFail("Delivered withdrawal needs no drain") }, @@ -2399,13 +2498,17 @@ final class PrivatePaykitServiceTests: XCTestCase { if shouldFail, failureStage == "queue" { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], - failedToQueue: [PrivatePaymentListSyncChange(counterparty: publicKey, outboundMessageId: nil, error: nil)], + acknowledged: [], failedToQueue: [PrivatePaymentListSyncChange( + counterparty: publicKey, + outboundMessageId: nil, + error: nil + )], failedToDeliver: [] ) } return PrivatePaymentListDeliveryReport( queued: [], cleared: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], - failedToQueue: [], failedToDeliver: [] + acknowledged: [.init(counterparty: publicKey, outboundMessageId: 1)], failedToQueue: [], failedToDeliver: [] ) }, drainMessages: { keys in @@ -2473,7 +2576,7 @@ final class PrivatePaykitServiceTests: XCTestCase { cleared.append(contentsOf: $0) return PrivatePaymentListDeliveryReport( queued: [], cleared: $0.map { .init(counterparty: $0, outboundMessageId: 1, error: nil) }, - failedToQueue: [], failedToDeliver: [] + acknowledged: $0.map { .init(counterparty: $0, outboundMessageId: 1) }, failedToQueue: [], failedToDeliver: [] ) }, drainMessages: { XCTAssertEqual(Set($0), Set(expectedKeys.dropFirst())) }, @@ -2535,7 +2638,11 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(Set(keys), [failed, successful]) return .init( queued: [], cleared: [.init(counterparty: successful, outboundMessageId: 1, error: nil)], - failedToQueue: failQueue ? [.init(counterparty: failed, outboundMessageId: nil, error: nil)] : [], + acknowledged: [.init(counterparty: successful, outboundMessageId: 1)], failedToQueue: failQueue ? [.init( + counterparty: failed, + outboundMessageId: nil, + error: nil + )] : [], failedToDeliver: failQueue ? [] : [.init( counterparty: failed, outboundMessageId: 2, reservationId: nil, error: CleanupDeliveryError(noPointer: .init()) @@ -2588,7 +2695,7 @@ final class PrivatePaykitServiceTests: XCTestCase { }, syncPaymentLists: { _ in XCTFail("An unsupported contact should not receive a private list") - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) @@ -2630,7 +2737,7 @@ final class PrivatePaykitServiceTests: XCTestCase { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], - failedToQueue: [], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) } @@ -2702,7 +2809,7 @@ final class PrivatePaykitServiceTests: XCTestCase { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], - failedToQueue: [], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) } @@ -2756,7 +2863,7 @@ final class PrivatePaykitServiceTests: XCTestCase { } XCTAssertEqual(updates.map(\.counterparty), [key]) publications += 1 - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: { try await deferOnce("peers"); return [] }, canPublish: { try await deferOnce("availability"); return true } @@ -2808,7 +2915,7 @@ final class PrivatePaykitServiceTests: XCTestCase { syncPaymentLists: { updates in XCTAssertEqual(updates.map(\.counterparty), [key]) publications += 1 - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, canPublish: { canPublish } )) @@ -2950,7 +3057,7 @@ final class PrivatePaykitServiceTests: XCTestCase { deferred.fulfill() throw PaykitPaymentRequestError.operationInProgress } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, canPublish: { if isPaused { @@ -3026,7 +3133,7 @@ final class PrivatePaykitServiceTests: XCTestCase { deferred.fulfill() throw PaykitPaymentRequestError.operationInProgress } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, canPublish: { isActive && sharingEnabled }, isApplicationActive: { @@ -3471,18 +3578,132 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(preparedKeys, [[publicKey]]) } + @MainActor + func testProfileRemovalDrainsRetainedPublicationWithoutWaitingForForegroundOrReview() async throws { + for wait in ["review", "foreground"] { + for prepayment in [false, true] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let admission = ProfileOperationAdmission(store: store) + let key = "pubky" + String(repeating: "y", count: 52) + let started = expectation(description: "Publication entered: \(wait)/\(prepayment)") + let foregroundWait = wait == "foreground" ? expectation(description: "Foreground waiter installed") : nil + var activeReads = 0 + let review = wait == "review" ? PaykitPaymentActivity.shared.beginReview() : nil + defer { + if let review { + PaykitPaymentActivity.shared.endReview(review) + } + } + let service = PrivatePaykitService(publicationOperations: .init( + currentPublicKey: { XCTFail("Suspended publication must not inspect identity"); return nil }, + ensureLink: { _ in XCTFail("Suspended publication must not link"); return .unknown }, + buildEndpoints: { _ in XCTFail("Suspended publication must not build endpoints"); return [] }, + syncPaymentLists: { _ in XCTFail("Suspended publication must not publish"); throw PrivatePaykitError.privateUnavailable }, + canPublish: { false }, + isApplicationActive: { + activeReads += 1 + if activeReads == 2 { + foregroundWait?.fulfill() + } + return false + } + ), profileAdmission: admission) + _ = await service.rememberSavedContacts([key], replacing: true) + let wallet = WalletViewModel() + let operation = { + started.fulfill() + _ = await service.refreshSavedContactEndpointsReturningError( + for: [key], wallet: wallet, forceRefreshLightning: false, requireImmediatePublication: false + ) + } + let task: Task? + if prepayment { + task = try await service.schedulePrePaymentPublication(for: key, operation: operation) + let duplicate = try await service.schedulePrePaymentPublication(for: key) { XCTFail("Duplicate publication ran") } + XCTAssertNil(duplicate) + } else { + await service.scheduleContactPreparation([key]) { _, _ in await operation() } + task = await service.preparationTask + } + await fulfillment(of: [started], timeout: 2) + if let foregroundWait { + await fulfillment(of: [foregroundWait], timeout: 2) + } + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + let drained = expectation(description: "Profile removal drains while publication remains unavailable") + let drain = Task { + try await admission.drain(before: removal) + drained.fulfill() + } + await fulfillment(of: [drained], timeout: 2) + if let review { + XCTAssertTrue(PaykitPaymentActivity.shared.shouldDeferRefresh) + PaykitPaymentActivity.shared.endReview(review) + } + // Also release a failed control, so a regression reports failure rather than leaving a waiter alive. + await service.invalidateContactPreparation() + await task?.value + try await drain.value + let keys = await service.prePaymentPublicationKeys + XCTAssertTrue(keys.isEmpty) + XCTAssertNil(try store.pending(), "Draining alone must not write a removal marker") + admission.release(removal) + } + } + } + + @MainActor + func testProfileRemovalCancelsTheRetainedMessageRetryIdleTask() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + let admission = ProfileOperationAdmission(store: store) + let review = PaykitPaymentActivity.shared.beginReview() + defer { PaykitPaymentActivity.shared.endReview(review) } + let service = PrivatePaykitService(profileAdmission: admission, messageRetryOperations: .init( + currentPublicKey: { _ in XCTFail("Review must defer SDK reads"); return nil }, + drain: { _ in XCTFail("Review must defer message processing"); return .live() } + )) + let key = "pubky" + String(repeating: "y", count: 52) + await service.schedulePrivatePaymentRecovery(for: key) + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while await service.pendingMessageDrainRetrySleep == nil, ContinuousClock.now < deadline { + await Task.yield() + } + let idleTask = await service.pendingMessageDrainRetrySleep + let idle = try XCTUnwrap(idleTask) + let worker = await service.pendingMessageDrainRetryTask + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + defer { admission.release(removal) } + let drained = expectation(description: "Retry's actual idle tail drains with review still open") + let drain = Task { + try await admission.drain(before: removal) + drained.fulfill() + } + await fulfillment(of: [drained], timeout: 2) + XCTAssertTrue(PaykitPaymentActivity.shared.shouldDeferRefresh) + XCTAssertTrue(idle.isCancelled) + PaykitPaymentActivity.shared.endReview(review) + await service.invalidateContactPreparation() + await worker?.value + try await drain.value + XCTAssertNil(try store.pending()) + } + func testProfileDeletionDropsPreparationAndAllowsNewWorkAfterDeletionEnds() async { let service = PrivatePaykitService() let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" _ = await service.rememberSavedContacts([publicKey], replacing: true) - await service.beginProfileDeletion() + let pause = await service.beginProfileDeletion() await service.scheduleContactPreparation([publicKey]) { _, _ in XCTFail("Profile deletion must not enqueue preparation") } let deletionTask = await service.preparationTask XCTAssertNil(deletionTask) - await service.endProfileDeletion() + await service.endProfileDeletion(id: pause) let resumedTask = await service.preparationTask XCTAssertNil(resumedTask) let prepared = expectation(description: "New preparation starts") @@ -3545,7 +3766,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertFalse(Task.isCancelled) XCTAssertEqual(updates.map(\.counterparty), [key]) published = true - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) _ = await service.syncLocalEndpointPublication( @@ -3569,7 +3790,13 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("No prepared link"); return [] }, - syncPaymentLists: { _ in XCTFail("No prepared link"); return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in XCTFail("No prepared link"); return .init( + queued: [], + cleared: [], + acknowledged: [], + failedToQueue: [], + failedToDeliver: [] + ) }, linkedPeers: { reads += 1; return [] } ) let task = Task { @@ -3729,7 +3956,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in [endpoint] }, syncPaymentLists: { _ in publications += 1 - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) var contactState = PrivatePaykitService.ContactState() @@ -3751,7 +3978,7 @@ final class PrivatePaykitServiceTests: XCTestCase { } return .init( queued: [], cleared: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], - failedToQueue: [], failedToDeliver: [] + acknowledged: [.init(counterparty: publicKey, outboundMessageId: 1)], failedToQueue: [], failedToDeliver: [] ) }, drainMessages: { XCTAssertEqual($0, [publicKey]) }, @@ -3832,7 +4059,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in XCTFail("Cleanup invalidates preparation"); return [] }, syncPaymentLists: { _ in XCTFail("Cleanup invalidates publication") - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) ) @@ -3841,7 +4068,7 @@ final class PrivatePaykitServiceTests: XCTestCase { let cleanup = Task { try await service.removePublishedEndpoints(operations: .init( linkedPeers: { [] }, - clearPaymentLists: { _ in nil }, + clearPaymentLists: { keys in Self.acknowledgedWithdrawal(keys) }, drainMessages: { _ in }, pendingDrainKeys: { _ in [] }, syncApp: {} @@ -3873,7 +4100,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in XCTFail("Removed contacts must not reserve addresses"); return [] }, syncPaymentLists: { _ in XCTFail("Removed contacts must not receive publications") - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) ) @@ -3892,7 +4119,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.NotFound(code: "not_found", context: "No App Registry") }, buildEndpoints: { _ in XCTFail("Unavailable contacts must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) for _ in 0 ..< 3 { _ = await service.syncLocalEndpointPublication( @@ -3933,7 +4160,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("Failed links must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: peers ) _ = await service.syncLocalEndpointPublication( @@ -3970,7 +4197,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.NotFound(code: "not_found", context: "No App Registry") }, buildEndpoints: { _ in XCTFail("Unavailable contacts must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) let removals: [(PrivatePaykitService) async -> Void] = [ { await $0.clearContactState(publicKey: publicKey) }, diff --git a/BitkitTests/PrivatePaymentReceiptTests.swift b/BitkitTests/PrivatePaymentReceiptTests.swift new file mode 100644 index 000000000..d27660f21 --- /dev/null +++ b/BitkitTests/PrivatePaymentReceiptTests.swift @@ -0,0 +1,477 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +final class PrivatePaymentReceiptTests: XCTestCase { + private let database = FileManager.default.temporaryDirectory + .appendingPathComponent("PrivatePaymentReceiptTests-\(UUID().uuidString)", isDirectory: true) + + override func setUp() async throws { + try await super.setUp() + snapshotAppDefaultsDomain() + _ = Bitkit.CoreService.shared + await drainCoreServiceQueue() + try FileManager.default.createDirectory(at: database, withIntermediateDirectories: true) + _ = try initDb(basePath: database.path) + } + + override func tearDown() async throws { + await drainCoreServiceQueue() + await repointCoreToAppStorage() + try? FileManager.default.removeItem(at: database) + try await super.tearDown() + } + + func testReceiptsCannotStartDuringReservedPersistedOrUnreadableWipe() async throws { + for state in ["reserved", "persisted", "unreadable"] { + let marker = database.appendingPathComponent("pending-\(state).json") + let store = ProfileRemovalStore(url: marker) + let admission = ProfileOperationAdmission(store: store) + let scope = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(scope) } + if state != "reserved" { + try admission.persist(scope) + admission.release(scope) + } + if state == "unreadable" { + try Data("unreadable".utf8).write(to: marker) + } + let activity = Bitkit.ActivityService(coreService: .shared, channelLookup: { _, _, _ in nil }) + activity.setPrivatePaykitContactResolvers(invoice: { _ in + XCTFail("Rejected receipt reached invoice lookup") + return nil + }, onchainAddresses: { _, _ in + XCTFail("Rejected receipt reached address lookup") + return nil + }, receiptOperation: { try await admission.withReceiptAttribution($0) }) + let hash = String(repeating: "a", count: 64) + let payments = [ + invoicePayment(hash: hash), + LDKNode.PaymentDetails(id: hash, kind: .onchain(txid: hash, status: .unconfirmed), amountMsat: 5_000_000, + feePaidMsat: 0, direction: .inbound, status: .succeeded, + latestUpdateTimestamp: UInt64(Date().timeIntervalSince1970)), + ] + for payment in payments { + do { + try await activity.syncLdkNodePayments([payment]) + XCTFail("Receipt started while wallet admission was closed: \(state)") + } catch { + let underlying = (error as? Bitkit.AppError)?.underlyingError ?? error + XCTAssertTrue(underlying is ProfileRemovalStore.StoreError, "Unexpected error: \(error)") + } + } + let details = LDKNode.TransactionDetails(amountSats: 5000, inputs: [], outputs: []) + for confirmed in [false, true] { + do { + if confirmed { + try await activity.handleOnchainTransactionConfirmed(txid: hash, details: details) + } else { + try await activity.handleOnchainTransactionReceived(txid: hash, details: details) + } + XCTFail("Rejected onchain event entered its storage path") + } catch is ProfileRemovalStore.StoreError {} + } + let storedDetails = try await Bitkit.ServiceQueue.background(.core) { + try BitkitCore.getTransactionDetails(walletId: WalletScope.default, txId: hash) + } + XCTAssertNil(storedDetails) + if state == "reserved" { + try await admission.drain(before: scope) + try admission.persist(scope) + } + let rows = try await activity.get(walletId: WalletScope.default) + XCTAssertTrue(rows.isEmpty) + } + } + + func testPrivateInvoiceReceiptsDuringRemovalRetainTheirContact() async throws { + let directory = database.appendingPathComponent("profile-removal") + let admission = ProfileOperationAdmission(store: ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"))) + let contact = "pubky" + String(repeating: "y", count: 52) + let hashes = (0 ... 3).map { String(repeating: String($0), count: 64) } + var state = PrivatePaykitService.ContactState() + state.localInvoice = .init(bolt11: "fixture", paymentHash: hashes[0], expiresAt: Date().timeIntervalSince1970 + 3600) + state.receivedInvoicePaymentHashes = Array(hashes.dropFirst()) + let encoded = try JSONEncoder().encode(PrivatePaykitService.PrivatePaykitState(contacts: [contact: state])) + UserDefaults.standard.set(encoded, forKey: PrivatePaykitService.cacheStateKey) + let privateService = PrivatePaykitService(profileAdmission: admission) + let activity = Bitkit.ActivityService(coreService: .shared, channelLookup: { _, _, _ in nil }) + activity.setPrivatePaykitContactResolvers(invoice: { hash in + await privateService.contactPublicKeyForReceipt(paymentHash: hash) + }, onchainAddresses: nil, receiptOperation: { operation in + try await admission.withReceiptAttribution(operation) + }) + let timestamp = UInt64(Date().timeIntervalSince1970) + let payments = hashes.map { hash in + LDKNode.PaymentDetails( + id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: nil), + amountMsat: 5_000_000, feePaidMsat: 0, direction: .inbound, status: .succeeded, + latestUpdateTimestamp: timestamp + ) + } + try await activity.syncLdkNodePayments([payments[1]]) + var rows = try await activity.get(filter: .lightning, walletId: WalletScope.default) + guard case let .lightning(baseline) = try XCTUnwrap(rows.first) else { return XCTFail("Missing baseline receipt") } + XCTAssertEqual(baseline.contact, contact) + + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + defer { admission.release(removal) } + let hiddenLiveContact = await privateService.contactPublicKey(forPrivateInvoicePaymentHash: hashes[0]) + XCTAssertNil(hiddenLiveContact) + try await activity.syncLdkNodePayments([payments[2]]) + try admission.persist(removal) + try await activity.syncLdkNodePayments([payments[0], payments[3]]) + let retained = await privateService.state.contacts[contact] + XCTAssertEqual(retained?.localInvoice?.paymentHash, hashes[0]) + XCTAssertEqual(retained?.receivedInvoicePaymentHashes, Array(hashes.dropFirst())) + _ = try await privateService.closeAndClear() + try admission.complete(removal) + try await activity.syncLdkNodePayments(payments) + rows = try await activity.get(filter: .lightning, walletId: WalletScope.default) + XCTAssertEqual(rows.count, 4) + for case let .lightning(row) in rows { + XCTAssertEqual(row.value, 5000) + XCTAssertEqual(row.status, .succeeded) + XCTAssertEqual(row.contact, contact, "Receipt \(row.id) lost its retained private-invoice association") + } + } + + func testReservedAddressReceiptsDuringRemovalRetainTheirContact() async throws { + let suite = "PendingReceiptReservations.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let contact = "pubky" + String(repeating: "y", count: 52) + let original: [String: Any] = [ + "version": 1, + "reservedReceiveIndexesByAddressType": ["nativeSegwit": [3, 7]], + "contactAssignments": [contact: ["addressType": "nativeSegwit", "receiveIndex": 7]], + "contactAssignmentHistory": [contact: [["addressType": "nativeSegwit", "receiveIndex": 3]]], + "restoredReservedReceiveIndexCeilingsByAddressType": ["taproot": 12], + ] + try defaults.set(JSONSerialization.data(withJSONObject: original), forKey: PrivatePaykitAddressReservationStore.defaultsKey) + let addresses = try nativeAddresses(directory: database.appendingPathComponent("node")) + var addressOperations = PrivatePaykitAddressReservationStore.AddressOperations.live + addressOperations.resolve = { type, index in + XCTAssertEqual(type, .nativeSegwit) + return try XCTUnwrap(addresses[index]) + } + let reservations = PrivatePaykitAddressReservationStore(defaults: defaults, operations: addressOperations) + let activity = Bitkit.ActivityService(coreService: .shared, channelLookup: { _, _, _ in nil }) + activity.setPrivatePaykitContactResolvers(invoice: nil, onchainAddresses: { address, outputs in + let revision = await reservations.attributionRevision + guard let contacts = try? await reservations.contactsForReceipt( + PaykitReceivedPaymentContacts(), outputAddresses: outputs, expectedRevision: revision + ) + else { return nil } + return contacts.contact(receivingAddress: address, outputAddresses: outputs) + }, receiptOperation: { operation in + try await ProfileOperationAdmission.shared.withReceiptAttribution(operation) + }) + let timestamp = UInt64(Date().timeIntervalSince1970) + var payments: [LDKNode.PaymentDetails] = [] + func receive(_ number: Int, at index: UInt32) async throws { + let txid = String(repeating: String(number), count: 64) + let address = try XCTUnwrap(addresses[index]) + UserDefaults.standard.set(address, forKey: "onchainAddress") + let details = BitkitCore.TransactionDetails(walletId: WalletScope.default, txId: txid, amountSats: 5000, inputs: [], outputs: [ + TxOutput(scriptpubkey: "", scriptpubkeyType: "v0_p2wpkh", scriptpubkeyAddress: address, value: 5000, n: 0), + ]) + try await Bitkit.ServiceQueue.background(.core) { try upsertTransactionDetails(detailsList: [details]) } + let payment = LDKNode.PaymentDetails(id: txid, kind: .onchain(txid: txid, status: .unconfirmed), amountMsat: 5_000_000, + feePaidMsat: 0, direction: .inbound, status: .succeeded, latestUpdateTimestamp: timestamp) + payments.append(payment) + try await activity.syncLdkNodePayments([payment]) + } + try await receive(1, at: 7) + let admission = ProfileOperationAdmission.shared + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + defer { + _ = try? admission.complete(removal) + admission.release(removal) + } + try await receive(2, at: 3) + try admission.persist(removal) + try await receive(3, at: 7) + _ = try await reservations.clearContactAssignmentsForRemoval() + try admission.complete(removal) + try await activity.syncLdkNodePayments(payments) + let rows = try await activity.get(filter: .onchain, walletId: WalletScope.default) + XCTAssertEqual(rows.count, 3) + for case let .onchain(row) in rows { + XCTAssertEqual(row.value, 5000) + XCTAssertEqual(row.contact, contact, "Receipt \(row.id) lost its retained reserved-address association") + } + let exclusions = await reservations.backupSnapshot() + XCTAssertEqual(exclusions, ["nativeSegwit": 7, "taproot": 12]) + } + + func testCapturedInvoiceSurvivesProfileCompletionBeforeInsertion() async throws { + let admission = ProfileOperationAdmission(store: ProfileRemovalStore(url: database.appendingPathComponent("pending.json"))) + let hash = String(repeating: "a", count: 64) + let contact = "pubky" + String(repeating: "y", count: 52) + let service = try invoiceService(hash: hash, contact: contact, admission: admission) + let captured = expectation(description: "Invoice association captured") + let gate = ReceiptGate() + let activity = Bitkit.ActivityService(coreService: .shared, channelLookup: { _, _, _ in nil }) + activity.setPrivatePaykitContactResolvers(invoice: { hash in + let result = await service.contactPublicKeyForReceipt(paymentHash: hash) + captured.fulfill() + await gate.wait() + return result + }, onchainAddresses: nil, receiptOperation: { try await admission.withReceiptAttribution($0) }) + let syncing = Task { try await activity.syncLdkNodePayments([self.invoicePayment(hash: hash)]) } + await fulfillment(of: [captured], timeout: 5) + do { + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + defer { admission.release(removal) } + try await admission.drain(before: removal) + try admission.persist(removal) + _ = try await service.closeAndClear() + try admission.complete(removal) + } catch { + await gate.release() + _ = try? await syncing.value + throw error + } + let replacement = try admission.reserveSessionTransition() + try await admission.drain(before: replacement) + admission.release(replacement) + await gate.release() + try await syncing.value + _ = try initDb(basePath: database.path) + let rows = try await activity.get(filter: .lightning, walletId: WalletScope.default) + guard case let .lightning(row) = try XCTUnwrap(rows.first) else { return XCTFail("Missing receipt") } + XCTAssertEqual(row.contact, contact) + XCTAssertEqual(row.value, 5000) + let cleared = await service.state.contacts + XCTAssertTrue(cleared.isEmpty) + } + + func testCancellationAndWipeRejectCapturedInvoiceAtBothDatabaseWritePaths() async throws { + for wipe in [false, true] { + for existingRow in [false, true] { + let directory = database.appendingPathComponent(UUID().uuidString) + let admission = ProfileOperationAdmission(store: ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"))) + let hash = String(repeating: wipe ? (existingRow ? "d" : "c") : (existingRow ? "b" : "a"), count: 64) + let contact = "pubky" + String(repeating: "y", count: 52) + let service = try invoiceService(hash: hash, contact: contact, admission: admission) + let captured = expectation(description: "Association captured before invalidation") + let gate = ReceiptGate() + let worker = ReceiptWorker() + let activity = Bitkit.ActivityService(coreService: .shared, channelLookup: { _, _, _ in nil }) + let payment = invoicePayment(hash: hash) + if existingRow { + try await activity.syncLdkNodePayments([payment]) + } + activity.setPrivatePaykitContactResolvers(invoice: { hash in + let result = await service.contactPublicKeyForReceipt(paymentHash: hash) + captured.fulfill() + await gate.wait() + return result + }, onchainAddresses: nil, receiptOperation: { operation in + let task = Task { try await admission.withReceiptAttribution(operation) } + await worker.set(task) + try await task.value + }) + let syncing = Task { try await activity.syncLdkNodePayments([payment]) } + await fulfillment(of: [captured], timeout: 5) + var wiping: ProfileOperationAdmission.Scope? + if wipe { + let scope = try admission.reserve(kind: .walletWipe, id: UUID()) + wiping = scope + XCTAssertThrowsError(try admission.persist(scope), "A captured receipt still owns its database write") + } else { + await worker.cancel() + } + await gate.release() + try await syncing.value + let rows = try await activity.get(filter: .lightning, walletId: WalletScope.default) + guard case let .lightning(row) = try XCTUnwrap(rows.first(where: { $0.activityId == hash })) + else { return XCTFail("Missing receipt") } + XCTAssertNil(row.contact, "Invalidated association entered the database") + XCTAssertEqual(row.value, 5000) + XCTAssertEqual(row.status, .succeeded) + if let wiping { + defer { admission.release(wiping) } + try await admission.drain(before: wiping) + try admission.persist(wiping) + try await activity.removeAll() + _ = try await service.closeAndClear() + try admission.complete(wiping) + admission.release(wiping) + try await admission.withReceiptAttribution { canAttribute in XCTAssertTrue(canAttribute()) } + activity.setPrivatePaykitContactResolvers(invoice: { hash in + await service.contactPublicKeyForReceipt(paymentHash: hash) + }, onchainAddresses: nil, receiptOperation: { try await admission.withReceiptAttribution($0) }) + try await activity.syncLdkNodePayments([payment]) + let replacementRows = try await activity.get(filter: .lightning, walletId: WalletScope.default) + guard case let .lightning(replacementRow) = try XCTUnwrap(replacementRows.first) + else { return XCTFail("Missing replacement receipt") } + XCTAssertNil(replacementRow.contact) + } + } + } + } + + func testAddressSnapshotPreservesContactAndAmbiguityAcrossProfileCleanup() async throws { + try await addressReceiptCrossingCleanup(ambiguous: false, wipe: false) + try await addressReceiptCrossingCleanup(ambiguous: true, wipe: false) + } + + func testWipeDrainsCapturedAddressBeforeSameSeedWalletReplacement() async throws { + try await addressReceiptCrossingCleanup(ambiguous: false, wipe: true) + } + + private func addressReceiptCrossingCleanup(ambiguous: Bool, wipe: Bool) async throws { + let directory = database.appendingPathComponent(UUID().uuidString) + let addresses = try nativeAddresses(directory: directory.appendingPathComponent("original")) + let suite = "ReceiptSnapshot.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let contact = "pubky" + String(repeating: "y", count: 52) + let other = ambiguous ? "pubky" + String(repeating: "b", count: 52) : contact + let original: [String: Any] = [ + "version": 1, + "reservedReceiveIndexesByAddressType": ["nativeSegwit": [3, 7]], + "contactAssignments": [contact: ["addressType": "nativeSegwit", "receiveIndex": 7]], + "contactAssignmentHistory": [other: [["addressType": "nativeSegwit", "receiveIndex": 3]]], + "restoredReservedReceiveIndexCeilingsByAddressType": [:], + ] + try defaults.set(JSONSerialization.data(withJSONObject: original), forKey: PrivatePaykitAddressReservationStore.defaultsKey) + let captured = expectation(description: "Assignment snapshot captured before derivation") + let gate = ReceiptGate() + var addressOperations = PrivatePaykitAddressReservationStore.AddressOperations.live + addressOperations.resolve = { _, index in + if await gate.shouldWait() { + captured.fulfill() + await gate.wait() + } + return try XCTUnwrap(addresses[index]) + } + let reservations = PrivatePaykitAddressReservationStore(defaults: defaults, operations: addressOperations) + let admission = ProfileOperationAdmission.shared + let activity = Bitkit.ActivityService(coreService: .shared, channelLookup: { _, _, _ in nil }) + activity.setPrivatePaykitContactResolvers(invoice: nil, onchainAddresses: { address, outputs in + let revision = await reservations.attributionRevision + guard let contacts = try? await reservations.contactsForReceipt( + PaykitReceivedPaymentContacts(), outputAddresses: outputs, expectedRevision: revision + ) + else { return nil } + return contacts.contact(receivingAddress: address, outputAddresses: outputs) + }, receiptOperation: { try await admission.withReceiptAttribution($0) }) + let txid = String(repeating: wipe ? "f" : (ambiguous ? "e" : "d"), count: 64) + let address = try XCTUnwrap(addresses[7]) + UserDefaults.standard.set(address, forKey: "onchainAddress") + let outputs = try [UInt32(7), 3].enumerated().map { position, index in + try BitkitCore.TxOutput( + scriptpubkey: "", + scriptpubkeyType: "v0_p2wpkh", + scriptpubkeyAddress: XCTUnwrap(addresses[index]), + value: 5000, + n: UInt32(position) + ) + } + let details = BitkitCore.TransactionDetails(walletId: WalletScope.default, txId: txid, amountSats: 10000, inputs: [], outputs: outputs) + try await Bitkit.ServiceQueue.background(.core) { try upsertTransactionDetails(detailsList: [details]) } + let payment = LDKNode.PaymentDetails(id: txid, kind: .onchain(txid: txid, status: .unconfirmed), amountMsat: 10_000_000, + feePaidMsat: 0, direction: .inbound, status: .succeeded, + latestUpdateTimestamp: UInt64(Date().timeIntervalSince1970)) + let syncing = Task { try await activity.syncLdkNodePayments([payment]) } + await fulfillment(of: [captured], timeout: 5) + let cleanup = try admission.reserve(kind: wipe ? .walletWipe : .removal, id: UUID(), expected: admission.snapshot()) + defer { + _ = try? admission.complete(cleanup) + admission.release(cleanup) + } + if wipe { + XCTAssertThrowsError(try admission.persist(cleanup), "Wipe cannot pass the captured receipt's database write") + } else { + try await admission.drain(before: cleanup) + try admission.persist(cleanup) + _ = try await reservations.clearContactAssignmentsForRemoval() + try admission.complete(cleanup) + } + await gate.release() + try await syncing.value + let rows = try await activity.get(filter: .onchain, walletId: WalletScope.default) + guard case let .onchain(row) = try XCTUnwrap(rows.first(where: { $0.activityId == txid })) else { return XCTFail("Missing receipt") } + XCTAssertEqual(row.value, 10000) + XCTAssertEqual(row.contact, ambiguous || wipe ? nil : contact) + if wipe { + try await admission.drain(before: cleanup) + try admission.persist(cleanup) + await reservations.clear() + try await activity.removeAll() + try admission.complete(cleanup) + admission.release(cleanup) + try await admission.withReceiptAttribution { canAttribute in XCTAssertTrue(canAttribute()) } + let replacement = try nativeAddresses(directory: directory.appendingPathComponent("replacement")) + XCTAssertEqual(replacement, addresses, "Same-seed replacement must not restore the ended wallet's association") + try await Bitkit.ServiceQueue.background(.core) { try upsertTransactionDetails(detailsList: [details]) } + try await activity.syncLdkNodePayments([payment]) + let replaced = try await activity.get(filter: .onchain, walletId: WalletScope.default) + guard case let .onchain(replacementRow) = try XCTUnwrap(replaced.first) else { return XCTFail("Missing replacement receipt") } + XCTAssertNil(replacementRow.contact) + } + } + + private func nativeAddresses(directory: URL) throws -> [UInt32: String] { + let builder = Builder() + builder.setNetwork(network: Env.network) + builder.setChainSourceEsplora(serverUrl: "http://127.0.0.1:1", config: nil) + builder.setStorageDirPath(storageDirPath: directory.path) + try builder.setEntropySeedBytes(seedBytes: [UInt8](repeating: 42, count: 64)) + let node = try builder.build() + let generated = try (0 ... 7).map { _ in try node.onchainPayment().newAddressInfo() } + return Dictionary(uniqueKeysWithValues: generated.map { ($0.index, $0.address) }) + } + + private func invoiceService(hash: String, contact: String, admission: ProfileOperationAdmission) throws -> PrivatePaykitService { + var state = PrivatePaykitService.ContactState() + state.localInvoice = .init(bolt11: "fixture", paymentHash: hash, expiresAt: Date().timeIntervalSince1970 + 3600) + try UserDefaults.standard.set(JSONEncoder().encode(PrivatePaykitService.PrivatePaykitState(contacts: [contact: state])), + forKey: PrivatePaykitService.cacheStateKey) + return PrivatePaykitService(profileAdmission: admission) + } + + private func invoicePayment(hash: String) -> LDKNode.PaymentDetails { + LDKNode.PaymentDetails(id: hash, kind: .bolt11(hash: hash, preimage: nil, secret: nil, description: nil, bolt11: nil), + amountMsat: 5_000_000, feePaidMsat: 0, direction: .inbound, status: .succeeded, + latestUpdateTimestamp: UInt64(Date().timeIntervalSince1970)) + } +} + +private actor ReceiptGate { + private var continuation: CheckedContinuation? + private var released = false + private var started = false + + func shouldWait() -> Bool { + defer { started = true } + return !started + } + + func wait() async { + if !released { + await withCheckedContinuation { continuation = $0 } + } + } + + func release() { + released = true + continuation?.resume() + continuation = nil + } +} + +private actor ReceiptWorker { + private var task: Task? + func set(_ task: Task) { + self.task = task + } + + func cancel() { + task?.cancel() + } +} diff --git a/BitkitTests/ProfileDestinationViewTests.swift b/BitkitTests/ProfileDestinationViewTests.swift index 319d61518..ffbc005ee 100644 --- a/BitkitTests/ProfileDestinationViewTests.swift +++ b/BitkitTests/ProfileDestinationViewTests.swift @@ -5,6 +5,93 @@ import XCTest @MainActor final class ProfileDestinationViewTests: XCTestCase { + func testChoiceWaitsForOriginalAdmissionAndDoesNotLoadReplacementRows() async throws { + for replaceOwner in [false, true] { + let manager = WaitingCreateProfileManager() + let admission = ProfileOperationAdmission.shared + let transition = try admission.reserveSessionTransition() + let view = PubkyChoiceView() + .environmentObject(manager as PubkyProfileManager) + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(ContactsManager()) + let window = try host(view) + defer { close(window) } + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(manager.rowLoads, 0) + if replaceOwner { + manager.publicKey = "replacement" + } + admission.release(transition) + if !replaceOwner { + await fulfillment(of: [manager.rowsLoaded], timeout: 2) + } else { + try await Task.sleep(for: .milliseconds(250)) + } + XCTAssertEqual(manager.rowLoads, replaceOwner ? 0 : 1) + } + } + + func testCreateWaitsForTheOriginalTransitionReleaseWithoutAnotherBusyEvent() async throws { + snapshotAppDefaultsDomain() + for replaceOwner in [false, true] { + let manager = WaitingCreateProfileManager() + XCTAssertFalse(manager.isRestoringSession) + XCTAssertFalse(manager.isAdoptingRingIdentity) + let admission = ProfileOperationAdmission.shared + let transition = try admission.reserveSessionTransition() + defer { admission.release(transition) } + let view = CreateProfileView() + .disabled(true) + .environmentObject(manager as PubkyProfileManager) + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + let window = try host(view) + defer { close(window) } + await fulfillment(of: [manager.presentationOwnerCaptured], timeout: 2) + XCTAssertEqual(manager.keyLoads, 0) + if replaceOwner { + manager.publicKey = "replacement" + } + admission.release(transition) + if !replaceOwner { + await fulfillment(of: [manager.loaded], timeout: 2) + } else { + try await Task.sleep(for: .milliseconds(250)) + } + XCTAssertEqual(manager.keyLoads, replaceOwner ? 0 : 1) + } + } + + func testInterruptedCleanupIsReachableWithoutAnActiveIdentity() async throws { + for state in ["profile", "walletWipe", "unavailable"] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let url = directory.appendingPathComponent("pending.json") + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProfileRemovalStore(url: url) + if state == "unavailable" { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("unreadable-record".utf8).write(to: url) + } else { + try store.begin(id: UUID(), scope: state == "profile" ? .profile : .walletWipe) + } + let manager = PubkyProfileManager(removalStore: store, removalAdmission: ProfileOperationAdmission(store: store)) + manager.publicKey = nil + manager.authState = .idle + let window = try hostProfile(manager) + defer { close(window) } + try await Task.sleep(for: .milliseconds(150)) + let (_, labels) = try snapshot(window, name: "Interrupted cleanup: \(state)") + let visible = labels.joined(separator: " ") + XCTAssertTrue(visible.localizedCaseInsensitiveContains(t("profile__recovery_title")), visible) + let action = state == "profile" ? "profile__sign_out" : state == "walletWipe" ? "security__reset_button_reset" : "common__retry" + XCTAssertTrue(visible.localizedCaseInsensitiveContains(t(action)), visible) + XCTAssertFalse(manager.isRestoringSession) + XCTAssertNil(manager.currentSession) + XCTAssertFalse(visible.localizedCaseInsensitiveContains(t("profile__choice_create"))) + } + } + func testContactsWaitsForIdentityLookupThenShowsRecovery() async throws { snapshotAppDefaultsDomain() UserDefaults.standard.removeObject(forKey: "pubky_profile_name") @@ -328,7 +415,13 @@ final class ProfileDestinationViewTests: XCTestCase { .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(ContactsManager()) + .environmentObject(WalletViewModel()) + .environmentObject(SessionManager()) .preferredColorScheme(.dark) + return try host(view) + } + + private func host(_ view: some View) throws -> UIWindow { let scene = try XCTUnwrap(UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .first { $0.activationState == .foregroundActive }) @@ -360,6 +453,7 @@ final class ProfileDestinationViewTests: XCTestCase { } private func close(_ window: UIWindow) { + window.endEditing(true) window.isHidden = true window.rootViewController = nil } @@ -420,6 +514,40 @@ final class ProfileDestinationViewTests: XCTestCase { } } +@MainActor +private final class WaitingCreateProfileManager: PubkyProfileManager { + var keyLoads = 0 + var rowLoads = 0 + let loaded = XCTestExpectation(description: "Create resumes when its original admission becomes ready") + let rowsLoaded = XCTestExpectation(description: "Choice resumes when its original admission becomes ready") + let presentationOwnerCaptured = XCTestExpectation(description: "The screen captures its original owner") + private var didCapturePresentationOwner = false + + override var removalPresentationOwner: RemovalPresentationOwner { + let owner = super.removalPresentationOwner + if !didCapturePresentationOwner { + didCapturePresentationOwner = true + presentationOwnerCaptured.fulfill() + } + return owner + } + + override func activePublicKey() async throws -> String { + keyLoads += 1 + loaded.fulfill() + return "waiting-original-public-key" + } + + override func fetchRemoteProfile(publicKey _: String) async -> PubkyProfile? { + nil + } + + override func loadRingIdentityProfiles(_: [String]) async { + rowLoads += 1 + rowsLoaded.fulfill() + } +} + @MainActor private final class ContactsViewTestManager: ContactsManager { override func loadObservedContacts(for publicKey: String) async throws {} diff --git a/BitkitTests/ProfileOperationAdmissionTests.swift b/BitkitTests/ProfileOperationAdmissionTests.swift new file mode 100644 index 000000000..3d99c814d --- /dev/null +++ b/BitkitTests/ProfileOperationAdmissionTests.swift @@ -0,0 +1,258 @@ +@testable import Bitkit +import XCTest + +final class ProfileOperationAdmissionTests: XCTestCase { + private var directory: URL! + private var store: ProfileRemovalStore! + private var admission: ProfileOperationAdmission! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + admission = ProfileOperationAdmission(store: store) + } + + override func tearDownWithError() throws { + if FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + } + + func testReceiptAttributionDoesNotGrantProfileAuthorityOrHideUnreadableState() async throws { + let id = UUID() + try store.begin(id: id) + try await admission.withReceiptAttribution { isCurrent in + XCTAssertTrue(isCurrent()) + XCTAssertNoThrow(try self.admission.checkReceiptAttribution()) + XCTAssertThrowsError(try self.admission.capture()) + XCTAssertThrowsError(try self.admission.checkCurrent(access: .local)) + } + try Data("unreadable".utf8).write(to: directory.appendingPathComponent("pending.json")) + do { + try await admission.withReceiptAttribution { _ in + XCTFail("Unreadable recovery admitted a receipt") + } + XCTFail("Unreadable recovery did not reject the receipt") + } catch ProfileRemovalStore.StoreError.unavailable {} + } + + func testReservationRevokesEarlierWorkAndDrainsItsActualTail() async throws { + let original = try admission.capture() + let generation = try admission.snapshot() + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: generation) + defer { admission.release(removal) } + XCTAssertThrowsError(try admission.validate(original)) + XCTAssertThrowsError(try admission.capture()) + let started = expectation(description: "Drain started") + let completed = expectation(description: "Drain completed") + let gate = try XCTUnwrap(admission) + let drain = Task { + started.fulfill() + try await gate.drain(before: removal) + completed.fulfill() + } + await fulfillment(of: [started], timeout: 2) + XCTAssertNil(try store.pending()) + admission.release(original) + try await drain.value + await fulfillment(of: [completed], timeout: 2) + try admission.persist(removal) + XCTAssertNotNil(try store.pending()) + } + + func testCancelledPreferenceCannotOverwriteAfterASuccessorReservationEvenIfCancelled() throws { + let original = try admission.capture() + defer { admission.release(original) } + admission.revoke(original) + XCTAssertNoThrow(try admission.validateCancelledPreference(original)) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + var preference = "successor" + func restoreOldPreferenceIfAdmitted() { + guard (try? admission.validateCancelledPreference(original)) != nil else { return } + preference = "old" + } + restoreOldPreferenceIfAdmitted() + XCTAssertEqual(preference, "successor") + admission.revoke(wipe) + admission.release(wipe) + restoreOldPreferenceIfAdmitted() + XCTAssertEqual(preference, "successor") + } + + func testFinishedOrRevokedScopeCannotBecomeCurrentAgain() async throws { + let original = try admission.capture() + admission.release(original) + do { + try await admission.withScope(original) { XCTFail("Finished scope ran") } + XCTFail("Finished scope accepted") + } catch ProfileRemovalStore.StoreError.changed {} + let expected = try admission.snapshot() + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: expected) + try admission.persist(removal) + try admission.complete(removal) + admission.release(removal) + XCTAssertThrowsError(try admission.validate(expected)) + XCTAssertThrowsError(try admission.reserve(kind: .removal, id: UUID(), expected: expected)) + XCTAssertNoThrow(try admission.snapshot()) + } + + func testPendingRestartAllowsOnlyExactLocalRecovery() async throws { + let id = UUID() + try store.begin(id: id) + let restarted = ProfileOperationAdmission(store: ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"))) + XCTAssertThrowsError(try restarted.capture()) + XCTAssertThrowsError(try restarted.reserve(kind: .localRecovery, id: UUID())) + let recovery = try restarted.reserve(kind: .localRecovery, id: id) + defer { restarted.release(recovery) } + try await restarted.withScope(recovery, access: .control) { + XCTAssertThrowsError(try restarted.capture()) + let local = try restarted.capture(access: .local) + restarted.release(local) + try restarted.complete(recovery) + } + XCTAssertNil(try store.pending()) + XCTAssertThrowsError(try restarted.validate(recovery)) + } + + func testWipePreemptsRemovalDrainWithoutOldReleaseReopeningAdmission() async throws { + let original = try admission.capture() + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + let started = expectation(description: "Removal awaiting earlier work") + let gate = try XCTUnwrap(admission) + let older = Task { + defer { gate.release(removal) } + started.fulfill() + do { + try await gate.drain(before: removal) + XCTFail("Revoked removal must not continue") + } catch ProfileRemovalStore.StoreError.changed {} + } + await fulfillment(of: [started], timeout: 2) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(wipe) } + try await older.value + XCTAssertThrowsError(try admission.capture()) + XCTAssertThrowsError(try admission.reserve(kind: .walletWipe, id: UUID())) + admission.release(original) + try await admission.drain(before: wipe) + XCTAssertTrue(admission.isWalletWipe(wipe)) + } + + func testDetachedWorkerCancellationStillDrainsItsIgnoredCancellationTail() async throws { + let gate = try XCTUnwrap(admission) + let started = expectation(description: "Detached native work entered") + let cancelled = expectation(description: "Worker cancellation requested") + let (stream, continuation) = AsyncStream.makeStream() + let original = Task { + try await gate.withDetached { + await withTaskCancellationHandler { + started.fulfill() + // A non-cancellable continuation models a native callback that has already entered. + await withCheckedContinuation { (held: CheckedContinuation) in + Task.detached { + for await _ in stream {} + held.resume() + } + } + } onCancel: { cancelled.fulfill() } + } + } + await fulfillment(of: [started], timeout: 2) + let removal = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + defer { admission.release(removal) } + await fulfillment(of: [cancelled], timeout: 2) + continuation.finish() + do { + try await original.value + XCTFail("Revoked worker returned success") + } catch ProfileRemovalStore.StoreError.changed { + } catch is CancellationError {} + try await admission.drain(before: removal) + try admission.persist(removal) + } + + func testFailedMarkerWriteCannotAuthorizeCompletionOrLoseOriginalObligation() throws { + let failing = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"), write: { _, _ in + throw CocoaError(.fileWriteNoPermission) + }) + let gate = ProfileOperationAdmission(store: failing) + let scope = try gate.reserve(kind: .removal, id: UUID(), expected: gate.snapshot()) + defer { gate.release(scope) } + XCTAssertThrowsError(try gate.persist(scope)) + XCTAssertThrowsError(try gate.complete(scope)) + XCTAssertNil(try failing.pending()) + } + + func testRemoteCleanupRequiresAnAcknowledgedPendingRecord() throws { + let scope = try admission.reserve(kind: .removal, id: UUID(), expected: admission.snapshot()) + defer { admission.release(scope) } + XCTAssertThrowsError(try admission.validate(scope)) + XCTAssertNoThrow(try admission.validate(scope, access: .control)) + XCTAssertThrowsError(try admission.validate(scope, access: .local)) + try admission.persist(scope) + XCTAssertNoThrow(try admission.validate(scope)) + try admission.complete(scope) + XCTAssertThrowsError(try admission.validate(scope)) + } + + func testFailedPendingWriteLeavesRemotePermissionClosed() throws { + let failing = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"), write: { _, _ in + throw CocoaError(.fileWriteNoPermission) + }) + let gate = ProfileOperationAdmission(store: failing) + let scope = try gate.reserve(kind: .removal, id: UUID(), expected: gate.snapshot()) + defer { gate.release(scope) } + XCTAssertThrowsError(try gate.persist(scope)) + XCTAssertThrowsError(try gate.validate(scope)) + } + + func testSdkWipeReusesPendingAttemptAndOnlyForgetsLocalCredentials() async throws { + let id = UUID() + try store.begin(id: id) + let provider = PaykitSdkSessionProvider(deleteKeychainValue: { _ in }) + let service = PaykitSdkService(sessionProvider: provider, profileAdmission: admission, sdkFactory: { + XCTFail("Local recovery constructed SDK") + throw CocoaError(.fileReadNoPermission) + }) + try await service.withWalletWipe { + try await service.forgetSessionAccess() + } + XCTAssertEqual(try store.pending()?.id, id, "The native barrier alone cannot acknowledge app cleanup") + } + + func testWipePersistsAfterAnAlreadyAdmittedPriorCompletionRemovesTheMarker() async throws { + let id = UUID() + let removal = try admission.reserve(kind: .removal, id: id, expected: admission.snapshot()) + try admission.persist(removal) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(wipe) } + // The prior completion had entered its acknowledged store operation before the new reservation. + try store.complete(id: id) + admission.release(removal) + try await admission.drain(before: wipe) + try admission.persist(wipe) + XCTAssertEqual(try store.pending()?.id, id) + XCTAssertEqual(try store.pending()?.scope, .walletWipe) + XCTAssertThrowsError(try admission.validate(wipe), "Recovery must stay local only") + } + + func testPublicProviderNeverLoadsOrClearsRetainedCredentials() throws { + let provider = PaykitSdkSessionProvider(publicOnly: true, loadSessionSecret: { + XCTFail("Public provider loaded retained session") + throw CocoaError(.fileReadNoPermission) + }, deleteKeychainValue: { _ in XCTFail("Public provider deleted a credential") }) + XCTAssertNil(try provider.loadSessionAccess()) + XCTAssertNil(try provider.loadLocalSecretKey()) + XCTAssertTrue(try provider.publicStorageAvailable()) + XCTAssertThrowsError(try provider.clearSessionAccess()) + } + + func testStoreBeginRejectsAnEntireRemovalIntervalEvenWhenFileIsAbsentAgain() throws { + let oldGeneration = try store.requireNoPending() + let id = UUID() + try store.begin(id: id) + try store.complete(id: id) + XCTAssertThrowsError(try store.begin(id: UUID(), expectedGeneration: oldGeneration)) + XCTAssertNil(try store.pending()) + } +} diff --git a/BitkitTests/ProfileOriginalAccessTests.swift b/BitkitTests/ProfileOriginalAccessTests.swift new file mode 100644 index 000000000..eff674f79 --- /dev/null +++ b/BitkitTests/ProfileOriginalAccessTests.swift @@ -0,0 +1,480 @@ +@testable import Bitkit +import Paykit +import XCTest + +final class ProfileOriginalAccessTests: XCTestCase { + private var owner: String { + get throws { try SharedPubkyKeychain.derivedPubky(fromSecretKeyHex: String(repeating: "01", count: 32)) } + } + + private final class Access: PubkySessionAccess, @unchecked Sendable { + var secret = "original-session" + var key: PaykitIdentitySecretKey? + + convenience init(secret: String = "original-session", key: PaykitIdentitySecretKey? = nil) { + self.init(noPointer: .init()) + self.secret = secret + self.key = key + } + + override func exportSessionSecret() -> String { + secret + } + + override func exportPaykitIdentitySecretKey() -> PaykitIdentitySecretKey? { + key + } + + override func withPaykitIdentitySecretKey(key: PaykitIdentitySecretKey) -> PubkySessionAccess { + Access(secret: secret, key: key) + } + } + + private final class Storage { + var session: String? = "original-session" + var credential: Data? = Data(String(repeating: "01", count: 32).utf8) + var failLoad = false + var failCredentialLoad = false + var adopted: PaykitSdkSessionProvider.AdoptedCredential? + var failAdoptedLoad = false + var adoptedReads = 0 + var onAdoptedRead: (() throws -> Void)? + var onDelete: ((Bitkit.KeychainEntryType) throws -> Void)? + var deleted: [String] = [] + var failAfterSessionDelete = false + var replaceCredentialAfterSessionDelete = false + + func provider() -> PaykitSdkSessionProvider { + let result = PaykitSdkSessionProvider(loadSessionSecret: { + if self.failLoad { + throw KeychainError.failedToLoad + } + return self.session + }, loadLocalCredential: { + if self.failCredentialLoad { + throw KeychainError.failedToLoad + } + return self.credential + }, loadAdoptedReference: { self.adopted?.reference }, loadAdoptedCredential: { _ in + self.adoptedReads += 1 + try self.onAdoptedRead?() + if self.failAdoptedLoad { + throw KeychainError.failedToLoad + } + return self.adopted + }, + deleteKeychainValue: { key in + self.deleted.append(key.storageKey) + try self.onDelete?(key) + if case .paykitSession = key { + self.session = nil + if self.replaceCredentialAfterSessionDelete { + self.credential = Data([9]) + } + if self.failAfterSessionDelete { + throw KeychainError.failedToDelete + } + } else { + self.credential = nil + } + }) + result.setLiveSessionAccess(Access()) + return result + } + } + + private final class Logout: PubkyGrantLogout, @unchecked Sendable { + private let lock = NSLock() + private var revoked = false + var isRevoked: Bool { + lock.withLock { revoked } + } + + override func revokeAdmission() { + lock.withLock { revoked = true } + } + } + + func testObservedCredentialMismatchCannotReviveCaptureByRestoringBytes() throws { + for replaceSession in [false, true] { + let storage = Storage() + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + let logout = Logout(noPointer: .init()) + let registration = try provider.registerGrantLogout(logout, access: original) + defer { provider.unregisterGrantLogout(registration) } + let session = storage.session + let credential = storage.credential + if replaceSession { + storage.session = "replacement" + } else { + storage.credential = Data([9]) + } + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + storage.session = session + storage.credential = credential + XCTAssertThrowsError(try provider.validateOriginalAccess(original), "Restored bytes cannot revive an observed ended capture") + XCTAssertTrue(logout.isRevoked, "Observed mismatch must also revoke the retained native handle") + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + XCTAssertTrue(storage.deleted.isEmpty) + } + } + + func testReadFailureCannotReviveCaptureWhenStorageRecovers() throws { + let storage = Storage() + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + let logout = Logout(noPointer: .init()) + let registration = try provider.registerGrantLogout(logout, access: original) + defer { provider.unregisterGrantLogout(registration) } + storage.failLoad = true + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + storage.failLoad = false + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + XCTAssertTrue(logout.isRevoked) + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + XCTAssertTrue(storage.deleted.isEmpty) + } + + func testObservedAdoptedSourceOrSecretReplacementCannotReviveCapture() throws { + let originalSource = PaykitSdkSessionProvider.AdoptedCredential(sourceApp: "ring", publicKey: "owner", secret: "original") + let replacements: [PaykitSdkSessionProvider.AdoptedCredential?] = [ + .init(sourceApp: "other-app", publicKey: "owner", secret: "original"), + .init(sourceApp: "ring", publicKey: "other-owner", secret: "original"), + .init(sourceApp: "ring", publicKey: "owner", secret: "replacement"), nil, + ] + for replacement in replacements { + let storage = Storage() + storage.adopted = originalSource + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + let logout = Logout(noPointer: .init()) + let registration = try provider.registerGrantLogout(logout, access: original) + defer { provider.unregisterGrantLogout(registration) } + storage.adopted = replacement + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + storage.adopted = originalSource + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + if replacement?.reference == originalSource.reference { + try provider.clearOriginalAccess(original) + } else { + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + } + XCTAssertTrue(logout.isRevoked) + XCTAssertEqual(storage.deleted.isEmpty, replacement?.reference != originalSource.reference) + } + } + + func testAdoptedReadFailureRetiresAllCapturesFromTheObservedEpoch() throws { + let storage = Storage() + storage.adopted = .init(sourceApp: "ring", publicKey: "owner", secret: "original") + let provider = storage.provider() + let originals = try [provider.captureOriginalAccess(expectedOwner: owner), provider.captureOriginalAccess(expectedOwner: owner)] + let logouts = [Logout(noPointer: .init()), Logout(noPointer: .init())] + let registrations = try zip(originals, logouts).map { try provider.registerGrantLogout($1, access: $0) } + defer { registrations.forEach { provider.unregisterGrantLogout($0) } } + storage.failAdoptedLoad = true + XCTAssertThrowsError(try provider.validateOriginalAccess(originals[0])) + storage.failAdoptedLoad = false + for original in originals { + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + } + XCTAssertTrue(logouts.allSatisfy(\.isRevoked)) + } + + func testOldCaptureValidationCannotRevokeANewerProviderEpoch() throws { + let storage = Storage() + let provider = storage.provider() + let old = try provider.captureOriginalAccess(expectedOwner: owner) + provider.setLiveSessionAccess(Access()) + let current = try provider.captureOriginalAccess(expectedOwner: owner) + let logout = Logout(noPointer: .init()) + let registration = try provider.registerGrantLogout(logout, access: current) + defer { provider.unregisterGrantLogout(registration) } + XCTAssertThrowsError(try provider.validateOriginalAccess(old)) + XCTAssertNoThrow(try provider.validateOriginalAccess(current)) + XCTAssertFalse(logout.isRevoked) + } + + func testRingLossEndsRemoteAdmissionButDoesNotBlockOwnedLocalErase() throws { + let storage = Storage() + storage.adopted = .init(sourceApp: "ring", publicKey: "owner", secret: "original") + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + let logout = Logout(noPointer: .init()) + let registration = try provider.registerGrantLogout(logout, access: original) + defer { provider.unregisterGrantLogout(registration) } + storage.failAdoptedLoad = true + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + XCTAssertTrue(logout.isRevoked) + storage.failAdoptedLoad = false + XCTAssertThrowsError(try provider.validateOriginalAccess(original), "Restoring Ring cannot revive remote admission") + storage.failAdoptedLoad = true + let foreignReads = storage.adoptedReads + try provider.clearOriginalAccess(original) + XCTAssertEqual(storage.adoptedReads, foreignReads, "Local erase must not read Ring") + XCTAssertNil(storage.session) + XCTAssertNil(storage.credential) + XCTAssertEqual(storage.adopted?.sourceApp, "ring", "Foreign material remains untouched") + } + + func testReferenceChangeObservedByForeignReaderPermanentlyEndsOwnedErase() throws { + let storage = Storage() + let source = PaykitSdkSessionProvider.AdoptedCredential(sourceApp: "ring", publicKey: "owner", secret: "original") + storage.adopted = source + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + let logout = Logout(noPointer: .init()) + let registration = try provider.registerGrantLogout(logout, access: original) + defer { provider.unregisterGrantLogout(registration) } + storage.onAdoptedRead = { storage.adopted = .init(sourceApp: "replacement", publicKey: "owner", secret: "original") } + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + storage.onAdoptedRead = nil + storage.adopted = source + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + XCTAssertTrue(logout.isRevoked) + XCTAssertTrue(storage.deleted.isEmpty) + } + + func testForeignLookupUsesOnlyTheSuppliedOriginalReference() throws { + let reference = PaykitSdkSessionProvider.AdoptedReference(sourceApp: "original-ring", publicKey: "original-owner") + var reads = 0 + let credential = try PaykitSdkSessionProvider.AdoptedCredential.load(reference: reference) { source, key in + reads += 1 + XCTAssertEqual(source, reference.sourceApp) + XCTAssertEqual(key, reference.publicKey) + return "original" + } + XCTAssertEqual(credential?.reference, reference) + XCTAssertEqual(reads, 1) + XCTAssertNil(try PaykitSdkSessionProvider.AdoptedCredential.load(reference: nil) { _, _ in + XCTFail("No reference means no foreign account read") + return nil + }) + } + + func testCaptureRejectsSelectedStoredCredentialForAnotherOwner() throws { + for adopted in [false, true] { + let storage = Storage() + let secret = String(repeating: "02", count: 32) + if adopted { + storage.credential = nil + storage.adopted = try .init(sourceApp: "ring", publicKey: SharedPubkyKeychain.derivedPubky(fromSecretKeyHex: secret), secret: secret) + } else { + storage.credential = Data(secret.utf8) + } + let provider = storage.provider() + XCTAssertThrowsError(try provider.captureOriginalAccess(expectedOwner: owner)) + XCTAssertTrue(storage.deleted.isEmpty) + } + } + + func testDeleteErrorReadbackMismatchOrFailurePermanentlyEndsConditionalErase() throws { + for target in [Bitkit.KeychainEntryType.paykitSession, .pubkySecretKey] { + for unreadable in [false, true] { + let storage = Storage() + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + let credential = storage.credential + storage.onDelete = { key in + guard key.storageKey == target.storageKey else { return } + if case .paykitSession = key { + if unreadable { + storage.failLoad = true + } else { + storage.session = "replacement" + } + } else { + if unreadable { + storage.failCredentialLoad = true + } else { + storage.credential = Data([9]) + } + } + throw KeychainError.failedToDelete + } + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + let deletions = storage.deleted + storage.onDelete = nil + storage.failLoad = false + storage.failCredentialLoad = false + storage.session = target.storageKey == "paykit_session" ? "original-session" : nil + storage.credential = credential + XCTAssertThrowsError(try provider.clearOriginalAccess(original), "Restored bytes cannot hide a failed-delete readback mismatch") + XCTAssertEqual(storage.deleted, deletions) + } + } + } + + func testFailedDeleteWithUnchangedOrOwnDeletedValueStillAllowsLocalCompletion() throws { + for target in [Bitkit.KeychainEntryType.paykitSession, .pubkySecretKey] { + for deleted in [false, true] { + let storage = Storage() + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + storage.onDelete = { key in + guard key.storageKey == target.storageKey else { return } + if deleted { + if case .paykitSession = key { + storage.session = nil + } else { + storage.credential = nil + } + } + throw KeychainError.failedToDelete + } + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + storage.onDelete = nil + try provider.clearOriginalAccess(original) + XCTAssertNil(storage.session) + XCTAssertNil(storage.credential) + } + } + } + + func testCaptureAcceptsMatchingAdoptedOwnerWithoutALocalRoot() throws { + let storage = Storage() + storage.credential = nil + storage.adopted = try .init(sourceApp: "ring", publicKey: owner, secret: String(repeating: "01", count: 32)) + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + XCTAssertNoThrow(try provider.validateOriginalAccess(original)) + } + + func testOnlyCancellationLeavesMatchingCaptureAdmittedAfterValidationFailure() throws { + for cancellation in [false, true] { + let storage = Storage() + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + let logout = Logout(noPointer: .init()) + let registration = try provider.registerGrantLogout(logout, access: original) + defer { provider.unregisterGrantLogout(registration) } + provider.setAdmissionCheck { + if cancellation { + throw CancellationError() + } + throw ProfileRemovalStore.StoreError.changed + } + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) { error in + XCTAssertEqual(error is CancellationError, cancellation) + } + provider.setAdmissionCheck(nil) + if cancellation { + XCTAssertNoThrow(try provider.validateOriginalAccess(original)) + XCTAssertFalse(logout.isRevoked) + } else { + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + XCTAssertTrue(logout.isRevoked) + } + } + } + + func testAdoptedCredentialCaptureReadsOnlyTheExactReferencedAccountAndRejectsIncompleteSources() throws { + let suite = "original-access-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var reads = 0 + let read: (String, String) throws -> String? = { source, key in + reads += 1 + XCTAssertEqual(source, "ring") + XCTAssertEqual(key, "owner") + return "original" + } + XCTAssertNil(try PaykitSdkSessionProvider.AdoptedCredential.load(defaults: defaults, readSecret: read)) + defaults.set("ring", forKey: AdoptedPubkyReference.sourceAppKey) + XCTAssertThrowsError(try PaykitSdkSessionProvider.AdoptedCredential.load(defaults: defaults, readSecret: read)) + XCTAssertEqual(reads, 0) + defaults.set("owner", forKey: AdoptedPubkyReference.pubkyKey) + XCTAssertEqual(try PaykitSdkSessionProvider.AdoptedCredential.load(defaults: defaults, readSecret: read), + .init(sourceApp: "ring", publicKey: "owner", secret: "original")) + XCTAssertEqual(reads, 1) + XCTAssertThrowsError(try PaykitSdkSessionProvider.AdoptedCredential.load(defaults: defaults, readSecret: { _, _ in nil })) + defaults.set(Data([1]), forKey: AdoptedPubkyReference.sourceAppKey) + XCTAssertThrowsError(try PaykitSdkSessionProvider.AdoptedCredential.load(defaults: defaults, readSecret: read)) + XCTAssertEqual(reads, 1) + } + + func testKeyAttachmentClonesCurrentAccessWithoutDiscardingIt() throws { + let storage = Storage() + let provider = storage.provider() + let original = try XCTUnwrap(provider.loadSessionAccess()) + let key = try PaykitIdentitySecretKey(bytes: Data(repeating: 7, count: 32), keyGeneration: 3) + provider.setPaykitIdentitySecretKey(key) + let attached = try XCTUnwrap(provider.loadSessionAccess()) + XCTAssertFalse(original === attached) + XCTAssertTrue(attached is Access, "Attachment must preserve the access object through the native clone API") + XCTAssertNil(original.exportPaykitIdentitySecretKey()) + XCTAssertEqual(attached.exportPaykitIdentitySecretKey()?.exportBytes(), key.exportBytes()) + XCTAssertEqual(attached.exportSessionSecret(), original.exportSessionSecret()) + } + + func testSameGenerationSignerReplacementInvalidatesOriginalCapture() throws { + let storage = Storage() + let provider = storage.provider() + try provider.setPaykitIdentitySecretKey(.init(bytes: Data(repeating: 1, count: 32), keyGeneration: 3)) + let original = try provider.captureOriginalAccess(expectedOwner: owner) + try provider.setPaykitIdentitySecretKey(.init(bytes: Data(repeating: 2, count: 32), keyGeneration: 3)) + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + XCTAssertTrue(storage.deleted.isEmpty) + } + + func testReplacementCredentialsOrSameSecretLifecycleCannotBeErasedByOriginalCapture() throws { + for replacement in 0 ..< 4 { + let storage = Storage() + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + switch replacement { + case 0: storage.session = "replacement-session" + case 1: storage.credential = Data([9]) + case 2: provider.setLiveSessionAccess(Access()) + default: provider.suspendStoredSessionAccess(); provider.resumeStoredSessionAccess() + } + XCTAssertThrowsError(try provider.validateOriginalAccess(original)) + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + XCTAssertTrue(storage.deleted.isEmpty) + } + } + + func testConditionalEraseTracksOwnPartialDeletionAndCannotEraseAnInterveningReplacement() throws { + for replacement in [false, true] { + let storage = Storage() + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + storage.failAfterSessionDelete = true + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + XCTAssertNil(storage.session) + XCTAssertNotNil(storage.credential) + storage.failAfterSessionDelete = false + if replacement { + storage.session = "replacement-session" + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + XCTAssertEqual(storage.deleted, ["paykit_session"]) + XCTAssertNotNil(storage.credential) + } else { + try provider.clearOriginalAccess(original) + XCTAssertNil(storage.credential) + XCTAssertEqual(storage.deleted, ["paykit_session", "paykit_session", "pubky_secret_key"]) + } + } + } + + func testConditionalEraseRechecksRemainingCredentialBetweenDeletes() throws { + let storage = Storage() + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + storage.replaceCredentialAfterSessionDelete = true + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + XCTAssertEqual(storage.credential, Data([9])) + XCTAssertEqual(storage.deleted, ["paykit_session"]) + } + + func testConditionalEraseRequiresAdmissionEvenAfterOriginalCapture() throws { + let storage = Storage() + let provider = storage.provider() + let original = try provider.captureOriginalAccess(expectedOwner: owner) + provider.setAdmissionCheck { throw ProfileRemovalStore.StoreError.changed } + XCTAssertThrowsError(try provider.clearOriginalAccess(original)) + XCTAssertTrue(storage.deleted.isEmpty) + } +} diff --git a/BitkitTests/ProfileRemovalCoordinatorTests.swift b/BitkitTests/ProfileRemovalCoordinatorTests.swift new file mode 100644 index 000000000..7a4ebbfe8 --- /dev/null +++ b/BitkitTests/ProfileRemovalCoordinatorTests.swift @@ -0,0 +1,1141 @@ +@testable import Bitkit +import Paykit +import XCTest + +@MainActor +final class ProfileRemovalCoordinatorTests: XCTestCase { + private let owner = "pubky" + String(repeating: "y", count: 52) + private var directory: URL! + private var store: ProfileRemovalStore! + private var admission: ProfileOperationAdmission! + private var deleted: [(String, String)] = [] + private var steps: [String] = [] + private var nativeRevision = "confirmed-revision" + private var profileAbsent = false + private var failStep: String? + private var cleanupCount = 0 + private var watermark = 42 + private var pauseID: UUID? + private var resumeID: UUID? + private var sourceMissing = false + private var eligibilityChecks = 0 + private var profileReads = 0 + private var contactReads = 0 + private var contactRemovals = 0 + private var appSyncCount = 0 + private var duringLocalCleanup: (() throws -> Void)? + private var duringEligibility: (() async throws -> Void)? + private var duringRevocation: (() async throws -> Void)? + private var duringPrivateWithdrawal: (() async throws -> Void)? + private var duringPause: (() -> Void)? + private var duringResume: (() async -> Void)? + private var revocations: [ProfileRevocationTestDouble] = [] + private var duringRevocationValidation: (() throws -> Void)? + + override func setUpWithError() throws { + snapshotAppDefaultsDomain() + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + admission = ProfileOperationAdmission(store: store) + } + + override func tearDownWithError() throws { + if FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + } + + private func makeManager(delete: (@Sendable (String, String) async throws -> Void)? = nil) -> PubkyProfileManager { + let services = PubkyProfileManager.RemovalServices( + readProfile: { [self] key in try await record(key: key) }, + deleteProfile: delete ?? { [self] key, revision in try await acknowledgeDelete(key: key, revision: revision) }, + withdrawPrivate: { [self] in try step("private"); try await duringPrivateWithdrawal?() }, + withdrawPublic: { [self] in try step("public") }, + syncApp: { [self] in + appSyncCount += 1 + if failStep == "app" { + throw TestError.failure("app") + } + }, + validateSubscriptions: { [self] expectedOwner in + XCTAssertEqual(expectedOwner, owner) + eligibilityChecks += 1 + try await duringEligibility?() + }, + captureRevocation: { [self] expectedOwner in + XCTAssertEqual(expectedOwner, owner) + let revocation = ProfileRevocationTestDouble(execute: { [self] in + try step("revoke"); try await duringRevocation?() + }, clear: { [self] in try completeLocal() }, validate: { [self] in + try admission.checkCurrent() + try duringRevocationValidation?() + }) + revocations.append(revocation) + return revocation.handle() + }, + completeLocal: { [self] in try completeLocal() }, + completeRevokedLocal: { try await $0.clearCredentials() }, + pause: { [self] in pauseID = $0; duringPause?() }, resume: { [self] in resumeID = $0; await duringResume?() } + ) + let manager = PubkyProfileManager(removalServices: services, removalStore: store, removalAdmission: admission, + sourceIsMissing: { [self] _, _ in sourceMissing }) + manager.publicKey = owner + manager.authState = .authenticated + return manager + } + + private func completeLocal() throws { + try duringLocalCleanup?() + try step("credentials") + watermark = 0 + cleanupCount += 1 + } + + func testMissingPrivateAcknowledgementStopsRemovalAndLeavesOnlyLocalRecovery() async throws { + for action in [PubkyProfileManager.RemovalAction.disconnect, .deleteProfile, .unlink] { + steps = [] + watermark = 42 + let privateService = PrivatePaykitService(profileAdmission: admission) + var withdrawals = 0 + duringPrivateWithdrawal = { [self] in + try await privateService.removePublishedEndpoints(for: [owner], deferAppSync: true, operations: .init( + linkedPeers: { [self] in + [.init(counterparty: owner, state: .linked, lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, + localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil)] + }, clearPaymentLists: { [self] _ in + withdrawals += 1 + return .init(queued: [], cleared: [.init(counterparty: owner, outboundMessageId: 41, error: nil)], + acknowledged: [], failedToQueue: [], failedToDeliver: []) + }, drainMessages: { _ in XCTFail("Unknown delivery must not be replayed") }, + pendingDrainKeys: { _ in [] }, syncApp: { XCTFail("Incomplete withdrawal must stop registry cleanup") } + )) + } + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: action, contacts: contacts()) + do { + _ = try await manager.confirmRemoval(confirmation) + XCTFail("Missing acknowledgement must stop removal") + } catch PrivatePaykitError.privateUnavailable {} + XCTAssertEqual(steps, ["private"]) + XCTAssertTrue(deleted.isEmpty) + XCTAssertEqual(contactRemovals, 0) + XCTAssertEqual(watermark, 42) + XCTAssertEqual(try store.pending()?.id, confirmation.id) + XCTAssertFalse(manager.canRetryRemoteRemoval(id: confirmation.id)) + _ = try await manager.finishPendingRemoval(id: confirmation.id) + XCTAssertEqual(steps, ["private", "credentials"]) + XCTAssertEqual(withdrawals, 1) + XCTAssertNil(try store.pending()) + } + } + + private func record(key: String) throws -> PaykitProfileRecord? { + profileReads += 1 + if profileAbsent { + return nil + } + return PaykitProfileRecord(publicKey: key, profile: PaykitProfile(displayName: "Owner", imageUri: nil, extraJson: nil), + path: "/profile", revision: nativeRevision, updatedAt: "2026-10-27T00:00:00Z") + } + + private func acknowledgeDelete(key: String, revision: String) throws { + deleted.append((key, revision)) + try step("delete") + guard revision == nativeRevision else { throw TestError.conflict } + } + + private func step(_ name: String) throws { + steps.append(name) + if failStep == name { + throw TestError.failure(name) + } + } + + private func contacts(failRemoval: Bool = false) -> ContactsManager { + ContactsManager(contactRecords: { [self] in + await MainActor.run { contactReads += 1 } + return [] + }, removeContactRecords: { [self] _ in + await MainActor.run { contactRemovals += 1 } + if failRemoval { + throw TestError.failure("contacts") + } + return [] + }, profileAdmission: admission) + } + + func testMissingAdoptedSourceSkipsSessionActivationAndUsesLocalCleanup() async throws { + sourceMissing = true + AdoptedPubkyReference.current = ("ring", owner) + let manager = makeManager() + await manager.initialize { XCTFail("A definitely removed source cannot activate retained credentials"); return .noSession } + XCTAssertEqual(steps, ["credentials"]) + XCTAssertEqual(cleanupCount, 1) + XCTAssertEqual(watermark, 0) + XCTAssertTrue(manager.adoptedSourceLost) + XCTAssertTrue(manager.isInitialized) + XCTAssertFalse(manager.isRestoringSession) + XCTAssertNil(manager.publicKey) + XCTAssertNil(try store.pending()) + XCTAssertNoThrow(try admission.snapshot()) + } + + func testMissingSourceCredentialFailureRetainsExactRecoveryForRestart() async throws { + sourceMissing = true + failStep = "credentials" + AdoptedPubkyReference.current = ("ring", owner) + let manager = makeManager() + await manager.initialize { XCTFail("No SDK restoration is allowed"); return .noSession } + let record = try XCTUnwrap(store.pending()) + XCTAssertEqual(record.scope, .profile) + XCTAssertEqual(watermark, 42) + XCTAssertFalse(manager.adoptedSourceLost) + XCTAssertTrue(manager.isInitialized) + XCTAssertFalse(manager.isRestoringSession) + XCTAssertEqual(steps, ["credentials"]) + failStep = nil + let restarted = makeManager() + _ = try await restarted.finishPendingRemoval(id: record.id) + XCTAssertEqual(steps, ["credentials", "credentials"]) + XCTAssertEqual(watermark, 0) + XCTAssertNil(try store.pending()) + XCTAssertTrue(deleted.isEmpty) + } + + func testReturningAdoptedSourceDuringPauseCancelsUnstartedCleanup() async throws { + sourceMissing = true + AdoptedPubkyReference.current = ("ring", owner) + duringPause = { self.sourceMissing = false } + let manager = makeManager() + await manager.checkAdoptedSource() + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(try store.pending()) + XCTAssertFalse(manager.adoptedSourceLost) + XCTAssertEqual(manager.publicKey, owner) + XCTAssertEqual(watermark, 42) + XCTAssertThrowsError(try admission.snapshot(), "A known lost source remains quarantined until explicit local recovery") + XCTAssertEqual(manager.removalRecovery, .unavailable) + } + + func testSourceLossCompletionDoesNotNavigateAfterWipeTakesOwnership() async throws { + sourceMissing = true + AdoptedPubkyReference.current = ("ring", owner) + let manager = makeManager() + let resuming = expectation(description: "Local completion acknowledged") + let (wait, release) = AsyncStream.makeStream() + defer { release.finish() } + duringResume = { resuming.fulfill(); for await _ in wait {} } + let cleanup = Task { await manager.checkAdoptedSource() } + await fulfillment(of: [resuming], timeout: 2) + XCTAssertNil(try store.pending()) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(wipe) } + release.finish() + await cleanup.value + XCTAssertFalse(manager.adoptedSourceLost, "The ended owner cannot navigate a replacement wipe") + } + + func testCancelledSuccessorWipeStillInvalidatesOldRemovalPresentation() async throws { + sourceMissing = true + AdoptedPubkyReference.current = ("ring", owner) + let manager = makeManager() + let resuming = expectation(description: "Local completion acknowledged") + let (wait, release) = AsyncStream.makeStream() + defer { release.finish() } + duringResume = { resuming.fulfill(); for await _ in wait {} } + let cleanup = Task { await manager.checkAdoptedSource() } + await fulfillment(of: [resuming], timeout: 2) + XCTAssertNil(try store.pending()) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + admission.revoke(wipe) + admission.release(wipe) + XCTAssertFalse(admission.isWalletWiping) + release.finish() + await cleanup.value + XCTAssertFalse(manager.adoptedSourceLost, "A canceled successor cannot restore an older completion's navigation authority") + } + + func testFailedSourceLossMarkerDoesNotReopenIdentityAdmission() async throws { + var failWrite = true + store = ProfileRemovalStore(url: directory.appendingPathComponent("failed-marker.json"), write: { data, url in + if failWrite { + throw TestError.failure("marker") + } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + }) + admission = ProfileOperationAdmission(store: store) + sourceMissing = true + AdoptedPubkyReference.current = ("ring", owner) + let manager = makeManager() + await manager.initialize { XCTFail("No retained-session activation"); return .noSession } + XCTAssertEqual(manager.removalRecovery, .unavailable) + XCTAssertEqual(watermark, 42) + XCTAssertTrue(steps.isEmpty) + XCTAssertThrowsError(try admission.snapshot()) + XCTAssertThrowsError(try admission.reserveSessionTransition()) + let sdk = PaykitSdkService(profileAdmission: admission, sdkFactory: { + XCTFail("Quarantine must reject before SDK construction") + throw TestError.failure("SDK") + }) + do { _ = try await sdk.currentPublicKey(); XCTFail("Identity read reactivated a missing source") } + catch ProfileRemovalStore.StoreError.changed {} + failWrite = false + manager.retryLocalRecoveryRecord() + let pending = try XCTUnwrap(store.pending()) + XCTAssertEqual(manager.pendingProfileRemovalID, pending.id) + XCTAssertEqual(pauseID, pending.id) + _ = try await manager.finishPendingRemoval(id: pending.id) + XCTAssertEqual(steps, ["credentials"]) + XCTAssertNil(try store.pending()) + XCTAssertNoThrow(try admission.snapshot()) + } + + func testMalformedMarkerRetryPreservesQuarantinedAttempt() async throws { + let url = directory.appendingPathComponent("failed-marker.json") + var failWrite = true + store = ProfileRemovalStore(url: url, write: { data, url in + if failWrite { + throw TestError.failure("marker") + } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + }) + admission = ProfileOperationAdmission(store: store) + sourceMissing = true + AdoptedPubkyReference.current = ("ring", owner) + let manager = makeManager() + await manager.initialize { XCTFail("No session activation"); return .noSession } + let originalID = try XCTUnwrap(pauseID) + XCTAssertEqual(manager.removalRecovery, .unavailable) + failWrite = false + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let future = Data(#"{"version":2,"scope":"profile","id":"damaged"}"#.utf8) + try future.write(to: url) + manager.retryLocalRecoveryRecord() + XCTAssertEqual(try Data(contentsOf: url), future) + XCTAssertEqual(manager.removalRecovery, .unavailable) + try Data(#"{"version":1,"scope":"profile","id":"damaged"}"#.utf8).write(to: url) + manager.retryLocalRecoveryRecord() + XCTAssertEqual(try store.pending()?.id, originalID) + XCTAssertTrue(steps.isEmpty) + XCTAssertEqual(watermark, 42) + _ = try await manager.finishPendingRemoval(id: originalID) + XCTAssertEqual(steps, ["credentials"]) + XCTAssertNil(try store.pending()) + XCTAssertNoThrow(try admission.snapshot()) + } + + func testQueuedConfirmationCannotCaptureReplacementScreenOwner() async throws { + let manager = makeManager() + let originalOwner = manager.removalPresentationOwner + manager.publicKey = "pubky" + String(repeating: "z", count: 52) + do { + _ = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts(), expectedOwner: originalOwner) + XCTFail("Old screen must not confirm a replacement identity") + } catch PubkyProfileManager.RemovalError.confirmationChanged {} + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(try store.pending()) + } + + func testWalletStartupRemainsIndependentOfProfileOnlyRecovery() throws { + XCTAssertFalse(PubkyProfileManager.RemovalRecovery.ready.blocksWalletStartup) + let id = UUID() + try store.begin(id: id, scope: .profile) + XCTAssertFalse(try PubkyProfileManager.RemovalRecovery.pending(XCTUnwrap(store.pending())).blocksWalletStartup) + try store.begin(id: id, scope: .walletWipe) + XCTAssertTrue(try PubkyProfileManager.RemovalRecovery.pending(XCTUnwrap(store.pending())).blocksWalletStartup) + XCTAssertTrue(PubkyProfileManager.RemovalRecovery.unavailable.blocksWalletStartup) + } + + func testCancelledAdoptionProfileReadKeepsPreviousSessionAndReference() async throws { + AdoptedPubkyReference.current = ("ring", "previous-reference") + let manager = makeManager() + let sdk = PaykitSdkService(profileAdmission: admission, sdkFactory: { + XCTFail("Injected sign-in and local rollback do not construct native SDK") + throw TestError.failure("SDK") + }) + let started = expectation(description: "Adoption profile read started") + let (wait, release) = AsyncStream.makeStream() + defer { release.finish() } + let adoption = Task { + try await manager.adoptRingIdentity(pubky: "new-reference", loadSecret: { _, _ in String(repeating: "02", count: 32) }, + signIn: { _ in }, fetchProfile: { _ in + started.fulfill() + for await _ in wait {} + return nil + }, sdkService: sdk) + } + await fulfillment(of: [started], timeout: 2) + XCTAssertEqual(manager.publicKey, owner, "Manager identity is not installed before the original attempt commits") + adoption.cancel() + do { _ = try await adoption.value; XCTFail("Cancelled adoption committed") } catch is CancellationError {} + XCTAssertEqual(manager.publicKey, owner) + XCTAssertEqual(manager.authState, .authenticated) + XCTAssertEqual(AdoptedPubkyReference.current?.pubky, "previous-reference") + XCTAssertNil(try store.pending()) + XCTAssertNoThrow(try admission.snapshot()) + } + + func testConfirmationCapturesOwnerBeforeDialogAndCannotBeConsumedTwice() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + let result = try await manager.confirmRemoval(confirmation) + XCTAssertTrue(result.remoteProfileDeleted) + XCTAssertEqual(deleted.count, 1) + XCTAssertEqual(deleted.first?.0, owner) + XCTAssertEqual(deleted.first?.1, "confirmed-revision") + XCTAssertEqual(steps, ["private", "public", "delete", "revoke", "credentials"]) + XCTAssertEqual(pauseID, confirmation.id) + XCTAssertEqual(resumeID, confirmation.id) + XCTAssertNil(try store.pending()) + XCTAssertNil(manager.publicKey) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("A copied confirmation cannot be replayed") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + XCTAssertEqual(deleted.count, 1) + } + + func testCancelInvalidatesIssuedConfirmation() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .disconnect) + manager.cancelRemovalConfirmation(confirmation) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Cancelled intent cannot be consumed") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + do { try await manager.deleteProfile(); XCTFail("No-argument entry cannot reuse a cancelled alert") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + XCTAssertTrue(steps.isEmpty) + } + + func testPresentationOwnerRejectsReplacementAndWipe() throws { + let manager = makeManager() + let original = manager.removalPresentationOwner + XCTAssertTrue(manager.ownsRemovalPresentation(original)) + manager.publicKey = "replacement" + XCTAssertFalse(manager.ownsRemovalPresentation(original)) + manager.publicKey = owner + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(wipe) } + XCTAssertFalse(manager.ownsRemovalPresentation(original)) + } + + func testNoArgumentDeletionCannotRecaptureCurrentIdentity() async throws { + let manager = makeManager() + do { try await manager.deleteProfile(); XCTFail("A fresh confirmation is required") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + XCTAssertTrue(deleted.isEmpty) + XCTAssertTrue(steps.isEmpty) + } + + func testConfirmationRejectsReplacementIdentityBeforeCleanup() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + manager.publicKey = "pubky" + String(repeating: "o", count: 52) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Replacement owner must remain untouched") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(try store.pending()) + } + + func testConfirmationRejectsSameKeyWithNewSessionRevision() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + try await PubkyProfileManager.restoreSessionBackupState( + nil, + deleteKeychainValue: { _ in }, + removeOwnSharedRecords: {}, + forgetSessionAccess: {} + ) + XCTAssertEqual(manager.publicKey, owner) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Same key does not preserve the original session") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + XCTAssertTrue(steps.isEmpty) + } + + func testFrozenProfileRevisionIsPassedToNativeConflictCheck() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + nativeRevision = "later-edit" + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("A later edit must not be deleted") } + catch TestError.conflict {} + XCTAssertEqual(deleted.first?.1, "confirmed-revision") + XCTAssertEqual(cleanupCount, 0) + XCTAssertEqual(watermark, 42) + XCTAssertEqual(try store.pending()?.id, confirmation.id) + XCTAssertNil(manager.currentSession) + XCTAssertEqual(manager.publicKey, owner) + } + + func testOriginallyAbsentProfileDoesNotDeleteLaterRecord() async throws { + profileAbsent = true + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + profileAbsent = false + let result = try await manager.confirmRemoval(confirmation) + XCTAssertFalse(result.remoteProfileDeleted) + XCTAssertTrue(deleted.isEmpty) + } + + func testIncompletePrivateCleanupStopsContactsProfileAndLocalCleanup() async throws { + failStep = "private" + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Cleanup failed") } + catch TestError.failure("private") {} + XCTAssertEqual(steps, ["private"]) + XCTAssertEqual(watermark, 42) + XCTAssertEqual(try store.pending()?.id, confirmation.id) + XCTAssertNil(manager.automaticPaykitSession) + XCTAssertEqual(manager.publicKey, owner) + } + + func testIncompleteContactCleanupStopsProfileDeletion() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts(failRemoval: true)) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Contact cleanup failed") } + catch TestError.failure("contacts") {} + XCTAssertTrue(deleted.isEmpty) + XCTAssertEqual(cleanupCount, 0) + XCTAssertNotNil(try store.pending()) + } + + func testCredentialFailureRetainsWatermarksAndRecoveryDoesNotRepeatRemoteWork() async throws { + failStep = "credentials" + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Credential failure must remain pending") } + catch TestError.failure("credentials") {} + XCTAssertEqual(watermark, 42) + XCTAssertEqual(deleted.count, 1) + failStep = nil + let before = steps + _ = try await manager.finishPendingRemoval(id: confirmation.id) + XCTAssertEqual(steps, before + ["credentials"]) + XCTAssertEqual(deleted.count, 1) + XCTAssertEqual(watermark, 0) + XCTAssertNil(try store.pending()) + } + + func testRestartWithoutCredentialsAllowsOnlyExactIDLocalCompletion() async throws { + let id = UUID() + try store.begin(id: id) + let manager = makeManager() + manager.publicKey = nil + manager.authState = .idle + do { _ = try await manager.finishPendingRemoval(id: UUID()); XCTFail("Wrong UUID cannot finish another obligation") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + _ = try await manager.finishPendingRemoval(id: id) + XCTAssertEqual(steps, ["credentials"]) + XCTAssertTrue(deleted.isEmpty) + XCTAssertNil(try store.pending()) + } + + func testUnlinkDoesNotDeleteRingOwnedProfile() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .unlink, contacts: contacts()) + _ = try await manager.confirmRemoval(confirmation) + XCTAssertTrue(deleted.isEmpty) + XCTAssertEqual(steps, ["private", "public", "revoke", "credentials"]) + } + + func testKnownSubscriptionRefusesPreparationWithoutBeginningCleanup() async throws { + duringEligibility = { throw PubkyServiceError.activeSubscription(endsAt: nil) } + for action in [PubkyProfileManager.RemovalAction.deleteProfile, .unlink] { + let manager = makeManager() + do { _ = try await manager.prepareRemoval(action: action, contacts: contacts()); XCTFail("Active subscriptions prevent removal") } + catch PubkyServiceError.activeSubscription {} + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(pauseID) + XCTAssertNil(try store.pending()) + XCTAssertNotNil(manager.currentSession) + XCTAssertNoThrow(try admission.snapshot()) + XCTAssertEqual(watermark, 42) + } + } + + func testSubscriptionAddedDuringDialogRefusesConfirmationBeforePending() async throws { + for action in [PubkyProfileManager.RemovalAction.deleteProfile, .unlink] { + duringEligibility = nil + eligibilityChecks = 0 + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: action, contacts: contacts()) + duringEligibility = { throw PubkyServiceError.activeSubscription(endsAt: nil) } + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Confirmation must recheck subscription eligibility") } + catch PubkyServiceError.activeSubscription {} + XCTAssertEqual(eligibilityChecks, 2) + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(pauseID) + XCTAssertNil(try store.pending()) + XCTAssertNotNil(manager.currentSession) + XCTAssertNoThrow(try admission.snapshot()) + } + } + + func testCancelledConfirmationDuringEligibilityCannotReserveRemoval() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + duringEligibility = { manager.cancelRemovalConfirmation(confirmation) } + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Cancelled dialog must not reserve removal") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(pauseID) + XCTAssertNil(try store.pending()) + XCTAssertNoThrow(try admission.snapshot()) + } + + func testLateSubscriptionRetainsPendingWithoutWithdrawingEndpoints() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + duringEligibility = { [self] in + if eligibilityChecks == 3 { + throw PubkyServiceError.activeSubscription(endsAt: nil) + } + } + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Later remote changes remain fail-closed") } + catch PubkyServiceError.activeSubscription {} + XCTAssertTrue(steps.isEmpty) + XCTAssertEqual(try store.pending()?.id, confirmation.id) + XCTAssertEqual(watermark, 42) + } + + func testSubscriptionRefusalAllowsSeparatelyConfirmedLocalDisconnect() async throws { + for action in [PubkyProfileManager.RemovalAction.deleteProfile, .unlink] { + for refuseDuringConfirmation in [false, true] { + steps = [] + pauseID = nil + watermark = 42 + duringEligibility = nil + let manager = makeManager() + let originalOwner = manager.removalPresentationOwner + let refuse = { throw PubkyServiceError.activeSubscription(endsAt: Date().addingTimeInterval(3600)) } + do { + if !refuseDuringConfirmation { + duringEligibility = refuse + } + let remote = try await manager.prepareRemoval(action: action, contacts: contacts(), expectedOwner: originalOwner) + duringEligibility = refuse + _ = try await manager.confirmRemoval(remote) + XCTFail("A future fixed term must refuse remote removal") + } catch PubkyServiceError.activeSubscription {} + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(pauseID) + XCTAssertNil(try store.pending(), "Refusal cannot fabricate a failed removal") + + let readsBefore = [profileReads, contactReads, eligibilityChecks] + let generation = try admission.snapshot() + let cancelled = try await manager.prepareRemoval(action: .disconnectLocally, contacts: contacts(), expectedOwner: originalOwner) + manager.cancelRemovalConfirmation(cancelled) + do { _ = try await manager.confirmRemoval(cancelled); XCTFail("Cancel must be inert") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + XCTAssertNoThrow(try admission.validate(generation)) + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(pauseID) + XCTAssertNil(try store.pending()) + XCTAssertEqual(watermark, 42) + XCTAssertNotNil(manager.currentSession) + + let local = try await manager.prepareRemoval(action: .disconnectLocally, contacts: contacts(), expectedOwner: originalOwner) + XCTAssertNotEqual(local.id, cancelled.id) + do { try await manager.deleteProfile(); XCTFail("Delete cannot consume a local-only confirmation") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + duringLocalCleanup = { [self] in + XCTAssertEqual(try store.pending()?.id, local.id, "Durable intent precedes credential erasure") + XCTAssertEqual(pauseID, local.id) + XCTAssertEqual(watermark, 42) + } + let completion = try await manager.confirmRemoval(local) + duringLocalCleanup = nil + XCTAssertFalse(completion.remoteProfileDeleted) + XCTAssertTrue(manager.ownsRemovalCompletion(completion)) + XCTAssertEqual([profileReads, contactReads, eligibilityChecks], readsBefore, "Local intent performs no SDK/contact reads") + XCTAssertEqual(contactRemovals, 0) + XCTAssertTrue(deleted.isEmpty) + XCTAssertEqual(steps, ["credentials"]) + XCTAssertEqual(resumeID, local.id) + XCTAssertEqual(watermark, 0) + XCTAssertNil(manager.publicKey) + XCTAssertNil(try store.pending()) + do { _ = try await manager.confirmRemoval(local); XCTFail("Local confirmation is one-use") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + } + } + } + + func testFreshLocalDisconnectRequiresOriginalPresentationOwner() async throws { + let manager = makeManager() + do { _ = try await manager.prepareRemoval(action: .disconnectLocally); XCTFail("Local intent must identify its screen owner") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + let originalOwner = manager.removalPresentationOwner + manager.publicKey = "replacement" + do { + _ = try await manager.prepareRemoval(action: .disconnectLocally, expectedOwner: originalOwner) + XCTFail("A stale screen cannot capture replacement access") + } catch PubkyProfileManager.RemovalError.confirmationChanged {} + manager.publicKey = owner + try await PubkyProfileManager.restoreSessionBackupState( + nil, deleteKeychainValue: { _ in }, removeOwnSharedRecords: {}, forgetSessionAccess: {} + ) + do { + _ = try await manager.prepareRemoval(action: .disconnectLocally, expectedOwner: originalOwner) + XCTFail("A new session for the same key does not belong to the old screen") + } catch PubkyProfileManager.RemovalError.confirmationChanged {} + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(pauseID) + XCTAssertNil(try store.pending()) + XCTAssertEqual(watermark, 42) + } + + func testLocalDisconnectRejectsSupersededOwnerGenerationAndConfirmation() async throws { + enum Replacement: CaseIterable { case identity, session, generation, confirmation, pending } + for replacement in Replacement.allCases { + let manager = makeManager() + let screenOwner = manager.removalPresentationOwner + let local = try await manager.prepareRemoval(action: .disconnectLocally, expectedOwner: screenOwner) + var pendingID: UUID? + switch replacement { + case .identity: + manager.publicKey = "replacement" + case .session: + try await PubkyProfileManager.restoreSessionBackupState( + nil, deleteKeychainValue: { _ in }, removeOwnSharedRecords: {}, forgetSessionAccess: {} + ) + case .generation: + let successor = try admission.reserve(kind: .localRecovery, id: UUID(), expected: admission.snapshot()) + admission.release(successor) + case .confirmation: + _ = try await manager.prepareRemoval(action: .disconnectLocally, expectedOwner: screenOwner) + case .pending: + let id = UUID() + let successor = try admission.reserve(kind: .localRecovery, id: id, expected: admission.snapshot()) + try admission.persist(successor) + admission.release(successor) + pendingID = id + } + do { _ = try await manager.confirmRemoval(local); XCTFail("Superseded local intent must not act: \(replacement)") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + catch PubkyProfileManager.RemovalError.confirmationChanged {} + catch is ProfileRemovalStore.StoreError {} + XCTAssertTrue(steps.isEmpty) + XCTAssertNil(pauseID) + XCTAssertEqual(try store.pending()?.id, pendingID) + XCTAssertEqual(watermark, 42) + XCTAssertEqual(manager.publicKey, replacement == .identity ? "replacement" : owner) + } + } + + func testLocalDisconnectCredentialFailureRetainsExactAttemptForRestart() async throws { + let manager = makeManager() + let local = try await manager.prepareRemoval(action: .disconnectLocally, expectedOwner: manager.removalPresentationOwner) + failStep = "credentials" + do { _ = try await manager.confirmRemoval(local); XCTFail("Credential erasure must acknowledge") } + catch TestError.failure("credentials") {} + XCTAssertEqual(try store.pending()?.id, local.id) + XCTAssertEqual(try store.pending()?.scope, .profile) + XCTAssertEqual(watermark, 42) + XCTAssertEqual(cleanupCount, 0) + do { + _ = try await manager.prepareRemoval(action: .disconnectLocally, expectedOwner: manager.removalPresentationOwner) + XCTFail("Fresh intent cannot replace a real pending attempt") + } catch is ProfileRemovalStore.StoreError {} + failStep = nil + let restarted = makeManager() + restarted.publicKey = nil + restarted.authState = .idle + do { _ = try await restarted.finishPendingRemoval(id: UUID()); XCTFail("Recovery must retain the exact original attempt") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + let completion = try await restarted.finishPendingRemoval(id: local.id) + XCTAssertFalse(completion.remoteProfileDeleted) + XCTAssertEqual(steps, ["credentials", "credentials"]) + XCTAssertEqual([profileReads, contactReads, contactRemovals, eligibilityChecks], [0, 0, 0, 0]) + XCTAssertTrue(deleted.isEmpty) + XCTAssertEqual(watermark, 0) + XCTAssertEqual(cleanupCount, 1) + XCTAssertNil(try store.pending()) + XCTAssertNil(restarted.currentSession) + } + + func testOrdinaryDisconnectRevokesGrantBeforeLocalCredentials() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .disconnect) + _ = try await manager.confirmRemoval(confirmation) + XCTAssertEqual(steps, ["private", "public", "revoke", "credentials"]) + XCTAssertTrue(deleted.isEmpty) + XCTAssertNil(try store.pending()) + XCTAssertEqual(watermark, 0) + } + + func testUncertainRevocationRetainsOriginalObligationAndLocalRecoveryNeverReplaysIt() async throws { + failStep = "revoke" + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .disconnect) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Revocation was not acknowledged") } + catch TestError.failure("revoke") {} + XCTAssertEqual(steps, ["private", "public", "revoke"]) + XCTAssertEqual(watermark, 42) + XCTAssertEqual(try store.pending()?.id, confirmation.id) + XCTAssertEqual(manager.publicKey, owner) + failStep = nil + _ = try await manager.finishPendingRemoval(id: confirmation.id) + XCTAssertEqual(steps, ["private", "public", "revoke", "credentials"]) + XCTAssertNil(try store.pending()) + } + + func testRevocationOwnerChangeCannotClearReplacementCredentials() async throws { + let manager = makeManager() + duringRevocation = { manager.publicKey = "replacement" } + let confirmation = try await manager.prepareRemoval(action: .disconnect) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Original owner ended") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + XCTAssertEqual(steps, ["private", "public", "revoke"]) + XCTAssertEqual(cleanupCount, 0) + XCTAssertEqual(watermark, 42) + XCTAssertEqual(try store.pending()?.id, confirmation.id) + XCTAssertEqual(manager.publicKey, "replacement") + } + + func testConfirmedRetryUsesOriginalGrantAndNeverRepeatsAcknowledgedCleanup() async throws { + let cases: [(PubkyProfileManager.RemovalAction, String)] = [ + (.disconnect, "public"), (.disconnect, "app"), (.disconnect, "revoke"), + (.deleteProfile, "revoke"), (.unlink, "revoke"), + ] + for (action, failure) in cases { + steps = [] + failStep = failure + appSyncCount = 0 + duringRevocation = nil + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: action, contacts: contacts()) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Remote removal should fail") } + catch let TestError.failure(step) { XCTAssertEqual(step, failure) } + let counts = [profileReads, contactReads, contactRemovals, eligibilityChecks, deleted.count, revocations.count] + let completed = steps + XCTAssertEqual(appSyncCount, failure == "public" ? 0 : 1) + XCTAssertTrue(manager.canRetryRemoteRemoval(id: confirmation.id)) + XCTAssertFalse(manager.canRetryRemoteRemoval(id: UUID())) + let cancelled = try await manager.prepareRemoteRemovalRetry(id: confirmation.id) + manager.cancelRemoteRemovalRetry(cancelled) + do { _ = try await manager.confirmRemoteRemovalRetry(cancelled); XCTFail("Cancelled confirmation was accepted") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + let stale = try await manager.prepareRemoteRemovalRetry(id: confirmation.id) + let fresh = try await manager.prepareRemoteRemovalRetry(id: confirmation.id) + XCTAssertNotEqual(stale.id, fresh.id) + XCTAssertEqual(fresh.removalID, confirmation.id) + do { _ = try await manager.confirmRemoteRemovalRetry(stale); XCTFail("Superseded confirmation was accepted") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + XCTAssertEqual(steps, completed, "Preparing and cancelling a dialog cannot negotiate or execute logout") + failStep = nil + duringRevocation = { [self] in + XCTAssertEqual(appSyncCount, failure == "app" ? 2 : 1, "Registry cleanup must finish before logout") + } + let result = try await manager.confirmRemoteRemovalRetry(fresh) + XCTAssertEqual(result.remoteProfileDeleted, action == .deleteProfile) + XCTAssertEqual(steps, completed + (failure == "public" ? ["public"] : []) + ["revoke", "credentials"]) + XCTAssertEqual(appSyncCount, failure == "app" ? 2 : 1) + XCTAssertEqual([profileReads, contactReads, contactRemovals, eligibilityChecks, deleted.count, revocations.count], counts) + XCTAssertNil(try store.pending()) + XCTAssertFalse(manager.canRetryRemoteRemoval(id: confirmation.id)) + do { _ = try await manager.confirmRemoteRemovalRetry(fresh); XCTFail("Confirmation was consumed twice") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + } + } + + func testPublicCleanupFailureCannotRetryDeletionOrUnlinkAsSignOut() async throws { + for action in [PubkyProfileManager.RemovalAction.deleteProfile, .unlink] { + for failure in ["public", "app"] { + steps = [] + watermark = 42 + failStep = failure + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: action, contacts: contacts()) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Public cleanup should fail") } + catch let TestError.failure(step) { XCTAssertEqual(step, failure) } + XCTAssertEqual(steps, ["private", "public"]) + XCTAssertTrue(deleted.isEmpty) + XCTAssertEqual(contactRemovals, 0) + XCTAssertEqual(watermark, 42) + XCTAssertEqual(try store.pending()?.id, confirmation.id) + XCTAssertFalse(manager.canRetryRemoteRemoval(id: confirmation.id)) + do { _ = try await manager.prepareRemoteRemovalRetry(id: confirmation.id); XCTFail("Destructive cleanup was not completed") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + _ = try await manager.finishPendingRemoval(id: confirmation.id) + XCTAssertEqual(steps, ["private", "public", "credentials"]) + XCTAssertNil(try store.pending()) + } + } + } + + func testRetiredCapturedGrantStopsBeforeAnyCleanup() async throws { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + let captured = try XCTUnwrap(revocations.last) + captured.handle().revoke() + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Original grant admission ended") } + catch ProfileRemovalStore.StoreError.changed {} + XCTAssertTrue(steps.isEmpty, "Discovering a retired grant after cleanup is too late") + XCTAssertTrue(deleted.isEmpty) + XCTAssertEqual(contactRemovals, 0) + XCTAssertEqual(cleanupCount, 0) + } + + func testWipeWaitsForRemovalResumeTailBeforeCompletingDrain() async throws { + try await assertWipeWaitsForResume(retry: false) + } + + func testWipeWaitsForRetryResumeTailBeforeCompletingDrain() async throws { + try await assertWipeWaitsForResume(retry: true) + } + + private func assertWipeWaitsForResume(retry: Bool) async throws { + failStep = "revoke" + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .disconnect) + var retryConfirmation: PubkyProfileManager.RemoteRemovalConfirmation? + if retry { + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Expected unknown logout") } + catch TestError.failure("revoke") {} + retryConfirmation = try await manager.prepareRemoteRemovalRetry(id: confirmation.id) + } + let resuming = expectation(description: "Removal resume is still active") + let barrier = NativeCompletion() + duringResume = { await barrier.wait { resuming.fulfill() } } + let removal = Task { + if let retryConfirmation { + return try await manager.confirmRemoteRemovalRetry(retryConfirmation) + } + return try await manager.confirmRemoval(confirmation) + } + await fulfillment(of: [resuming], timeout: 2) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(wipe) } + let waiting = expectation(description: "Successor wipe starts draining") + var drained = false + let successor = Task { + waiting.fulfill() + try await admission.drain(before: wipe) + drained = true + } + await fulfillment(of: [waiting], timeout: 2) + XCTAssertFalse(drained, "Resume and acknowledgement reconciliation remain part of the original operation") + XCTAssertTrue(manager.isDeletingProfile) + await barrier.finish() + do { _ = try await removal.value; XCTFail("Expected original unknown result") } + catch TestError.failure("revoke") {} + try await successor.value + XCTAssertTrue(drained) + XCTAssertFalse(manager.canRetryRemoteRemoval(id: confirmation.id)) + } + + func testCapturedGrantRetirementDuringCleanupStopsFollowingStages() async throws { + for action in [PubkyProfileManager.RemovalAction.disconnect, .deleteProfile] { + steps = [] + cleanupCount = 0 + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: action, contacts: contacts()) + duringPrivateWithdrawal = { [self] in try XCTUnwrap(revocations.last).handle().revoke() } + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Original grant admission ended during withdrawal") } + catch ProfileRemovalStore.StoreError.changed {} + XCTAssertEqual(steps, ["private"]) + XCTAssertTrue(deleted.isEmpty) + XCTAssertEqual(contactRemovals, 0) + XCTAssertEqual(cleanupCount, 0) + XCTAssertEqual(appSyncCount, 0) + XCTAssertFalse(manager.canRetryRemoteRemoval(id: confirmation.id)) + _ = try await manager.finishPendingRemoval(id: confirmation.id) + } + } + + func testUnknownRetryNeedsAnotherConfirmationAndDrainsBeforeOfferingIt() async throws { + failStep = "public" + let manager = makeManager() + let initial = try await manager.prepareRemoval(action: .disconnect) + do { _ = try await manager.confirmRemoval(initial); XCTFail("Expected public cleanup failure") } + catch TestError.failure("public") {} + let retry = try await manager.prepareRemoteRemovalRetry(id: initial.id) + failStep = nil + let entered = expectation(description: "Retry attempt entered") + let barrier = NativeCompletion() + duringRevocation = { + await barrier.wait { entered.fulfill() } + throw TestError.failure("unknown") + } + let operation = Task { try await manager.confirmRemoteRemovalRetry(retry) } + await fulfillment(of: [entered], timeout: 2) + XCTAssertFalse(manager.canRetryRemoteRemoval(id: initial.id)) + do { _ = try await manager.confirmRemoteRemovalRetry(retry); XCTFail("Duplicate tap started an overlapping attempt") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + do { _ = try await manager.finishPendingRemoval(id: initial.id); XCTFail("Local cleanup overtook the native tail") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + XCTAssertEqual(steps, ["private", "public", "public", "revoke"]) + await barrier.finish() + do { _ = try await operation.value; XCTFail("Expected unknown retry result") } + catch TestError.failure("unknown") {} + XCTAssertTrue(manager.canRetryRemoteRemoval(id: initial.id)) + do { _ = try await manager.confirmRemoteRemovalRetry(retry); XCTFail("Consumed nonce confirmation was reused") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + let next = try await manager.prepareRemoteRemovalRetry(id: initial.id) + XCTAssertNotEqual(next.id, retry.id) + duringRevocation = nil + _ = try await manager.confirmRemoteRemovalRetry(next) + XCTAssertEqual(steps, ["private", "public", "public", "revoke", "revoke", "credentials"]) + XCTAssertEqual(appSyncCount, 1) + XCTAssertEqual(revocations.count, 1) + } + + func testFailedRetryValidationCannotReviveAfterTheOriginalValuesReturn() async throws { + for confirmationOpen in [false, true] { + steps = [] + failStep = "revoke" + let manager = makeManager() + let initial = try await manager.prepareRemoval(action: .disconnect) + do { _ = try await manager.confirmRemoval(initial); XCTFail("Expected unknown logout") } + catch TestError.failure("revoke") {} + var retry: PubkyProfileManager.RemoteRemovalConfirmation? + if confirmationOpen { + retry = try await manager.prepareRemoteRemovalRetry(id: initial.id) + } + duringRevocationValidation = { throw TestError.failure("changed-credentials") } + do { + if let retry { + _ = try await manager.confirmRemoteRemovalRetry(retry) + } else { + _ = try await manager.prepareRemoteRemovalRetry(id: initial.id) + } + XCTFail("Changed binding must refuse retry") + } catch TestError.failure("changed-credentials") {} + duringRevocationValidation = nil + XCTAssertFalse(manager.canRetryRemoteRemoval(id: initial.id)) + XCTAssertTrue(try XCTUnwrap(revocations.last).revoked) + XCTAssertEqual(try store.pending()?.id, initial.id) + do { _ = try await manager.prepareRemoteRemovalRetry(id: initial.id); XCTFail("Restored values cannot revive the same removal") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + XCTAssertEqual(steps, ["private", "public", "revoke"]) + _ = try await manager.finishPendingRemoval(id: initial.id) + XCTAssertEqual(steps, ["private", "public", "revoke", "credentials"]) + } + } + + func testAcknowledgementAfterFailedResponsePermitsOnlyLocalCompletion() async throws { + let manager = makeManager() + duringRevocation = { [self] in + revocations.last?.acknowledge() + throw TestError.failure("lost-response") + } + let initial = try await manager.prepareRemoval(action: .disconnect) + do { _ = try await manager.confirmRemoval(initial); XCTFail("Response failed") } + catch TestError.failure("lost-response") {} + XCTAssertFalse(manager.canRetryRemoteRemoval(id: initial.id)) + XCTAssertTrue(try XCTUnwrap(revocations.last).revoked) + do { _ = try await manager.prepareRemoteRemovalRetry(id: initial.id); XCTFail("Known logout cannot be retried") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + _ = try await manager.finishPendingRemoval(id: initial.id) + XCTAssertEqual(steps, ["private", "public", "revoke", "credentials"]) + } + + func testUnknownLogoutCannotRestoreRetryFromPersistentRecordOrReplacementOwner() async throws { + failStep = "revoke" + let manager = makeManager() + let initial = try await manager.prepareRemoval(action: .disconnect) + do { _ = try await manager.confirmRemoval(initial); XCTFail("Expected unknown logout") } + catch TestError.failure("revoke") {} + let restarted = makeManager() + XCTAssertFalse(restarted.canRetryRemoteRemoval(id: initial.id)) + do { _ = try await restarted.prepareRemoteRemovalRetry(id: initial.id); XCTFail("Record cannot recreate remote authority") } + catch PubkyProfileManager.RemovalError.confirmationChanged {} + let retry = try await manager.prepareRemoteRemovalRetry(id: initial.id) + manager.publicKey = "replacement" + XCTAssertFalse(manager.canRetryRemoteRemoval(id: initial.id)) + do { _ = try await manager.confirmRemoteRemovalRetry(retry); XCTFail("Old owner cannot act on replacement access") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + XCTAssertEqual(steps, ["private", "public", "revoke"]) + XCTAssertEqual(cleanupCount, 0) + failStep = nil + _ = try await manager.finishPendingRemoval(id: initial.id) + } + + func testWipeAfterUnknownLogoutRetiresIdleIntentWithoutWaitingForAnotherConfirmation() async throws { + failStep = "revoke" + let manager = makeManager() + let initial = try await manager.prepareRemoval(action: .disconnect) + do { _ = try await manager.confirmRemoval(initial); XCTFail("Expected unknown logout") } + catch TestError.failure("revoke") {} + let retry = try await manager.prepareRemoteRemovalRetry(id: initial.id) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(wipe) } + try await admission.drain(before: wipe) + XCTAssertTrue(try XCTUnwrap(revocations.last).revoked) + XCTAssertFalse(manager.canRetryRemoteRemoval(id: initial.id)) + admission.revoke(wipe) + do { _ = try await manager.confirmRemoteRemovalRetry(retry); XCTFail("Cancelled successor wipe cannot revive logout") } + catch PubkyProfileManager.RemovalError.confirmationRequired {} + XCTAssertEqual(steps, ["private", "public", "revoke"]) + } + + func testCancelledRevocationCannotEnterLocalFinalization() async throws { + let started = expectation(description: "Original grant revocation entered") + let barrier = NativeCompletion() + duringRevocation = { await barrier.wait { started.fulfill() } } + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .disconnect) + let operation = Task { try await manager.confirmRemoval(confirmation) } + await fulfillment(of: [started], timeout: 2) + operation.cancel() + XCTAssertEqual(cleanupCount, 0) + await barrier.finish() + do { _ = try await operation.value; XCTFail("Cancellation must retain recovery") } + catch is CancellationError {} + XCTAssertEqual(steps, ["private", "public", "revoke"]) + XCTAssertEqual(watermark, 42) + XCTAssertEqual(try store.pending()?.id, confirmation.id) + } + + func testReadOnlySignOutDoesNotRequireRemoteAccess() async throws { + let manager = makeManager() + manager.publicKey = nil + manager.authState = .idle + let confirmation = try await manager.prepareRemoval(action: .disconnect) + _ = try await manager.confirmRemoval(confirmation) + XCTAssertEqual(steps, ["credentials"]) + } + + func testUncertain404LikeErrorNeverBecomesSuccess() async throws { + let failure = NSError(domain: "SessionEndpoint", code: 404, userInfo: [NSLocalizedDescriptionKey: "Missing session"]) + let manager = makeManager(delete: { _, _ in throw failure }) + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("Missing session is not acknowledged profile deletion") } + catch { XCTAssertEqual((error as NSError).domain, failure.domain) } + XCTAssertEqual(watermark, 42) + XCTAssertEqual(manager.publicKey, owner) + XCTAssertNotNil(try store.pending()) + } + + func testCancelledAdmittedNativeWriteDrainsBeforeReleasingOwnership() async throws { + let started = expectation(description: "Native write entered") + let barrier = NativeCompletion() + let manager = makeManager(delete: { _, _ in await barrier.wait { started.fulfill() } }) + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, contacts: contacts()) + let operation = Task { try await manager.confirmRemoval(confirmation) } + await fulfillment(of: [started], timeout: 2) + operation.cancel() + XCTAssertTrue(manager.isDeletingProfile) + XCTAssertNil(manager.currentSession) + XCTAssertThrowsError(try admission.snapshot()) + await barrier.finish() + do { _ = try await operation.value; XCTFail("Cancellation cannot enter local finalization") } + catch is CancellationError {} + XCTAssertEqual(cleanupCount, 0) + XCTAssertFalse(manager.isDeletingProfile) + XCTAssertEqual(try store.pending()?.id, confirmation.id) + } + + private enum TestError: Error { case failure(String), conflict } + private actor NativeCompletion { + var continuation: CheckedContinuation? + func wait(started: @Sendable () -> Void) async { + await withCheckedContinuation { continuation = $0; started() } + } + + func finish() { + continuation?.resume(); continuation = nil + } + } +} diff --git a/BitkitTests/ProfileRemovalLeaseTests.swift b/BitkitTests/ProfileRemovalLeaseTests.swift new file mode 100644 index 000000000..7a96010b9 --- /dev/null +++ b/BitkitTests/ProfileRemovalLeaseTests.swift @@ -0,0 +1,114 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class ProfileRemovalLeaseTests: XCTestCase { + private var directory: URL! + private var store: ProfileRemovalStore! + private var admission: ProfileOperationAdmission! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + admission = ProfileOperationAdmission(store: store) + } + + override func tearDownWithError() throws { + if FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + } + + func testRetiringIdleIntentAllowsExactLocalRecovery() async throws { + let id = UUID() + let scope = try admission.reserve(kind: .removal, id: id, expected: admission.snapshot()) + let remote = ProfileRevocationTestDouble() + let handle = remote.handle() + let lease = ProfileRemovalLease(scope: scope, revokeRemoteAdmission: handle.revoke) + try await lease.withOperation { try self.admission.persist(scope) } + XCTAssertTrue(lease.canResume) + lease.retire() + lease.retire() + XCTAssertTrue(remote.revoked) + XCTAssertFalse(lease.canResume) + let local = try admission.reserve(kind: .localRecovery, id: id) + defer { admission.release(local) } + try await admission.drain(before: local) + try admission.complete(local) + } + + func testWipeRevokesIdleAuthorityButDrainsActiveTailUntilItActuallyReturns() async throws { + let id = UUID() + let scope = try admission.reserve(kind: .removal, id: id, expected: admission.snapshot()) + let remote = ProfileRevocationTestDouble() + let handle = remote.handle() + let lease = ProfileRemovalLease(scope: scope, revokeRemoteAdmission: handle.revoke) + let active = expectation(description: "Original native work is active") + let barrier = NativeTail() + let operation = Task { + try await lease.withOperation { + try self.admission.persist(scope) + await barrier.wait { active.fulfill() } + } + } + await fulfillment(of: [active], timeout: 2) + XCTAssertFalse(lease.canResume) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + defer { admission.release(wipe) } + XCTAssertTrue(remote.revoked) + let waiting = expectation(description: "Wipe starts draining") + var drained = false + let wipeTask = Task { + waiting.fulfill() + try await admission.drain(before: wipe) + drained = true + } + await fulfillment(of: [waiting], timeout: 2) + XCTAssertFalse(drained, "Releasing the idle holder must not release active native work") + await barrier.finish() + do { _ = try await operation.value; XCTFail("Wipe ended original admission") } + catch ProfileRemovalStore.StoreError.changed {} + try await wipeTask.value + XCTAssertTrue(drained) + XCTAssertFalse(lease.canResume) + } + + func testCancellationRetiresIdleHolderAndCannotReviveIntent() async throws { + let id = UUID() + let scope = try admission.reserve(kind: .removal, id: id, expected: admission.snapshot()) + let remote = ProfileRevocationTestDouble() + let handle = remote.handle() + let lease = ProfileRemovalLease(scope: scope, revokeRemoteAdmission: handle.revoke) + let active = expectation(description: "Native work entered") + let barrier = NativeTail() + let operation = Task { + try await lease.withOperation { + try self.admission.persist(scope) + await barrier.wait { active.fulfill() } + } + } + await fulfillment(of: [active], timeout: 2) + operation.cancel() + XCTAssertTrue(remote.revoked) + XCTAssertFalse(lease.canResume) + XCTAssertThrowsError(try admission.reserve(kind: .localRecovery, id: id), "Active holder still owns its tail") + await barrier.finish() + do { _ = try await operation.value; XCTFail("Cancelled operation cannot succeed") } + catch is CancellationError {} + let local = try admission.reserve(kind: .localRecovery, id: id) + defer { admission.release(local) } + try await admission.drain(before: local) + XCTAssertFalse(lease.canResume) + } + + private actor NativeTail { + private var continuation: CheckedContinuation? + func wait(entered: @Sendable () -> Void) async { + await withCheckedContinuation { continuation = $0; entered() } + } + + func finish() { + continuation?.resume(); continuation = nil + } + } +} diff --git a/BitkitTests/ProfileRemovalReservationTests.swift b/BitkitTests/ProfileRemovalReservationTests.swift new file mode 100644 index 000000000..5e229b907 --- /dev/null +++ b/BitkitTests/ProfileRemovalReservationTests.swift @@ -0,0 +1,60 @@ +@testable import Bitkit +import XCTest + +final class ProfileRemovalReservationTests: XCTestCase { + private let key = "privatePaykitAddressReservations" + + private func defaults() throws -> UserDefaults { + let suite = "ProfileRemovalReservations.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + addTeardownBlock { defaults.removePersistentDomain(forName: suite) } + return defaults + } + + func testRemovalClearsAttributionAndRetainsReservedIndexesAndRestoredCeilings() async throws { + let defaults = try defaults() + let original: [String: Any] = [ + "version": 1, + "reservedReceiveIndexesByAddressType": ["nativeSegwit": [3, 7]], + "contactAssignments": ["old-owner": ["addressType": "nativeSegwit", "receiveIndex": 7]], + "contactAssignmentHistory": ["old-owner": [["addressType": "nativeSegwit", "receiveIndex": 3]]], + "restoredReservedReceiveIndexCeilingsByAddressType": ["taproot": 12], + ] + try defaults.set(JSONSerialization.data(withJSONObject: original), forKey: key) + let store = PrivatePaykitAddressReservationStore(defaults: defaults) + try await store.clearContactAssignmentsForRemoval() + let result = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(defaults.data(forKey: key))) as? [String: Any]) + XCTAssertEqual(result["contactAssignments"] as? [String: String], [:]) + XCTAssertEqual(result["contactAssignmentHistory"] as? [String: String], [:]) + XCTAssertEqual(Set((result["reservedReceiveIndexesByAddressType"] as? [String: [Int]])?["nativeSegwit"] ?? []), [3, 7]) + XCTAssertEqual(result["restoredReservedReceiveIndexCeilingsByAddressType"] as? [String: Int], ["taproot": 12]) + let snapshot = await store.backupSnapshot() + XCTAssertEqual(snapshot, ["nativeSegwit": 7, "taproot": 12]) + } + + func testRemovalRejectsUnreadableLedgerWithoutReplacingItWithEmptyState() async throws { + let defaults = try defaults() + for original in [Data("invalid-ledger".utf8), Data("{\"version\":2}".utf8)] { + defaults.set(original, forKey: key) + let store = PrivatePaykitAddressReservationStore(defaults: defaults) + do { + try await store.clearContactAssignmentsForRemoval() + XCTFail("Unreadable reservation exclusions must remain intact") + } catch ProfileRemovalStore.StoreError.unavailable {} + XCTAssertEqual(defaults.data(forKey: key), original) + } + } + + func testRemovalRejectsAStaleLoadedLedgerInsteadOfOverwritingNewerExclusions() async throws { + let defaults = try defaults() + let stale = PrivatePaykitAddressReservationStore(defaults: defaults) + let replacement = PrivatePaykitAddressReservationStore(defaults: defaults) + await replacement.restoreBackup(["taproot": 28]) + let original = defaults.data(forKey: key) + do { + try await stale.clearContactAssignmentsForRemoval() + XCTFail("A stale owner cannot acknowledge cleanup of newer state") + } catch ProfileRemovalStore.StoreError.unavailable {} + XCTAssertEqual(defaults.data(forKey: key), original) + } +} diff --git a/BitkitTests/ProfileRemovalStoreTests.swift b/BitkitTests/ProfileRemovalStoreTests.swift new file mode 100644 index 000000000..b3284991f --- /dev/null +++ b/BitkitTests/ProfileRemovalStoreTests.swift @@ -0,0 +1,100 @@ +@testable import Bitkit +import XCTest + +final class ProfileRemovalStoreTests: XCTestCase { + private var url: URL! + + override func setUpWithError() throws { + url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + .appendingPathComponent("pending.json") + } + + override func tearDownWithError() throws { + if FileManager.default.fileExists(atPath: url.deletingLastPathComponent().path) { + try FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } + } + + func testPendingSurvivesReconstructionAndOnlyExactAttemptCompletes() throws { + let id = UUID() + let first = ProfileRemovalStore(url: url) + try first.begin(id: id) + let reopened = ProfileRemovalStore(url: url) + XCTAssertEqual(try reopened.pending()?.id, id) + XCTAssertThrowsError(try reopened.requireNoPending()) + XCTAssertThrowsError(try reopened.begin(id: UUID())) + XCTAssertThrowsError(try reopened.complete(id: UUID())) + try reopened.complete(id: id) + XCTAssertNil(try ProfileRemovalStore(url: url).pending()) + } + + func testGenerationRejectsWorkAcrossEntirePendingInterval() throws { + let store = ProfileRemovalStore(url: url) + let generation = try store.requireNoPending() + let id = UUID() + try store.begin(id: id) + try store.complete(id: id) + XCTAssertThrowsError(try store.validate(generation: generation)) + XCTAssertNoThrow(try store.validate(generation: store.requireNoPending())) + } + + func testMalformedOrNewerDataFailsClosedWithoutOverwritingIt() throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let values = try [Data("invalid".utf8), JSONEncoder().encode(ProfileRemovalStore.Pending(version: 2, id: UUID(), scope: .profile))] + for bytes in values { + try bytes.write(to: url) + let store = ProfileRemovalStore(url: url) + XCTAssertThrowsError(try store.pending()) + XCTAssertThrowsError(try store.requireNoPending()) + XCTAssertThrowsError(try store.begin(id: UUID())) + XCTAssertThrowsError(try store.complete(id: UUID())) + XCTAssertEqual(try Data(contentsOf: url), bytes) + } + } + + func testExplicitRecoveryAssignsExactUUIDOnlyToKnownMalformedRecord() throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let bytes = Data(#"{"version":1,"scope":"walletWipe","id":"damaged"}"#.utf8) + try bytes.write(to: url) + let store = ProfileRemovalStore(url: url) + XCTAssertThrowsError(try store.pending()) + try store.recoverMalformedRecord() + let record = try XCTUnwrap(store.pending()) + XCTAssertEqual(record.scope, .walletWipe) + XCTAssertEqual(try ProfileRemovalStore(url: url).pending(), record) + XCTAssertThrowsError(try store.complete(id: UUID(), scope: .walletWipe)) + try store.recoverMalformedRecord() + XCTAssertEqual(try store.pending(), record) + try store.complete(id: record.id, scope: .walletWipe) + let future = Data(#"{"version":2,"scope":"profile","id":"damaged"}"#.utf8) + try future.write(to: url) + XCTAssertThrowsError(try store.recoverMalformedRecord()) + XCTAssertEqual(try Data(contentsOf: url), future) + } + + func testFailedWriteCannotAuthorizeDestructiveWork() throws { + let store = ProfileRemovalStore(url: url, write: { _, _ in throw CocoaError(.fileWriteNoPermission) }) + XCTAssertThrowsError(try store.begin(id: UUID())) + XCTAssertNil(try store.pending()) + } + + func testFailedCompletionRetainsPendingForLocalRecovery() throws { + let id = UUID() + let store = ProfileRemovalStore(url: url, remove: { _ in throw CocoaError(.fileWriteNoPermission) }) + try store.begin(id: id) + XCTAssertThrowsError(try store.complete(id: id)) + XCTAssertEqual(try ProfileRemovalStore(url: url).pending()?.id, id) + } + + func testWalletWipeCannotBeDowngradedOrCompletedAsProfileCleanup() throws { + let id = UUID() + let store = ProfileRemovalStore(url: url) + try store.begin(id: id) + try store.begin(id: id, scope: .walletWipe) + XCTAssertThrowsError(try store.begin(id: id)) + XCTAssertThrowsError(try store.complete(id: id)) + XCTAssertEqual(try store.pending()?.scope, .walletWipe) + try store.complete(id: id, scope: .walletWipe) + XCTAssertNil(try store.pending()) + } +} diff --git a/BitkitTests/ProfileRevocationTestDouble.swift b/BitkitTests/ProfileRevocationTestDouble.swift new file mode 100644 index 000000000..0dfb86741 --- /dev/null +++ b/BitkitTests/ProfileRevocationTestDouble.swift @@ -0,0 +1,46 @@ +@testable import Bitkit +import Foundation + +final class ProfileRevocationTestDouble: @unchecked Sendable { + private let lock = NSLock() + private var didAcknowledge = false + private var didRevoke = false + private let execute: @MainActor () async throws -> Void + private let clear: @MainActor () async throws -> Void + private let validate: @MainActor () async throws -> Void + + init(execute: @escaping @MainActor () async throws -> Void = {}, + clear: @escaping @MainActor () async throws -> Void = {}, + validate: @escaping @MainActor () async throws -> Void = {}) + { + self.execute = execute + self.clear = clear + self.validate = validate + } + + var revoked: Bool { + lock.withLock { didRevoke } + } + + func acknowledge() { + lock.withLock { didAcknowledge = true } + } + + private func requireCurrent() throws { + guard !revoked else { throw ProfileRemovalStore.StoreError.changed } + } + + func handle() -> ProfileSessionRevocation { + ProfileSessionRevocation(validate: { [self] in + try requireCurrent() + try await validate() + }, execute: { [self] in + try requireCurrent() + try await execute() + acknowledge() + }, acknowledged: { [self] in lock.withLock { didAcknowledge } }, clearCredentials: { [self] in + guard lock.withLock({ didAcknowledge }) else { throw ProfileRemovalStore.StoreError.changed } + try await clear() + }, revoke: { [self] in lock.withLock { didRevoke = true } }) + } +} diff --git a/BitkitTests/ProfileSessionTransitionTests.swift b/BitkitTests/ProfileSessionTransitionTests.swift new file mode 100644 index 000000000..c1c2c1f5e --- /dev/null +++ b/BitkitTests/ProfileSessionTransitionTests.swift @@ -0,0 +1,448 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class ProfileSessionTransitionTests: XCTestCase { + private var directory: URL! + private var store: ProfileRemovalStore! + private var admission: ProfileOperationAdmission! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + admission = ProfileOperationAdmission(store: store) + } + + override func tearDownWithError() throws { + if FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + } + + func testFailedCreationHandsItsOwnNewRevisionBackForExplicitRetry() async throws { + snapshotAppDefaultsDomain() + let manager = PubkyProfileManager() + let admission = ProfileOperationAdmission.shared + var owner = manager.removalPresentationOwner + var generation = try admission.snapshot() + for _ in 0 ..< 2 { + let handedBack = expectation(description: "Reconciled failure returns an exact retry context") + try await admission.withSessionTransition(expected: generation) { + XCTAssertTrue(manager.ownsRemovalPresentation(owner)) + do { + try await manager.createIdentity(name: "Original", bio: "", links: [], loadStoredSecretKey: { + throw URLError(.notConnectedToInternet) + }) + XCTFail("The controlled remote preparation must fail") + } catch let error as URLError { + XCTAssertEqual(error.code, .notConnectedToInternet) + XCTAssertFalse(manager.ownsRemovalPresentation(owner), "The manager advances its session revision even on failure") + try manager.schedulePresentationContext { nextOwner, nextGeneration in + owner = nextOwner; generation = nextGeneration + handedBack.fulfill() + } + } + } + await fulfillment(of: [handedBack], timeout: 2) + XCTAssertTrue(manager.ownsRemovalPresentation(owner)) + XCTAssertNoThrow(try admission.validate(generation)) + } + } + + func testCreationDoesNotAuthenticateAgainAfterRequiredLocalSignupFailure() async throws { + for error: Error in [Bitkit.KeychainError.failedToSave, + PubkyCredentialLocalError( + underlyingError: Bitkit.KeychainError.failedToSave, + cleanupError: Bitkit.KeychainError.failedToDelete + ), + ProfileRemovalStore.StoreError.unavailable] + { + var fallbacks = 0 + try await ProfileOperationAdmission.shared.withSessionTransition { + do { + _ = try await PubkyProfileManager.signUpOrSignIn(signUp: { throw error }, signIn: { + fallbacks += 1 + return "replacement" + }) + XCTFail("Required local failure must escape the authentication fallback") + } catch { + XCTAssertTrue(error is Bitkit.KeychainError || error is PubkyCredentialLocalError || error is ProfileRemovalStore.StoreError) + } + } + XCTAssertEqual(fallbacks, 0) + } + } + + func testCreationRetainsRemoteRegistrationFallbackForItsOriginalOwner() async throws { + var fallbacks = 0 + let result = try await ProfileOperationAdmission.shared.withSessionTransition { + try await PubkyProfileManager.signUpOrSignIn(signUp: { throw PubkyServiceError.authFailed("Already registered") }, signIn: { + fallbacks += 1 + return "original" + }) + } + XCTAssertEqual(result, "original") + XCTAssertEqual(fallbacks, 1) + } + + func testQueuedPresentationHandoffCannotCaptureASuccessorWipe() async throws { + let manager = PubkyProfileManager(removalStore: store, removalAdmission: admission) + let transition = try admission.reserveSessionTransition() + var handedBack = false + try await admission.withScope(transition, access: .control) { + try manager.schedulePresentationContext { _, _ in handedBack = true } + } + admission.release(transition) + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + admission.revoke(wipe) + admission.release(wipe) + // The queued callback cannot run on MainActor until this function yields. + for _ in 0 ..< 10 { + await Task.yield() + } + XCTAssertFalse(handedBack) + XCTAssertNoThrow(try admission.snapshot()) + } + + func testTransitionDrainsEarlierOuterWorkBeforeMutation() async throws { + let old = try admission.capture() + let transition = try admission.reserveSessionTransition() + var entered = false + let operation = Task { + try await admission.withScope(transition, access: .control) { + try await admission.drain(before: transition) + try admission.checkCurrent() + entered = true + } + } + await Task.yield() + XCTAssertFalse(entered) + XCTAssertThrowsError(try admission.validate(old)) + XCTAssertThrowsError(try admission.capture()) + admission.release(old) + try await operation.value + XCTAssertTrue(entered) + admission.release(transition) + XCTAssertNoThrow(try admission.snapshot()) + XCTAssertNil(try store.pending()) + } + + func testNestedTransitionReusesOwnerButStableSnapshotsRemainClosed() async throws { + let before = try admission.snapshot() + try await admission.withSessionTransition { + XCTAssertThrowsError(try self.admission.snapshot()) + let operation = try self.admission.operationGeneration() + XCTAssertEqual(operation, before) + try await self.admission.withSessionTransition(expected: operation) { + try self.admission.validateOperationGeneration(operation) + try await self.admission.withOperation { try self.admission.checkCurrent() } + } + let gate = try XCTUnwrap(self.admission) + let competing = Task.detached { try gate.reserveSessionTransition() } + do { _ = try await competing.value; XCTFail("An unrelated transition entered") } + catch ProfileRemovalStore.StoreError.changed {} + } + XCTAssertThrowsError(try admission.validate(before)) + XCTAssertNil(try store.pending()) + } + + func testOrdinaryScopeCannotUpgradeItsInheritedChildren() async throws { + try await admission.withOperation { + do { + try await self.admission.withSessionTransition { XCTFail("Ordinary scope was upgraded") } + XCTFail("A new transition must start at its outer entry") + } catch ProfileRemovalStore.StoreError.changed {} + try self.admission.checkCurrent() + } + } + + func testRegistrationGenerationCanActivateOnce() async throws { + let registration = try admission.snapshot() + try await admission.withSessionTransition(expected: registration) { + try self.admission.validateOperationGeneration(registration) + } + do { + try await admission.withSessionTransition(expected: registration) { XCTFail("Old registration was reused") } + XCTFail("Ended registration must be stale") + } catch ProfileRemovalStore.StoreError.changed {} + } + + func testPendingAndUnavailableStorageCannotStartTransition() throws { + try store.begin(id: UUID()) + XCTAssertThrowsError(try admission.reserveSessionTransition()) + try Data("unreadable-format".utf8).write(to: directory.appendingPathComponent("pending.json")) + XCTAssertThrowsError(try admission.reserveSessionTransition()) + } + + func testTransitionCannotAcquireRemovalCompletionAuthority() async throws { + try await admission.withSessionTransition { + let scope = try XCTUnwrap(ProfileOperationAdmission.current) + XCTAssertThrowsError(try self.admission.persist(scope)) + XCTAssertThrowsError(try self.admission.complete(scope)) + XCTAssertNil(try self.store.pending()) + } + } + + func testOldTransitionReleaseCannotReopenPreemptingWipe() async throws { + let transition = try admission.reserveSessionTransition() + let wipe = try admission.reserve(kind: .walletWipe, id: UUID()) + admission.release(transition) + XCTAssertTrue(admission.isWalletWiping) + XCTAssertThrowsError(try admission.snapshot()) + try await admission.withScope(wipe, access: .control) { try await self.admission.drain(before: wipe) } + admission.release(wipe) + XCTAssertNoThrow(try admission.snapshot()) + } + + @MainActor + func testAutomaticSessionWorkUsesOrdinaryAdmissionAfterTransition() async throws { + let gate = try XCTUnwrap(admission) + let manager = PubkyProfileManager(removalStore: store, removalAdmission: gate) + manager.publicKey = "original" + manager.authState = .authenticated + let session = try XCTUnwrap(manager.automaticPaykitSession) + let completed = expectation(description: "Automatic work uses live admission") + var didStart = false + try await gate.withSessionTransition { + try manager.scheduleAutomaticPaykitSession { owner in + didStart = true + XCTAssertEqual(owner, session) + do { + try gate.checkCurrent() + _ = try gate.snapshot() + } catch { + XCTFail("Automatic work must have ordinary admission after the transition: \(error)") + } + completed.fulfill() + } + XCTAssertFalse(didStart) + } + await fulfillment(of: [completed], timeout: 2) + } + + @MainActor + func testAutomaticSessionWorkCannotStartAfterASuccessorReservesAdmission() async throws { + let gate = try XCTUnwrap(admission) + let manager = PubkyProfileManager(removalStore: store, removalAdmission: gate) + manager.publicKey = "same-key" + manager.authState = .authenticated + for successorIsWipe in [false, true] { + let transition = try gate.reserveSessionTransition() + var didStart = false + try await gate.withScope(transition, access: .control) { + try manager.scheduleAutomaticPaykitSession { _ in didStart = true } + } + // Release and replace without yielding the main actor to the scheduled startup. + gate.release(transition) + let successor = try successorIsWipe + ? gate.reserve(kind: .walletWipe, id: UUID()) + : gate.reserveSessionTransition() + try await gate.drain(before: successor) + gate.release(successor) + XCTAssertFalse(didStart, "A successor with the same visible key must still invalidate queued startup") + } + } + + @MainActor + func testAutomaticSessionWorkRetainsAdmissionUntilItsAsyncTailReturns() async throws { + let gate = try XCTUnwrap(admission) + let manager = PubkyProfileManager(removalStore: store, removalAdmission: gate) + manager.publicKey = "original" + manager.authState = .authenticated + let started = expectation(description: "Automatic startup suspended") + let finished = expectation(description: "Automatic startup tail returned") + var continuation: CheckedContinuation? + var tailReturned = false + try await gate.withSessionTransition { + try manager.scheduleAutomaticPaykitSession { _ in + await withCheckedContinuation { + continuation = $0 + started.fulfill() + } + do { + try gate.checkCurrent() + XCTFail("The wipe must revoke further authenticated work") + } catch ProfileRemovalStore.StoreError.changed { + } catch { + XCTFail("Unexpected admission error: \(error)") + } + tailReturned = true + finished.fulfill() + } + } + await fulfillment(of: [started], timeout: 2) + let wipe = try gate.reserve(kind: .walletWipe, id: UUID()) + defer { gate.release(wipe) } + var drained = false + let drain = Task { + try await gate.drain(before: wipe) + drained = true + } + await Task.yield() + XCTAssertFalse(drained) + continuation?.resume() + try await drain.value + await fulfillment(of: [finished], timeout: 2) + XCTAssertTrue(tailReturned) + XCTAssertTrue(drained) + } + + func testOptionalWorkReceivesCommittedOwnerWithoutRetainingTransition() async throws { + let started = expectation(description: "Optional work scheduled after transition") + let gate = try XCTUnwrap(admission) + let old = try admission.snapshot() + var scope: ProfileOperationAdmission.Scope? + try await admission.withSessionTransition { + try self.admission.scheduleAfterTransition { next in + XCTAssertNil(ProfileOperationAdmission.current) + started.fulfill() + gate.release(next) + } + XCTAssertThrowsError(try self.admission.snapshot()) + } + await fulfillment(of: [started], timeout: 1) + XCTAssertThrowsError(try admission.validate(old)) + try await admission.withSessionTransition { scope = ProfileOperationAdmission.current } + XCTAssertNotNil(scope) + } + + func testReplacementRejectsQueuedOptionalWorkFromEarlierTransition() async throws { + let gate = try XCTUnwrap(admission) + let holder = TransitionScopeHolder() + try await gate.withSessionTransition { + try gate.scheduleAfterTransition { holder.set($0) } + } + let optional = try XCTUnwrap(holder.get()) + let replacement = try gate.reserveSessionTransition() + XCTAssertThrowsError(try gate.validate(optional)) + gate.release(optional) + try await gate.withScope(replacement, access: .control) { try await gate.drain(before: replacement) } + gate.release(replacement) + } + + func testFailedRecoveryRecordBlocksNewIdentityAndKeepsWipeLocal() async throws { + var allowWrite = false + let failedStore = ProfileRemovalStore(url: directory.appendingPathComponent("failure.json"), write: { data, url in + guard allowWrite else { throw CocoaError(.fileWriteNoPermission) } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + }) + let gate = ProfileOperationAdmission(store: failedStore) + do { + try await gate.withSessionTransition { + let receipt = try gate.retainLocalCompletion() + defer { gate.release(receipt) } + do { try gate.recordLocalCompletionFailure(receipt, id: UUID()); XCTFail("Marker write unexpectedly succeeded") } + catch let error as CocoaError { XCTAssertEqual(error.code, .fileWriteNoPermission) } + XCTAssertThrowsError(try gate.checkCurrent()) + } + XCTFail("Unpersisted cleanup reopened the original transition") + } catch ProfileRemovalStore.StoreError.changed {} + XCTAssertTrue(gate.hasUnpersistedLocalFailure) + XCTAssertNil(try failedStore.pending()) + XCTAssertThrowsError(try gate.snapshot()) + let unexpected = try? gate.reserveSessionTransition() + if let unexpected { + gate.release(unexpected) + } + XCTAssertNil(unexpected, "New identity must not bypass failed durable cleanup") + allowWrite = true + let wipe = try gate.reserve(kind: .walletWipe, id: UUID()) + defer { gate.release(wipe) } + try await gate.withScope(wipe, access: .control) { + try await gate.drain(before: wipe) + try gate.persist(wipe) + XCTAssertFalse(try gate.permitsRemoteCleanup(wipe), "Uncertain retained credentials must not authorize remote cleanup") + try gate.complete(wipe) + } + XCTAssertFalse(gate.hasUnpersistedLocalFailure) + } + + func testAcknowledgedCreationIsNotRolledBackByLateOriginalCancellation() async throws { + let gate = try XCTUnwrap(admission) + let committed = expectation(description: "Identity creation acknowledged") + let (stream, release) = AsyncStream.makeStream() + defer { release.finish() } + let task = Task { + try await gate.withSessionTransition { + try gate.acknowledgeSessionTransition(XCTUnwrap(ProfileOperationAdmission.current)) + committed.fulfill() + for await _ in stream {} + try gate.validateAcknowledgedCompletion() + return 42 + } + } + await fulfillment(of: [committed], timeout: 1) + task.cancel() + release.finish() + let result = try await task.value + XCTAssertEqual(result, 42) + XCTAssertNoThrow(try gate.snapshot()) + } + + func testAcknowledgedCreationStillRejectsWipeSupersession() async throws { + let gate = try XCTUnwrap(admission) + let scope = try gate.reserveSessionTransition() + try ProfileOperationAdmission.$current.withValue(scope) { try gate.acknowledgeSessionTransition(scope) } + let wipe = try gate.reserve(kind: .walletWipe, id: UUID()) + XCTAssertThrowsError(try ProfileOperationAdmission.$current.withValue(scope) { try gate.validateAcknowledgedCompletion() }) + gate.release(scope) + try await gate.withScope(wipe, access: .control) { try await gate.drain(before: wipe) } + gate.release(wipe) + } + + func testFailedTransitionDropsOptionalWork() async throws { + let gate = try XCTUnwrap(admission) + do { + try await gate.withSessionTransition { + try gate.scheduleAfterTransition { _ in XCTFail("Failed transition scheduled a publication") } + throw ProfileRemovalStore.StoreError.unavailable + } + XCTFail("Expected the original failure") + } catch ProfileRemovalStore.StoreError.unavailable {} + XCTAssertNoThrow(try gate.snapshot()) + } + + func testRevokedTransitionNeverSchedulesOptionalWork() throws { + let gate = try XCTUnwrap(admission) + let scope = try gate.reserveSessionTransition() + try ProfileOperationAdmission.$current.withValue(scope) { + try gate.scheduleAfterTransition { _ in XCTFail("Revoked owner scheduled optional work") } + } + gate.revoke(scope) + gate.release(scope) + XCTAssertNoThrow(try gate.snapshot()) + } + + func testExplicitReplacementDrainsBackgroundInitialization() async throws { + let gate = try XCTUnwrap(admission) + let initial = try gate.reserveSessionTransition(backgroundInitialization: true) + let replacement = try gate.reserveSessionTransition() + XCTAssertThrowsError(try gate.validate(initial)) + var entered = false + let task = Task { + try await gate.withScope(replacement, access: .control) { + try await gate.drain(before: replacement) + entered = true + } + } + await Task.yield() + XCTAssertFalse(entered) + gate.release(initial) + try await task.value + XCTAssertTrue(entered) + gate.release(replacement) + } +} + +private final class TransitionScopeHolder: @unchecked Sendable { + private let lock = NSLock() + private var scope: ProfileOperationAdmission.Scope? + func set(_ value: ProfileOperationAdmission.Scope) { + lock.withLock { scope = value } + } + + func get() -> ProfileOperationAdmission.Scope? { + lock.withLock { scope } + } +} diff --git a/BitkitTests/ProfileWipeAdmissionTests.swift b/BitkitTests/ProfileWipeAdmissionTests.swift new file mode 100644 index 000000000..0dc542053 --- /dev/null +++ b/BitkitTests/ProfileWipeAdmissionTests.swift @@ -0,0 +1,171 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class ProfileWipeAdmissionTests: XCTestCase { + private var directory: URL! + private var store: ProfileRemovalStore! + private var admission: ProfileOperationAdmission! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + store = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json")) + admission = ProfileOperationAdmission(store: store) + } + + override func tearDownWithError() throws { + if FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + } + + private func service(delete: @escaping (Bitkit.KeychainEntryType) throws -> Void = { _ in }) -> PaykitSdkService { + PaykitSdkService(sessionProvider: .init(loadSessionSecret: { + XCTFail("Local wipe must not load a retained session") + return nil + }, deleteKeychainValue: delete), profileAdmission: admission, sdkFactory: { + XCTFail("Local wipe must not construct or authenticate an SDK") + throw ProfileRemovalStore.StoreError.unavailable + }) + } + + func testFreshWipePersistsBeforeActualCleanupAndCompletesLast() async throws { + let sdk = service() + var paused: UUID? + var resumed: UUID? + var cleanupRan = false + try await AppReset.withWipeAdmission(admission: admission, sdkService: sdk, + pause: { paused = $0 }, resume: { resumed = $0 }) + { permitsRemote in + let pending = try XCTUnwrap(self.store.pending()) + XCTAssertEqual(pending.scope, .walletWipe) + XCTAssertTrue(permitsRemote) + XCTAssertEqual(paused, pending.id) + XCTAssertNil(resumed) + try await sdk.forgetSessionAccess() + cleanupRan = true + } + XCTAssertTrue(cleanupRan) + XCTAssertEqual(paused, resumed) + XCTAssertNil(try store.pending()) + XCTAssertNoThrow(try admission.snapshot()) + } + + func testStaleWipeRecoveryCannotStartANewWipe() async throws { + for hasReplacement in [false, true] { + let original = UUID() + try store.begin(id: original, scope: .walletWipe) + try store.complete(id: original, scope: .walletWipe) + let replacement = UUID() + if hasReplacement { + try store.begin(id: replacement, scope: .walletWipe) + } + do { + try await AppReset.withWipeAdmission(admission: admission, sdkService: service(), expectedPendingID: original, + pause: { _ in XCTFail("Stale recovery paused new work") }, resume: { _ in }) + { _ in + XCTFail("A stale recovery alert must not reset a replacement wallet") + } + XCTFail("Old recovery UUID was accepted") + } catch ProfileRemovalStore.StoreError.changed {} + XCTAssertEqual(try store.pending()?.id, hasReplacement ? replacement : nil) + if hasReplacement { + try store.complete(id: replacement, scope: .walletWipe) + } + } + } + + func testFailedMarkerPersistenceNeverStartsDestruction() async throws { + let failed = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"), write: { _, _ in + throw CocoaError(.fileWriteNoPermission) + }) + admission = ProfileOperationAdmission(store: failed) + var paused: UUID? + var resumed: UUID? + do { + try await AppReset.withWipeAdmission(admission: admission, sdkService: service(), + pause: { paused = $0 }, resume: { resumed = $0 }) + { _ in + XCTFail("Destruction ran without durable pending") + } + XCTFail("Persistence failure was hidden") + } catch let error as CocoaError { XCTAssertEqual(error.code, .fileWriteNoPermission) } + XCTAssertEqual(paused, resumed) + XCTAssertNil(try failed.pending()) + } + + func testCredentialFailureRetainsWatermarksAndRetryIsLocalOnlyForSameID() async throws { + var watermark = 41 + var failedID: UUID? + let failing = service { _ in throw KeychainError.failedToDelete } + do { + try await AppReset.withWipeAdmission(admission: admission, sdkService: failing, pause: { _ in }, resume: { _ in }) { _ in + failedID = try self.store.pending()?.id + try await failing.forgetSessionAccess() + watermark = 0 + } + XCTFail("Credential failure was hidden") + } catch {} + XCTAssertEqual(watermark, 41) + XCTAssertNotNil(failedID) + XCTAssertEqual(try store.pending()?.id, failedID) + let retry = service() + try await AppReset.withWipeAdmission(admission: admission, sdkService: retry, pause: { _ in }, resume: { _ in }) { permitsRemote in + XCTAssertFalse(permitsRemote) + XCTAssertEqual(try self.store.pending()?.id, failedID) + try await retry.forgetSessionAccess() + watermark = 0 + } + XCTAssertEqual(watermark, 0) + XCTAssertNil(try store.pending()) + } + + func testPrivatePausedWorkerIsCancelledAndDrainedBeforeSdkWipe() async throws { + let privateService = PrivatePaykitService(profileAdmission: admission) + await privateService.setBackgroundWorkPaused(true) + let entered = expectation(description: "Paused private operation entered") + let worker = await privateService.profileTask { + entered.fulfill() + _ = await privateService.waitForBackgroundWork(generation: 0) + } + await fulfillment(of: [entered], timeout: 2) + let wipe = Task { + try await AppReset.withWipeAdmission(admission: admission, sdkService: service(), pause: { _ in }, resume: { _ in }) { _ in + XCTAssertNotNil(try self.store.pending()) + } + } + let finished = expectation(description: "Revoked worker and wipe completed") + Task { + await worker?.value + do { try await wipe.value } catch { XCTFail("Wipe failed: \(error)") } + finished.fulfill() + } + await fulfillment(of: [finished], timeout: 2) + XCTAssertNil(try store.pending()) + } + + func testFailedFinalAcknowledgementRetainsPending() async throws { + do { + try await AppReset.withWipeAdmission(admission: admission, sdkService: service(), pause: { _ in }, resume: { _ in }) { _ in + throw ProfileRemovalStore.StoreError.unavailable + } + XCTFail("Failed flush must not complete wipe") + } catch ProfileRemovalStore.StoreError.unavailable {} + XCTAssertEqual(try store.pending()?.scope, .walletWipe) + XCTAssertThrowsError(try admission.snapshot()) + } + + func testCancellationAfterAcknowledgedCompletionDoesNotRecreatePending() async throws { + let cancelling = ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"), remove: { + try FileManager.default.removeItem(at: $0) + withUnsafeCurrentTask { $0?.cancel() } + }) + admission = ProfileOperationAdmission(store: cancelling) + let sdk = service() + let operation = Task { + try await AppReset.withWipeAdmission(admission: admission, sdkService: sdk, pause: { _ in }, resume: { _ in }) { _ in } + } + try await operation.value + XCTAssertNil(try cancelling.pending()) + } +} diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift index f1f3d662b..7a4acd411 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -238,10 +238,13 @@ final class PubkyIdentityRepublishTests: XCTestCase { func testCancellationDuringPublicationStopsAllAuthApprovalPaths() async { for kind in Approval.allCases { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let admission = ProfileOperationAdmission(store: ProfileRemovalStore(url: directory.appendingPathComponent("pending.json"))) let started = expectation(description: "Publication started for \(kind)") let gate = AsyncStream.makeStream() let bootstrap = RepublishBootstrap(noPointer: .init()) - let service = PaykitSdkService(bootstrapFactory: { _, _ in bootstrap }) + let service = PaykitSdkService(profileAdmission: admission, bootstrapFactory: { _, _ in bootstrap }) bootstrap.operation = { _ in started.fulfill() for await _ in gate.stream {} diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 66676fcd5..e08804149 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -13,236 +13,88 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testActiveSubscriptionStopsProfileRemovalBeforePrivateOrContactCleanup() async throws { + func testLocalCleanupRetainsConsumedWatermarksWhenCredentialDeletionFails() async throws { try await withEmptyIdentityStorage { - for entry in ["screen", "manager"] { - let key = "pubky" + String(repeating: "y", count: 52) - let manager = PubkyProfileManager(profileDeletionValidator: { expectedIdentity in - XCTAssertEqual(expectedIdentity, key) - throw PubkyServiceError.activeSubscription(endsAt: nil) - }, profileDeleter: { _ in - XCTFail("An active subscription must preserve the profile") - }) - manager.publicKey = key - manager.authState = .authenticated - let session = try XCTUnwrap(manager.currentSession) - let contacts = ContactsManager(contactRecords: { - XCTFail("An active subscription must stop before contact cleanup") - return [] - }) - UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) - UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey) - do { - if entry == "screen" { - try await manager.performProfileDeletion { - try await EditProfileView.performDeleteProfile( - session: session, pubkyProfile: manager, contactsManager: contacts - ) { XCTFail("An active subscription must preserve private endpoints") } - } - } else { - try await manager.deleteProfile() - } - XCTFail("Expected subscription refusal") - } catch PubkyServiceError.activeSubscription {} - XCTAssertEqual(manager.currentSession, session) - XCTAssertFalse(manager.isDeletingProfile) - XCTAssertTrue(UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)) - XCTAssertTrue(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)) - } - } - } - - @MainActor - func testProfileRemovalStopsBeforeDeletingWhenCleanupIsIncomplete() async throws { - snapshotAppDefaultsDomain() - for failure in ["private endpoints", "contacts"] { let key = "pubky" + String(repeating: "y", count: 52) - let originalError = KeychainError.failedToDelete - let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { _ in - XCTFail("Incomplete cleanup must not delete the profile") - throw PublicPaykitError.publicationFailed - }) - manager.publicKey = key - manager.authState = .authenticated - let session = try XCTUnwrap(manager.currentSession) - let contact = Bitkit.PubkyProfile.placeholder(publicKey: key) - ContactsManager.restoreContactProfileOverrides([key: PubkyProfileData.from(profile: contact)]) - let contacts = ContactsManager(contactRecords: { - XCTAssertEqual(failure, "contacts", "Private withdrawal failure must stop before contact removal") - throw originalError - }) - contacts.contacts = [PubkyContact(publicKey: key, profile: contact)] - + let original = try JSONEncoder().encode(PrivatePaykitService.PrivatePaykitState( + contacts: [key: .init(consumedPrivatePaymentListVersion: 17)] + )) + UserDefaults.standard.set(original, forKey: PrivatePaykitService.cacheStateKey) + try Keychain.upsert(key: .paykitSession, data: Data("retained-session".utf8)) do { - try await manager.performProfileDeletion { - try await EditProfileView.performDeleteProfile( - session: session, pubkyProfile: manager, contactsManager: contacts - ) { - if failure == "private endpoints" { - throw originalError - } - } - } - XCTFail("Incomplete cleanup must fail") - } catch { - XCTAssertTrue(error is KeychainError, "Preserve the cleanup failure, got \(error)") - } - - XCTAssertEqual(contacts.contacts.map(\.publicKey), [key]) - XCTAssertEqual(Set(ContactsManager.backupContactProfileOverrides()?.keys ?? [:].keys), [key]) - XCTAssertEqual(manager.currentSession, session) - XCTAssertFalse(manager.isDeletingProfile) + try await PubkyProfileManager.clearLocalState(forgetSessionAccess: { throw KeychainError.failedToDelete }) + XCTFail("Credential deletion must be acknowledged before local cleanup") + } catch KeychainError.failedToDelete {} + XCTAssertEqual(UserDefaults.standard.data(forKey: PrivatePaykitService.cacheStateKey), original) + XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "retained-session") } } @MainActor - func testProfileDeletionPausesAutomaticWorkWithoutEndingTheSession() async throws { - for outcome in ["success", "failure", "cancellation"] { - let manager = PubkyProfileManager() - manager.publicKey = "pubky" + String(repeating: "y", count: 52) - manager.authState = .authenticated - let session = try XCTUnwrap(manager.currentSession) - let started = expectation(description: "Deletion started") - let (resume, continuation) = AsyncStream.makeStream() - defer { continuation.finish() } - let deletion = Task { - try await manager.performProfileDeletion { - started.fulfill() - for await _ in resume {} - XCTAssertEqual(manager.currentSession, session) - if outcome == "failure" { - throw KeychainError.failedToDelete - } - try Task.checkCancellation() - } - } - await fulfillment(of: [started], timeout: 2) - XCTAssertNil(manager.automaticPaykitSession, outcome) - XCTAssertEqual(manager.currentSession, session, "Explicit cleanup retains its session") - let deleting = await PrivatePaykitService.shared.isDeletingProfile - XCTAssertTrue(deleting) - if outcome == "cancellation" { - deletion.cancel() - } - continuation.finish() + func testLocalCleanupRetainsConsumedWatermarksWhenSharedCredentialDeletionFails() async throws { + try await withEmptyIdentityStorage { + let original = try JSONEncoder().encode(PrivatePaykitService.PrivatePaykitState( + contacts: ["saved-owner": .init(consumedPrivatePaymentListVersion: 17)] + )) + UserDefaults.standard.set(original, forKey: PrivatePaykitService.cacheStateKey) + var flushed = false do { - try await deletion.value - XCTAssertEqual(outcome, "success") - } catch { - XCTAssertNotEqual(outcome, "success") - if outcome == "cancellation" { - XCTAssertTrue(error is CancellationError) - } - } - XCTAssertEqual(manager.automaticPaykitSession, session, outcome) - let stillDeleting = await PrivatePaykitService.shared.isDeletingProfile - XCTAssertFalse(stillDeleting) - } - } - - @MainActor - func testEndingDeletionDoesNotRestoreAnEndedOrChangedSession() async throws { - let manager = PubkyProfileManager() - manager.publicKey = "pubky" + String(repeating: "y", count: 52) - manager.authState = .authenticated - let original = manager.automaticPaykitSession - try await manager.performProfileDeletion { - manager.publicKey = "pubky" + String(repeating: "o", count: 52) - XCTAssertNil(manager.automaticPaykitSession) + try await PubkyProfileManager.clearLocalState( + forgetSessionAccess: {}, + removeSharedCredentials: { throw KeychainError.failedToDelete }, + flushPreferences: { flushed = true; return true } + ) + XCTFail("Shared credential deletion must be acknowledged before clearing consumption state") + } catch KeychainError.failedToDelete {} + XCTAssertFalse(flushed) + XCTAssertEqual(UserDefaults.standard.data(forKey: PrivatePaykitService.cacheStateKey), original) } - XCTAssertNotEqual(manager.automaticPaykitSession, original) - try await manager.performProfileDeletion { manager.authState = .idle } - XCTAssertNil(manager.automaticPaykitSession) } @MainActor - func testProfileDeletionRefusesIdentityChangesBeforeTheyTouchSessionState() async throws { + func testLocalDisconnectCannotTransferPublicCleanupToReplacementIdentity() async throws { try await withEmptyIdentityStorage { - UserDefaults.standard.set(false, forKey: "pubky_profile_setup_pending") - for mutation in ["adoption", "creation", "disconnect", "restore"] { - let manager = PubkyProfileManager() - manager.publicKey = "pubky" + String(repeating: "y", count: 52) - manager.authState = .authenticated - let session = try XCTUnwrap(manager.currentSession) - - try await manager.performProfileDeletion { - try Task.checkCancellation() - do { - switch mutation { - case "adoption": - _ = try await manager.adoptRingIdentity( - pubky: String(repeating: "o", count: 52), - loadSecret: { _, _ in - XCTFail("Deletion must reject adoption before reading another identity") - return nil - } - ) - case "creation": - try await manager.createIdentity(name: "Replacement", bio: "", links: [], loadStoredSecretKey: { - XCTFail("Deletion must reject creation before loading credentials") - throw CancellationError() - }) - case "disconnect": - try await manager.signOut(performSessionCleanup: { - XCTFail("An unrelated disconnect must not enter deletion's session") - throw CancellationError() - }) - default: - try await PubkyProfileManager.restoreSessionBackupState( - nil, - deleteKeychainValue: { _ in XCTFail("Deletion must retain its credentials") }, - removeOwnSharedRecords: { XCTFail("Deletion must retain its shared identity") }, - forgetSessionAccess: { XCTFail("Deletion must reject restore before ending its session") } - ) - } - XCTFail("Identity mutation must be rejected: \(mutation)") - } catch { - guard case PubkyServiceError.identityChanged = error else { - XCTFail("Unexpected error for \(mutation): \(error)") - return - } - } - XCTAssertEqual(manager.currentSession, session, mutation) - } - XCTAssertEqual(manager.currentSession, session, mutation) - XCTAssertFalse(manager.isDeletingProfile, mutation) - } + PublicPaykitService.queueEndpointReconciliation() + let oldRevision = UserDefaults.standard.string(forKey: PublicPaykitService.reconciliationRevisionKey) + XCTAssertNotNil(oldRevision) + XCTAssertTrue(PublicPaykitService.isCleanupPending) + do { + try await PubkyProfileManager.clearLocalState(forgetSessionAccess: { throw KeychainError.failedToDelete }) + XCTFail("Required erasure failure must retain repair state") + } catch KeychainError.failedToDelete {} + XCTAssertTrue(PublicPaykitService.isCleanupPending) + try await PubkyProfileManager.clearLocalState(forgetSessionAccess: {}, removeSharedCredentials: {}) + let replacement = PubkyProfileManager() + replacement.publicKey = "pubky" + String(repeating: "z", count: 52) + replacement.authState = .authenticated + XCTAssertNotNil(replacement.currentSession) + XCTAssertFalse(PublicPaykitService.isCleanupPending, "Old-owner withdrawal cannot be offered to the replacement identity") + XCTAssertNil(UserDefaults.standard.object(forKey: PublicPaykitService.reconciliationRevisionKey)) + let domain = try XCTUnwrap(Bundle.main.bundleIdentifier) + XCTAssertEqual(UserDefaults.standard.persistentDomain(forName: domain)?[PublicPaykitService.cleanupPendingKey] as? Bool, false) } } @MainActor - func testProfileDeletionDoesNotOverlapSessionInitialization() async throws { + func testLocalCleanupRequiresAcknowledgedPreferenceFlush() async throws { try await withEmptyIdentityStorage { - let manager = PubkyProfileManager() - let started = expectation(description: "Session initialization started") - let (resume, continuation) = AsyncStream.makeStream() - defer { continuation.finish() } - let initialization = Task { - await manager.initialize { - started.fulfill() - for await _ in resume {} - return .noSession - } - } - await fulfillment(of: [started], timeout: 2) + await PrivatePaykitAddressReservationStore.shared.clear() + UserDefaults.standard.set("old-name", forKey: "pubky_profile_name") + UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + var flushCount = 0 do { - try await manager.performProfileDeletion { XCTFail("Deletion must wait for session initialization") } - XCTFail("Expected deletion to be refused") - } catch PubkyServiceError.identityChanged {} - continuation.finish() - await initialization.value - try await manager.performProfileDeletion { - await manager.initialize { - XCTFail("Session initialization must wait for deletion") - return .noSession - } - } - let resumed = expectation(description: "Session initialization resumed") - await manager.initialize { - resumed.fulfill() - return .noSession - } - await fulfillment(of: [resumed], timeout: 2) + try await PubkyProfileManager.clearLocalState( + forgetSessionAccess: {}, removeSharedCredentials: {}, flushPreferences: { + flushCount += 1 + XCTAssertNil(UserDefaults.standard.object(forKey: "pubky_profile_name")) + XCTAssertFalse(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)) + XCTAssertNotNil(UserDefaults.standard.data(forKey: PrivatePaykitService.cacheStateKey)) + return false + } + ) + XCTFail("An unacknowledged flush cannot complete local cleanup") + } catch ProfileRemovalStore.StoreError.unavailable {} + XCTAssertEqual(flushCount, 1) } } @@ -276,130 +128,6 @@ final class PubkyProfileManagerTests: XCTestCase { } } - @MainActor - func testDefaultProfileDeletionKeepsOwnershipUntilItsAdmittedWriteDrains() async throws { - try await withEmptyIdentityStorage { - for outcome in ["failure", "cancellation", "ownerChanged"] { - let original = "pubky" + String(repeating: "y", count: 52) - let started = expectation(description: "Profile delete admitted") - let (resume, continuation) = AsyncStream.makeStream() - defer { continuation.finish() } - let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { expectedIdentity in - XCTAssertEqual(expectedIdentity, original) - started.fulfill() - for await _ in resume {} - if outcome == "failure" { - throw PublicPaykitError.publicationFailed - } - }) - manager.publicKey = original - manager.authState = .authenticated - let session = try XCTUnwrap(manager.currentSession) - UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) - UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey) - let deletion = Task { try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false) } - await fulfillment(of: [started], timeout: 5) - XCTAssertTrue(manager.isDeletingProfile) - let overlapRejected = expectation(description: "Overlapping deletion refused") - let overlappingDeletion = Task { - do { - try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false) - XCTFail("A separate deletion must not share another call's admission") - } catch PubkyServiceError.identityChanged { - } catch { - XCTFail("Unexpected overlapping-deletion error: \(error)") - } - overlapRejected.fulfill() - } - await fulfillment(of: [overlapRejected], timeout: 2) - if outcome == "cancellation" { - deletion.cancel() - } - if outcome == "ownerChanged" { - manager.publicKey = "pubky" + String(repeating: "o", count: 52) - } - do { - _ = try await manager.adoptRingIdentity(pubky: original, loadSecret: { _, _ in - XCTFail("Replacement must wait for admitted deletion to drain") - return nil - }) - XCTFail("Expected adoption to be refused") - } catch PubkyServiceError.identityChanged { - } catch { - XCTFail("Unexpected adoption error during deletion: \(error)") - } - XCTAssertTrue(manager.isDeletingProfile, "Cancellation must not release admitted work") - continuation.finish() - await overlappingDeletion.value - do { - try await deletion.value - XCTFail("Expected deletion to stop before clearing sharing or signing out") - } catch { - switch outcome { - case "failure": XCTAssertTrue(error is PublicPaykitError) - case "cancellation": XCTAssertTrue(error is CancellationError) - default: - guard case PubkyServiceError.identityChanged = error else { - return XCTFail("Expected stale deletion to be refused, got \(error)") - } - } - } - XCTAssertTrue(UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)) - XCTAssertTrue(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)) - XCTAssertFalse(manager.isDeletingProfile) - if outcome != "ownerChanged" { - XCTAssertEqual(manager.currentSession, session) - } - var readSecret = false - do { - _ = try await manager.adoptRingIdentity(pubky: original, loadSecret: { _, _ in - readSecret = true - return nil - }) - XCTFail("The fixture has no Ring credential") - } catch PubkyServiceError.authFailed {} - XCTAssertTrue(readSecret, "Identity admission resumes after deletion drains") - } - } - } - - @MainActor - func testProfileDeletionRequiresAcknowledgedCompletionBeforeSignOut() async throws { - try await withEmptyIdentityStorage { - let errors: [NSError?] = [ - nil, - NSError(domain: "ProfileDeleteFixture", code: 404, - userInfo: [NSLocalizedDescriptionKey: "Delete failed: HTTP 404 Not Found at session endpoint"]), - NSError(domain: NSCocoaErrorDomain, code: CocoaError.fileReadNoSuchFile.rawValue), - ] - for failure in errors { - let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { _ in - if let failure { - throw failure - } - }) - manager.publicKey = "pubky" + String(repeating: "y", count: 52) - manager.authState = .authenticated - let session = try XCTUnwrap(manager.currentSession) - UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) - UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey) - - do { - try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false) - XCTAssertNil(failure, "An unavailable profile does not confirm its deletion") - } catch { - XCTAssertEqual((error as NSError).domain, failure?.domain) - XCTAssertEqual((error as NSError).code, failure?.code) - } - - XCTAssertEqual(manager.currentSession, failure == nil ? nil : session) - XCTAssertEqual(UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey), failure != nil) - XCTAssertEqual(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey), failure != nil) - XCTAssertFalse(manager.isDeletingProfile) - } - } - } - @MainActor func testAutomaticPaykitSessionAppearsWhenTheSessionChangeEndsAndIsAnnounced() async throws { try await withEmptyIdentityStorage { @@ -424,34 +152,63 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testDeletionSessionRejectsAReplacementAndANewSessionForTheSameKey() async throws { - try await withEmptyIdentityStorage { - let secret = String(repeating: "01", count: 32) - let original = try PubkyProfileManager.publicKeyFromSecretKey(secret) - let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { _ in - XCTFail("A stale deletion must not reach profile storage") - }) - manager.publicKey = original + func testSessionRecoveryReloadsMissingProfileWithoutReauthenticationOrWaiting() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Recovered")]) + await stub.makeUnreachable(ringKeyA) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + manager.publicKey = ringKeyA manager.authState = .authenticated - let session = try XCTUnwrap(manager.currentSession) - try manager.requireCurrentSession(session) - manager.publicKey = "pubky" + String(repeating: "o", count: 52) - XCTAssertThrowsError(try manager.requireCurrentSession(session)) - do { - try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session) - XCTFail("Deletion must reject a replacement identity") - } catch PubkyServiceError.identityChanged {} - manager.publicKey = original - _ = try await manager.adoptRingIdentity( - pubky: original, loadSecret: { _, _ in secret }, signIn: { _ in }, fetchProfile: { _ in nil } + manager.isInitialized = true + let session = manager.currentSession + await manager.loadProfile() + XCTAssertNil(manager.profile) + + await stub.makeReachable(ringKeyA) + await stub.setHoldsRequests(true) + for _ in 0 ..< 3 { + await manager.restoreSessionIfNeeded(hasStoredIdentity: { + XCTFail("An active identity must not inspect or import saved credentials") + return false + }) + } + await stub.waitForRequests(2) + XCTAssertTrue(manager.isLoadingProfile) + XCTAssertEqual(manager.currentSession, session) + let heldRequests = await stub.requests + XCTAssertEqual(heldRequests, [ringKeyA, ringKeyA]) + + await stub.release(request: 1) + await waitUntil("the missing profile recovers") { manager.profile?.name == "Recovered" } + await manager.restoreSessionIfNeeded() + await Task.yield() + let completedRequests = await stub.requests + XCTAssertEqual(completedRequests, [ringKeyA, ringKeyA]) + XCTAssertEqual(manager.cachedName, "Recovered") + } + } + + @MainActor + func testSessionRecoveryDropsMissingProfileResultFromClearedStateWithSameKey() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub( + profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Stale")], + holdsRequests: true ) - XCTAssertEqual(manager.publicKey, original) - XCTAssertNotEqual(manager.currentSession, session) - XCTAssertThrowsError(try manager.requireCurrentSession(session)) - do { - try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session) - XCTFail("Deletion must reject a new session for the same key") - } catch PubkyServiceError.identityChanged {} + let manager = ObservedProfileReadManager(remoteProfileResolver: { try await stub.resolve($0) }) + manager.publicKey = ringKeyA + manager.authState = .authenticated + manager.isInitialized = true + + await manager.restoreSessionIfNeeded() + await stub.waitForRequests(1) + manager.clearAuthenticatedStateForTesting() + manager.publicKey = ringKeyA + manager.authState = .authenticated + await stub.release(request: 0) + await waitUntil("the old profile read finishes") { manager.completedReads == 1 } + XCTAssertNil(manager.profile) + XCTAssertNil(manager.cachedName) } } @@ -870,6 +627,15 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + private func startLocalProfileWipe() -> Task { + Task.detached { @MainActor in + try await AppReset.withWipeAdmission(pause: { _ in }, resume: { _ in }) { _ in + try await PubkyProfileManager.clearLocalState() + } + } + } + @MainActor func testFailedRingAdoptionDoesNotRestoreIdentityAfterLocalReset() async throws { let savedReference = AdoptedPubkyReference.current @@ -920,14 +686,15 @@ final class PubkyProfileManagerTests: XCTestCase { ) XCTFail("Expected sign-in failure") } catch { - XCTAssertEqual(error.localizedDescription, PubkyServiceError.authFailed("sign-in failed").localizedDescription) + XCTAssertTrue(error is CancellationError || error is ProfileRemovalStore.StoreError || error is PubkyServiceError) } } await fulfillment(of: [started], timeout: 2) - await PubkyProfileManager.clearLocalState() - XCTAssertNil(AdoptedPubkyReference.current) + let wipe = startLocalProfileWipe() + await waitUntil("wipe closes admission") { ProfileOperationAdmission.shared.isWalletWiping } continuation.finish() await adoption.value + try await wipe.value XCTAssertNil(AdoptedPubkyReference.current) XCTAssertFalse(try PubkyProfileManager.hasStoredIdentity()) @@ -938,7 +705,7 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testFailedRingAdoptionRestoresPreviousIdentityAfterFailedSignOut() async throws { + func testFailedRingAdoptionRestoresPreviousIdentityAfterConcurrentSignOutIsRejected() async throws { let savedReference = AdoptedPubkyReference.current let savedSession = try Keychain.load(key: .paykitSession) let defaults = UserDefaults.standard @@ -984,12 +751,10 @@ final class PubkyProfileManagerTests: XCTestCase { await fulfillment(of: [adoptionStarted], timeout: 2) do { - try await manager.signOut(performSessionCleanup: { - throw PubkyServiceError.authFailed("sign-out failed") - }) + try await manager.signOut() XCTFail("Expected sign-out failure") } catch { - XCTAssertEqual(error.localizedDescription, PubkyServiceError.authFailed("sign-out failed").localizedDescription) + XCTAssertTrue(error is ProfileRemovalStore.StoreError || error is PubkyServiceError) } XCTAssertEqual(AdoptedPubkyReference.current?.pubky, "pending-ring-identity") @@ -1106,20 +871,24 @@ final class PubkyProfileManagerTests: XCTestCase { } await fulfillment(of: [profileFetchStarted], timeout: 2) + var wipe: Task? switch teardown { case .signOut: - try await manager.signOut(performSessionCleanup: {}) + do { try await manager.signOut(); XCTFail("Sign-out cannot replace an admitted adoption") } + catch { XCTAssertTrue(error is ProfileRemovalStore.StoreError || error is PubkyServiceError) } + adoption.cancel() case .reset: - await PubkyProfileManager.clearLocalState() + wipe = startLocalProfileWipe() + await waitUntil("wipe closes admission") { ProfileOperationAdmission.shared.isWalletWiping } } - continuation.finish() do { _ = try await adoption.value XCTFail("Expected abandoned Ring adoption to stop") } catch { - XCTAssertTrue(error is CancellationError) + XCTAssertTrue(error is CancellationError || error is ProfileRemovalStore.StoreError) } + try await wipe?.value XCTAssertNil(manager.profile) XCTAssertNil(defaults.string(forKey: "pubky_profile_name")) XCTAssertNil(defaults.string(forKey: "pubky_profile_image_uri")) @@ -1400,6 +1169,7 @@ final class PubkyProfileManagerTests: XCTestCase { let manager = RecoveryProfileManager() let started = expectation(description: "recovery started") let (stream, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } let recovery = Task { await manager.initialize { started.fulfill() @@ -1560,41 +1330,49 @@ final class PubkyProfileManagerTests: XCTestCase { } for resetStep in ["register", "authorize", "activate"] { let manager = PubkyProfileManager() - let session = PubkyRegisteredIdentity( + let session = try PubkyRegisteredIdentity( result: PubkySessionBootstrapResult( sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test", capability: .privateLinkCapable ), - walletGeneration: 0 + walletGeneration: 0, + profileGeneration: ProfileOperationAdmission.shared.snapshot() ) + var wipe: Task? do { try await manager.completeSignupAuthenticationForTesting( publicKey: "pubky_test", registerIdentity: { if resetStep == "register" { - await PubkyProfileManager.clearLocalState() + wipe = self.startLocalProfileWipe() + await self.waitUntil("wipe admission") { ProfileOperationAdmission.shared.isWalletWiping } } return session }, approveAuth: { XCTAssertNotEqual(resetStep, "register", "Late registration must not authorize after reset") if resetStep == "authorize" { - await PubkyProfileManager.clearLocalState() + wipe = self.startLocalProfileWipe() + await self.waitUntil("wipe admission") { ProfileOperationAdmission.shared.isWalletWiping } } }, activateIdentity: { _ in XCTAssertEqual(resetStep, "activate", "Late approval must not activate after reset") - await PubkyProfileManager.clearLocalState() + wipe = self.startLocalProfileWipe() + await self.waitUntil("wipe admission") { ProfileOperationAdmission.shared.isWalletWiping } } ) XCTFail("Signup interrupted by reset must be abandoned") - } catch is CancellationError {} + } catch is CancellationError {} catch ProfileRemovalStore.StoreError.changed {} + try await wipe?.value XCTAssertNil(manager.publicKey) XCTAssertEqual(manager.authState, .idle) XCTAssertFalse(UserDefaults.standard.bool(forKey: "pubky_profile_setup_pending")) } let manager = KeyDerivationProbeProfileManager() + var wipe: Task? manager.deriveKeysOperation = { - await PubkyProfileManager.clearLocalState() + wipe = self.startLocalProfileWipe() + await self.waitUntil("wipe during derivation") { ProfileOperationAdmission.shared.isWalletWiping } return ("pubky_test", "unused") } let homeserver = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -1602,7 +1380,8 @@ final class PubkyProfileManagerTests: XCTestCase { do { try await manager.approveSignupAuth(request: request) XCTFail("Keys derived from the wiped wallet must not register an identity") - } catch is CancellationError {} + } catch is CancellationError {} catch ProfileRemovalStore.StoreError.changed {} + try await wipe?.value } @MainActor @@ -1618,11 +1397,12 @@ final class PubkyProfileManagerTests: XCTestCase { for failingStep in [nil, "register", "authorize", "activate"] { defaults.set(true, forKey: "pubky_profile_setup_pending") let manager = PubkyProfileManager() - let session = PubkyRegisteredIdentity( + let session = try PubkyRegisteredIdentity( result: PubkySessionBootstrapResult( sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test", capability: .privateLinkCapable ), - walletGeneration: 0 + walletGeneration: 0, + profileGeneration: ProfileOperationAdmission.shared.snapshot() ) var events: [String] = [] func perform(_ step: String) throws { @@ -1672,11 +1452,12 @@ final class PubkyProfileManagerTests: XCTestCase { } let manager = PubkyProfileManager() - let session = PubkyRegisteredIdentity( + let session = try PubkyRegisteredIdentity( result: PubkySessionBootstrapResult( sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test", capability: .privateLinkCapable ), - walletGeneration: 0 + walletGeneration: 0, + profileGeneration: ProfileOperationAdmission.shared.snapshot() ) var shouldFailActivation = true var activationCount = 0 @@ -1746,11 +1527,12 @@ final class PubkyProfileManagerTests: XCTestCase { for cancelSignup in [false, true] { let manager = PubkyProfileManager() - let session = PubkyRegisteredIdentity( + let session = try PubkyRegisteredIdentity( result: PubkySessionBootstrapResult( sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_first", capability: .privateLinkCapable ), - walletGeneration: 0 + walletGeneration: 0, + profileGeneration: ProfileOperationAdmission.shared.snapshot() ) let approvalStarted = expectation(description: "Approval started") let signupFinished = expectation(description: "Signup stops without waiting for approval") @@ -1792,6 +1574,28 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(manager.publicKey) XCTAssertFalse(manager.isProfileSetupPending) + do { + try await manager.completeSignupAuthenticationForTesting( + publicKey: "pubky_retry", registerIdentity: { session }, approveAuth: {}, + activateIdentity: { _ in XCTFail("A retry cannot overtake the old native approval") } + ) + XCTFail("The cancelled approval still owns its native tail") + } catch ProfileRemovalStore.StoreError.changed {} + approvalContinuation?.resume() + approvalContinuation = nil + await fulfillment(of: [approvalFinished], timeout: 2) + await signup.value + // The callback fulfils before its retained admission is released. + let drained = expectation(description: "Original approval admission drains") + Task { @MainActor in + for _ in 0 ..< 100 { + if (try? ProfileOperationAdmission.shared.snapshot()) != nil { + drained.fulfill(); return + } + try? await Task.sleep(for: .milliseconds(5)) + } + } + await fulfillment(of: [drained], timeout: 2) var didActivateRetry = false try await manager.completeSignupAuthenticationForTesting( publicKey: "pubky_retry", @@ -1801,10 +1605,6 @@ final class PubkyProfileManagerTests: XCTestCase { ) XCTAssertTrue(didActivateRetry) - approvalContinuation?.resume() - approvalContinuation = nil - await fulfillment(of: [approvalFinished], timeout: 2) - await signup.value XCTAssertEqual(manager.publicKey, "pubky_retry") XCTAssertEqual(manager.authState, .authenticated) XCTAssertTrue(manager.isProfileSetupPending) @@ -1906,6 +1706,8 @@ final class PubkyProfileManagerTests: XCTestCase { let stub = RemoteProfileStub(holdsRequests: true) let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + manager.publicKey = ringKeyA + manager.authState = .authenticated let adoption = Task { try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) } await stub.waitForRequests(1) let load = Task { await manager.loadProfile() } @@ -1946,67 +1748,6 @@ final class PubkyProfileManagerTests: XCTestCase { } } - @MainActor - func testSessionRecoveryReloadsMissingProfileWithoutReauthenticationOrWaiting() async { - await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Recovered")]) - await stub.makeUnreachable(ringKeyA) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - manager.publicKey = ringKeyA - manager.authState = .authenticated - manager.isInitialized = true - let session = manager.currentSession - await manager.loadProfile() - XCTAssertNil(manager.profile) - - await stub.makeReachable(ringKeyA) - await stub.setHoldsRequests(true) - for _ in 0 ..< 3 { - await manager.restoreSessionIfNeeded(hasStoredIdentity: { - XCTFail("An active identity must not inspect or import saved credentials") - return false - }) - } - await stub.waitForRequests(2) - XCTAssertTrue(manager.isLoadingProfile) - XCTAssertEqual(manager.currentSession, session) - let heldRequests = await stub.requests - XCTAssertEqual(heldRequests, [ringKeyA, ringKeyA]) - - await stub.release(request: 1) - await waitUntil("the missing profile recovers") { manager.profile?.name == "Recovered" } - await manager.restoreSessionIfNeeded() - await Task.yield() - let completedRequests = await stub.requests - XCTAssertEqual(completedRequests, [ringKeyA, ringKeyA]) - XCTAssertEqual(manager.cachedName, "Recovered") - } - } - - @MainActor - func testSessionRecoveryDropsMissingProfileResultFromClearedStateWithSameKey() async { - await withRestoredProfileDefaults { - let stub = RemoteProfileStub( - profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Stale")], - holdsRequests: true - ) - let manager = ObservedProfileReadManager(remoteProfileResolver: { try await stub.resolve($0) }) - manager.publicKey = ringKeyA - manager.authState = .authenticated - manager.isInitialized = true - - await manager.restoreSessionIfNeeded() - await stub.waitForRequests(1) - manager.clearAuthenticatedStateForTesting() - manager.publicKey = ringKeyA - manager.authState = .authenticated - await stub.release(request: 0) - await waitUntil("the old profile read finishes") { manager.completedReads == 1 } - XCTAssertNil(manager.profile) - XCTAssertNil(manager.cachedName) - } - } - // MARK: - Profile saves /// The review regression: a remembered Ring profile with tags was removed remotely. Adoption reuses its cached row and @@ -3467,7 +3208,8 @@ final class PubkyProfileManagerTests: XCTestCase { await stub.waitForRequests(1) let adoption = Task { try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) } - await waitUntil("\(name): adoption reaches the row lookup") { manager.publicKey == ringKeyA } + await Task.yield() + XCTAssertNil(manager.publicKey, "Uncommitted row lookup must not install the new identity") if rowLookupFinds { await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice"), for: ringKeyA) } @@ -3956,29 +3698,48 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertTrue(didRemoveSharedRecords) } - func testRestoreSessionBackupStateClearsCredentialsWhenForgetFailsWithoutBackup() async throws { - var store = makeKeychainStore( - paykitSession: "stale-session", - pubkySecretKey: "stale-local-secret" - ) - var didRemoveSharedRecords = false - - try await PubkyProfileManager.restoreSessionBackupState( - nil, - loadKeychainString: { store[$0.storageKey] }, - persistKeychainString: { store[$0.storageKey] = $1 }, - deleteKeychainValue: { store.removeValue(forKey: $0.storageKey) }, - removeOwnSharedRecords: { didRemoveSharedRecords = true }, - forgetSessionAccess: { throw PubkyServiceError.authFailed("offline") }, - signInWithSecretKey: { _ in - XCTFail("Missing pubky state should not sign in") - return "unused-session" + func testRestoreSessionBackupStateStopsBeforeReplacementWhenLocalCleanupFails() async throws { + for failure in ["forget", "session", "secret", "shared"] { + var store = makeKeychainStore(paykitSession: "stale-session", pubkySecretKey: "stale-secret") + var events: [String] = [] + do { + try await PubkyProfileManager.restoreSessionBackupState( + nil, + loadKeychainString: { store[$0.storageKey] }, + persistKeychainString: { _, _ in XCTFail("Failed cleanup cannot install a new key") }, + deleteKeychainValue: { key in + let step = key.storageKey == KeychainEntryType.paykitSession.storageKey ? "session" : "secret" + events.append(step) + if failure == step { + throw KeychainError.failedToDelete + } + store.removeValue(forKey: key.storageKey) + }, + removeOwnSharedRecords: { + events.append("shared") + if failure == "shared" { + throw KeychainError.failedToDelete + } + }, + forgetSessionAccess: { + events.append("forget") + if failure == "forget" { + throw KeychainError.failedToDelete + } + }, + signInWithSecretKey: { _ in XCTFail("Failed cleanup cannot authenticate"); return "unused" } + ) + XCTFail("Local cleanup failure must propagate") + } catch let error as ProfileSessionRestoreLocalError { + XCTAssertNotNil(error.underlyingError as? KeychainError) + XCTAssertTrue(BackupRestoreFailurePolicy.isFatal(.metadata, error: error)) } - ) - - XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) - XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) - XCTAssertTrue(didRemoveSharedRecords) + XCTAssertEqual(events.last, failure) + if failure == "forget" || failure == "session" { + XCTAssertEqual(store[KeychainEntryType.paykitSession.storageKey], "stale-session") + XCTAssertEqual(store[KeychainEntryType.pubkySecretKey.storageKey], "stale-secret") + } + } } func testRestoreSessionBackupStateForLegacyExternalSessionClearsCredentials() async throws { @@ -4573,6 +4334,22 @@ private actor SessionRecoveryAttempts { } } +private actor ProfileDeletionNativeCompletion { + private var continuation: CheckedContinuation? + + func wait(started: () -> Void) async { + await withCheckedContinuation { + continuation = $0 + started() + } + } + + func finish() { + continuation?.resume() + continuation = nil + } +} + @MainActor private final class ObservedProfileReadManager: PubkyProfileManager { private(set) var completedReads = 0 diff --git a/BitkitTests/PublicPaykitAddressReservationTests.swift b/BitkitTests/PublicPaykitAddressReservationTests.swift index 050661f78..86853fcc0 100644 --- a/BitkitTests/PublicPaykitAddressReservationTests.swift +++ b/BitkitTests/PublicPaykitAddressReservationTests.swift @@ -6,6 +6,126 @@ final class PublicPaykitAddressReservationTests: XCTestCase { private let alice = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private let bob = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" + @MainActor + func testRemovalAndWipeDrainPublicDerivationBeforeClearingReservations() async throws { + for kind in [ProfileOperationAdmission.Kind.removal, .walletWipe] { + let defaults = try makeIsolatedDefaults() + let wallet = AddressWallet() + let held = expectation(description: "Public address derivation entered") + var finish: CheckedContinuation? + var operations = wallet.operations + operations.next = { type in + let info = await wallet.next(addressType: type) + await withCheckedContinuation { + finish = $0 + held.fulfill() + } + return info + } + let store = PrivatePaykitAddressReservationStore(defaults: defaults, operations: operations) + let allocation = Task { try await store.currentOrRotatedPublicAddress(for: alice) } + await fulfillment(of: [held], timeout: 2) + let admission = ProfileOperationAdmission.shared + let removal = try admission.reserve(kind: kind, id: UUID(), expected: admission.snapshot()) + defer { admission.release(removal) } + let drainStarted = expectation(description: "Removal drain entered") + var drained = false + let drain = Task { + drainStarted.fulfill() + try await admission.drain(before: removal) + drained = true + } + await fulfillment(of: [drainStarted], timeout: 2) + XCTAssertFalse(drained, "Cleanup cannot pass an outstanding LDK address continuation") + finish?.resume() + do { + _ = try await allocation.value + XCTFail("Revoked public allocation returned an assignment") + } catch ProfileRemovalStore.StoreError.changed { + } catch is CancellationError {} + try await drain.value + let snapshot = await store.backupSnapshot() + XCTAssertEqual(snapshot, ["nativeSegwit": 0], "The revealed address must remain excluded before cleanup") + let data = try XCTUnwrap(defaults.data(forKey: PrivatePaykitAddressReservationStore.defaultsKey)) + let ledger = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertTrue((ledger["publicAssignments"] as? [String: Any] ?? [:]).isEmpty) + if kind == .walletWipe { + await store.clear() + XCTAssertNil(defaults.data(forKey: PrivatePaykitAddressReservationStore.defaultsKey)) + } else { + try await store.clearContactAssignmentsForRemoval() + let retained = await store.backupSnapshot() + XCTAssertEqual(retained, snapshot) + } + } + } + + @MainActor + func testRemovalAndWipeDrainPublicPreparationAndPublicationCallbacks() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) + UserDefaults.standard.set(false, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey) + UserDefaults.standard.set(false, forKey: PublicPaykitService.usdtPaymentOptionEnabledKey) + for kind in [ProfileOperationAdmission.Kind.removal, .walletWipe] { + for stage in ["reservation", "snapshot", "publication"] { + PublicPaykitService.setCleanupPending(false) + let store = try PrivatePaykitAddressReservationStore(defaults: makeIsolatedDefaults(), operations: AddressWallet().operations) + let profile = PubkyProfileManager() + profile.publicKey = alice + profile.authState = .authenticated + let session = try XCTUnwrap(profile.currentSession) + let wallet = PublicActivityWallet(pubkyProfile: profile) + wallet.reservations = store + wallet.channels = [] + let held = expectation(description: "Public \(stage) callback entered") + var finish: CheckedContinuation? + let hold = { + await withCheckedContinuation { + finish = $0 + held.fulfill() + } + } + switch stage { + case "reservation": wallet.afterAddressReservation = hold + case "snapshot": wallet.afterEndpointSnapshot = hold + default: wallet.afterEndpointWrite = hold + } + let publication = Task { + try await wallet.syncReceivePublicPaykitEndpoints(isSessionCurrent: { profile.currentSession == session }) + } + await fulfillment(of: [held], timeout: 2) + let admission = ProfileOperationAdmission.shared + let removal = try admission.reserve(kind: kind, id: UUID(), expected: admission.snapshot()) + defer { admission.release(removal) } + let drainStarted = expectation(description: "Removal drain entered") + var drained = false + let drain = Task { + drainStarted.fulfill() + try await admission.drain(before: removal) + drained = true + } + await fulfillment(of: [drainStarted], timeout: 2) + XCTAssertFalse(drained, "Cleanup cannot pass the \(stage) callback") + let pendingBeforeRelease = PublicPaykitService.isCleanupPending + finish?.resume() + do { + try await publication.value + XCTFail("Revoked public \(stage) returned success") + } catch ProfileRemovalStore.StoreError.changed { + } catch is CancellationError {} + try await drain.value + XCTAssertEqual(PublicPaykitService.isCleanupPending, pendingBeforeRelease, + "A revoked callback cannot change the successor's reconciliation state") + XCTAssertEqual(wallet.publishedAddresses.count, stage == "publication" ? 1 : 0) + await store.clear() + let cleared = await store.backupSnapshot() + XCTAssertNil(cleared) + } + } + } + func testPublicPrivateAndOrdinaryReceiveAddressesRemainSeparate() async throws { let defaults = try makeIsolatedDefaults() let wallet = AddressWallet() diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 5cbc41658..ca1bb4998 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -396,7 +396,11 @@ final class PublicPaykitServiceTests: XCTestCase { do { try await publication.value XCTFail("Expected the ended session to stop the next write") - } catch PubkyServiceError.sessionNotActive {} + } catch PubkyServiceError.sessionNotActive { + XCTAssertFalse(cancelTask) + } catch is CancellationError { + XCTAssertTrue(cancelTask) + } XCTAssertEqual(calls, ["app", "app-finished"]) } } diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index 73222a1c6..086fe3129 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -65,7 +65,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { let publication = Task { try await route.refresh(wallet) } await fulfillment(of: [preparation.started], timeout: 5) let deletion = Task { - try await profile.performProfileDeletion { + try await profile.withAutomaticWorkPausedForTesting { try await PublicPaykitService.syncPublishedEndpoints( publish: false, buildEndpoints: { [] }, syncApp: {}, applyEndpoints: { endpoints = $0.map(\.value) } @@ -90,7 +90,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { wallet.replaceEmptyPublicInvoice = true var published: [String] = [] do { - try await profile.performProfileDeletion { + try await profile.withAutomaticWorkPausedForTesting { await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) XCTAssertTrue(wallet.publicPaykitBolt11.isEmpty) XCTAssertTrue(PublicPaykitService.isCleanupPending) @@ -121,7 +121,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { for restoration in [PubkyProfileManager.SessionInitializationResult.restored(publicKey: owner), .restorationDeferred, .restorationFailed] { for firstPublication in FirstPublication.allCases { AdoptedPubkyReference.current = ("startup-test", owner) - let profile = StartupPublicationProfile() + let profile = StartupPublicationProfile(sourceIsMissing: { _, _ in false }) let (_, wallet) = publicationWallet(route: .channelUsable, profile: profile) profile.clearAuthenticatedStateForTesting() wallet.replaceEmptyPublicInvoice = true @@ -151,7 +151,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { switch firstPublication { case .skipped: do { - try await profile.performProfileDeletion { + try await profile.withAutomaticWorkPausedForTesting { do { try await PublicPaykitService.syncCurrentPublishedEndpoints( wallet: wallet, isSessionCurrent: isCurrent, syncApp: {}, applyEndpoints: { published = $0.map(\.value) } @@ -188,7 +188,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { let owner = "pubky" + String(repeating: "y", count: 52) for state in State.allCases { AdoptedPubkyReference.current = state == .noStoredIdentity ? nil : ("startup-test", owner) - let profile = StartupPublicationProfile() + let profile = StartupPublicationProfile(sourceIsMissing: { _, _ in false }) let (_, wallet) = publicationWallet(route: .channelUsable, profile: profile) profile.clearAuthenticatedStateForTesting() wallet.replaceEmptyPublicInvoice = true @@ -239,7 +239,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } await fulfillment(of: [creation.started], timeout: 5) do { - try await profile.performProfileDeletion { + try await profile.withAutomaticWorkPausedForTesting { await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) creation.release() do { @@ -469,7 +469,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { ) } await fulfillment(of: [creation.started], timeout: 5) - try await profile.performProfileDeletion { + try await profile.withAutomaticWorkPausedForTesting { XCTAssertEqual(profile.currentSession, session) creation.release() do { @@ -506,7 +506,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } await fulfillment(of: [write.started], timeout: 5) do { - try await profile.performProfileDeletion { + try await profile.withAutomaticWorkPausedForTesting { await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) throw PublicPaykitError.publicationFailed } @@ -531,7 +531,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { case .cancelled: break } do { - try await profile.performProfileDeletion { + try await profile.withAutomaticWorkPausedForTesting { await Task { if state == .cancelled { withUnsafeCurrentTask { $0?.cancel() } @@ -575,7 +575,11 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { do { _ = try await preparation.value XCTFail("Expected superseded preparation") - } catch PubkyServiceError.sessionNotActive {} + } catch PubkyServiceError.sessionNotActive { + XCTAssertNotEqual(state, .cancelled) + } catch is CancellationError { + XCTAssertEqual(state, .cancelled) + } XCTAssertEqual(wallet.publicPaykitBolt11, "ln-retained", "\(state)") } } @@ -742,7 +746,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { let publication = Task { try await route.refresh(wallet) } await fulfillment(of: [write.started], timeout: 5) let deletion = Task { - try await profile.performProfileDeletion { + try await profile.withAutomaticWorkPausedForTesting { cleanup.fulfill() try await PublicPaykitService.syncPublishedEndpoints( publish: false, buildEndpoints: { [] }, syncApp: {}, @@ -810,17 +814,17 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { wallet.invoiceAmountSats = 1000 let refresh = Task { try await wallet.refreshBip21(forceRefreshBolt11: true) } await fulfillment(of: [wallet.olderStarted], timeout: 5) - await PrivatePaykitService.shared.beginProfileDeletion() + let pause = await PrivatePaykitService.shared.beginProfileDeletion() wallet.releaseOlderInvoice() do { try await refresh.value XCTAssertTrue(wallet.hasCoherentReceiveInvoice) XCTAssertEqual(wallet.endpointSyncCount, 0) } catch { - await PrivatePaykitService.shared.endProfileDeletion() + await PrivatePaykitService.shared.endProfileDeletion(id: pause) throw error } - await PrivatePaykitService.shared.endProfileDeletion() + await PrivatePaykitService.shared.endProfileDeletion(id: pause) wallet.failPublicEndpointRefresh = true for _ in 0 ..< 2 { await wallet.refreshPublicPaykitEndpointsOnForeground { true } @@ -873,14 +877,14 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { let wallet = GatedReceiveWallet() wallet.publicPaykitBolt11 = "ln-paid" wallet.publicPaykitBolt11PaymentHash = "paid-hash" - await PrivatePaykitService.shared.beginProfileDeletion() + let pause = await PrivatePaykitService.shared.beginProfileDeletion() wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "unrelated") XCTAssertEqual(wallet.publicPaykitBolt11, "ln-paid") XCTAssertFalse(PublicPaykitService.isCleanupPending) wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid-hash") XCTAssertTrue(wallet.publicPaykitBolt11.isEmpty) XCTAssertTrue(PublicPaykitService.isCleanupPending) - await PrivatePaykitService.shared.endProfileDeletion() + await PrivatePaykitService.shared.endProfileDeletion(id: pause) } func testPaidPublicInvoiceIsReplacedWithoutWaitingForMaintenanceRound() async { @@ -1401,9 +1405,11 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { preparation.release() do { try await older?.value - XCTAssertTrue(cancelAfterWrite) + XCTFail("A failed or cancelled publication cannot report success") } catch PublicPaykitError.publicationFailed { XCTAssertFalse(cancelAfterWrite) + } catch is CancellationError { + XCTAssertTrue(cancelAfterWrite) } XCTAssertEqual(published, ["bcrt1qnewer", "bcrt1qolder"]) XCTAssertTrue(PublicPaykitService.isCleanupPending) diff --git a/BitkitTests/ReceiveSheetSessionTests.swift b/BitkitTests/ReceiveSheetSessionTests.swift index 99c9a04db..428c2a575 100644 --- a/BitkitTests/ReceiveSheetSessionTests.swift +++ b/BitkitTests/ReceiveSheetSessionTests.swift @@ -66,12 +66,12 @@ final class ReceiveSheetSessionTests: XCTestCase { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: nil, - proposalAppId: "bitkit", + proposalAppId: "bitkit", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: nil, acceptedEventId: nil, - acceptedOutboundStatus: nil, + acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index d108f5773..c79af1aba 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -135,7 +135,7 @@ final class SendSheetLifecycleTests: XCTestCase { counterparty: "pubkypayee", paymentRequestId: UUID().uuidString, localRole: .payer, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: UUID().uuidString, proposalAppId: "bitkit", + proposalEventId: UUID().uuidString, proposalAppId: "bitkit", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), @@ -145,7 +145,7 @@ final class SendSheetLifecycleTests: XCTestCase { paymentEndpoints: nil, requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), - acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, + acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: 1, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: nil, invalidReason: nil diff --git a/BitkitTests/SharedPubkyKeychainTests.swift b/BitkitTests/SharedPubkyKeychainTests.swift index 28d701c06..3f61f7574 100644 --- a/BitkitTests/SharedPubkyKeychainTests.swift +++ b/BitkitTests/SharedPubkyKeychainTests.swift @@ -64,4 +64,43 @@ final class SharedPubkyKeychainTests: XCTestCase { } } } + + func testStrictOwnCleanupRejectsUnavailableOrMalformedEnumeration() { + for status in [errSecInteractionNotAllowed, errSecSuccess] { + XCTAssertThrowsError(try SharedPubkyKeychain.removeAllOwnOrThrow(copyMatching: { _, _ in status }, delete: { _ in + XCTFail("Enumeration must succeed before deletion") + return errSecSuccess + })) + } + } + + func testStrictOwnCleanupPreservesForeignRecordsAndRequiresDeletionAcknowledgement() throws { + let own = "\(SharedPubkyKeychain.ownSourceApp):owned" + let foreign = "\(SharedPubkyKeychain.ringSourceApp):foreign" + for deletionStatus in [errSecSuccess, errSecItemNotFound, errSecInteractionNotAllowed] { + var deleted: [String] = [] + let operation = { + try SharedPubkyKeychain.removeAllOwnOrThrow(copyMatching: { _, result in + result?.pointee = [[kSecAttrAccount as String: own], [kSecAttrAccount as String: foreign]] as CFArray + return errSecSuccess + }, delete: { query in + deleted.append((query as NSDictionary)[kSecAttrAccount] as! String) + return deletionStatus + }) + } + if deletionStatus == errSecInteractionNotAllowed { + XCTAssertThrowsError(try operation()) + } else { + XCTAssertNoThrow(try operation()) + } + XCTAssertEqual(deleted, [own]) + } + } + + func testStrictOwnCleanupAcceptsOnlyConfirmedAbsence() throws { + XCTAssertNoThrow(try SharedPubkyKeychain.removeAllOwnOrThrow(copyMatching: { _, _ in errSecItemNotFound }, delete: { _ in + XCTFail("No owned records exist") + return errSecSuccess + })) + } } diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 16492304e..881088030 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -56,7 +56,7 @@ final class TransferServiceActivityTests: XCTestCase { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", - proposalAppId: "bitkit", + proposalAppId: "bitkit", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -72,7 +72,7 @@ final class TransferServiceActivityTests: XCTestCase { metadata: PrivateJsonObject(text: "{}") ), acceptedEventId: nil, - acceptedOutboundStatus: nil, + acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitUITests/ProfileRecoveryUITests.swift b/BitkitUITests/ProfileRecoveryUITests.swift index 578c7408a..373268681 100644 --- a/BitkitUITests/ProfileRecoveryUITests.swift +++ b/BitkitUITests/ProfileRecoveryUITests.swift @@ -36,18 +36,6 @@ final class ProfileRecoveryUITests: XCTestCase { waitForClipboard("profile-recovery-fixture") } - scrollToBottom() - app.buttons["ProfileSignOut"].tap() - app.alerts.buttons["Disconnect"].tap() - waitForStatus("Disconnecting") - XCTAssertTrue(app.staticTexts["ProfileViewName"].exists) - app.buttons["ProfileFixtureFail"].tap() - waitForStatus("Idle") - XCTAssertTrue(app.staticTexts["ProfileViewName"].exists) - let errorToast = app.staticTexts["Disconnect Profile"] - XCTAssertTrue(errorToast.waitForExistence(timeout: 5)) - XCTAssertTrue(errorToast.waitForNonExistence(timeout: 10)) - app.buttons["ProfileFixtureRestore"].tap() XCTAssertEqual(app.buttons["ProfileFixtureRestore"].value as? String, "Restored") assertReadOnlyControls() @@ -75,6 +63,41 @@ final class ProfileRecoveryUITests: XCTestCase { XCTAssertTrue(app.staticTexts["ProfileFixtureEditing"].waitForExistence(timeout: 5)) } + func testFailedDisconnectRetainsSafetyStateUntilExplicitLocalCompletion() { + XCTAssertTrue(app.staticTexts["ProfileViewName"].waitForExistence(timeout: 10)) + scrollToBottom() + app.buttons["ProfileSignOut"].tap() + app.alerts.buttons["Disconnect"].tap() + waitForStatus("Disconnecting") + let pending = app.staticTexts["ProfileFixturePendingID"].label + XCTAssertNotEqual(pending, "None") + XCTAssertEqual(app.staticTexts["ProfileFixtureLocalState"].label, "Credentials: present, watermark: 42, remote: 0") + app.buttons["ProfileFixtureFail"].tap() + waitForStatus("Idle") + let disconnect = app.buttons["ProfileRecoveryDisconnect"] + XCTAssertTrue(disconnect.waitForExistence(timeout: 5)) + XCTAssertEqual(app.staticTexts["ProfileFixturePendingID"].label, pending) + XCTAssertEqual(app.staticTexts["ProfileFixtureLocalState"].label, "Credentials: present, watermark: 42, remote: 0") + XCTAssertFalse(app.staticTexts["ProfileViewName"].exists) + app.buttons["ProfileFixtureRestore"].tap() + XCTAssertEqual(app.buttons["ProfileFixtureRestore"].value as? String, "Unavailable") + XCTAssertEqual(app.staticTexts["ProfileFixturePendingID"].label, pending) + + disconnect.tap() + app.alerts.buttons["Cancel"].tap() + XCTAssertEqual(app.staticTexts["ProfileFixturePendingID"].label, pending) + disconnect.tap() + app.alerts.buttons["Disconnect"].tap() + waitForStatus("Disconnecting") + XCTAssertEqual(app.staticTexts["ProfileFixturePendingID"].label, pending) + app.buttons["ProfileFixtureLoad"].tap() + waitForStatus("Idle") + XCTAssertEqual(app.staticTexts["ProfileFixtureLocalState"].label, "Credentials: cleared, watermark: 0, remote: 0") + XCTAssertEqual(app.staticTexts["ProfileFixturePendingID"].label, "None") + XCTAssertFalse(disconnect.exists) + XCTAssertTrue(app.staticTexts["ProfileFixtureCompleted"].waitForExistence(timeout: 5)) + } + private func assertReadOnlyControls() { scrollToTop() let edit = app.buttons["ProfileEdit"] diff --git a/changelog.d/next/1143.fixed.md b/changelog.d/next/1143.fixed.md new file mode 100644 index 000000000..3fe23b2cd --- /dev/null +++ b/changelog.d/next/1143.fixed.md @@ -0,0 +1 @@ +Require acknowledged private-endpoint withdrawal before profile removal even when contact sharing is off, bind confirmed logout retries to the original grant without repeating completed cleanup, and allow local Disconnect when subscriptions prevent removal. diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 3c83c19a1..a21556843 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -12,17 +12,15 @@ Hold publication, change sharing or identity, then release it. New work must sto `delete-profile.xml` verifies visible progress and completion when deleting a disposable profile. Use a test identity that may be deleted. -With network fault injection, interrupt profile deletion after private endpoint withdrawal but before profile deletion or final App Registry synchronization. Verify the existing cleanup marker survives restart, normal reconciliation uses the current sharing preference, and failed withdrawal or registry publication remains pending until a successful retry. Successful deletion must still remove public endpoints, synchronize the App Registry with private sharing disabled, and sign out. Ordinary disconnect, contact deletion and sharing-setting changes must continue to synchronize immediately. Use the same disposable identity and record confirmation-to-onboarding time before and after; do not shorten uncertain-write safety waits or treat a small request reduction as a general latency guarantee. +`profile-fixed-term-disconnect.xml` covers the separate local-only Disconnect offered when an active subscription refuses Delete or Ring Unlink before cleanup begins. Fixed-term cancellation stays unavailable. Cancel must leave the current session and remote records intact; confirmation forgets access on this device without cancelling subscriptions, withdrawing endpoints, deleting remote profile/contacts or revoking the remote grant. Use independently provisioned payer/payee and Ring fixtures with peer/server observation. Repeat with a subscription accepted while the original confirmation is open. These checks require controlled fixtures beyond standard runner capabilities and are distinct from recovery of a real pending attempt. -While deletion is paused after private withdrawal, trigger foreground or network-restoration reconciliation. Verify it does not start public reconciliation or acknowledge the pending marker until deletion ends; already admitted work may complete. +`profile-removal-recovery.xml` covers original-owner confirmation, uncertain deletion, strict local cleanup and restart recovery. Use separately provisioned, unfunded identities and controlled operation/storage faults. Successful deletion still withdraws public/private endpoints, uses the confirmed native profile revision and finishes required local cleanup before leaving Profile. Partial or unknown results must never appear as success. -During profile deletion, automatic contact refresh, request intake and endpoint publication must not start new work. Already admitted operations and explicit deletion cleanup still finish. After a failed deletion, normal automatic work must resume for the retained identity. Verifying SDK admission and a controlled cleanup failure requires operation gates beyond the journey runner capabilities; this remains a manual check rather than a timed deletion guarantee. +After a destructive attempt starts, its durable local obligation keeps authenticated work, automatic endpoint publication and identity replacement paused until explicit local Disconnect completes. Cancellation, leaving the screen, foregrounding, reconnecting and restart do not replay remote deletion. A pending profile cleanup must keep ordinary receive/accounting available; a pending wallet wipe must keep wallet startup blocked. Required credential erasure must acknowledge before consumed-payment-list safety state is cleared, and the exact obligation is removed last. Hold each phase to verify stale completions cannot act on another owner, including a later session for the same key. -Hold private withdrawal or an admitted profile delete, then attempt Ring adoption, identity creation, Disconnect or session-backup restoration. Each must be refused before changing credentials or the active session; cancellation must not release that protection before admitted deletion work drains. After failure, verify identity operations and a new deletion attempt work again. Separately revoke the original session with a controlled hook, release cleanup, and verify the old attempt does not clear another owner's repair markers, contacts, profile or session. Repeat with a new session for the same key and inspect remote profile state, not only navigation. These checks require session-replacement and operation gates beyond the journey runner capabilities. +The iOS recovery journey deliberately differs from Android's Review UI: this pinned native contact read can reconcile remote writes. This patch therefore offers local Disconnect rather than claiming a read-only remote retry. Native observation and conditional contact deletion across edits, re-adds and ABA remain separate work. Local Disconnect never asserts that remote deletion succeeded and must not erase Ring-owned credentials. -With a contact link held during deletion, let deletion succeed and verify that the retained link opens the public contact profile while signed out, without a QR error or a private contact-list read. For failed deletion, hold contact preparation until after cleanup fails, then release it and verify that the retained link retries once preparation is available without a repeated error loop. Controlling this ordering requires preparation and cleanup-failure gates beyond the journey runner capabilities. - -If public invoice replacement is deferred during deletion, make deletion fail and the first resumed publication fail transiently. Verify that public publication resumes without waiting for contact refresh to complete, remains pending after the publication error, and is retried by normal foreground maintenance without another foreground event or payment. After sign-out, identity replacement, cancellation or disabled public sharing, an older failed refresh must not mark new publication work pending. Controlled payment events and publication failures require gates beyond the journey runner capabilities. +The automatic-publication timing checks below use a temporary in-process admission pause **without a durable removal obligation**. They verify ordinary recovery after that pause, not resumption after uncertain destructive cleanup. For a real failed removal, expect the durable recovery screen and no publication until acknowledged local completion and a subsequent explicit sign-in. Hold invoice preparation or the final identity lookup before publication, begin deletion, then release it. New public/private endpoint rotation must stay deferred while matching paid-invoice invalidation and receipt attribution still complete. Already-admitted writes may finish, but an older publication must not acknowledge a newer paid-invoice invalidation. These checks require controlled payment events and SDK-admission gates beyond the journey runner capabilities. @@ -32,7 +30,7 @@ On iOS, public publication and endpoint removal share an endpoint lock. A public Hold initial session restoration with public payments enabled, turn public payments off, then release restoration. No automatic public endpoint preparation or publication may resume, and pending cleanup must remain available. Repeat with publication waiting for SDK admission. If a write was already admitted, let it finish and verify it cannot clear the cleanup requested by disabling public sharing. These checks require restoration and SDK-operation gates beyond the journey runner capabilities. -While deletion is held, make a Lightning channel usable or ready, then fail deletion while retaining the same identity. Public endpoint preparation must refresh the invoice rather than reuse its old route hints, even if an older invoice creation finishes after the channel change. A skipped or failed publication must leave reconciliation pending; an older admitted write must not acknowledge a newer channel refresh. Sign-out, identity replacement, cancellation or disabled sharing must not let older preparation refill the invalidated cache. Also hold an older public endpoint snapshot before publication, pay its invoice and publish the replacement, then release the older snapshot. The replacement must become pending again and be republished by normal reconciliation. These checks require channel, failure and publication gates beyond the journey runner capabilities. +While deletion is held, make a Lightning channel usable or ready, then release the temporary admission pause while retaining the same identity. Public endpoint preparation must refresh the invoice rather than reuse its old route hints, even if an older invoice creation finishes after the channel change. A skipped or failed publication must leave reconciliation pending; an older admitted write must not acknowledge a newer channel refresh. Sign-out, identity replacement, cancellation or disabled sharing must not let older preparation refill the invalidated cache. Also hold an older public endpoint snapshot before publication, pay its invoice and publish the replacement, then release the older snapshot. The replacement must become pending again and be republished by normal reconciliation. These checks require channel, failure and publication gates beyond the journey runner capabilities. Repeat the held snapshot check while enabling public payments and while a failed enable restores the previous public sharing preference. A late stale publication must remain pending for repair; a successful current publication must acknowledge earlier pending work. Explicit disabling must still remove public endpoints and retain failed cleanup for retry without changing private sharing. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml index 9b7a6747e..402d04f7a 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -7,7 +7,17 @@ unrelated saved contact, and record endpoint/contact state before confirming removal. Verify the subscription warning appears before cleanup; cancel or expire the subscription in the disposable fixture and confirm removal again to verify it can proceed. - Also inject a profile-deletion error whose message contains 404 Not Found, without confirming deletion. + Also make profile DELETE return HTTP 404 while a fresh read still confirms the profile exists. + The pinned SDK normalizes this to a thrown HTTP 404 error; it must not count as confirmed deletion. + With the pinned Paykit release, also return a cleared private list without a positive acknowledgement, + including a pending handshake and an empty outgoing queue, or an acknowledgement for another + peer or outbound message. These must remain incomplete; only the exact intended withdrawals + with positive acknowledgements may advance cleanup. In another fresh fixture, disable Pay Contacts + after establishing a saved linked contact and verify the own app registry has privatePayments=false. + Retain the saved contact and encrypted link, then test first-attempt profile deletion both with + an acknowledged empty list and with its acknowledgement withheld. Sharing must stay disabled. + Failures before grant logout remain local-only. A confirmed logout retry requires the captured + original live grant and must not adopt a replacement grant, identity, client or signer. These controls are not available in standard staging. This journey covers stopping on incomplete cleanup, not cross-device conditional deletion or safe manual replay of an uncertain operation. @@ -17,11 +27,13 @@ After ending the subscription, explicitly confirm removal again and verify acknowledged cleanup succeeds Open Edit Profile and confirm deletion with the cleanup fault enabled Verify failure options appear without a success notification or onboarding navigation - Verify a failed private withdrawal is followed by no contact or profile deletion + Verify a failed private withdrawal or a missing or mismatched positive acknowledgement is followed by no contact or profile deletion Verify unknown contact removal keeps local contact data and prevents profile deletion Verify partial contact removal preserves contacts and overrides not acknowledged as removed - Verify an uncertain profile-deletion error preserves the session and sharing settings without success feedback - Cancel the failure dialog and verify the app starts no second deletion + Verify an uncertain profile-deletion error preserves required safety state and shows local recovery without success feedback or automatic publication + Cancel the local Disconnect confirmation and verify the app starts no second deletion Restart the app and verify no profile deletion is automatically replayed + In the fresh sharing-disabled fixture, confirm profile deletion and verify an empty private list is acknowledged before contact or profile deletion; verify no app registration or sharing re-enable occurs + Repeat with another fresh sharing-disabled fixture and withhold the exact withdrawal acknowledgement; verify contact and profile deletion stop and only local recovery remains available diff --git a/journeys/profile/profile-fixed-term-disconnect.xml b/journeys/profile/profile-fixed-term-disconnect.xml new file mode 100644 index 000000000..cfc1ffd47 --- /dev/null +++ b/journeys/profile/profile-fixed-term-disconnect.xml @@ -0,0 +1,23 @@ + + + Use a disposable authenticated identity with an accepted future fixed-term subscription and no + pending irreversible payment. Run separately as payer and payee, then repeat with a disposable + identity adopted from Pubky Ring for Unlink parity. Provision each fixture without live funds. + A controlled homeserver and independent authenticated peer must observe the original profile, + subscription, contacts, endpoints and session grant before and after the flow. Record app and + SDK revisions. The fixture and remote mutation journal are beyond standard runner capabilities. + To test late refusal, hold the original Delete/Unlink confirmation, then accept the subscription + independently before confirming; do not alter the app's eligibility result or cancellation policy. + A real post-marker failure belongs to the separate profile-removal-recovery journey. + + + Open the fixed-term subscription and verify End subscription is unavailable + Open Edit Profile and request Delete Profile, or Unlink pubky for a Ring identity; verify subscription refusal and truthful expiry guidance without remote mutation + Verify the separate Disconnect choice (id "ProfileLocalDisconnect") is visible and explains that this action only removes this device’s access and makes no further changes to the profile, subscriptions or access on the server + Choose Disconnect and verify a fresh local-only confirmation; cancel it and verify the original profile remains connected with no pending cleanup or remote mutation + Choose Disconnect again and confirm (id "ProfileLocalDisconnectConfirm"); verify profile onboarding appears only after required local cleanup completes, without claiming remote deletion + Using the independent peer and homeserver journal, verify that the original profile, subscription, contacts, endpoints and grant remain unchanged and no remote cleanup or cancellation was sent + Close and relaunch Bitkit; open Profile and verify onboarding remains visible with no restored credentials or automatic publication + For the Ring fixture, open Pubky Ring and verify the original identity and profile remain available + + diff --git a/journeys/profile/profile-removal-recovery.xml b/journeys/profile/profile-removal-recovery.xml new file mode 100644 index 000000000..09126c5eb --- /dev/null +++ b/journeys/profile/profile-removal-recovery.xml @@ -0,0 +1,47 @@ + + + Use a disposable authenticated profile and controlled homeserver/operation/storage faults. + These fault gates are outside the standard Capabilities; no live funds or wallet wipe is needed. + Keep a redacted operation counter. Recreate the fixture separately for each failure checkpoint. + Local Disconnect does not assert remote deletion. Conditional contact deletion across devices is + not proved by local ownership, and requires the separate native CAS fixture. + Unknown private withdrawal and uncertain contact or profile cleanup offer local Disconnect only: + contact reads can reconcile writes and must not be used for remote Review. An ordinary Disconnect + with an acknowledged private withdrawal may retry unfinished public endpoint or app-registry cleanup + after fresh confirmation. Keep each public-stage acknowledgement and never repeat private withdrawal. + Separately lose the logout response after all preceding cleanup + was acknowledged under a genuinely live grant. That case permits a freshly confirmed logout-only + retry while its original process, owner and grant remain current. Record proof-request counts + without retaining credentials or nonce values. An old bearer observer does not authorize retry. + Repeat the credential-mismatch case both before Retry preparation and while its fresh confirmation + is open; the first observed mismatch must retire Retry even after the original values return. + Separately refuse preparation before any recovery marker for owned Delete and Ring Unlink; + verify the error title matches Delete Profile or Unlink pubky respectively. + + + Open Edit Profile, request deletion and verify preparation opens a confirmation without destructive profile or contact mutation + Confirm once, lose a mutation acknowledgement and verify failure feedback without success or automatic replay + Verify no remote Retry or Review action is offered after uncertain contact or profile cleanup + In separate fixtures, edit the remote profile or change or add a contact after the failure; verify the app starts no further remote cleanup + Choose Disconnect from the failure options and verify a separate local-only confirmation appears + Cancel local Disconnect and verify neither local cleanup nor remote mutation runs + Confirm local Disconnect while credential erasure is unavailable and verify recovery remains pending without clearing consumed-payment-list safety state + Restore local storage, restart after credential erasure but before local completion and open Profile + Verify pending cleanup and Disconnect remain visible with no stored identity, no authenticated activation and no automatic remote mutation + Background and foreground the app, then reconnect the test device network; verify cleanup guidance remains available without an expired-session or reconnect-profile error + Confirm local Disconnect and verify all required local cleanup finishes before leaving Profile + In a fresh fixture, replace the sign-in before an old confirmation or completion is delivered and verify it cannot act on or navigate away from the replacement + Open ordinary receive and verify address accounting remains available while profile cleanup is pending + In the separate lost-logout-response fixture, verify Retry appears only after the prior native attempt and local tail return + Open Retry, cancel its fresh Disconnect Profile confirmation and verify no proof negotiation or remote mutation occurs + Confirm Retry once and tap again while its response is held; verify exactly one fresh proof attempt, the original removal UUID and grant, and no repeated private-list, public-endpoint, app, contact or profile cleanup or reads + Allow a same-grant bearer refresh and verify confirmed proof logout can still complete; replace the grant, identity, client, signer or same-seed process lifecycle and verify the old intent cannot act or erase replacement credentials + Lose another logout response without an acknowledgement and verify a new confirmation is required; record an acknowledgement before cancellation or local-storage failure and verify only local completion remains available + Restart after unknown logout and verify the persistent record offers only local Disconnect, with no grant recapture or SDK restoration for retry + Choose local Disconnect after unknown logout and verify it can retire the idle retry intent and finish; cancel during an active attempt and verify cleanup waits for its actual native tail + In a fresh controlled fixture, change stored credentials or the adopted source after unknown logout, attempt Retry to observe the mismatch, then restore the original values; verify the original retry remains unavailable, no native logout is dispatched and local Disconnect still works + In a separate ordinary Disconnect fixture, acknowledge withdrawal for every original private target, then fail public endpoint cleanup; verify Retry appears only after all work returns and retains the original identity, grant and removal UUID + Cancel a fresh Retry confirmation and verify no mutation; restore the public service, confirm a new Retry and verify only unfinished public cleanup and original-grant logout run, without repeating private withdrawal + Repeat with public endpoints acknowledged and app-registry cleanup failing; verify fresh Retry skips both private withdrawal and the acknowledged public endpoints before finishing the registry and original logout + +