From 8b6cd5dec45f7d8b06b42f8872b80d10e2b5b902 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 27 Oct 2026 07:48:38 +0200 Subject: [PATCH 01/15] fix: bind profile removal to its original owner --- Bitkit/AppScene.swift | 13 +- Bitkit/MainNavView.swift | 30 +- Bitkit/Managers/ContactsManager.swift | 405 ++++-- Bitkit/Managers/PubkyProfileManager.swift | 1235 ++++++++++++----- .../Localization/ar.lproj/Localizable.strings | 14 +- .../Localization/ca.lproj/Localizable.strings | 14 +- .../Localization/cs.lproj/Localizable.strings | 14 +- .../Localization/de.lproj/Localizable.strings | 14 +- .../Localization/el.lproj/Localizable.strings | 14 +- .../Localization/en.lproj/Localizable.strings | 11 +- .../es-419.lproj/Localizable.strings | 14 +- .../Localization/es.lproj/Localizable.strings | 14 +- .../Localization/fr.lproj/Localizable.strings | 14 +- .../Localization/it.lproj/Localizable.strings | 14 +- .../Localization/nl.lproj/Localizable.strings | 14 +- .../Localization/pl.lproj/Localizable.strings | 14 +- .../pt-BR.lproj/Localizable.strings | 14 +- .../Localization/pt.lproj/Localizable.strings | 14 +- .../Localization/ru.lproj/Localizable.strings | 14 +- Bitkit/Services/BackupService.swift | 57 +- Bitkit/Services/ContactPaymentsService.swift | 322 +++-- ...PrivatePaykitAddressReservationStore.swift | 100 +- .../PrivatePaykitService+Backup.swift | 72 +- .../PrivatePaykitService+Contacts.swift | 416 +++--- .../PrivatePaykitService+Invoices.swift | 70 +- .../PrivatePaykitService+Payments.swift | 569 ++++---- .../Services/PrivatePaykitService+State.swift | 57 +- Bitkit/Services/PrivatePaykitService.swift | 33 +- Bitkit/Services/PubkyService.swift | 770 ++++++++-- Bitkit/Services/PublicPaykitService.swift | 7 + Bitkit/Utilities/AppReset.swift | 113 +- Bitkit/Utilities/Keychain.swift | 58 +- .../Utilities/ProfileOperationAdmission.swift | 691 +++++++++ Bitkit/Utilities/ProfileRemovalStore.swift | 145 ++ Bitkit/Utilities/SharedPubkyKeychain.swift | 32 +- .../Testing/ContactImportUITestFixture.swift | 31 +- .../ProfileRecoveryUITestFixture.swift | 108 +- Bitkit/Views/Contacts/AddContactView.swift | 37 +- Bitkit/Views/Contacts/ContactDetailView.swift | 33 +- .../Contacts/ContactImportOverviewView.swift | 40 +- .../Contacts/ContactImportSelectView.swift | 35 +- Bitkit/Views/Contacts/EditContactView.swift | 42 +- Bitkit/Views/Profile/CreateProfileView.swift | 122 +- Bitkit/Views/Profile/EditProfileView.swift | 152 +- Bitkit/Views/Profile/PayContactsView.swift | 17 +- .../Profile/ProfileRemovalRecoveryView.swift | 86 ++ Bitkit/Views/Profile/ProfileView.swift | 78 +- Bitkit/Views/Profile/PubkyChoiceView.swift | 109 +- Bitkit/Views/Settings/DevSettingsView.swift | 39 +- .../Views/Settings/GeneralSettingsView.swift | 27 +- BitkitTests/BackupServiceTests.swift | 109 ++ BitkitTests/ContactPaymentsServiceTests.swift | 218 ++- BitkitTests/ContactsManagerTests.swift | 82 +- BitkitTests/KeychainTests.swift | 58 + BitkitTests/PaykitPublicReadLaneTests.swift | 31 +- BitkitTests/PaykitSdkClientConfigTests.swift | 361 ++++- BitkitTests/PaykitSdkIdentityCheckTests.swift | 12 +- BitkitTests/PaykitSdkOperationLockTests.swift | 22 +- BitkitTests/PaykitStorageCallbackTests.swift | 57 + BitkitTests/PrivatePaykitServiceTests.swift | 10 +- BitkitTests/ProfileDestinationViewTests.swift | 114 ++ .../ProfileOperationAdmissionTests.swift | 240 ++++ .../ProfileRemovalCoordinatorTests.swift | 510 +++++++ .../ProfileRemovalReservationTests.swift | 60 + BitkitTests/ProfileRemovalStoreTests.swift | 100 ++ .../ProfileSessionTransitionTests.swift | 355 +++++ BitkitTests/ProfileWipeAdmissionTests.swift | 171 +++ BitkitTests/PubkyProfileManagerTests.swift | 579 +++----- BitkitTests/ReceiveInvoiceRefreshTests.swift | 30 +- BitkitTests/SharedPubkyKeychainTests.swift | 39 + BitkitUITests/ProfileRecoveryUITests.swift | 47 +- changelog.d/next/1687.fixed.md | 1 + journeys/profile/README.md | 14 +- journeys/profile/profile-cleanup-failure.xml | 4 +- journeys/profile/profile-removal-recovery.xml | 27 + 75 files changed, 7276 insertions(+), 2333 deletions(-) create mode 100644 Bitkit/Utilities/ProfileOperationAdmission.swift create mode 100644 Bitkit/Utilities/ProfileRemovalStore.swift create mode 100644 Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift create mode 100644 BitkitTests/ProfileOperationAdmissionTests.swift create mode 100644 BitkitTests/ProfileRemovalCoordinatorTests.swift create mode 100644 BitkitTests/ProfileRemovalReservationTests.swift create mode 100644 BitkitTests/ProfileRemovalStoreTests.swift create mode 100644 BitkitTests/ProfileSessionTransitionTests.swift create mode 100644 BitkitTests/ProfileWipeAdmissionTests.swift create mode 100644 changelog.d/next/1687.fixed.md create mode 100644 journeys/profile/profile-removal-recovery.xml diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index c39b57cfb..31e519ce9 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -509,7 +509,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() @@ -799,13 +799,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 { @@ -817,7 +819,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) } @@ -968,7 +970,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 } @@ -998,6 +1000,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, @@ -1085,6 +1088,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() diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index f901371ad..2a4443812 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -616,7 +616,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) @@ -627,7 +629,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 { @@ -642,7 +648,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) @@ -652,25 +660,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 3c2a5b595..f66da9dfc 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 } @@ -691,62 +697,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") } } @@ -762,34 +773,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 @@ -799,7 +813,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 } } @@ -820,29 +835,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 @@ -877,9 +899,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 @@ -920,25 +942,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 @@ -948,44 +979,99 @@ 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] + } + + 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 @@ -994,16 +1080,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 { @@ -1026,11 +1117,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") @@ -1067,8 +1162,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 = [] } @@ -1217,7 +1316,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 54fb3f711..8e01f30ab 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1,5 +1,7 @@ +import Combine import Foundation import enum Paykit.PaykitError +import struct Paykit.PaykitProfileRecord import struct Paykit.PubkySessionBootstrapResult import SwiftUI @@ -60,7 +62,119 @@ 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 + } + + 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 completeLocal: @MainActor () 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 removePublicPaykitEndpoints(context: "Profile removal") }, + completeLocal: { try await clearLocalState() }, + 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? + } + + private var removalPreparation: UUID? + private var removalPlan: RemovalPlan? + private var failedRemovalID: UUID? private enum SessionInitializationMode { case userVisible @@ -81,6 +195,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? @@ -121,7 +252,6 @@ class PubkyProfileManager: ObservableObject { private let remoteProfileResolver: RemoteProfileResolver private let profilePublisher: ProfilePublisher private let avatarUploader: AvatarUploader - 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 @@ -157,18 +287,47 @@ class PubkyProfileManager: ObservableObject { avatarUploader: @escaping AvatarUploader = { try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1) }, - 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.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 { @@ -253,18 +412,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 @@ -287,7 +487,9 @@ class PubkyProfileManager: ObservableObject { if completedRecoveryVersion != recoveryVersion, revision == Self.sessionRevision { return } - guard !Task.isCancelled, publicKey == nil, authState == .idle, Self.sessionMutationCount == 0 else { return } + refreshRemovalRecovery() + guard removalRecovery == .ready, !Task.isCancelled, publicKey == nil, authState == .idle, + Self.sessionMutationCount == 0 else { return } do { guard try hasStoredIdentity() else { return } await initialize(mode: .automaticRecovery, initializeSession: initializeSession) @@ -312,7 +514,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 @@ -344,9 +546,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 @@ -370,6 +574,7 @@ class PubkyProfileManager: ObservableObject { isInitialized = true return } + guard confirmedMissingSource() == nil else { return } initializationErrorMessage = nil Self.publishOwnSharedRecord() @@ -390,7 +595,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) @@ -403,19 +608,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 + } - clearAuthenticatedState() - await Self.clearLocalState() - adoptedSourceLost = true + 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) + } + + 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 @@ -423,23 +666,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. @@ -467,9 +714,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 { @@ -502,44 +751,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, @@ -548,25 +764,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") } } @@ -595,62 +881,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) @@ -668,38 +995,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 { @@ -728,9 +1074,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) } } @@ -757,6 +1105,7 @@ class PubkyProfileManager: ObservableObject { } } + @discardableResult private func createProfile( publicKey: String, name: String, @@ -764,8 +1113,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) @@ -784,42 +1134,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( @@ -829,38 +1188,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) { @@ -906,9 +1272,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 @@ -1024,59 +1392,203 @@ 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 != .disconnect 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 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 + if action != .disconnect { + guard currentSession != nil, contacts != nil else { throw PubkyServiceError.sessionNotActive } + } + let confirmation = RemovalConfirmation(id: UUID(), action: action) + removalPreparation = confirmation.id + removalPlan = nil + do { + return try await admission.withOperation { + let profile = action == .deleteProfile && owner != nil + ? try await removalServices.readProfile(owner!) : nil + let snapshot = action != .disconnect ? try await contacts?.prepareRemovalSnapshot() : nil + 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: owner != nil && authState == .authenticated, + contacts: contacts, contactSnapshot: snapshot) + return confirmation + } + } catch { + 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 = 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) + 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 + } + } + + func finishPendingRemoval(id: UUID) async throws -> RemovalCompletion { + let pending = try removalStore.pending() + guard pending?.id == id, pending?.scope == .profile else { throw RemovalError.confirmationChanged } + let scope = try removalAdmission.reserve(kind: .localRecovery, id: id) + return try await runRemoval(scope: scope, id: id, plan: nil) + } + + private func runRemoval(scope: ProfileOperationAdmission.Scope, id: UUID, plan: RemovalPlan?, + validateLocalIntent: (() throws -> Void)? = nil) async throws -> RemovalCompletion + { + let admission = removalAdmission + defer { admission.release(scope) } Self.isProfileDeletionInFlight = true isDeletingProfile = true defer { - isDeletingProfile = false Self.isProfileDeletionInFlight = false + isDeletingProfile = false + refreshRemovalRecovery() } - await PrivatePaykitService.shared.beginProfileDeletion() + await removalServices.pause(id) + var completion: RemovalCompletion? do { - try await operation() + try await admission.withScope(scope, access: .control) { + 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 plan, plan.withdrawEndpoints { + if let snapshot = plan.contactSnapshot { + try await plan.contacts?.validateRemovalSnapshot(snapshot) + } + try validateRemovalOwner(plan) + try await removalServices.withdrawPrivate() + try validateRemovalOwner(plan) + try await removalServices.withdrawPublic() + try validateRemovalOwner(plan) + if let snapshot = plan.contactSnapshot { + try await plan.contacts?.removeConfirmedContacts(snapshot) + } + try validateRemovalOwner(plan) + 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 + } + try validateRemovalOwner(plan) + } + try validateLocalIntent?() + try admission.requireLocalOnly(scope) + try await removalServices.completeLocal() + clearAuthenticatedStateAfterLocalCleanup() + plan?.contacts?.finishProfileRemoval() + let endedRevision = Self.sessionRevision + let completedGeneration = try admission.complete(scope) + completion = RemovalCompletion(remoteProfileDeleted: remoteDeleted, endedRevision: endedRevision, + admissionGeneration: completedGeneration) + failedRemovalID = nil + } } catch { - await PrivatePaykitService.shared.endProfileDeletion() + await removalServices.resume(id) + if let completion { + return completion + } throw error } - await PrivatePaykitService.shared.endProfileDeletion() + await removalServices.resume(id) + guard let completion else { throw ProfileRemovalStore.StoreError.changed } + return completion } - 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) - } else { - try await performProfileDeletion { - try await deleteProfile(for: session, cleanPrivatePaykitEndpoints: cleanPrivatePaykitEndpoints) - } - } + func ownsRemovalCompletion(_ completion: RemovalCompletion) -> Bool { + Self.sessionRevision == completion.endedRevision && publicKey == nil && + (try? removalAdmission.validate(completion.admissionGeneration)) != nil } - private func deleteProfile(for session: SignedInSession, cleanPrivatePaykitEndpoints: Bool) async throws { - try requireCurrentSession(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 - - 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( @@ -1096,24 +1608,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( @@ -1140,13 +1637,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( @@ -1188,6 +1687,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 @@ -1200,7 +1700,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) @@ -1211,12 +1714,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 { @@ -1233,7 +1739,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) @@ -1298,7 +1806,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, @@ -1318,6 +1826,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 @@ -1410,32 +1928,56 @@ 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() + 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) @@ -1512,54 +2054,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( @@ -1726,7 +2271,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) } @@ -1762,6 +2307,7 @@ class PubkyProfileManager: ObservableObject { } nonisolated static func hasStoredIdentityForBackup() throws -> Bool { + try ProfileRemovalStore.shared.requireNoPending() if AdoptedPubkyReference.current != nil { return true } @@ -1872,8 +2418,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() @@ -1882,31 +2428,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 { @@ -1999,3 +2552,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 bd85944ab..dafb66f25 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1015,13 +1015,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" = "الدفع لجهات الاتصال"; @@ -1638,3 +1638,13 @@ "subscriptions__allowance_limit_title" = "تم بلوغ الحد"; "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" = "جارٍ إكمال تنظيف الملف الشخصي…"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 194a214be..d4dd9ed18 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1125,13 +1125,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"; @@ -1822,3 +1822,13 @@ "subscriptions__allowance_limit_title" = "Límit assolit"; "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…"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 1a1cf5c82..d7eca7970 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1230,13 +1230,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"; @@ -1961,3 +1961,13 @@ "subscriptions__allowance_limit_title" = "Limit dosažen"; "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…"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index a3af19f31..c80120d77 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1228,13 +1228,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"; @@ -1958,3 +1958,13 @@ "subscriptions__allowance_limit_title" = "Limit erreicht"; "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…"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 03a6e79c7..43f44fdf1 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1026,13 +1026,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" = "Πληρωμή Επαφών"; @@ -1693,3 +1693,13 @@ "subscriptions__allowance_limit_title" = "Το όριο εξαντλήθηκε"; "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" = "Ολοκλήρωση εκκαθάρισης προφίλ…"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9ee361576..9b0b3fc0c 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1244,13 +1244,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"; @@ -2050,3 +2050,10 @@ "usdt__error_bridge_maximum" = "Amount is above this route’s maximum. Try a smaller amount."; "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…"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 456943a31..09b30db86 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1233,13 +1233,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"; @@ -1964,3 +1964,13 @@ "subscriptions__allowance_limit_title" = "Límite alcanzado"; "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…"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index a0d36bae3..db8c60288 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1142,13 +1142,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"; @@ -1842,3 +1842,13 @@ "subscriptions__allowance_limit_title" = "Límite alcanzado"; "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…"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 26d35f483..bd9107e3e 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1241,13 +1241,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"; @@ -1972,3 +1972,13 @@ "subscriptions__allowance_limit_title" = "Limite atteinte"; "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…"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 792e01c10..6ba4520ef 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1207,13 +1207,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"; @@ -1931,3 +1931,13 @@ "subscriptions__allowance_limit_title" = "Limite Raggiunto"; "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…"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 0cc8b0481..ab95a1dbf 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1236,13 +1236,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"; @@ -1966,3 +1966,13 @@ "subscriptions__allowance_limit_title" = "Limiet bereikt"; "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…"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 7fd3cf098..06414072d 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1240,13 +1240,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"; @@ -1971,3 +1971,13 @@ "subscriptions__allowance_limit_title" = "Osiągnięto limit"; "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…"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 7f5b0015f..006fc80b3 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1241,13 +1241,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"; @@ -1972,3 +1972,13 @@ "subscriptions__allowance_limit_title" = "Limite Atingido"; "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…"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 1090ee9ed..ea59350bb 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1004,13 +1004,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"; @@ -1618,3 +1618,13 @@ "subscriptions__allowance_limit_title" = "Limite atingido"; "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…"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 507d733ad..f6e87795f 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1226,13 +1226,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" = "Платежи Контактам"; @@ -1952,3 +1952,13 @@ "subscriptions__allowance_limit_title" = "Лимит Достигнут"; "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" = "Завершение очистки профиля…"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 7903e1c8e..303eb6ffc 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 } } @@ -124,6 +137,7 @@ class BackupService { private var lastNotificationTime: UInt64 = 0 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 @@ -152,6 +166,7 @@ class BackupService { init( defaults: UserDefaults = .standard, + profileAdmission: ProfileOperationAdmission = .shared, backupData: ((BackupCategory) async throws -> Data)? = nil, usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(), uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in @@ -165,6 +180,7 @@ class BackupService { } ) { self.defaults = defaults + self.profileAdmission = profileAdmission self.backupData = backupData self.usdtRecoveryBackup = usdtRecoveryBackup self.uploadBackup = uploadBackup @@ -341,7 +357,9 @@ class BackupService { 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 { @@ -592,12 +610,11 @@ class BackupService { try await SettingsViewModel.shared.restoreAppCacheData(payload.cache) - 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. @@ -1099,9 +1116,11 @@ class BackupService { case .metadata: let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) + let profileGeneration = try ProfileOperationAdmission.shared.snapshot() let cache = try 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 @@ -1125,6 +1144,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, @@ -1191,7 +1211,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 @@ -1213,7 +1248,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 1c6c21870..757640217 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -11,6 +11,13 @@ enum ContactPaymentsService { @MainActor private static var latestChange = 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 var preparePrivateEndpoints: ( @@ -82,51 +89,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. @@ -139,46 +150,61 @@ 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 } + + enableAllPaymentOptions(defaults: defaults) + + if !enabled { + if let error = await disable(operations: operations, defaults: defaults, isChangeCurrent: isChangeCurrent) { + throw error + } + return + } - enableAllPaymentOptions(defaults: defaults) + let previousState = StoredState( + sharesPublicEndpoints: defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), + sharesPrivateEndpoints: defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), + hasConfirmedPreference: defaults.bool(forKey: confirmedPreferenceKey), + publicCleanupPending: defaults.bool(forKey: PublicPaykitService.cleanupPendingKey), + privateCleanupPending: defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) + ) - if !enabled { - if let error = await disable(operations: operations, defaults: defaults, isChangeCurrent: isChangeCurrent) { + do { + try await enable( + contactPublicKeys: contactPublicKeys, + canUsePrivatePayments: canUsePrivatePayments, + operations: operations, + defaults: defaults, + isChangeCurrent: isChangeCurrent + ) + } catch { + if Task.isCancelled, let scope = ProfileOperationAdmission.current, + (try? scope.admission.validateCancelledPreference(scope)) != nil, callerIsCurrent() + { + defaults.set(previousState.sharesPublicEndpoints, forKey: PublicPaykitService.publishingEnabledKey) + 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, + canUsePrivatePayments: canUsePrivatePayments, + operations: operations, + defaults: defaults, + isChangeCurrent: isChangeCurrent + ) throw error } - return - } - - let previousState = StoredState( - sharesPublicEndpoints: defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), - sharesPrivateEndpoints: defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), - hasConfirmedPreference: defaults.bool(forKey: confirmedPreferenceKey), - publicCleanupPending: defaults.bool(forKey: PublicPaykitService.cleanupPendingKey), - privateCleanupPending: defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) - ) - - do { - try await enable( - contactPublicKeys: contactPublicKeys, - canUsePrivatePayments: canUsePrivatePayments, - operations: operations, - defaults: defaults, - isChangeCurrent: isChangeCurrent - ) - } catch { - await restore( - previousState, - contactPublicKeys: contactPublicKeys, - canUsePrivatePayments: canUsePrivatePayments, - operations: operations, - defaults: defaults, - isChangeCurrent: isChangeCurrent - ) - throw error } } @@ -188,84 +214,95 @@ enum ContactPaymentsService { operations: Operations, defaults: UserDefaults = .standard ) async throws -> Bool { - latestChange += 1 - let change = latestChange - let session = pubkyProfile.currentSession - let isChangeCurrent: ChangeCheck = { - Self.latestChange == change && 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 + let change = latestChange + let session = pubkyProfile.currentSession + let isChangeCurrent = try ownedChange { + Self.latestChange == change && 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 @@ -303,7 +340,7 @@ enum ContactPaymentsService { defaults.set(true, forKey: confirmedPreferenceKey) try await operations.syncPublicEndpoints(true, isChangeCurrent) - + try Task.checkCancellation() guard isChangeCurrent() else { return } if canUsePrivatePayments { operations.setPrivateCleanupPending(false) @@ -317,6 +354,7 @@ enum ContactPaymentsService { { throw error } + try Task.checkCancellation() } @MainActor diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index 6ca324c20..e3b63bd49 100644 --- a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -11,11 +11,12 @@ actor PrivatePaykitAddressReservationStore { walletBackupDataChangedSubject.eraseToAnyPublisher() } - private static let defaultsKey = "privatePaykitAddressReservations" + static let defaultsKey = "privatePaykitAddressReservations" private static let schemaVersion = 1 private let defaults: UserDefaults private(set) var attributionRevision: UInt64 = 0 + private var storedLedgerWasUnreadable = false private var ledger: Ledger { didSet { attributionRevision &+= 1 } } @@ -26,11 +27,12 @@ actor PrivatePaykitAddressReservationStore { self.defaults = defaults 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 } } @@ -46,6 +48,7 @@ actor PrivatePaykitAddressReservationStore { } func restoreBackup(_ highestReservedReceiveIndexByAddressType: [String: UInt32]?) { + storedLedgerWasUnreadable = false guard let highestReservedReceiveIndexByAddressType else { ledger = .empty persist() @@ -77,16 +80,18 @@ actor PrivatePaykitAddressReservationStore { } func contactPublicKeyForAttribution(forReservedAddress address: String) async throws -> String? { - guard !address.isEmpty else { return nil } - for (publicKey, assignment) in contactAssignmentsForAttribution() { - guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), - addressType.matchesAddressFormat(address, network: Env.network) - else { continue } - if try await self.address(for: addressType, receiveIndex: assignment.receiveIndex) == address { - return publicKey + return try await ProfileOperationAdmission.shared.withOperation(access: .local) { + guard !address.isEmpty else { return nil } + for (publicKey, assignment) in contactAssignmentsForAttribution() { + guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), + addressType.matchesAddressFormat(address, network: Env.network) + else { continue } + if try await self.address(for: addressType, receiveIndex: assignment.receiveIndex) == address { + return publicKey + } } + return nil } - return nil } func currentContactPublicKey(forReservedAddress address: String) async -> String? { @@ -106,28 +111,30 @@ actor PrivatePaykitAddressReservationStore { } func currentOrRotatedAddress(for publicKey: String) async throws -> String { - let assignmentKey = try Self.contactAssignmentKey(publicKey: 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.contactAssignmentKey(publicKey: 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 CoreService.shared.utility.isAddressUsed(address: existing.address) - if !isUsed { - return existing.address + do { + let isUsed = try await CoreService.shared.utility.isAddressUsed(address: 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) + } } private func reservedAddressDetails(forAssignmentKey assignmentKey: String) async throws -> (address: String, addressType: LDKNode.AddressType)? { @@ -281,11 +288,16 @@ actor PrivatePaykitAddressReservationStore { // MARK: - Cleanup func clear() { + storedLedgerWasUnreadable = false 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 = [:] @@ -293,6 +305,32 @@ 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 { + guard Self.decodeStoredLedger(encoded) == ledger, defaults.data(forKey: Self.defaultsKey) == encoded else { + throw ProfileRemovalStore.StoreError.changed + } + } + func clearContactAssignment(publicKey: String) { clearContactAssignments(publicKeys: [publicKey]) } @@ -349,6 +387,10 @@ actor PrivatePaykitAddressReservationStore { reserved.insert(addressInfo.index) ledger.reservedReceiveIndexesByAddressType[addressTypeKey] = reserved + // 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 @@ -451,7 +493,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 9c2a92cb6..7c70ee0ab 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -7,46 +7,50 @@ 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 + 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 + } + ) + 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 + 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 + } + } 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 84311dbe1..7d45e62b0 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -106,37 +106,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: { 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: { 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( @@ -145,13 +148,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( @@ -224,18 +229,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 } @@ -254,6 +262,8 @@ extension PrivatePaykitService { func invalidateContactPreparation() { preparationGeneration += 1 + preparationTask?.cancel() + preparationTask = nil resumeBackgroundWorkWaiters() pendingPreparationKeys.removeAll() pendingPreparationOperation = nil @@ -297,13 +307,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 @@ -367,34 +380,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 } } @@ -403,6 +419,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) }) @@ -428,6 +445,7 @@ 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") @@ -457,6 +475,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) @@ -489,6 +508,7 @@ extension PrivatePaykitService { } } + try profileAdmission.checkCurrent() let successfulPublicKeys = publicKeySet.subtracting(failedPublicKeys) if applyPublishedEndpointCleanupResults( successfulPublicKeys: successfulPublicKeys, @@ -515,128 +535,134 @@ extension PrivatePaykitService { } 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] { @@ -1018,30 +1044,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" + ) + } } } } @@ -1069,7 +1097,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 { @@ -1093,7 +1121,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 733d1d671..76ea925c0 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? { @@ -88,6 +97,7 @@ extension PrivatePaykitService { @MainActor func privateEndpointPublicationUnavailabilityReason(wallet: WalletViewModel) async -> 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 6de3653f8..f494dafd2 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -32,7 +32,7 @@ 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 state.contacts.first { _, contactState in contactState.localInvoice?.paymentHash == paymentHash || @@ -41,50 +41,59 @@ 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) } - guard await canPublishPrivateEndpoints(wallet: wallet) else { return } - _ = await refreshSavedContactEndpointsReturningError( - for: [normalizedKey], - wallet: wallet, - forceRefreshLightning: false, - requireImmediatePublication: false - ) + if prePaymentPublicationKeys.insert(normalizedKey).inserted { + let scope = try profileAdmission.capture() + Task { + defer { + prePaymentPublicationKeys.remove(normalizedKey) + profileAdmission.release(scope) + } + guard (try? profileAdmission.validate(scope)) != nil, + await canPublishPrivateEndpoints(wallet: wallet) else { return } + _ = await 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 - } - guard let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty) else { - throw PrivatePaykitError.invalidPublicKey - } + 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 + } - if let proof = try await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request), - let result = Self.retainedHardwarePaymentLaunchResult(proof: proof, request: request) - { - return result - } + 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 - ) + return try await beginContactPayment( + to: publicKey, + paymentRequest: request + ) + } } static func retainedHardwarePaymentLaunchResult( @@ -106,115 +115,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) } } @@ -262,42 +275,44 @@ extension PrivatePaykitService { context: PrivatePaykitPaymentContext, attemptId: UUID ) 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 - } + 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.cachedResolvedEndpoints.removeAll() - state.contacts[publicKey] = contactState - do { - try persistStateOrThrow(markWalletBackup: true) - } catch { - state.contacts[publicKey] = previousContactState - throw error - } + let previousContactState = state.contacts[publicKey] + contactState.consumedPrivatePaymentListVersion = paymentListVersion + 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" - ) + 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( @@ -306,66 +321,70 @@ 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 - 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 + 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 newer consumption is left alone. func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) throws { - guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { - throw PrivatePaykitError.invalidPublicKey - } - guard var contactState = state.contacts[publicKey], contactState.consumedPrivatePaymentListVersion == version else { return } - let previousContactState = state.contacts[publicKey] - contactState.consumedPrivatePaymentListVersion = previousVersion - 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 else { return } + let previousContactState = state.contacts[publicKey] + contactState.consumedPrivatePaymentListVersion = previousVersion + 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? { @@ -378,36 +397,40 @@ 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.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.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 } - guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version 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 } + guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version 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 + } } } @@ -544,57 +567,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 404488c90..fbb9264a2 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,22 +36,25 @@ 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 - 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 + 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/PubkyService.swift b/Bitkit/Services/PubkyService.swift index ba09e591a..d37294148 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 @@ -85,21 +86,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) + } + } } } @@ -111,23 +120,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 { @@ -432,6 +443,7 @@ actor PaykitSdkService { struct BackupIdentity: Equatable { let runtimeGeneration: UInt64 let walletGeneration: Int + let profileGeneration: ProfileOperationAdmission.Generation } static let shared = PaykitSdkService() @@ -443,9 +455,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)? @@ -459,23 +472,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() } } @@ -484,6 +676,7 @@ actor PaykitSdkService { var sdk = try handle() let status: IdentityStatus do { + try profileAdmission.checkCurrent() status = try await sdk.initialize() } catch { invalidatePaykitKeyIfNeeded(after: error) @@ -494,6 +687,7 @@ actor PaykitSdkService { resetRuntime() do { sdk = try handle() + try profileAdmission.checkCurrent() status = try await sdk.initialize() } catch { sessionProvider.resumeStoredSessionAccess() @@ -506,35 +700,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) @@ -577,7 +827,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 { @@ -591,6 +843,7 @@ actor PaykitSdkService { } do { + try profileAdmission.checkCurrent() let identity = try publicKey ?? sessionProvider.loadLocalSecretKey().map { try Paykit.pubkyPublicKeyFromSecret(localSecretKey: $0) } @@ -639,14 +892,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, @@ -658,17 +916,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 + } + } } } @@ -677,7 +940,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), @@ -685,26 +948,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() @@ -715,7 +1011,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, @@ -731,7 +1027,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, @@ -804,6 +1100,17 @@ 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 sdk.deletePaykitProfile(expectedRevision: expectedRevision) + } + } + func deletePaykitProfile(expectedIdentity: String? = nil) async throws { try await withStateRevisionTracking { sdk in if let expectedIdentity { @@ -971,6 +1278,7 @@ actor PaykitSdkService { try await withStateRevisionTracking(priority: priority) { sdk in var capabilities = try await appCapabilities(for: sdk.identityStatus()) capabilities.privatePayments = capabilities.privatePayments && privatePaymentsEnabled + try profileAdmission.checkCurrent() _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } } @@ -1215,7 +1523,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) } } @@ -1264,7 +1572,7 @@ actor PaykitSdkService { } func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? { - try await operationLock.withLock { + try await withLockedSdkOperation { try await handle().allowanceAccountingState() } } @@ -1385,7 +1693,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 } } @@ -1395,56 +1703,80 @@ 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() } + } + 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) } - // Payment may have started while this export waited for the SDK lock. - if priority == .background, await PaykitPaymentActivity.shared.isActive { - return nil + 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() - markWalletBackupDataChanged() + 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 { @@ -1457,7 +1789,7 @@ actor PaykitSdkService { func clearState() async { do { - try await operationLock.withLock { + try await withLockedSdkOperation(access: .local) { resetRuntime() markWalletBackupDataChanged() } @@ -1487,6 +1819,7 @@ actor PaykitSdkService { } private func handle() throws -> PaykitSdk { + try profileAdmission.checkCurrent() if let sdk { return sdk } @@ -1501,50 +1834,82 @@ 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, _ operation: (PaykitSdk) async throws -> T ) async throws -> T { - 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 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() } } - do { - return try await withSdkErrorHandling { - try await refreshPaykitKey() - return try await .some(operation(handle())) + let result: T? = try await withLockedSdkOperation(priority: priority) { + 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 } + } + do { + return try await withSdkErrorHandling { + try await refreshPaykitKey() + 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 + } } } } @@ -1589,6 +1954,7 @@ actor PaykitSdkService { try Task.checkCancellation() } try await admit() + try profileAdmission.checkCurrent() }, operation: { try await operation(sdk) } ) @@ -1655,6 +2021,7 @@ actor PaykitSdkService { private func resetRuntime() { runtimeGeneration &+= 1 sdk = nil + publicSdk = nil cachedPaykitKey = nil cachedBackupState = nil } @@ -1680,12 +2047,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)) } @@ -1695,6 +2063,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) } @@ -1708,12 +2077,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 } @@ -1721,6 +2093,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) } @@ -1738,14 +2111,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 { @@ -1757,11 +2133,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) @@ -1772,6 +2164,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) @@ -1803,6 +2196,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 } @@ -1876,6 +2272,7 @@ final class PaykitSdkOperationLock: @unchecked Sendable { } } + private let profileAdmission: ProfileOperationAdmission private let maxInteractiveBypasses = 3 private let lock = NSLock() private var isLocked = false @@ -1889,34 +2286,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. @@ -1952,14 +2376,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 { @@ -2001,11 +2425,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 { @@ -2027,7 +2451,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 { @@ -2214,15 +2638,32 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab private var liveSessionAccess: PubkySessionAccess? private var paykitIdentitySecretKey: PaykitIdentitySecretKey? 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) }, deleteKeychainValue: @escaping (KeychainEntryType) throws -> Void = { try Keychain.delete(key: $0) } ) { + self.publicOnly = publicOnly self.loadSessionSecret = loadSessionSecret self.deleteKeychainValue = deleteKeychainValue } + func setAdmissionCheck(_ check: (() throws -> Void)?) { + lock.withLock { admissionCheck = check } + } + + private func validateAdmission() throws { + let check = lock.withLock { admissionCheck } + do { try check?() } + 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() liveSessionAccess = access @@ -2238,7 +2679,9 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab } 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() } @@ -2255,7 +2698,7 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab let access = try PubkySessionAccess( clientId: PaykitSdkService.clientID, sessionSecret: sessionSecret, - localSecretKey: loadLocalSecretKey(), + localSecretKey: readLocalSecretKey(), paykitIdentitySecretKey: paykitIdentitySecretKey ) liveSessionAccess = access @@ -2286,6 +2729,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) @@ -2293,6 +2740,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 } @@ -2389,3 +2842,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 121534890..87949c1d8 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) 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..8dd1bb5aa 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -245,52 +245,52 @@ 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] - - SecItemDelete(query as CFDictionary) - - Logger.info("Deleted \(key) from keychain") + ] + 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 + } } } } diff --git a/Bitkit/Utilities/ProfileOperationAdmission.swift b/Bitkit/Utilities/ProfileOperationAdmission.swift new file mode 100644 index 000000000..09534bbbf --- /dev/null +++ b/Bitkit/Utilities/ProfileOperationAdmission.swift @@ -0,0 +1,691 @@ +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 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? + + 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)] = [] + + init(store: ProfileRemovalStore) { + self.store = store + } + + 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 + } + 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, entries.keys.contains(where: { $0 != 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, + !entries.keys.contains(where: { $0 != 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 || !entries.keys.contains(where: { $0 != id }) else { return false } + ready.append(continuation) + return true + } + return ready + } + ready.forEach { $0.resume() } + } +} diff --git a/Bitkit/Utilities/ProfileRemovalStore.swift b/Bitkit/Utilities/ProfileRemovalStore.swift new file mode 100644 index 000000000..b788f37b5 --- /dev/null +++ b/Bitkit/Utilities/ProfileRemovalStore.swift @@ -0,0 +1,145 @@ +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 + } + 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 + } + 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..62d3344ff 100644 --- a/Bitkit/Utilities/SharedPubkyKeychain.swift +++ b/Bitkit/Utilities/SharedPubkyKeychain.swift @@ -135,12 +135,40 @@ 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 { + 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):") { + 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 ca5356dd4..6f5e57d08 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,16 +35,29 @@ } CaptionText(clipboard) .accessibilityIdentifier("ProfileFixtureClipboardValue") - CaptionText(profile.isLoadingProfile ? "Loading" : profile.isDisconnectPending ? "Disconnecting" : "Idle") + CaptionText(profile.isLoadingProfile ? "Loading" : local.isPending ? "Disconnecting" : "Idle") .accessibilityIdentifier("ProfileFixtureStatus") + 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("Local cleanup complete") + .accessibilityIdentifier("ProfileFixtureCompleted") + } else { + ProfileDestinationView(hasSeenIntro: true) + } + } + .navigationDestination(for: Route.self) { route in + if route == .editProfile { + CaptionText("Edit profile") + .accessibilityIdentifier("ProfileFixtureEditing") } + } } } .environmentObject(profile as PubkyProfileManager) @@ -46,6 +66,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 @@ -55,20 +112,35 @@ imageUrl: nil, links: [], tags: ["public-tag"], status: nil ) - @Published private(set) var isDisconnectPending = false + private let local: ProfileRecoveryLocalState 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() }, + completeLocal: { try await local.complete() }, + 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( @@ -89,14 +161,8 @@ isLoadingProfile = false } - override func signOut() async throws { - isDisconnectPending = true - _ = await withCheckedContinuation { pendingResult = $0 } - isDisconnectPending = false - throw PubkyServiceError.sessionNotActive - } - func restoreSession() { + guard removalRecovery == .ready else { return } publicKey = Self.savedProfile.publicKey authState = .authenticated } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 45a7d1942..7c95d7a97 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -13,6 +13,7 @@ struct AddContactView: View { let publicKey: String + @State private var screenSession: PubkyProfileManager.SignedInSession? @State private var fetchedProfile: PubkyProfile? @State private var isLoading = true @State private var isSaving = false @@ -53,6 +54,9 @@ struct AddContactView: View { .background(Color.customBlack) .navigationBarHidden(true) .task { + if screenSession == nil { + screenSession = pubkyProfile.currentSession + } await loadProfile() } } @@ -215,8 +219,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): @@ -234,6 +243,9 @@ struct AddContactView: View { // MARK: - Save Contact private func saveContact() async { + 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 } @@ -243,15 +255,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 092006c0f..d989e7a15 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -7,6 +7,7 @@ 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] = [] @@ -14,13 +15,26 @@ struct EditProfileView: View { @State private var isSaving = false @State private var isDeleting = false @State private var showDeleteConfirmation = false - @State private var showDeleteFailureOptions = false + @State private var removalConfirmation: PubkyProfileManager.RemovalConfirmation? + @State private var confirmationOwner: 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 copy = (removalConfirmation?.action == .unlink || (removalConfirmation == nil && pubkyProfile.isRingAdoptedIdentity)) ? RemovalCopy + .unlink : RemovalCopy.delete + Group { + if pubkyProfile.removalRecovery != .ready, !isDeleting { + ProfileRemovalRecoveryView() + } else { + editContent(copy: copy) + } + } + } + + private func editContent(copy: RemovalCopy) -> some View { ZStack { ProfileEditFormView( navigationTitle: t("profile__edit_nav_title"), @@ -38,7 +52,10 @@ 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 prepareDelete(owner: owner) } + } ) { avatarPicker } @@ -58,27 +75,23 @@ struct EditProfileView: View { .navigationBarHidden(true) .disabled(isDeleting) .task { + if screenOwner == nil { + screenOwner = pubkyProfile.removalPresentationOwner + } loadProfileData() } + .onDisappear { + preparationTask?.cancel() + pubkyProfile.cancelRemovalConfirmation(removalConfirmation) + } .alert(copy.title, isPresented: $showDeleteConfirmation) { Button(copy.confirm, role: .destructive) { Task { await deleteProfile() } } - Button(t("common__dialog_cancel"), role: .cancel) {} + Button(t("common__dialog_cancel"), role: .cancel) { pubkyProfile.cancelRemovalConfirmation(removalConfirmation) } } message: { Text(copy.description) } - .alert(copy.errorTitle, isPresented: $showDeleteFailureOptions) { - Button(t("common__retry")) { - Task { await deleteProfile() } - } - Button(t("profile__sign_out"), role: .destructive) { - Task { await disconnectAfterFailedDelete() } - } - Button(t("common__dialog_cancel"), role: .cancel) {} - } message: { - Text(copy.errorDescription) - } } // MARK: - Avatar Picker @@ -147,62 +160,37 @@ 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 prepareDelete(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 { - guard !Task.isCancelled, pubkyProfile.publicKey == session.publicKey else { return } - Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") - showDeleteFailureOptions = true - } - } - } catch { - guard !Task.isCancelled, pubkyProfile.currentSession == session else { return } - Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") - showDeleteFailureOptions = true - } - } - - 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 } + removalConfirmation = try await pubkyProfile.prepareRemoval( + action: pubkyProfile.isRingAdoptedIdentity ? .unlink : .deleteProfile, contacts: contactsManager, expectedOwner: presentationOwner ) + confirmationOwner = presentationOwner + showDeleteConfirmation = true + } catch { + guard !Task.isCancelled, pubkyProfile.ownsRemovalPresentation(presentationOwner) else { return } + app.toast(type: .error, title: t("profile__sign_out_title"), description: error.localizedDescription) } - navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] - } - - static func performDeleteProfile( - session: PubkyProfileManager.SignedInSession, - pubkyProfile: PubkyProfileManager, - contactsManager: ContactsManager, - removePrivateEndpoints: () async throws -> Void - ) async throws { - try pubkyProfile.requireCurrentSession(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 { + private func deleteProfile() async { + guard !isDeleting, let confirmation = removalConfirmation, let presentationOwner = confirmationOwner else { return } + isDeleting = true + defer { isDeleting = false } do { - try await pubkyProfile.signOut() - navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + let result = try await pubkyProfile.confirmRemoval(confirmation) + if pubkyProfile.ownsRemovalCompletion(result) { + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } } catch { - app.toast(type: .error, title: t("profile__sign_out_title"), description: error.localizedDescription) + pubkyProfile.refreshRemovalRecovery() + guard !Task.isCancelled, pubkyProfile.ownsRemovalFailure(confirmation, owner: presentationOwner) else { return } + if pubkyProfile.pendingProfileRemovalID != confirmation.id { + app.toast(type: .error, title: t("profile__sign_out_title"), description: error.localizedDescription) + } } } @@ -212,23 +200,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) } @@ -243,8 +239,6 @@ private struct RemovalCopy { let title: String let confirm: String let description: String - let errorTitle: String - let errorDescription: String static var delete: RemovalCopy { RemovalCopy( @@ -254,9 +248,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") ) } @@ -268,9 +260,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..86297006e --- /dev/null +++ b/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct ProfileRemovalRecoveryView: View { + @EnvironmentObject private var app: AppViewModel + @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 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")) + 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) + .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 disconnect(id: UUID) async { + isWorking = true + defer { isWorking = false } + do { _ = try await pubkyProfile.finishPendingRemoval(id: id) } + 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 0c2510135..d65f32372 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -107,9 +107,7 @@ struct DevSettingsView: View { if enabled { showPaykitWarning = true } else { - Task { - await disablePaykitUI() - } + startDisablingPaykitUI() } } ), @@ -259,23 +257,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 9c59653b2..5620234d3 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -35,7 +35,7 @@ struct GeneralSettingsView: View { (hasConfirmedContactPaymentsPreference && (sharesPublicPaykitEndpoints || sharesPrivatePaykitEndpoints)) }, set: { enabled in - Task { await updateContactPayments(enabled) } + startUpdatingContactPayments(enabled) } ) } @@ -181,7 +181,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) } } @@ -203,26 +203,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/BackupServiceTests.swift b/BitkitTests/BackupServiceTests.swift index 76ae93fab..0cf06eff7 100644 --- a/BitkitTests/BackupServiceTests.swift +++ b/BitkitTests/BackupServiceTests.swift @@ -5,6 +5,74 @@ import XCTest @MainActor final class BackupServiceTests: XCTestCase { + 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 testChangesDuringCaptureAndUploadRemainRequired() async throws { for changeDuringCapture in [true, false] { let suite = "BackupServiceTests.\(UUID().uuidString)" @@ -414,6 +482,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 9aa56091e..a0ecd795a 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -241,6 +241,9 @@ final class ContactPaymentsServiceTests: XCTestCase { } XCTAssertTrue(operations.privatePublications.isEmpty) XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) + XCTAssertEqual(operations.publicPublicationValues, [true], "Cancellation must not replay remote publication or withdrawal") + XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) } } @@ -597,15 +600,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() } @@ -616,7 +620,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) @@ -860,131 +864,105 @@ 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 publicBeforeLock, privateBeforeLock, publicHoldingLock - } - let cases: [(name: String, held: HeldPublication, calls: [String], writes: [String], keepsFlags: Bool)] = [ - ( - "public publication waiting for its lock", .publicBeforeLock, - ["public:true"], ["private:removed", "public:removal failed"], false - ), - ( - "private publication waiting for its lock", .privateBeforeLock, - ["public:true", "private:publish"], ["public:published", "private:removed", "public:removal failed"], false - ), - ( - "public publication holding its lock while sign-out fails", .publicHoldingLock, - ["public:true"], ["private:removal failed", "public:published"], 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 ["public-before-lock", "private-before-lock", "public-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 + } + }, + completeLocal: { completeLocalCount += 1 }, + pause: { + _ in pauseCount += 1; if pauseCount == 1 { + paused.fulfill() + } + }, resume: { _ in } ) - } - operations.syncPublicEndpoints = { publish, isSessionCurrent in - guard publish else { return } - if held == .publicBeforeLock { - reachedHeldPoint.fulfill() - for await _ in gate {} - } - try await PublicPaykitService.withEndpointLock(unlessSessionEnded: isSessionCurrent) { - if held == .publicHoldingLock { - 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.syncPublicEndpoints = { publish, isCurrent in + guard publish else { return } + if heldStage == "public-before-lock" { + started.fulfill(); await native.value } - writes.append("public:published") - } - } - 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 == .publicHoldingLock - 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 == "public-holding-lock" { + started.fulfill(); await native.value } - writes.append("private:removed") + writes.append("public: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 != "public-before-lock" ? ["public: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) - XCTAssertEqual(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), testCase.keepsFlags, testCase.name) - XCTAssertEqual(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), testCase.keepsFlags, testCase.name) - XCTAssertEqual(operations.publicCleanupValues, held == .privateBeforeLock ? [false] : [], testCase.name) - XCTAssertEqual(operations.privateCleanupValues, held == .privateBeforeLock ? [false] : [], testCase.name) - XCTAssertTrue(PublicPaykitService.isCleanupPending, testCase.name) } } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 0323836fa..face99e28 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) } @@ -2176,14 +2243,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/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/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/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 6c919cff3..9615fdf2d 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 { @@ -605,7 +685,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() } // Shorter than the republish timeout, so an activation that awaited the publication would miss it. @@ -688,7 +772,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() } @@ -696,7 +784,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)) @@ -705,6 +804,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" @@ -803,6 +1146,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? @@ -840,7 +1184,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? { @@ -907,6 +1252,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 } @@ -916,7 +1262,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 diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index 6683dbb1f..1d1f4a8f7 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -30,17 +30,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 { diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index 6e624d698..26bc7e197 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -282,7 +282,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") } @@ -365,7 +365,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") } @@ -430,6 +430,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() } 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/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index c0bfa70bb..3de270977 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1151,7 +1151,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() @@ -2684,14 +2688,14 @@ final class PrivatePaykitServiceTests: XCTestCase { 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") diff --git a/BitkitTests/ProfileDestinationViewTests.swift b/BitkitTests/ProfileDestinationViewTests.swift index 319d61518..3f4efac9a 100644 --- a/BitkitTests/ProfileDestinationViewTests.swift +++ b/BitkitTests/ProfileDestinationViewTests.swift @@ -5,6 +5,91 @@ 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() + let view = CreateProfileView() + .environmentObject(manager as PubkyProfileManager) + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + let window = try host(view) + defer { close(window) } + try await Task.sleep(for: .milliseconds(200)) + 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 +413,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 }) @@ -420,6 +511,29 @@ 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") + + 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..b6798ee6d --- /dev/null +++ b/BitkitTests/ProfileOperationAdmissionTests.swift @@ -0,0 +1,240 @@ +@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 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/ProfileRemovalCoordinatorTests.swift b/BitkitTests/ProfileRemovalCoordinatorTests.swift new file mode 100644 index 000000000..b86833ce5 --- /dev/null +++ b/BitkitTests/ProfileRemovalCoordinatorTests.swift @@ -0,0 +1,510 @@ +@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 duringPause: (() -> Void)? + private var duringResume: (() async -> 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") }, + withdrawPublic: { [self] in try step("public") }, + completeLocal: { [self] in + try step("credentials") + watermark = 0 + cleanupCount += 1 + }, + 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 record(key: String) throws -> PaykitProfileRecord? { + 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: { [] }, removeContactRecords: { _ in + 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", "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", "credentials"]) + } + + 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/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/ProfileSessionTransitionTests.swift b/BitkitTests/ProfileSessionTransitionTests.swift new file mode 100644 index 000000000..ba5beac10 --- /dev/null +++ b/BitkitTests/ProfileSessionTransitionTests.swift @@ -0,0 +1,355 @@ +@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()) + } + + 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/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index c15f25c33..72a9ac8a8 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -12,196 +12,88 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testProfileRemovalStopsBeforeDeletingWhenCleanupIsIncomplete() async throws { - snapshotAppDefaultsDomain() - for failure in ["private endpoints", "contacts"] { + func testLocalCleanupRetainsConsumedWatermarksWhenCredentialDeletionFails() async throws { + try await withEmptyIdentityStorage { let key = "pubky" + String(repeating: "y", count: 52) - let originalError = KeychainError.failedToDelete - let manager = PubkyProfileManager(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) } } @@ -235,162 +127,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(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(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 testDeletionSessionRejectsAReplacementAndANewSessionForTheSameKey() async throws { - try await withEmptyIdentityStorage { - let secret = String(repeating: "01", count: 32) - let original = try PubkyProfileManager.publicKeyFromSecretKey(secret) - let manager = PubkyProfileManager(profileDeleter: { _ in - XCTFail("A stale deletion must not reach profile storage") - }) - manager.publicKey = original - 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 } - ) - 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 {} - } - } - @MainActor func testNavigationLookupReadsStoredIdentityWithoutCachedMetadata() async throws { let key = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" @@ -806,6 +542,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 @@ -856,14 +601,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()) @@ -874,7 +620,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 @@ -920,12 +666,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") @@ -1042,20 +786,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")) @@ -1336,6 +1084,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() @@ -1496,41 +1245,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" @@ -1538,7 +1295,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 @@ -1554,11 +1312,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 { @@ -1608,11 +1367,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 @@ -1682,11 +1442,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") @@ -1728,6 +1489,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", @@ -1737,10 +1520,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) @@ -1842,6 +1621,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() } @@ -3342,7 +3123,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) } @@ -3831,29 +3613,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 { @@ -4445,3 +4246,19 @@ private actor SessionRecoveryAttempts { return count } } + +private actor ProfileDeletionNativeCompletion { + private var continuation: CheckedContinuation? + + func wait(started: () -> Void) async { + await withCheckedContinuation { + continuation = $0 + started() + } + } + + func finish() { + continuation?.resume() + continuation = nil + } +} diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index 61f470507..74cf829bd 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -64,7 +64,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) } @@ -89,7 +89,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) @@ -120,7 +120,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 @@ -150,7 +150,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) } @@ -187,7 +187,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 @@ -238,7 +238,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 { @@ -467,7 +467,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 { @@ -503,7 +503,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 } @@ -528,7 +528,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() } @@ -740,7 +740,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: {}, @@ -808,17 +808,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 } @@ -871,14 +871,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 testAdmittedPublicWriteCannotAcknowledgeNewerPaidInvoiceInvalidation() async throws { 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/BitkitUITests/ProfileRecoveryUITests.swift b/BitkitUITests/ProfileRecoveryUITests.swift index 17b66e9bd..899c71552 100644 --- a/BitkitUITests/ProfileRecoveryUITests.swift +++ b/BitkitUITests/ProfileRecoveryUITests.swift @@ -34,18 +34,6 @@ final class ProfileRecoveryUITests: XCTestCase { popup.tap() } - 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() @@ -73,6 +61,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"].exists) + } + private func assertReadOnlyControls() { scrollToTop() let edit = app.buttons["ProfileEdit"] diff --git a/changelog.d/next/1687.fixed.md b/changelog.d/next/1687.fixed.md new file mode 100644 index 000000000..c3abb0857 --- /dev/null +++ b/changelog.d/next/1687.fixed.md @@ -0,0 +1 @@ +Keep interrupted profile removal bound to its original identity and require acknowledged local cleanup before reconnecting. diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 99d1bdad1..acf97a4e2 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -2,17 +2,13 @@ `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-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. -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. +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. -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. +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. -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. - -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. @@ -22,7 +18,7 @@ On iOS, public publication and endpoint removal share an endpoint lock. A public Hold initial session restoration with public contact payments enabled, turn contact payments off, then release restoration. No automatic 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 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 contact payments and while a failed enable restores the previous 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 endpoints and retain failed cleanup for retry. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml index f9fd8c2e2..115e30937 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -13,8 +13,8 @@ Verify a failed private withdrawal 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 diff --git a/journeys/profile/profile-removal-recovery.xml b/journeys/profile/profile-removal-recovery.xml new file mode 100644 index 000000000..4fa21d69a --- /dev/null +++ b/journeys/profile/profile-removal-recovery.xml @@ -0,0 +1,27 @@ + + + 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. + iOS currently offers local Disconnect only after an uncertain attempt. Its pinned native contact + read can reconcile writes, so remote Review/reconfirmation is deliberately unavailable until a + native observation API can enforce read-only reconciliation. This differs from the Android UI. + + + 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 the uncertain attempt + 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 + + From 2ca19ed380684701d9a2e20eeef3c6cd99e21844 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 27 Oct 2026 07:50:55 +0200 Subject: [PATCH 02/15] chore: rename changelog fragment --- changelog.d/next/{1687.fixed.md => 1143.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1687.fixed.md => 1143.fixed.md} (100%) diff --git a/changelog.d/next/1687.fixed.md b/changelog.d/next/1143.fixed.md similarity index 100% rename from changelog.d/next/1687.fixed.md rename to changelog.d/next/1143.fixed.md From 91379a54f8874e57d05a7e63490e4f00ca020451 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 28 Oct 2026 07:42:03 +0200 Subject: [PATCH 03/15] test: synchronize profile admission and keyboard setup --- BitkitTests/ContactPaymentsServiceTests.swift | 13 +++++++++---- BitkitTests/ProfileDestinationViewTests.swift | 11 ++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 43a71a87e..86cee13dd 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -1112,8 +1112,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 @@ -1154,13 +1155,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 diff --git a/BitkitTests/ProfileDestinationViewTests.swift b/BitkitTests/ProfileDestinationViewTests.swift index 3f4efac9a..234844221 100644 --- a/BitkitTests/ProfileDestinationViewTests.swift +++ b/BitkitTests/ProfileDestinationViewTests.swift @@ -40,13 +40,21 @@ final class ProfileDestinationViewTests: XCTestCase { XCTAssertFalse(manager.isAdoptingRingIdentity) let admission = ProfileOperationAdmission.shared let transition = try admission.reserveSessionTransition() + defer { admission.release(transition) } let view = CreateProfileView() .environmentObject(manager as PubkyProfileManager) .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) + let keyboardShown = XCTNSNotificationExpectation(name: UIResponder.keyboardDidShowNotification) let window = try host(view) defer { close(window) } - try await Task.sleep(for: .milliseconds(200)) + let hostedView = try XCTUnwrap(window.rootViewController?.view) + if hostedView.keyboardLayoutGuide.layoutFrame.height > hostedView.safeAreaInsets.bottom { + keyboardShown.fulfill() + } + // Let keyboard presentation settle when available before measuring admission resumption. + let keyboardResult = await XCTWaiter.fulfillment(of: [keyboardShown], timeout: 5) + print("Keyboard presentation setup: \(keyboardResult)") XCTAssertEqual(manager.keyLoads, 0) if replaceOwner { manager.publicKey = "replacement" @@ -451,6 +459,7 @@ final class ProfileDestinationViewTests: XCTestCase { } private func close(_ window: UIWindow) { + window.endEditing(true) window.isHidden = true window.rootViewController = nil } From f1fa5eea7d536e9a1e0e97b24b1ec3a63683dc4b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 28 Oct 2026 10:57:40 +0200 Subject: [PATCH 04/15] fix: retain receipt contacts during profile cleanup --- Bitkit/AppScene.swift | 23 +- Bitkit/Services/CoreService.swift | 47 +- ...PrivatePaykitAddressReservationStore.swift | 49 ++- .../PrivatePaykitService+Payments.swift | 8 + .../Utilities/ProfileOperationAdmission.swift | 74 +++- .../PaykitReceivedPaymentContactsTests.swift | 42 ++ BitkitTests/PrivatePaymentReceiptTests.swift | 411 ++++++++++++++++++ .../ProfileOperationAdmissionTests.swift | 16 + 8 files changed, 644 insertions(+), 26 deletions(-) create mode 100644 BitkitTests/PrivatePaymentReceiptTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 865422998..5f3851296 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -469,21 +469,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) } ) } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 54329c40b..14ccaaa56 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,24 @@ 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 + } + + 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 +626,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 +735,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 @@ -956,6 +981,12 @@ class ActivityService { } 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 +1044,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 +1059,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() + } } } diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index e3b63bd49..b891b8f73 100644 --- a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -15,6 +15,7 @@ actor PrivatePaykitAddressReservationStore { private static let schemaVersion = 1 private let defaults: UserDefaults + private let addressLookup: ((LDKNode.AddressType, UInt32) async throws -> String)? private(set) var attributionRevision: UInt64 = 0 private var storedLedgerWasUnreadable = false private var ledger: Ledger { @@ -23,8 +24,9 @@ actor PrivatePaykitAddressReservationStore { // MARK: - Initialization - init(defaults: UserDefaults = .standard) { + init(defaults: UserDefaults = .standard, addressLookup: ((LDKNode.AddressType, UInt32) async throws -> String)? = nil) { self.defaults = defaults + self.addressLookup = addressLookup if let data = defaults.data(forKey: Self.defaultsKey), let decoded = Self.decodeStoredLedger(data), decoded.version == Self.schemaVersion @@ -81,17 +83,41 @@ actor PrivatePaykitAddressReservationStore { func contactPublicKeyForAttribution(forReservedAddress address: String) async throws -> String? { return try await ProfileOperationAdmission.shared.withOperation(access: .local) { - guard !address.isEmpty else { return nil } - for (publicKey, assignment) in contactAssignmentsForAttribution() { - guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), - addressType.matchesAddressFormat(address, network: Env.network) - else { continue } - if try await self.address(for: addressType, receiveIndex: assignment.receiveIndex) == address { - return publicKey - } + 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 assignments { + guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), + addressType.matchesAddressFormat(address, network: Env.network) + else { continue } + if try await self.address(for: addressType, receiveIndex: assignment.receiveIndex) == address { + return publicKey } - return nil } + return nil } func currentContactPublicKey(forReservedAddress address: String) async -> String? { @@ -419,6 +445,9 @@ actor PrivatePaykitAddressReservationStore { } private func address(for addressType: LDKNode.AddressType, receiveIndex: UInt32) async throws -> String { + if let addressLookup { + return try await addressLookup(addressType, receiveIndex) + } let addressInfo = try await LightningService.shared.addressInfoForType(addressType, atIndex: receiveIndex) return addressInfo.address } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index f494dafd2..6eb460581 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -33,7 +33,15 @@ extension ContactPaymentContext { extension PrivatePaykitService { func contactPublicKey(forPrivateInvoicePaymentHash paymentHash: String) -> String? { 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) diff --git a/Bitkit/Utilities/ProfileOperationAdmission.swift b/Bitkit/Utilities/ProfileOperationAdmission.swift index 09534bbbf..5b0f74402 100644 --- a/Bitkit/Utilities/ProfileOperationAdmission.swift +++ b/Bitkit/Utilities/ProfileOperationAdmission.swift @@ -27,6 +27,12 @@ final class ProfileOperationAdmission: @unchecked Sendable { fileprivate let stored: UUID } + private struct ReceiptScope: Sendable { + let admission: ProfileOperationAdmission + let id: UUID + let generation: UUID + } + private struct Entry { let generation: Generation let kind: Kind @@ -44,6 +50,7 @@ final class ProfileOperationAdmission: @unchecked Sendable { static let shared = ProfileOperationAdmission(store: .shared) static let didReleaseOperation = Notification.Name("ProfileOperationAdmissionDidReleaseOperation") @TaskLocal static var current: Scope? + @TaskLocal private static var currentReceipt: ReceiptScope? private let lock = NSLock() private let store: ProfileRemovalStore @@ -52,11 +59,65 @@ final class ProfileOperationAdmission: @unchecked Sendable { private var exclusive: UUID? private var localFailureID: UUID? private var drainWaiters: [(UUID, CheckedContinuation)] = [] + private var receiptGeneration = UUID() + private var receipts: [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 Task.checkCancellation() + let receipt = try? captureReceipt() + guard let receipt else { + return try await Self.$currentReceipt.withValue(nil) { try await operation { false } } + } + defer { + _ = lock.withLock { receipts.removeValue(forKey: receipt.id) } + resumeDrains() + } + try await withTaskCancellationHandler { + try await Self.$currentReceipt.withValue(receipt) { + try Task.checkCancellation() + try await operation { (try? self.validateReceipt(receipt)) != nil } + } + } onCancel: { + self.lock.withLock { + if self.receipts[receipt.id] != nil { + self.receipts[receipt.id] = false + } + } + } + } + + func checkReceiptAttribution() throws { + try Task.checkCancellation() + guard let receipt = Self.currentReceipt, receipt.admission === self else { throw ProfileRemovalStore.StoreError.changed } + try validateReceipt(receipt) + } + + private func captureReceipt() throws -> ReceiptScope { + let expected = lock.withLock { receiptGeneration } + guard try store.pending()?.scope != .walletWipe else { throw ProfileRemovalStore.StoreError.pending } + return try lock.withLock { + guard localFailureID == nil, receiptGeneration == expected, + exclusive.flatMap({ entries[$0]?.kind }) != .walletWipe else { throw ProfileRemovalStore.StoreError.changed } + let receipt = ReceiptScope(admission: self, id: UUID(), generation: expected) + receipts[receipt.id] = true + return receipt + } + } + + private func validateReceipt(_ receipt: ReceiptScope) throws { + guard try store.pending()?.scope != .walletWipe else { throw ProfileRemovalStore.StoreError.pending } + try lock.withLock { + guard receipt.admission === self, receipts[receipt.id] == true, receipt.generation == receiptGeneration, + 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() @@ -493,6 +554,9 @@ final class ProfileOperationAdmission: @unchecked Sendable { if let expected, expected.live != generation { throw ProfileRemovalStore.StoreError.changed } + if kind == .walletWipe { + receiptGeneration = UUID() + } for key in entries.keys { entries[key]?.revoked = true } @@ -516,7 +580,7 @@ final class ProfileOperationAdmission: @unchecked Sendable { try validate(scope, access: .control) await withCheckedContinuation { continuation in let ready = lock.withLock { - if entries[scope.id]?.revoked == false, entries.keys.contains(where: { $0 != scope.id }) { + if entries[scope.id]?.revoked == false, hasOutstandingWork(before: scope.id) { drainWaiters.append((scope.id, continuation)) return false } @@ -534,7 +598,7 @@ final class ProfileOperationAdmission: @unchecked Sendable { try validate(scope, access: .control) let entry = try lock.withLock { guard let entry = entries[scope.id], entry.kind != .ordinary, entry.kind != .sessionTransition, - !entries.keys.contains(where: { $0 != scope.id }) else { throw ProfileRemovalStore.StoreError.changed } + !hasOutstandingWork(before: scope.id) else { throw ProfileRemovalStore.StoreError.changed } return entry } let pending = try store.pending() @@ -680,7 +744,7 @@ final class ProfileOperationAdmission: @unchecked Sendable { let ready: [CheckedContinuation] = lock.withLock { var ready: [CheckedContinuation] = [] drainWaiters.removeAll { id, continuation in - guard entries[id]?.revoked != false || !entries.keys.contains(where: { $0 != id }) else { return false } + guard entries[id]?.revoked != false || !hasOutstandingWork(before: id) else { return false } ready.append(continuation) return true } @@ -688,4 +752,8 @@ final class ProfileOperationAdmission: @unchecked Sendable { } ready.forEach { $0.resume() } } + + private func hasOutstandingWork(before id: UUID) -> Bool { + entries.keys.contains(where: { $0 != id }) || (entries[id]?.kind == .walletWipe && !receipts.isEmpty) + } } diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift index 333604885..4c1aa7984 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), diff --git a/BitkitTests/PrivatePaymentReceiptTests.swift b/BitkitTests/PrivatePaymentReceiptTests.swift new file mode 100644 index 000000000..5a65d17c3 --- /dev/null +++ b/BitkitTests/PrivatePaymentReceiptTests.swift @@ -0,0 +1,411 @@ +@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 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")) + let reservations = PrivatePaykitAddressReservationStore(defaults: defaults, addressLookup: { type, index in + XCTAssertEqual(type, .nativeSegwit) + return try XCTUnwrap(addresses[index]) + }) + 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() + let reservations = PrivatePaykitAddressReservationStore(defaults: defaults, addressLookup: { _, index in + if await gate.shouldWait() { + captured.fulfill() + await gate.wait() + } + return try XCTUnwrap(addresses[index]) + }) + 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/ProfileOperationAdmissionTests.swift b/BitkitTests/ProfileOperationAdmissionTests.swift index b6798ee6d..1a775923a 100644 --- a/BitkitTests/ProfileOperationAdmissionTests.swift +++ b/BitkitTests/ProfileOperationAdmissionTests.swift @@ -18,6 +18,22 @@ final class ProfileOperationAdmissionTests: XCTestCase { } } + 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")) + try await admission.withReceiptAttribution { isCurrent in + XCTAssertFalse(isCurrent()) + XCTAssertThrowsError(try self.admission.checkReceiptAttribution()) + } + } + func testReservationRevokesEarlierWorkAndDrainsItsActualTail() async throws { let original = try admission.capture() let generation = try admission.snapshot() From 1f1bead03bad38f67dbd39dad1d3a73540a0a78f Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 28 Oct 2026 13:47:07 +0200 Subject: [PATCH 05/15] fix: drain receipt and restore writes before wallet wipe --- Bitkit/AppScene.swift | 38 ++- Bitkit/Services/BackupService.swift | 16 +- Bitkit/Services/CoreService.swift | 262 ++++++++++-------- Bitkit/Services/MigrationsService.swift | 12 +- .../Utilities/ProfileOperationAdmission.swift | 60 ++-- Bitkit/ViewModels/ActivityListViewModel.swift | 22 +- BitkitTests/ActivityListSyncTests.swift | 52 ++++ BitkitTests/BackupServiceTests.swift | 121 ++++++++ BitkitTests/PrivatePaymentReceiptTests.swift | 62 +++++ .../ProfileOperationAdmissionTests.swift | 10 +- 10 files changed, 477 insertions(+), 178 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5f3851296..1a763750e 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1161,26 +1161,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 { @@ -1195,7 +1201,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 } @@ -1234,7 +1240,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 diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index d5ae2b753..09826b64d 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -119,7 +119,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 @@ -169,6 +169,7 @@ 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 @@ -186,6 +187,7 @@ class BackupService { ) { self.defaults = defaults self.profileAdmission = profileAdmission + self.walletRestoreGate = walletRestoreGate self.backupData = backupData self.usdtRecoveryBackup = usdtRecoveryBackup self.uploadBackup = uploadBackup @@ -561,6 +563,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 @@ -605,6 +613,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() diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 14ccaaa56..7f39898c0 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -116,6 +116,14 @@ class ActivityService { 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) @@ -825,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() + } } } @@ -965,17 +979,19 @@ 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) + } } } } @@ -1079,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 729d5142d..d3bbcb67f 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -2149,7 +2149,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/Utilities/ProfileOperationAdmission.swift b/Bitkit/Utilities/ProfileOperationAdmission.swift index 5b0f74402..3bacfb570 100644 --- a/Bitkit/Utilities/ProfileOperationAdmission.swift +++ b/Bitkit/Utilities/ProfileOperationAdmission.swift @@ -27,7 +27,7 @@ final class ProfileOperationAdmission: @unchecked Sendable { fileprivate let stored: UUID } - private struct ReceiptScope: Sendable { + private struct WalletOperationScope: Sendable { let admission: ProfileOperationAdmission let id: UUID let generation: UUID @@ -50,7 +50,7 @@ final class ProfileOperationAdmission: @unchecked Sendable { static let shared = ProfileOperationAdmission(store: .shared) static let didReleaseOperation = Notification.Name("ProfileOperationAdmissionDidReleaseOperation") @TaskLocal static var current: Scope? - @TaskLocal private static var currentReceipt: ReceiptScope? + @TaskLocal private static var currentWalletOperation: WalletOperationScope? private let lock = NSLock() private let store: ProfileRemovalStore @@ -59,8 +59,8 @@ final class ProfileOperationAdmission: @unchecked Sendable { private var exclusive: UUID? private var localFailureID: UUID? private var drainWaiters: [(UUID, CheckedContinuation)] = [] - private var receiptGeneration = UUID() - private var receipts: [UUID: Bool] = [:] + private var walletGeneration = UUID() + private var walletOperations: [UUID: Bool] = [:] init(store: ProfileRemovalStore) { self.store = store @@ -68,24 +68,34 @@ final class ProfileOperationAdmission: @unchecked Sendable { /// 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 Task.checkCancellation() - let receipt = try? captureReceipt() - guard let receipt else { - return try await Self.$currentReceipt.withValue(nil) { try await operation { false } } + 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 { receipts.removeValue(forKey: receipt.id) } + _ = lock.withLock { walletOperations.removeValue(forKey: receipt.id) } resumeDrains() } - try await withTaskCancellationHandler { - try await Self.$currentReceipt.withValue(receipt) { + return try await withTaskCancellationHandler { + try await Self.$currentWalletOperation.withValue(receipt) { try Task.checkCancellation() - try await operation { (try? self.validateReceipt(receipt)) != nil } + return try await operation { (try? self.validateWalletOperation(receipt)) != nil } } } onCancel: { self.lock.withLock { - if self.receipts[receipt.id] != nil { - self.receipts[receipt.id] = false + if self.walletOperations[receipt.id] != nil { + self.walletOperations[receipt.id] = false } } } @@ -93,26 +103,26 @@ final class ProfileOperationAdmission: @unchecked Sendable { func checkReceiptAttribution() throws { try Task.checkCancellation() - guard let receipt = Self.currentReceipt, receipt.admission === self else { throw ProfileRemovalStore.StoreError.changed } - try validateReceipt(receipt) + guard let receipt = Self.currentWalletOperation, receipt.admission === self else { throw ProfileRemovalStore.StoreError.changed } + try validateWalletOperation(receipt) } - private func captureReceipt() throws -> ReceiptScope { - let expected = lock.withLock { receiptGeneration } + 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, receiptGeneration == expected, + guard localFailureID == nil, walletGeneration == expected, exclusive.flatMap({ entries[$0]?.kind }) != .walletWipe else { throw ProfileRemovalStore.StoreError.changed } - let receipt = ReceiptScope(admission: self, id: UUID(), generation: expected) - receipts[receipt.id] = true + let receipt = WalletOperationScope(admission: self, id: UUID(), generation: expected) + walletOperations[receipt.id] = true return receipt } } - private func validateReceipt(_ receipt: ReceiptScope) throws { + private func validateWalletOperation(_ receipt: WalletOperationScope) throws { guard try store.pending()?.scope != .walletWipe else { throw ProfileRemovalStore.StoreError.pending } try lock.withLock { - guard receipt.admission === self, receipts[receipt.id] == true, receipt.generation == receiptGeneration, + guard receipt.admission === self, walletOperations[receipt.id] == true, receipt.generation == walletGeneration, localFailureID == nil, exclusive.flatMap({ entries[$0]?.kind }) != .walletWipe else { throw ProfileRemovalStore.StoreError.changed } } @@ -555,7 +565,7 @@ final class ProfileOperationAdmission: @unchecked Sendable { throw ProfileRemovalStore.StoreError.changed } if kind == .walletWipe { - receiptGeneration = UUID() + walletGeneration = UUID() } for key in entries.keys { entries[key]?.revoked = true @@ -754,6 +764,6 @@ final class ProfileOperationAdmission: @unchecked Sendable { } private func hasOutstandingWork(before id: UUID) -> Bool { - entries.keys.contains(where: { $0 != id }) || (entries[id]?.kind == .walletWipe && !receipts.isEmpty) + entries.keys.contains(where: { $0 != id }) || (entries[id]?.kind == .walletWipe && !walletOperations.isEmpty) } } diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 9fe8ccbd9..4a80b4df4 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -227,17 +227,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/BitkitTests/ActivityListSyncTests.swift b/BitkitTests/ActivityListSyncTests.swift index 4b9ac3a0a..fb1b25e2d 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, blocktankService: Bitkit.CoreService.shared.blocktank + ), 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 cda5b47fc..96b24a895 100644 --- a/BitkitTests/BackupServiceTests.swift +++ b/BitkitTests/BackupServiceTests.swift @@ -6,6 +6,127 @@ 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)" diff --git a/BitkitTests/PrivatePaymentReceiptTests.swift b/BitkitTests/PrivatePaymentReceiptTests.swift index 5a65d17c3..dd091bb99 100644 --- a/BitkitTests/PrivatePaymentReceiptTests.swift +++ b/BitkitTests/PrivatePaymentReceiptTests.swift @@ -23,6 +23,68 @@ final class PrivatePaymentReceiptTests: XCTestCase { 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"))) diff --git a/BitkitTests/ProfileOperationAdmissionTests.swift b/BitkitTests/ProfileOperationAdmissionTests.swift index 1a775923a..3d99c814d 100644 --- a/BitkitTests/ProfileOperationAdmissionTests.swift +++ b/BitkitTests/ProfileOperationAdmissionTests.swift @@ -28,10 +28,12 @@ final class ProfileOperationAdmissionTests: XCTestCase { XCTAssertThrowsError(try self.admission.checkCurrent(access: .local)) } try Data("unreadable".utf8).write(to: directory.appendingPathComponent("pending.json")) - try await admission.withReceiptAttribution { isCurrent in - XCTAssertFalse(isCurrent()) - XCTAssertThrowsError(try self.admission.checkReceiptAttribution()) - } + 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 { From acc028f4671a5858fec3ed0123f9f5e7b33a59e0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 05:44:05 +0200 Subject: [PATCH 06/15] test: synchronize profile creation admission setup --- BitkitTests/ProfileDestinationViewTests.swift | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/BitkitTests/ProfileDestinationViewTests.swift b/BitkitTests/ProfileDestinationViewTests.swift index 234844221..ffbc005ee 100644 --- a/BitkitTests/ProfileDestinationViewTests.swift +++ b/BitkitTests/ProfileDestinationViewTests.swift @@ -42,19 +42,13 @@ final class ProfileDestinationViewTests: XCTestCase { let transition = try admission.reserveSessionTransition() defer { admission.release(transition) } let view = CreateProfileView() + .disabled(true) .environmentObject(manager as PubkyProfileManager) .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) - let keyboardShown = XCTNSNotificationExpectation(name: UIResponder.keyboardDidShowNotification) let window = try host(view) defer { close(window) } - let hostedView = try XCTUnwrap(window.rootViewController?.view) - if hostedView.keyboardLayoutGuide.layoutFrame.height > hostedView.safeAreaInsets.bottom { - keyboardShown.fulfill() - } - // Let keyboard presentation settle when available before measuring admission resumption. - let keyboardResult = await XCTWaiter.fulfillment(of: [keyboardShown], timeout: 5) - print("Keyboard presentation setup: \(keyboardResult)") + await fulfillment(of: [manager.presentationOwnerCaptured], timeout: 2) XCTAssertEqual(manager.keyLoads, 0) if replaceOwner { manager.publicKey = "replacement" @@ -526,6 +520,17 @@ private final class WaitingCreateProfileManager: PubkyProfileManager { 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 From 08f299033a8edb2a85af3ba43887eceb518dbfcc Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 08:52:04 +0200 Subject: [PATCH 07/15] test: expose reservation cleanup acknowledgement fault --- Bitkit/Services/PrivatePaykitAddressReservationStore.swift | 3 +++ Bitkit/Utilities/Keychain.swift | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index b891b8f73..62e6ac735 100644 --- a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -352,6 +352,9 @@ actor PrivatePaykitAddressReservationStore { } 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 } diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 1ac7b532a..66e3a0823 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -304,7 +304,7 @@ class Keychain { static let allowed: Set = [ "paykit_session:delete", "pubky_secret_key:delete", "shared-own:enumerate", "shared-own:delete", - "preferences:flush", "marker:persist", "marker:complete", + "preferences:flush", "reservations:acknowledge", "marker:persist", "marker:complete", ] private static func selected() -> String? { From b426e656dca10bac3d69ffb68fcfe794b53c1066 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 13:38:39 +0200 Subject: [PATCH 08/15] fix: allow local disconnect when subscriptions block removal --- Bitkit/Managers/PubkyProfileManager.swift | 18 +- .../Localization/ar.lproj/Localizable.strings | 1 + .../Localization/ca.lproj/Localizable.strings | 1 + .../Localization/cs.lproj/Localizable.strings | 1 + .../Localization/de.lproj/Localizable.strings | 1 + .../Localization/el.lproj/Localizable.strings | 1 + .../Localization/en.lproj/Localizable.strings | 1 + .../es-419.lproj/Localizable.strings | 1 + .../Localization/es.lproj/Localizable.strings | 1 + .../Localization/fr.lproj/Localizable.strings | 1 + .../Localization/it.lproj/Localizable.strings | 1 + .../Localization/nl.lproj/Localizable.strings | 1 + .../Localization/pl.lproj/Localizable.strings | 1 + .../pt-BR.lproj/Localizable.strings | 1 + .../Localization/pt.lproj/Localizable.strings | 1 + .../Localization/ru.lproj/Localizable.strings | 1 + .../ProfileRecoveryUITestFixture.swift | 9 +- Bitkit/Views/Profile/EditProfileView.swift | 55 ++++-- .../Profile/ProfileRemovalRecoveryView.swift | 9 +- .../ProfileRemovalCoordinatorTests.swift | 168 +++++++++++++++++- BitkitUITests/ProfileRecoveryUITests.swift | 2 +- changelog.d/next/1143.fixed.md | 2 +- journeys/profile/README.md | 2 + .../profile/profile-fixed-term-disconnect.xml | 23 +++ journeys/profile/profile-removal-recovery.xml | 2 +- 25 files changed, 276 insertions(+), 29 deletions(-) create mode 100644 journeys/profile/profile-fixed-term-disconnect.xml diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 4235aacc1..8084a9935 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -67,6 +67,7 @@ class PubkyProfileManager: ObservableObject { case deleteProfile case unlink case disconnect + case disconnectLocally } struct RemovalConfirmation: Identifiable, Equatable { @@ -1421,7 +1422,9 @@ class PubkyProfileManager: ObservableObject { #endif func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true, expectedSession: SignedInSession? = nil) async throws { - guard let plan = removalPlan, plan.confirmation.action != .disconnect else { throw RemovalError.confirmationRequired } + 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 @@ -1433,6 +1436,9 @@ class PubkyProfileManager: ObservableObject { 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 } @@ -1444,7 +1450,8 @@ class PubkyProfileManager: ObservableObject { let owner = publicKey let revision = Self.sessionRevision let profileGeneration = profileWriteGeneration - if action != .disconnect { + let removesContacts = action == .deleteProfile || action == .unlink + if removesContacts { guard currentSession != nil, contacts != nil else { throw PubkyServiceError.sessionNotActive } } let confirmation = RemovalConfirmation(id: UUID(), action: action) @@ -1454,7 +1461,7 @@ class PubkyProfileManager: ObservableObject { return try await admission.withOperation { let profile = action == .deleteProfile && owner != nil ? try await removalServices.readProfile(owner!) : nil - let snapshot = action != .disconnect ? try await contacts?.prepareRemovalSnapshot() : nil + let snapshot = removesContacts ? try await contacts?.prepareRemovalSnapshot() : nil if snapshot != nil, let owner { try await removalServices.validateSubscriptions(owner) } @@ -1463,8 +1470,9 @@ class PubkyProfileManager: ObservableObject { publicKey == owner, profileWriteGeneration == profileGeneration else { throw RemovalError.confirmationChanged } removalPlan = RemovalPlan(confirmation: confirmation, sessionRevision: revision, owner: owner, profileGeneration: profileGeneration, admissionGeneration: generation, - profile: profile, withdrawEndpoints: owner != nil && authState == .authenticated, - contacts: contacts, contactSnapshot: snapshot) + profile: profile, + withdrawEndpoints: action != .disconnectLocally && owner != nil && authState == .authenticated, + contacts: removesContacts ? contacts : nil, contactSnapshot: snapshot) return confirmation } } catch { diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 40760ee7e..30116dd3b 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -915,6 +915,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."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 68d2f5504..bd4bb4655 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1031,6 +1031,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" = "Això només desconnecta el teu perfil en aquest dispositiu. El perfil i les subscripcions es mantenen al servidor. No cancel·la les subscripcions ni revoca l’accés remot."; "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."; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 14d00f9c3..2e6178030 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1136,6 +1136,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" = "Tímto se váš profil odpojí pouze na tomto zařízení. Váš profil a předplatná zůstanou na serveru. Předplatná se nezruší a vzdálený přístup nebude odvolán."; "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."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 780cdeb7f..2d04448b5 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1134,6 +1134,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" = "Dadurch wird dein Profil nur auf diesem Gerät getrennt. Dein Profil und deine Abonnements bleiben auf dem Server. Abonnements werden nicht gekündigt und der Fernzugriff wird nicht widerrufen."; "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."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 7ca66505f..f477cb69e 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -932,6 +932,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."; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 728a63eae..887642268 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1147,6 +1147,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" = "This only disconnects your profile on this device. Your profile and subscriptions remain on the server. It does not cancel subscriptions or revoke remote access."; "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."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 5dceabc5f..a674175fb 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1139,6 +1139,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" = "Esto solo desconecta tu perfil en este dispositivo. Tu perfil y tus suscripciones permanecen en el servidor. No cancela las suscripciones ni revoca el acceso remoto."; "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."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 34ac32527..40f3e3961 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1048,6 +1048,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" = "Esto solo desconecta tu perfil en este dispositivo. Tu perfil y tus suscripciones permanecen en el servidor. No cancela las suscripciones ni revoca el acceso remoto."; "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."; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 2b301d208..691b30528 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1147,6 +1147,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" = "Cela déconnecte votre profil uniquement sur cet appareil. Votre profil et vos abonnements restent sur le serveur. Cela ne résilie pas les abonnements et ne révoque pas l’accès à distance."; "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."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 9dd0c66cc..65a602a24 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1113,6 +1113,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" = "Questa operazione disconnette il tuo profilo solo su questo dispositivo. Il profilo e gli abbonamenti rimangono sul server. Non annulla gli abbonamenti né revoca l’accesso remoto."; "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."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index bbce50849..b2eb04e12 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1142,6 +1142,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" = "Dit koppelt je profiel alleen op dit apparaat los. Je profiel en abonnementen blijven op de server staan. Abonnementen worden niet opgezegd en externe toegang wordt niet ingetrokken."; "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."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 32ad8a008..518669961 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1146,6 +1146,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" = "Spowoduje to odłączenie profilu tylko na tym urządzeniu. Profil i subskrypcje pozostaną na serwerze. Nie anuluje to subskrypcji ani nie odbiera dostępu zdalnego."; "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."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 94c1ac689..f047cde93 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1147,6 +1147,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" = "Isso desconecta seu perfil apenas neste dispositivo. Seu perfil e suas assinaturas permanecem no servidor. Não cancela as assinaturas nem revoga o acesso remoto."; "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."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 4d317b2bf..68937d120 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -907,6 +907,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" = "Isto apenas desliga o teu perfil neste dispositivo. O teu perfil e as subscrições permanecem no servidor. Não cancela as subscrições nem revoga o acesso remoto."; "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."; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 2186c3917..6878679f4 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1132,6 +1132,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."; diff --git a/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift b/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift index bb7cea0c9..a12fc0473 100644 --- a/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift +++ b/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift @@ -47,8 +47,8 @@ NavigationStack(path: $navigation.path) { Group { if !local.credentialsPresent, profile.removalRecovery == .ready { - CaptionText("Local cleanup complete") - .accessibilityIdentifier("ProfileFixtureCompleted") + CaptionText("Waiting for profile navigation") + .accessibilityIdentifier("ProfileFixtureAwaitingCompletion") } else { ProfileDestinationView(hasSeenIntro: true) } @@ -57,6 +57,11 @@ 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") } } } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 835fab01c..3e64a73d5 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -14,16 +14,18 @@ struct EditProfileView: View { @State private var tags: [String] = [] @State private var isSaving = false @State private var isDeleting = false - @State private var showDeleteConfirmation = 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 = (removalConfirmation?.action == .unlink || (removalConfirmation == nil && 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 Group { if pubkyProfile.removalRecovery != .ready, !isDeleting { @@ -35,7 +37,9 @@ struct EditProfileView: View { } private func editContent(copy: RemovalCopy) -> some View { - ZStack { + let isLocalDisconnect = removalConfirmation?.action == .disconnectLocally + + return ZStack { ProfileEditFormView( navigationTitle: t("profile__edit_nav_title"), name: $username, @@ -54,11 +58,26 @@ struct EditProfileView: View { onCancel: { navigation.navigateBack() }, onDelete: { guard let owner = screenOwner, pubkyProfile.ownsRemovalPresentation(owner) else { return } - preparationTask = Task { await prepareDelete(owner: owner) } + 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) @@ -84,13 +103,14 @@ struct EditProfileView: View { preparationTask?.cancel() pubkyProfile.cancelRemovalConfirmation(removalConfirmation) } - .alert(copy.title, isPresented: $showDeleteConfirmation) { - Button(copy.confirm, role: .destructive) { - Task { await deleteProfile() } + .alert(isLocalDisconnect ? t("profile__sign_out_title") : copy.title, isPresented: $showRemovalConfirmation) { + Button(isLocalDisconnect ? t("profile__sign_out") : copy.confirm, role: .destructive) { + Task { await confirmRemoval() } } + .accessibilityIdentifier(isLocalDisconnect ? "ProfileLocalDisconnectConfirm" : "ProfileRemovalConfirm") Button(t("common__dialog_cancel"), role: .cancel) { pubkyProfile.cancelRemovalConfirmation(removalConfirmation) } } message: { - Text(copy.description) + Text(isLocalDisconnect ? t("profile__disconnect_local_description") : copy.description) } } @@ -160,23 +180,25 @@ struct EditProfileView: View { // MARK: - Delete Profile - private func prepareDelete(owner presentationOwner: PubkyProfileManager.RemovalPresentationOwner) async { + private func prepareRemoval(action: PubkyProfileManager.RemovalAction, + owner presentationOwner: PubkyProfileManager.RemovalPresentationOwner) async + { guard !isDeleting, pubkyProfile.ownsRemovalPresentation(presentationOwner) else { return } isDeleting = true defer { isDeleting = false } do { removalConfirmation = try await pubkyProfile.prepareRemoval( - action: pubkyProfile.isRingAdoptedIdentity ? .unlink : .deleteProfile, contacts: contactsManager, expectedOwner: presentationOwner + action: action, contacts: contactsManager, expectedOwner: presentationOwner ) confirmationOwner = presentationOwner - showDeleteConfirmation = true + showRemovalConfirmation = true } catch { guard !Task.isCancelled, pubkyProfile.ownsRemovalPresentation(presentationOwner) else { return } - showRemovalError(error) + showRemovalError(error, owner: presentationOwner) } } - private func deleteProfile() async { + private func confirmRemoval() async { guard !isDeleting, let confirmation = removalConfirmation, let presentationOwner = confirmationOwner else { return } isDeleting = true defer { isDeleting = false } @@ -189,13 +211,14 @@ struct EditProfileView: View { pubkyProfile.refreshRemovalRecovery() guard !Task.isCancelled, pubkyProfile.ownsRemovalFailure(confirmation, owner: presentationOwner) else { return } if pubkyProfile.pendingProfileRemovalID != confirmation.id { - showRemovalError(error) + showRemovalError(error, owner: presentationOwner) } } } - private func showRemovalError(_ error: Error) { + private func showRemovalError(_ error: Error, 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)]) } diff --git a/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift b/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift index 86297006e..77b3bd9ed 100644 --- a/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift +++ b/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift @@ -2,6 +2,7 @@ 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 @@ -65,8 +66,12 @@ struct ProfileRemovalRecoveryView: View { private func disconnect(id: UUID) async { isWorking = true defer { isWorking = false } - do { _ = try await pubkyProfile.finishPendingRemoval(id: id) } - catch { show(error, expectedID: id, expectedScope: .profile) } + 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 { diff --git a/BitkitTests/ProfileRemovalCoordinatorTests.swift b/BitkitTests/ProfileRemovalCoordinatorTests.swift index 40d4fe615..85e4e4579 100644 --- a/BitkitTests/ProfileRemovalCoordinatorTests.swift +++ b/BitkitTests/ProfileRemovalCoordinatorTests.swift @@ -19,6 +19,10 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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 duringLocalCleanup: (() throws -> Void)? private var duringEligibility: (() async throws -> Void)? private var duringRevocation: (() async throws -> Void)? private var duringPause: (() -> Void)? @@ -50,6 +54,7 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { }, revokeSession: { [self] in try step("revoke"); try await duringRevocation?() }, completeLocal: { [self] in + try duringLocalCleanup?() try step("credentials") watermark = 0 cleanupCount += 1 @@ -64,6 +69,7 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { } private func record(key: String) throws -> PaykitProfileRecord? { + profileReads += 1 if profileAbsent { return nil } @@ -85,7 +91,11 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { } private func contacts(failRemoval: Bool = false) -> ContactsManager { - ContactsManager(contactRecords: { [] }, removeContactRecords: { _ in + ContactsManager(contactRecords: { [self] in + await MainActor.run { contactReads += 1 } + return [] + }, removeContactRecords: { [self] _ in + await MainActor.run { contactRemovals += 1 } if failRemoval { throw TestError.failure("contacts") } @@ -526,6 +536,162 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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) diff --git a/BitkitUITests/ProfileRecoveryUITests.swift b/BitkitUITests/ProfileRecoveryUITests.swift index d22987ec4..a1e556e69 100644 --- a/BitkitUITests/ProfileRecoveryUITests.swift +++ b/BitkitUITests/ProfileRecoveryUITests.swift @@ -93,7 +93,7 @@ final class ProfileRecoveryUITests: XCTestCase { XCTAssertEqual(app.staticTexts["ProfileFixtureLocalState"].label, "Credentials: cleared, watermark: 0, remote: 0") XCTAssertEqual(app.staticTexts["ProfileFixturePendingID"].label, "None") XCTAssertFalse(disconnect.exists) - XCTAssertTrue(app.staticTexts["ProfileFixtureCompleted"].exists) + XCTAssertTrue(app.staticTexts["ProfileFixtureCompleted"].waitForExistence(timeout: 5)) } private func assertReadOnlyControls() { diff --git a/changelog.d/next/1143.fixed.md b/changelog.d/next/1143.fixed.md index c3abb0857..24708430a 100644 --- a/changelog.d/next/1143.fixed.md +++ b/changelog.d/next/1143.fixed.md @@ -1 +1 @@ -Keep interrupted profile removal bound to its original identity and require acknowledged local cleanup before reconnecting. +Keep interrupted profile removal tied to its original identity, finish local cleanup before reconnecting, and allow local Disconnect when active subscriptions prevent removal. diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 2e2649bca..70375d0ec 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -2,6 +2,8 @@ `delete-profile.xml` verifies visible progress and completion when deleting a disposable profile. Use a test identity that may be deleted. +`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. + `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. 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. diff --git a/journeys/profile/profile-fixed-term-disconnect.xml b/journeys/profile/profile-fixed-term-disconnect.xml new file mode 100644 index 000000000..46d03dddc --- /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 the remote profile and subscriptions remain, with no subscription cancellation or remote access revocation + 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 index 4fa21d69a..8df440513 100644 --- a/journeys/profile/profile-removal-recovery.xml +++ b/journeys/profile/profile-removal-recovery.xml @@ -5,7 +5,7 @@ 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. - iOS currently offers local Disconnect only after an uncertain attempt. Its pinned native contact + This journey covers local Disconnect after an uncertain attempt. Its pinned native contact read can reconcile writes, so remote Review/reconfirmation is deliberately unavailable until a native observation API can enforce read-only reconciliation. This differs from the Android UI. From 2aed345c6f4ca23b252470665d53da0d94f67000 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 14:04:35 +0200 Subject: [PATCH 09/15] fix: describe local disconnect without remote state promises --- Bitkit/Resources/Localization/ar.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/ca.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/cs.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/de.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/el.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/en.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/es-419.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/es.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/fr.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/it.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/nl.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/pl.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/pt.lproj/Localizable.strings | 2 +- Bitkit/Resources/Localization/ru.lproj/Localizable.strings | 2 +- journeys/profile/profile-fixed-term-disconnect.xml | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 30116dd3b..6ec66b9a4 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -915,7 +915,7 @@ "profile__sign_out" = "قطع الاتصال"; "profile__sign_out_title" = "فصل الملف الشخصي"; "profile__sign_out_description" = "سيؤدي هذا إلى فصل ملف Pubky الشخصي عن Bitkit. يمكنك إعادة الاتصال في أي وقت."; -"profile__disconnect_local_description" = "يؤدي هذا إلى فصل ملفك الشخصي على هذا الجهاز فقط. يبقى ملفك الشخصي واشتراكاتك على الخادم. لا يؤدي ذلك إلى إلغاء الاشتراكات أو سحب صلاحية الوصول عن بُعد."; +"profile__disconnect_local_description" = "أزل وصول هذا الجهاز وأكمل التنظيف المحلي. لا يُجري هذا أي تغييرات إضافية على ملفك الشخصي أو اشتراكاتك أو صلاحيات وصولك على الخادم."; "profile__choice_title" = "ادخل إلى\nويب الحرية"; "profile__choice_description" = "أنشئ pubky وملفًا شخصيًا جديدين في Bitkit."; "profile__choice_description_ring" = "استخدم pubky موجودًا من Pubky Ring."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index bd4bb4655..5f7056c8d 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1031,7 +1031,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" = "Això només desconnecta el teu perfil en aquest dispositiu. El perfil i les subscripcions es mantenen al servidor. No cancel·la les subscripcions ni revoca l’accés remot."; +"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."; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 2e6178030..6bd02eca8 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1136,7 +1136,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" = "Tímto se váš profil odpojí pouze na tomto zařízení. Váš profil a předplatná zůstanou na serveru. Předplatná se nezruší a vzdálený přístup nebude odvolán."; +"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."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 2d04448b5..346ef12c7 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1134,7 +1134,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" = "Dadurch wird dein Profil nur auf diesem Gerät getrennt. Dein Profil und deine Abonnements bleiben auf dem Server. Abonnements werden nicht gekündigt und der Fernzugriff wird nicht widerrufen."; +"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."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index f477cb69e..8150ad78f 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -932,7 +932,7 @@ "profile__sign_out" = "Αποσύνδεση"; "profile__sign_out_title" = "Αποσύνδεση προφίλ"; "profile__sign_out_description" = "Αυτό θα αποσυνδέσει το προφίλ Pubky σου από το Bitkit. Μπορείς να το συνδέσεις ξανά οποιαδήποτε στιγμή."; -"profile__disconnect_local_description" = "Αυτό αποσυνδέει το προφίλ σας μόνο σε αυτή τη συσκευή. Το προφίλ και οι συνδρομές σας παραμένουν στον διακομιστή. Δεν ακυρώνει τις συνδρομές ούτε ανακαλεί την απομακρυσμένη πρόσβαση."; +"profile__disconnect_local_description" = "Αφαιρέστε την πρόσβαση αυτής της συσκευής και ολοκληρώστε την τοπική εκκαθάριση. Αυτό δεν κάνει άλλες αλλαγές στο προφίλ, τις συνδρομές ή την πρόσβασή σας στον διακομιστή."; "profile__choice_title" = "Μπες στον\nελεύθερο ιστό"; "profile__choice_description" = "Δημιούργησε νέο pubky και προφίλ στο Bitkit."; "profile__choice_description_ring" = "Χρησιμοποίησε ένα υπάρχον pubky από το Pubky Ring."; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 887642268..2ea76c45f 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1147,7 +1147,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" = "This only disconnects your profile on this device. Your profile and subscriptions remain on the server. It does not cancel subscriptions or revoke remote access."; +"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."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index a674175fb..f29f2137e 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1139,7 +1139,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" = "Esto solo desconecta tu perfil en este dispositivo. Tu perfil y tus suscripciones permanecen en el servidor. No cancela las suscripciones ni revoca el acceso remoto."; +"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."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 40f3e3961..e0fbd9ef0 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1048,7 +1048,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" = "Esto solo desconecta tu perfil en este dispositivo. Tu perfil y tus suscripciones permanecen en el servidor. No cancela las suscripciones ni revoca el acceso remoto."; +"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."; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 691b30528..5e71c50fa 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1147,7 +1147,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" = "Cela déconnecte votre profil uniquement sur cet appareil. Votre profil et vos abonnements restent sur le serveur. Cela ne résilie pas les abonnements et ne révoque pas l’accès à distance."; +"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."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 65a602a24..1c1cc3643 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1113,7 +1113,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" = "Questa operazione disconnette il tuo profilo solo su questo dispositivo. Il profilo e gli abbonamenti rimangono sul server. Non annulla gli abbonamenti né revoca l’accesso remoto."; +"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."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index b2eb04e12..9f8c8a6e1 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1142,7 +1142,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" = "Dit koppelt je profiel alleen op dit apparaat los. Je profiel en abonnementen blijven op de server staan. Abonnementen worden niet opgezegd en externe toegang wordt niet ingetrokken."; +"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."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 518669961..85ee5625c 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1146,7 +1146,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" = "Spowoduje to odłączenie profilu tylko na tym urządzeniu. Profil i subskrypcje pozostaną na serwerze. Nie anuluje to subskrypcji ani nie odbiera dostępu zdalnego."; +"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."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index f047cde93..acfa77e1c 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1147,7 +1147,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" = "Isso desconecta seu perfil apenas neste dispositivo. Seu perfil e suas assinaturas permanecem no servidor. Não cancela as assinaturas nem revoga o acesso remoto."; +"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."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 68937d120..899083e92 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -907,7 +907,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" = "Isto apenas desliga o teu perfil neste dispositivo. O teu perfil e as subscrições permanecem no servidor. Não cancela as subscrições nem revoga o acesso remoto."; +"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."; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 6878679f4..b9bcc085d 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1132,7 +1132,7 @@ "profile__sign_out" = "Отключить"; "profile__sign_out_title" = "Отключить Профиль"; "profile__sign_out_description" = "Ваш профиль Pubky будет отключён от Bitkit. Вы можете переподключиться в любое время."; -"profile__disconnect_local_description" = "Это отключит ваш профиль только на этом устройстве. Ваш профиль и подписки останутся на сервере. Подписки не будут отменены, а удалённый доступ не будет отозван."; +"profile__disconnect_local_description" = "Удалите доступ этого устройства и завершите локальную очистку. Это не вносит дополнительных изменений в ваш профиль, подписки или доступ на сервере."; "profile__choice_title" = "Войдите в\nсвободный веб"; "profile__choice_description" = "Создайте новый pubky и профиль в Bitkit."; "profile__choice_description_ring" = "Используйте существующий pubky из Pubky Ring."; diff --git a/journeys/profile/profile-fixed-term-disconnect.xml b/journeys/profile/profile-fixed-term-disconnect.xml index 46d03dddc..cfc1ffd47 100644 --- a/journeys/profile/profile-fixed-term-disconnect.xml +++ b/journeys/profile/profile-fixed-term-disconnect.xml @@ -13,7 +13,7 @@ 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 the remote profile and subscriptions remain, with no subscription cancellation or remote access revocation + 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 From 6e5dccb6327dabc866e0e9db9c9f00942e77f186 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 30 Oct 2026 06:47:38 +0200 Subject: [PATCH 10/15] Require acknowledged endpoint withdrawal with Paykit RC3 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../PrivatePaykitService+Contacts.swift | 33 +++- BitkitTests/HwFundingSignerTests.swift | 4 +- BitkitTests/PaykitAllowanceTests.swift | 4 +- BitkitTests/PaykitContactLifecycleTests.swift | 14 +- BitkitTests/PaykitIssuerInteropTests.swift | 4 +- .../PaykitPaymentProofServiceTests.swift | 4 +- .../PaykitPaymentRequestServiceTests.swift | 4 +- .../PaykitReceivedPaymentContactsTests.swift | 4 +- BitkitTests/PaykitSdkIdentityCheckTests.swift | 4 +- BitkitTests/PaykitSdkOperationLockTests.swift | 2 +- .../PaymentNavigationHelperTests.swift | 2 + BitkitTests/PrivatePaykitServiceTests.swift | 173 ++++++++++++++---- .../ProfileRemovalCoordinatorTests.swift | 41 ++++- BitkitTests/ReceiveSheetSessionTests.swift | 4 +- BitkitTests/SendSheetLifecycleTests.swift | 4 +- .../TransferServiceActivityTests.swift | 4 +- changelog.d/next/1143.fixed.md | 2 +- journeys/profile/profile-cleanup-failure.xml | 7 +- 20 files changed, 245 insertions(+), 75 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f83b181fa..0ab4a6524 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-rc3"; }; }; 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..fb2ba504d 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" : "3a4b51544a2fa8909bb8ee549af63ca2038b26f3", + "version" : "0.1.1-rc3" } }, { diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 99cdb77ca..3a00b375f 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -501,15 +501,15 @@ extension PrivatePaykitService { } 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() { @@ -588,6 +588,21 @@ 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 { await removeSavedContacts(publicKeys: [publicKey]) } 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/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 8d37753e2..228586c50 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -77,9 +77,9 @@ 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, @@ -90,7 +90,7 @@ final class PaykitContactLifecycleTests: XCTestCase { try await service.requireNoActiveSubscriptions(expectedIdentity: sdk.publicKey) XCTFail("Preflight must include subscriptions from every app and role") } catch let PubkyServiceError.activeSubscription(endsAt) { - XCTAssertNil(endsAt) + XCTAssertEqual(endsAt, testCase.endsAt.flatMap(PaykitPaymentRequest.parseDate)) } XCTAssertTrue(sdk.events.isEmpty) do { @@ -136,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 @@ -535,7 +535,7 @@ 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) }, - failedToQueue: failed.map { .init(counterparty: $0.counterparty, outboundMessageId: nil, error: nil) }, + acknowledged: [], failedToQueue: failed.map { .init(counterparty: $0.counterparty, outboundMessageId: nil, error: nil) }, failedToDeliver: [] ) } @@ -548,7 +548,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 6f21e9fcc..97d8bd10b 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -10056,7 +10056,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( @@ -10072,7 +10072,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/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift index 2c4540d29..a9f7b40a1 100644 --- a/BitkitTests/PaykitReceivedPaymentContactsTests.swift +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -345,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"), @@ -353,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/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index 90648e610..668001863 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -896,7 +896,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"), @@ -904,7 +904,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, diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index f63f8d054..5be7a465f 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -1102,7 +1102,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/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 234c29f15..c07e1b8f3 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? @@ -1239,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: []) } )) @@ -1281,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: []) } )) @@ -1703,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) @@ -1749,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() @@ -2256,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 { @@ -2282,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) @@ -2306,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 { @@ -2331,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] { @@ -2352,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") }, @@ -2403,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 @@ -2477,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())) }, @@ -2539,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()) @@ -2592,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: []) } ) @@ -2634,7 +2737,7 @@ final class PrivatePaykitServiceTests: XCTestCase { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], - failedToQueue: [], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) } @@ -2706,7 +2809,7 @@ final class PrivatePaykitServiceTests: XCTestCase { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], - failedToQueue: [], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) } @@ -2760,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 } @@ -2812,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 } )) @@ -2954,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 { @@ -3030,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: { @@ -3663,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( @@ -3687,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 { @@ -3847,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() @@ -3869,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]) }, @@ -3949,7 +4058,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: []) } ) ) @@ -3958,7 +4067,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: {} @@ -3990,7 +4099,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: []) } ) ) @@ -4009,7 +4118,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( @@ -4050,7 +4159,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( @@ -4087,7 +4196,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/ProfileRemovalCoordinatorTests.swift b/BitkitTests/ProfileRemovalCoordinatorTests.swift index 85e4e4579..370bc5754 100644 --- a/BitkitTests/ProfileRemovalCoordinatorTests.swift +++ b/BitkitTests/ProfileRemovalCoordinatorTests.swift @@ -25,6 +25,7 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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)? @@ -45,7 +46,7 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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") }, + withdrawPrivate: { [self] in try step("private"); try await duringPrivateWithdrawal?() }, withdrawPublic: { [self] in try step("public") }, validateSubscriptions: { [self] expectedOwner in XCTAssertEqual(expectedOwner, owner) @@ -68,6 +69,44 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { return manager } + 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) + _ = 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 { 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/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/changelog.d/next/1143.fixed.md b/changelog.d/next/1143.fixed.md index 24708430a..ee4aa135f 100644 --- a/changelog.d/next/1143.fixed.md +++ b/changelog.d/next/1143.fixed.md @@ -1 +1 @@ -Keep interrupted profile removal tied to its original identity, finish local cleanup before reconnecting, and allow local Disconnect when active subscriptions prevent removal. +Require acknowledged private-endpoint withdrawal before profile removal, keep interrupted cleanup tied to its original identity, and allow local Disconnect when subscriptions prevent removal. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml index 9ecc85b58..ff1d2899e 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -9,6 +9,11 @@ disposable fixture and confirm removal again to verify it can proceed. 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 Paykit 0.1.1-rc3, 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. Interrupted recovery remains local-only: + the SDK's ordinary sign-out can restore a newer bearer and cannot enforce original-bearer revocation. 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. @@ -18,7 +23,7 @@ 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 required safety state and shows local recovery without success feedback or automatic publication From 2c6cbdbf1a67ca5cd5cd9e11565a0e1566afe004 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 30 Oct 2026 07:48:38 +0200 Subject: [PATCH 11/15] test: isolate auth cancellation admission --- BitkitTests/PubkyIdentityRepublishTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 {} From 0e4ab09a84bc2687730e2efcbb3302f23561e70b Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 31 Oct 2026 01:19:25 +0200 Subject: [PATCH 12/15] fix: retain original grant cleanup and disabled sharing withdrawals --- Bitkit/Managers/PubkyProfileManager.swift | 220 +++++++- .../Services/ProfileSessionRevocation.swift | 30 ++ Bitkit/Services/PubkyService.swift | 306 ++++++++++- Bitkit/Utilities/ProfileRemovalLease.swift | 49 ++ .../ProfileRecoveryUITestFixture.swift | 3 +- .../Profile/ProfileRemovalRecoveryView.swift | 40 ++ BitkitTests/ContactPaymentsServiceTests.swift | 5 +- BitkitTests/PaykitContactLifecycleTests.swift | 154 +++++- BitkitTests/PaykitSdkClientConfigTests.swift | 10 +- BitkitTests/ProfileOriginalAccessTests.swift | 480 ++++++++++++++++++ .../ProfileRemovalCoordinatorTests.swift | 247 ++++++++- BitkitTests/ProfileRemovalLeaseTests.swift | 114 +++++ BitkitTests/ProfileRevocationTestDouble.swift | 46 ++ changelog.d/next/1143.fixed.md | 2 +- journeys/profile/profile-cleanup-failure.xml | 10 +- journeys/profile/profile-removal-recovery.xml | 18 +- 16 files changed, 1677 insertions(+), 57 deletions(-) create mode 100644 Bitkit/Services/ProfileSessionRevocation.swift create mode 100644 Bitkit/Utilities/ProfileRemovalLease.swift create mode 100644 BitkitTests/ProfileOriginalAccessTests.swift create mode 100644 BitkitTests/ProfileRemovalLeaseTests.swift create mode 100644 BitkitTests/ProfileRevocationTestDouble.swift diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index b89f7ff09..70e7a8aff 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -137,8 +137,9 @@ class PubkyProfileManager: ObservableObject { var withdrawPrivate: @MainActor () async throws -> Void var withdrawPublic: @MainActor () async throws -> Void var validateSubscriptions: @MainActor (String) async throws -> Void - var revokeSession: @MainActor () 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 @@ -147,10 +148,14 @@ class PubkyProfileManager: ObservableObject { 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 removePublicPaykitEndpoints(context: "Profile removal") }, + withdrawPublic: { + try await PublicPaykitService.removePublishedEndpoints() + try await PublicPaykitService.syncPaykitApp(privateSharingEnabled: false) + }, validateSubscriptions: { try await PaykitSdkService.shared.requireNoActiveSubscriptions(expectedIdentity: $0) }, - revokeSession: { try await PubkyService.signOut() }, + 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) } ) @@ -175,8 +180,32 @@ class PubkyProfileManager: ObservableObject { 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 remoteProfileDeleted = false + var logoutStarted = false + var logoutAcknowledged = false + + 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? @@ -1476,7 +1505,9 @@ class PubkyProfileManager: ObservableObject { } 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 @@ -1485,17 +1516,22 @@ class PubkyProfileManager: ObservableObject { 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: action != .disconnectLocally && owner != nil && authState == .authenticated, - contacts: removesContacts ? contacts : nil, contactSnapshot: snapshot) + withdrawEndpoints: withdrawEndpoints, + contacts: removesContacts ? contacts : nil, contactSnapshot: snapshot, revocation: captured) return confirmation } } catch { + captured?.revoke() if removalPreparation == confirmation.id { removalPreparation = nil } @@ -1512,6 +1548,7 @@ class PubkyProfileManager: ObservableObject { removalPreparation = nil } if removalPlan?.confirmation.id == confirmation.id { + removalPlan?.revocation?.revoke() removalPlan = nil } } @@ -1557,18 +1594,150 @@ class PubkyProfileManager: ObservableObject { } } + 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) + } + 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.logoutStarted, !live.logoutAcknowledged, + live.privateAcknowledged, live.publicAcknowledged, 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 { + Self.isProfileDeletionInFlight = false + isDeletingProfile = false + refreshRemovalRecovery() + } + let id = confirmation.removalID + let scope = live.lease.scope + var completion: RemovalCompletion? + do { + try await withRemovalWork(lease: live.lease, id: id, live: live) { + try await validateRemoteRemovalOwner(live.plan) + // Endpoint, contact and profile cleanup already completed under this same live intent. + 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 { + if live.logoutAcknowledged || Task.isCancelled || !live.lease.canResume || (try? validateRemovalOwner(live.plan)) == nil { + live.lease.retire() + } + if let completion { + return completion + } + throw error + } + live.lease.retire() + liveRemoval = nil + guard let completion else { throw ProfileRemovalStore.StoreError.changed } + return completion + } + + 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 { + liveRemoval = live + } + } + throw error + } + await removalServices.resume(id) + } + } + + 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 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 - defer { admission.release(scope) } + 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 { @@ -1576,10 +1745,9 @@ class PubkyProfileManager: ObservableObject { isDeletingProfile = false refreshRemovalRecovery() } - await removalServices.pause(id) var completion: RemovalCompletion? do { - try await admission.withScope(scope, access: .control) { + try await withRemovalWork(lease: lease, id: id, live: live) { try await admission.drain(before: scope) if let plan { try validateRemovalOwner(plan) @@ -1590,49 +1758,51 @@ class PubkyProfileManager: ObservableObject { refreshRemovalRecovery() var remoteDeleted = false if let plan, plan.withdrawEndpoints { + try await validateRemoteRemovalOwner(plan) if let snapshot = plan.contactSnapshot { try await plan.contacts?.validateRemovalSnapshot(snapshot) if let owner = plan.owner { try await removalServices.validateSubscriptions(owner) } } - try validateRemovalOwner(plan) + try await validateRemoteRemovalOwner(plan) try await removalServices.withdrawPrivate() - try validateRemovalOwner(plan) + live?.privateAcknowledged = true + try await validateRemoteRemovalOwner(plan) try await removalServices.withdrawPublic() - try validateRemovalOwner(plan) + live?.publicAcknowledged = true + try await validateRemoteRemovalOwner(plan) if let snapshot = plan.contactSnapshot { try await plan.contacts?.removeConfirmedContacts(snapshot) } - try validateRemovalOwner(plan) + try await validateRemoteRemovalOwner(plan) 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 validateRemovalOwner(plan) - try await removalServices.revokeSession() + try await validateRemoteRemovalOwner(plan) + guard let revocation = plan.revocation else { throw RemovalError.confirmationChanged } + live?.logoutStarted = true + try await revocation.execute() + live?.logoutAcknowledged = await revocation.acknowledged() + guard live?.logoutAcknowledged == true else { throw RemovalError.confirmationChanged } try validateRemovalOwner(plan) } try validateLocalIntent?() - try admission.requireLocalOnly(scope) - try await removalServices.completeLocal() - clearAuthenticatedStateAfterLocalCleanup() - plan?.contacts?.finishProfileRemoval() - let endedRevision = Self.sessionRevision - let completedGeneration = try admission.complete(scope) - completion = RemovalCompletion(remoteProfileDeleted: remoteDeleted, endedRevision: endedRevision, - admissionGeneration: completedGeneration) - failedRemovalID = nil + completion = try await completeRemoval(scope: scope, plan: plan, remoteDeleted: remoteDeleted) } } catch { - await removalServices.resume(id) + if let live, live.logoutStarted { + retainIntent = !live.logoutAcknowledged && !Task.isCancelled && lease.canResume && + (try? validateRemovalOwner(live.plan)) != nil + } if let completion { return completion } throw error } - await removalServices.resume(id) guard let completion else { throw ProfileRemovalStore.StoreError.changed } return completion } 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 a4dc1f8a4..77fb96eee 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1386,12 +1386,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) }) { @@ -1411,6 +1405,7 @@ actor PaykitSdkService { if let isSessionCurrent, await !isSessionCurrent() { throw PubkyServiceError.sessionNotActive } + try profileAdmission.checkCurrent() return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( updates: updates, clearUnlistedLinkedPeers: false @@ -1859,6 +1854,72 @@ actor PaykitSdkService { } } + 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. @@ -2803,9 +2864,15 @@ 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)? @@ -2813,10 +2880,16 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab 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 } @@ -2824,9 +2897,13 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab lock.withLock { admissionCheck = check } } - private func validateAdmission() throws { + private func checkAdmission() throws { let check = lock.withLock { admissionCheck } - do { try check?() } + 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") @@ -2835,6 +2912,7 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab func setLiveSessionAccess(_ access: PubkySessionAccess) { lock.lock() + invalidateGrantAdmissions() liveSessionAccess = access paykitIdentitySecretKey = access.exportPaykitIdentitySecretKey() lock.unlock() @@ -2842,6 +2920,7 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab func clearLiveSessionAccess() { lock.lock() + invalidateGrantAdmissions() liveSessionAccess = nil paykitIdentitySecretKey = nil lock.unlock() @@ -2870,6 +2949,7 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab localSecretKey: readLocalSecretKey(), paykitIdentitySecretKey: paykitIdentitySecretKey ) + invalidateGrantAdmissions() liveSessionAccess = access return access } @@ -2886,6 +2966,7 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab func suspendStoredSessionAccess() { lock.lock() + invalidateGrantAdmissions() liveSessionAccess = nil isStoredSessionAccessSuspended = true lock.unlock() @@ -2925,10 +3006,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 } } 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/Testing/ProfileRecoveryUITestFixture.swift b/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift index a12fc0473..4e847b4c3 100644 --- a/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift +++ b/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift @@ -135,8 +135,9 @@ withdrawPrivate: { try local.rejectRemote() }, withdrawPublic: { try local.rejectRemote() }, validateSubscriptions: { _ in try local.rejectRemote() }, - revokeSession: { 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)) diff --git a/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift b/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift index 77b3bd9ed..ce149ae69 100644 --- a/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift +++ b/Bitkit/Views/Profile/ProfileRemovalRecoveryView.swift @@ -9,6 +9,8 @@ struct ProfileRemovalRecoveryView: View { @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? @@ -22,6 +24,12 @@ struct ProfileRemovalRecoveryView: View { 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 } @@ -47,6 +55,18 @@ struct ProfileRemovalRecoveryView: View { .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 { @@ -63,6 +83,26 @@ struct ProfileRemovalRecoveryView: View { } 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 } diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index c1eb8686a..6ea13c859 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -903,8 +903,11 @@ final class ContactPaymentsServiceTests: XCTestCase { } }, validateSubscriptions: { _ in XCTFail("Disconnect does not remove contacts") }, - revokeSession: { XCTFail("Failed endpoint withdrawal cannot revoke the session") }, + 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() diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 228586c50..63195dd53 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -352,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 { @@ -376,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" @@ -394,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) @@ -426,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] = [] @@ -444,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, @@ -480,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 } @@ -535,7 +675,9 @@ 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: [], failedToQueue: failed.map { .init(counterparty: $0.counterparty, outboundMessageId: nil, 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: [] ) } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 9615fdf2d..86d0643d9 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -1278,6 +1278,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" @@ -1288,7 +1289,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/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 index 370bc5754..d14964c18 100644 --- a/BitkitTests/ProfileRemovalCoordinatorTests.swift +++ b/BitkitTests/ProfileRemovalCoordinatorTests.swift @@ -28,6 +28,8 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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() @@ -53,13 +55,19 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { eligibilityChecks += 1 try await duringEligibility?() }, - revokeSession: { [self] in try step("revoke"); try await duringRevocation?() }, - completeLocal: { [self] in - try duringLocalCleanup?() - try step("credentials") - watermark = 0 - cleanupCount += 1 + 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, @@ -69,6 +77,13 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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 = [] @@ -770,6 +785,226 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { XCTAssertEqual(manager.publicKey, "replacement") } + func testConfirmedRetryUsesOriginalGrantAndNeverRepeatsAcknowledgedCleanup() async throws { + for action in [PubkyProfileManager.RemovalAction.disconnect, .deleteProfile, .unlink] { + steps = [] + failStep = "revoke" + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: action, contacts: contacts()) + do { _ = try await manager.confirmRemoval(confirmation); XCTFail("First logout response is unknown") } + catch TestError.failure("revoke") {} + let counts = [profileReads, contactReads, contactRemovals, eligibilityChecks, deleted.count, revocations.count] + let completed = steps + 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 + let result = try await manager.confirmRemoteRemovalRetry(fresh) + XCTAssertEqual(result.remoteProfileDeleted, action == .deleteProfile) + XCTAssertEqual(steps, completed + ["revoke", "credentials"]) + 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 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 { + let manager = makeManager() + let confirmation = try await manager.prepareRemoval(action: .deleteProfile, 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) + XCTAssertFalse(manager.canRetryRemoteRemoval(id: confirmation.id)) + } + + func testUnknownRetryNeedsAnotherConfirmationAndDrainsBeforeOfferingIt() 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) + 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", "revoke", "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", "revoke", "revoke", "revoke", "credentials"]) + XCTAssertEqual(revocations.count, 1) + } + + func testFailedRetryValidationCannotReviveAfterTheOriginalValuesReturn() 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") {} + duringRevocationValidation = { throw TestError.failure("changed-credentials") } + do { _ = try await manager.prepareRemoteRemovalRetry(id: initial.id); XCTFail("Changed binding must refuse confirmation") } + catch TestError.failure("changed-credentials") {} + duringRevocationValidation = nil + XCTAssertFalse(manager.canRetryRemoteRemoval(id: initial.id)) + XCTAssertTrue(try XCTUnwrap(revocations.last).revoked) + 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() 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/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/changelog.d/next/1143.fixed.md b/changelog.d/next/1143.fixed.md index ee4aa135f..3fe23b2cd 100644 --- a/changelog.d/next/1143.fixed.md +++ b/changelog.d/next/1143.fixed.md @@ -1 +1 @@ -Require acknowledged private-endpoint withdrawal before profile removal, keep interrupted cleanup tied to its original identity, and allow local Disconnect when subscriptions prevent removal. +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/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml index ff1d2899e..191336482 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -12,8 +12,12 @@ With Paykit 0.1.1-rc3, 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. Interrupted recovery remains local-only: - the SDK's ordinary sign-out can restore a newer bearer and cannot enforce original-bearer revocation. + 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. @@ -29,5 +33,7 @@ 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-removal-recovery.xml b/journeys/profile/profile-removal-recovery.xml index 8df440513..65d7f979a 100644 --- a/journeys/profile/profile-removal-recovery.xml +++ b/journeys/profile/profile-removal-recovery.xml @@ -5,14 +5,16 @@ 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. - This journey covers local Disconnect after an uncertain attempt. Its pinned native contact - read can reconcile writes, so remote Review/reconfirmation is deliberately unavailable until a - native observation API can enforce read-only reconciliation. This differs from the Android UI. + Failures before logout offer local Disconnect only: contact reads can reconcile writes and must + not be used for remote Review. 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. 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 the uncertain attempt + 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 @@ -23,5 +25,13 @@ 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 From d6c1d9a5245c1c116a9907273090378ddb979eb0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 31 Oct 2026 14:37:42 +0200 Subject: [PATCH 13/15] fix: retire unavailable profile retries at confirmation Retire the original grant lease when the fresh confirmation rejects its local binding, preserving uncertain transport retries and explicit local recovery. Keep removal error titles bound to the requested action. --- Bitkit/Managers/PubkyProfileManager.swift | 6 ++- Bitkit/Views/Profile/EditProfileView.swift | 15 ++++-- .../ProfileRemovalCoordinatorTests.swift | 46 ++++++++++++------- journeys/profile/profile-removal-recovery.xml | 4 ++ 4 files changed, 50 insertions(+), 21 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 70e7a8aff..39606cddb 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1661,9 +1661,11 @@ class PubkyProfileManager: ObservableObject { let id = confirmation.removalID let scope = live.lease.scope var completion: RemovalCompletion? + var didValidateOriginalBinding = false do { try await withRemovalWork(lease: live.lease, id: id, live: live) { try await validateRemoteRemovalOwner(live.plan) + didValidateOriginalBinding = true // Endpoint, contact and profile cleanup already completed under this same live intent. try await revocation.execute() live.logoutAcknowledged = await revocation.acknowledged() @@ -1672,7 +1674,9 @@ class PubkyProfileManager: ObservableObject { completion = try await completeRemoval(scope: scope, plan: live.plan, remoteDeleted: live.remoteProfileDeleted) } } catch { - if live.logoutAcknowledged || Task.isCancelled || !live.lease.canResume || (try? validateRemovalOwner(live.plan)) == nil { + if (!didValidateOriginalBinding && !(error is CancellationError)) || live.logoutAcknowledged || Task.isCancelled || + !live.lease.canResume || (try? validateRemovalOwner(live.plan)) == nil + { live.lease.retire() } if let completion { diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 3e64a73d5..2b381ab2a 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -194,7 +194,7 @@ struct EditProfileView: View { showRemovalConfirmation = true } catch { guard !Task.isCancelled, pubkyProfile.ownsRemovalPresentation(presentationOwner) else { return } - showRemovalError(error, owner: presentationOwner) + showRemovalError(error, action: action, owner: presentationOwner) } } @@ -211,12 +211,14 @@ struct EditProfileView: View { pubkyProfile.refreshRemovalRecovery() guard !Task.isCancelled, pubkyProfile.ownsRemovalFailure(confirmation, owner: presentationOwner) else { return } if pubkyProfile.pendingProfileRemovalID != confirmation.id { - showRemovalError(error, owner: presentationOwner) + showRemovalError(error, action: confirmation.action, owner: presentationOwner) } } } - private func showRemovalError(_ error: Error, owner: PubkyProfileManager.RemovalPresentationOwner) { + private func showRemovalError(_ error: Error, action: PubkyProfileManager.RemovalAction, + owner: PubkyProfileManager.RemovalPresentationOwner) + { if case let PubkyServiceError.activeSubscription(endsAt) = error { localDisconnectOwner = owner let description = endsAt.map { @@ -224,7 +226,12 @@ struct EditProfileView: View { } app.toast(type: .error, title: t("profile__delete_active_subscription"), description: description) } else { - app.toast(type: .error, title: t("profile__sign_out_title"), description: error.localizedDescription) + 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) } } diff --git a/BitkitTests/ProfileRemovalCoordinatorTests.swift b/BitkitTests/ProfileRemovalCoordinatorTests.swift index d14964c18..62750d56f 100644 --- a/BitkitTests/ProfileRemovalCoordinatorTests.swift +++ b/BitkitTests/ProfileRemovalCoordinatorTests.swift @@ -931,22 +931,36 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { } func testFailedRetryValidationCannotReviveAfterTheOriginalValuesReturn() 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") {} - duringRevocationValidation = { throw TestError.failure("changed-credentials") } - do { _ = try await manager.prepareRemoteRemovalRetry(id: initial.id); XCTFail("Changed binding must refuse confirmation") } - catch TestError.failure("changed-credentials") {} - duringRevocationValidation = nil - XCTAssertFalse(manager.canRetryRemoteRemoval(id: initial.id)) - XCTAssertTrue(try XCTUnwrap(revocations.last).revoked) - 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"]) + 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 { diff --git a/journeys/profile/profile-removal-recovery.xml b/journeys/profile/profile-removal-recovery.xml index 65d7f979a..a66b344d7 100644 --- a/journeys/profile/profile-removal-recovery.xml +++ b/journeys/profile/profile-removal-recovery.xml @@ -10,6 +10,10 @@ 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 From f6b4587cb4057f486399f532052b0488d2175dd9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 31 Oct 2026 15:33:58 +0200 Subject: [PATCH 14/15] fix: register public sharing intent before admission Record newer public-sharing choices before the asynchronous admission hop so older writes cannot pass freshness checks after a newer request starts. Keep ownership validation and endpoint work inside admission. --- Bitkit/Services/ContactPaymentsService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 0a5c1b629..87f2f6cc1 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -222,9 +222,9 @@ enum ContactPaymentsService { operations: Operations, defaults: UserDefaults = .standard ) async throws -> Bool { + latestPublicChange += 1 + let change = latestPublicChange return try await ProfileOperationAdmission.shared.withOperation { - latestPublicChange += 1 - let change = latestPublicChange guard let session = pubkyProfile.currentSession else { return false } let callerIsCurrent: ChangeCheck = { Self.latestPublicChange == change && pubkyProfile.currentSession == session } let isChangeCurrent = try ownedChange(callerIsCurrent) From 630be7593c422e23c16fe132eddb1b8ff32ff36b Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 31 Oct 2026 17:45:06 +0200 Subject: [PATCH 15/15] fix: retry unfinished public profile cleanup Retain each acknowledged cleanup stage for ordinary Disconnect after private withdrawal. Require fresh confirmation and the original live binding before finishing public cleanup and revoking the captured grant. Cover stage ordering, excluded actions, canceled confirmations and ownership retirement. --- Bitkit/Managers/PubkyProfileManager.swift | 82 +++++++++++------ .../ProfileRecoveryUITestFixture.swift | 1 + BitkitTests/ContactPaymentsServiceTests.swift | 1 + .../ProfileRemovalCoordinatorTests.swift | 91 +++++++++++++++---- journeys/profile/profile-removal-recovery.xml | 10 +- 5 files changed, 135 insertions(+), 50 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 39606cddb..5b8d8201f 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -136,6 +136,7 @@ class PubkyProfileManager: ObservableObject { 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 @@ -148,10 +149,8 @@ class PubkyProfileManager: ObservableObject { 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() - try await PublicPaykitService.syncPaykitApp(privateSharingEnabled: false) - }, + 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() }, @@ -193,10 +192,16 @@ class PubkyProfileManager: ObservableObject { 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 @@ -1601,6 +1606,30 @@ class PubkyProfileManager: ObservableObject { 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() @@ -1614,8 +1643,7 @@ class PubkyProfileManager: ObservableObject { func canRetryRemoteRemoval(id: UUID) -> Bool { guard !Self.isProfileDeletionInFlight, !isDeletingProfile, let live = liveRemoval, - live.plan.confirmation.id == id, live.logoutStarted, !live.logoutAcknowledged, - live.privateAcknowledged, live.publicAcknowledged, live.lease.canResume, + 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 @@ -1661,12 +1689,11 @@ class PubkyProfileManager: ObservableObject { let id = confirmation.removalID let scope = live.lease.scope var completion: RemovalCompletion? - var didValidateOriginalBinding = false do { try await withRemovalWork(lease: live.lease, id: id, live: live) { - try await validateRemoteRemovalOwner(live.plan) - didValidateOriginalBinding = true - // Endpoint, contact and profile cleanup already completed under this same live intent. + 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 } @@ -1674,7 +1701,7 @@ class PubkyProfileManager: ObservableObject { completion = try await completeRemoval(scope: scope, plan: live.plan, remoteDeleted: live.remoteProfileDeleted) } } catch { - if (!didValidateOriginalBinding && !(error is CancellationError)) || live.logoutAcknowledged || Task.isCancelled || + if live.logoutAcknowledged || Task.isCancelled || !live.lease.canResume || (try? validateRemovalOwner(live.plan)) == nil { live.lease.retire() @@ -1702,7 +1729,7 @@ class PubkyProfileManager: ObservableObject { 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 { + if live.logoutStarted || live.canRetry { liveRemoval = live } } @@ -1761,45 +1788,44 @@ class PubkyProfileManager: ObservableObject { failedRemovalID = id refreshRemovalRecovery() var remoteDeleted = false - if let plan, plan.withdrawEndpoints { - try await validateRemoteRemovalOwner(plan) + 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(plan) + try await validateRemoteRemovalOwner(live) try await removalServices.withdrawPrivate() - live?.privateAcknowledged = true - try await validateRemoteRemovalOwner(plan) - try await removalServices.withdrawPublic() - live?.publicAcknowledged = true - try await validateRemoteRemovalOwner(plan) + 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(plan) + 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 + live.remoteProfileDeleted = true } - try await validateRemoteRemovalOwner(plan) + try await validateRemoteRemovalOwner(live) guard let revocation = plan.revocation else { throw RemovalError.confirmationChanged } - live?.logoutStarted = true + live.logoutStarted = true try await revocation.execute() - live?.logoutAcknowledged = await revocation.acknowledged() - guard live?.logoutAcknowledged == true else { throw RemovalError.confirmationChanged } + 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.logoutStarted { - retainIntent = !live.logoutAcknowledged && !Task.isCancelled && lease.canResume && + if let live, live.canRetry { + retainIntent = !Task.isCancelled && lease.canResume && (try? validateRemovalOwner(live.plan)) != nil } if let completion { diff --git a/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift b/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift index 4e847b4c3..bce23efe0 100644 --- a/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift +++ b/Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift @@ -134,6 +134,7 @@ 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() }, diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index ece373676..674fda3ad 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -1002,6 +1002,7 @@ final class ContactPaymentsServiceTests: XCTestCase { 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() diff --git a/BitkitTests/ProfileRemovalCoordinatorTests.swift b/BitkitTests/ProfileRemovalCoordinatorTests.swift index 62750d56f..7a4ebbfe8 100644 --- a/BitkitTests/ProfileRemovalCoordinatorTests.swift +++ b/BitkitTests/ProfileRemovalCoordinatorTests.swift @@ -22,6 +22,7 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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)? @@ -50,6 +51,12 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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 @@ -115,6 +122,7 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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) @@ -786,15 +794,22 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { } func testConfirmedRetryUsesOriginalGrantAndNeverRepeatsAcknowledgedCleanup() async throws { - for action in [PubkyProfileManager.RemovalAction.disconnect, .deleteProfile, .unlink] { + let cases: [(PubkyProfileManager.RemovalAction, String)] = [ + (.disconnect, "public"), (.disconnect, "app"), (.disconnect, "revoke"), + (.deleteProfile, "revoke"), (.unlink, "revoke"), + ] + for (action, failure) in cases { steps = [] - failStep = "revoke" + 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("First logout response is unknown") } - catch TestError.failure("revoke") {} + 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) @@ -809,9 +824,13 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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 + ["revoke", "credentials"]) + 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)) @@ -820,6 +839,31 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { } } + 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()) @@ -882,24 +926,30 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { } func testCapturedGrantRetirementDuringCleanupStopsFollowingStages() async throws { - let manager = makeManager() - let confirmation = try await manager.prepareRemoval(action: .deleteProfile, 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) - XCTAssertFalse(manager.canRetryRemoteRemoval(id: confirmation.id)) + 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 = "revoke" + failStep = "public" 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") {} + 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") @@ -915,7 +965,7 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { 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", "revoke", "revoke"]) + XCTAssertEqual(steps, ["private", "public", "public", "revoke"]) await barrier.finish() do { _ = try await operation.value; XCTFail("Expected unknown retry result") } catch TestError.failure("unknown") {} @@ -926,7 +976,8 @@ final class ProfileRemovalCoordinatorTests: XCTestCase { XCTAssertNotEqual(next.id, retry.id) duringRevocation = nil _ = try await manager.confirmRemoteRemovalRetry(next) - XCTAssertEqual(steps, ["private", "public", "revoke", "revoke", "revoke", "credentials"]) + XCTAssertEqual(steps, ["private", "public", "public", "revoke", "revoke", "credentials"]) + XCTAssertEqual(appSyncCount, 1) XCTAssertEqual(revocations.count, 1) } diff --git a/journeys/profile/profile-removal-recovery.xml b/journeys/profile/profile-removal-recovery.xml index a66b344d7..09126c5eb 100644 --- a/journeys/profile/profile-removal-recovery.xml +++ b/journeys/profile/profile-removal-recovery.xml @@ -5,8 +5,11 @@ 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. - Failures before logout offer local Disconnect only: contact reads can reconcile writes and must - not be used for remote Review. Separately lose the logout response after all preceding cleanup + 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. @@ -37,5 +40,8 @@ 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