diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5c0eb41f1..04b14ae57 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -345,7 +345,7 @@ struct AppScene: View { @StateObject private var channelDetails = ChannelDetailsViewModel.shared @StateObject private var migrations = MigrationsService.shared @StateObject private var languageManager = LanguageManager.shared - @StateObject private var pubkyProfile = PubkyProfileManager() + @StateObject private var pubkyProfile: PubkyProfileManager @StateObject private var contactsManager = ContactsManager() @State private var keyboardManager = KeyboardManager() @State private var trezorManager: TrezorManager @@ -407,10 +407,13 @@ struct AppScene: View { _sheets = StateObject(wrappedValue: sheetViewModel) _navigation = StateObject(wrappedValue: navigationViewModel) let feeEstimatesManager = FeeEstimatesManager() + let pubkyProfile = PubkyProfileManager() + _pubkyProfile = StateObject(wrappedValue: pubkyProfile) let walletVm = WalletViewModel( transferService: transferService, sheetViewModel: sheetViewModel, - feeEstimatesManager: feeEstimatesManager + feeEstimatesManager: feeEstimatesManager, + pubkyProfile: pubkyProfile ) _app = StateObject(wrappedValue: AppViewModel( sheetViewModel: sheetViewModel, @@ -511,22 +514,18 @@ struct AppScene: View { .task(id: foregroundContactSession) { shopContactImport?.task.cancel() shopContactImport = nil - guard let session = foregroundContactSession else { return } - startShopContactImport() - do { - try await contactsManager.refreshContacts(for: session.publicKey) - } catch is CancellationError { - return - } catch { - Logger.warn("Failed to refresh shared contacts: \(error)", context: "AppScene") - } + await refreshForegroundContacts() } .task(id: publicPaykitForegroundSession) { - guard let session = publicPaykitForegroundSession else { return } - await foregroundWalletSync?.value - guard !Task.isCancelled else { return } - await wallet.refreshPublicPaykitEndpointsOnForeground { - !Task.isCancelled && isPaykitSceneActive && pubkyProfile.currentSession == session + await refreshForegroundPublicPaykitEndpoints() + } + .onChange(of: pubkyProfile.isDeletingProfile) { _, isDeleting in + guard !isDeleting, isPaykitSceneActive else { return } + Task { + await refreshForegroundPublicPaykitEndpoints() + await refreshForegroundContacts() + await prepareSavedPaykitContacts() + await refreshIncomingPaykitPaymentRequests(onlyWhileActive: true) } } .task { await handlePendingPaykitSubscriptionNotification() } @@ -605,7 +604,11 @@ struct AppScene: View { if authState == .authenticated, let pk = pubkyProfile.publicKey { paykitPaymentRequestManager.activate(identity: pk) Task { + guard let session = pubkyProfile.automaticPaykitSession, + PubkyPublicKeyFormat.matches(pk, session.publicKey) + else { return } await handlePendingPaykitSubscriptionNotification() + guard pubkyProfile.automaticPaykitSession == session else { return } try? await contactsManager.loadContacts(for: pk) if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { await retryPendingPaykitEndpointRemoval() @@ -640,7 +643,7 @@ struct AppScene: View { } } .onReceive(PrivatePaykitService.contactLinkCompletedPublisher.receive(on: DispatchQueue.main)) { completion in - guard isPaykitSceneActive, pubkyProfile.authState == .authenticated, + guard isPaykitSceneActive, pubkyProfile.automaticPaykitSession != nil, PubkyPublicKeyFormat.matches(completion.identity, pubkyProfile.publicKey), contactsManager.contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, completion.publicKey) }) else { return } @@ -658,7 +661,7 @@ struct AppScene: View { try await PaykitPaymentActivity.shared.waitUntilIdle() } catch { return } guard !Task.isCancelled, request == pendingPaykitProofRefresh else { return } - await refreshIncomingPaykitPaymentRequests(mode: .stored, forceFresh: true) + await refreshIncomingPaykitPaymentRequests(mode: .stored, forceFresh: true, automatic: false) guard !Task.isCancelled else { return } paykitProofRefresh.complete(request) } @@ -1323,11 +1326,35 @@ struct AppScene: View { return pubkyProfile.currentSession } + private func refreshForegroundContacts() async { + guard let session = foregroundContactSession, pubkyProfile.automaticPaykitSession == session else { return } + startShopContactImport() + do { + try await contactsManager.refreshContacts(for: session.publicKey) + } catch is CancellationError { + return + } catch { + Logger.warn("Failed to refresh shared contacts: \(error)", context: "AppScene") + } + } + + private func refreshForegroundPublicPaykitEndpoints() async { + guard let session = publicPaykitForegroundSession, pubkyProfile.automaticPaykitSession == session else { return } + await foregroundWalletSync?.value + guard !Task.isCancelled, pubkyProfile.automaticPaykitSession == session else { return } + await wallet.refreshPublicPaykitEndpointsOnForeground { + !Task.isCancelled && isPaykitSceneActive && pubkyProfile.automaticPaykitSession == session + } + } + private func refreshPrivateOnlyPaykitApp() async { let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) - guard privateSharingEnabled, !publicSharingEnabled else { return } + guard privateSharingEnabled, !publicSharingEnabled, + let session = pubkyProfile.automaticPaykitSession + else { return } guard await PubkyService.currentPublicKey() != nil else { return } + guard pubkyProfile.automaticPaykitSession == session else { return } do { try await PublicPaykitService.syncPaykitApp() @@ -1337,13 +1364,13 @@ struct AppScene: View { } private func prepareSavedPaykitContacts(expectedIdentity: String? = nil) async { - guard let session = pubkyProfile.currentSession, + guard let session = pubkyProfile.automaticPaykitSession, expectedIdentity == nil || PubkyPublicKeyFormat.matches(expectedIdentity, session.publicKey) else { return } let snapshot = contactsManager.savedContactsSnapshot() await PrivatePaykitService.shared.prepareSavedContacts( snapshot.publicKeys, wallet: wallet, isSessionCurrent: { - pubkyProfile.authState == .authenticated && pubkyProfile.currentSession == session && snapshot.isCurrent() + pubkyProfile.automaticPaykitSession == session && snapshot.isCurrent() } ) } @@ -1354,7 +1381,8 @@ struct AppScene: View { mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .ordered, - onlyWhileActive: Bool = false + onlyWhileActive: Bool = false, + automatic: Bool = true ) async -> Bool { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, @@ -1364,15 +1392,22 @@ struct AppScene: View { return false } - guard !Task.isCancelled, !onlyWhileActive || isPaykitSceneActive else { return false } + guard let session = pubkyProfile.currentSession else { return false } + let canRefresh = { + !Task.isCancelled && (!onlyWhileActive || isPaykitSceneActive) && + (automatic ? pubkyProfile.automaticPaykitSession : pubkyProfile.currentSession) == session + } + guard canRefresh() else { return false } paykitPaymentRequestManager.updateSavedPublicKeys(contactsManager.contacts.map(\.publicKey)) if mode == .full { await PaykitPaymentProofService.shared.reconcile() await PaykitUsdtPaymentService.shared.reconcile(wallet: wallet.usdtWallet) } - guard !Task.isCancelled, !onlyWhileActive || isPaykitSceneActive else { return false } - guard let identity = pubkyProfile.publicKey else { return false } - let refreshed = await paykitPaymentRequestManager.refresh(mode: mode, forceFresh: forceFresh, messagePriority: messagePriority) + guard canRefresh() else { return false } + let identity = session.publicKey + let refreshed = await paykitPaymentRequestManager.refresh( + mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, canRefresh: canRefresh + ) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return false } @@ -1388,10 +1423,10 @@ struct AppScene: View { } catch { Logger.warn("Failed to attribute received Paykit payments: \(error)", context: "AppScene") } - if presentItems { + if presentItems, canRefresh(), pubkyProfile.automaticPaykitSession == session { await presentNextIncomingPaykitItem() } - if mode == .full, !onlyWhileActive || isPaykitSceneActive { + if mode == .full, canRefresh() { await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) } return refreshed @@ -1442,7 +1477,9 @@ struct AppScene: View { private func pollIncomingPaykitPaymentRequests() async { guard scenePhase == .active, network.isConnected else { return } - await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) + if let session = pubkyProfile.automaticPaykitSession { + await PubkyService.republishIdentityIfNeeded(publicKey: session.publicKey) + } await refreshIncomingPaykitPaymentRequests(messagePriority: .background, onlyWhileActive: true) var schedule = PaykitPaymentRequestPollingSchedule() while !Task.isCancelled { @@ -1452,7 +1489,8 @@ struct AppScene: View { return } guard !isWalletBackupRestoreRunning, - !BackupService.shared.hasPendingWalletRestore() + !BackupService.shared.hasPendingWalletRestore(), + let session = pubkyProfile.automaticPaykitSession else { continue } let mode: PaykitPaymentRequestRefreshMode switch schedule.takeRound(isConnected: network.isConnected) { @@ -1463,7 +1501,6 @@ struct AppScene: View { case .refreshInboxAndMaintenance: mode = .full } - let session = pubkyProfile.currentSession startShopContactImport() if mode == .full { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) @@ -1475,7 +1512,7 @@ struct AppScene: View { scenePhase == .active, network.isConnected, PaykitFeatureFlags.isUIEnabled, - let session, session == pubkyProfile.currentSession, + session == pubkyProfile.automaticPaykitSession, !app.showDrawer, sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet, @@ -1489,13 +1526,15 @@ struct AppScene: View { } private func startShopContactImport() { - guard shopContactImport == nil, let session = foregroundContactSession else { return } + guard shopContactImport == nil, let session = foregroundContactSession, + pubkyProfile.automaticPaykitSession == session + else { return } let id = UUID() let task = Task { defer { if shopContactImport?.id == id { shopContactImport = nil } } do { try await contactsManager.importShopContacts(for: session.publicKey) { - foregroundContactSession == session + foregroundContactSession == session && pubkyProfile.automaticPaykitSession == session } } catch is CancellationError { return @@ -1510,7 +1549,7 @@ struct AppScene: View { guard scenePhase == .active, isPinVerified || !settings.pinEnabled, PaykitFeatureFlags.isUIEnabled, - pubkyProfile.currentSession != nil, + pubkyProfile.automaticPaykitSession != nil, !app.showDrawer, sheets.activeSheetConfiguration == nil || canRetryIncomingPaymentRequestPreparation, !sheets.isReplacingSheet, @@ -1821,7 +1860,7 @@ struct AppScene: View { pubkyProfile.authState == .authenticated else { return } guard let target = PaykitSubscriptionNotificationTargetStore.load() else { return } - guard let session = pubkyProfile.currentSession else { return } + guard let session = pubkyProfile.automaticPaykitSession else { return } guard target.matches(identity: session.publicKey) else { PaykitSubscriptionNotificationTargetStore.clear() return @@ -1831,7 +1870,7 @@ struct AppScene: View { ) else { return } guard await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored), !Task.isCancelled, - pubkyProfile.currentSession == session, + pubkyProfile.automaticPaykitSession == session, PaykitSubscriptionNotificationTargetStore.load() == target, IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( manager: paykitPaymentRequestManager, app: app, sheets: sheets @@ -1859,6 +1898,7 @@ struct AppScene: View { private func presentNextIncomingPaykitItem() async { guard scenePhase == .active, + pubkyProfile.automaticPaykitSession != nil, isPinVerified || !settings.pinEnabled else { return } await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( @@ -1871,7 +1911,9 @@ struct AppScene: View { } private func retryPendingPaykitEndpointRemoval() async { + guard let session = pubkyProfile.automaticPaykitSession else { return } await ContactPaymentsService.reconcilePendingEndpoints { + guard pubkyProfile.automaticPaykitSession == session else { return } let privateCleanupPending = UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) await PrivatePaykitService.shared.retryPendingEndpointReconciliation( wallet: wallet, @@ -1881,16 +1923,19 @@ struct AppScene: View { PublicPaykitService.setCleanupPending(true) } - if PublicPaykitService.isCleanupPending { + if PublicPaykitService.isCleanupPending, pubkyProfile.automaticPaykitSession == session { do { switch PublicPaykitService.pendingReconciliationMode() { case .publishEndpoints: - try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) { + !Task.isCancelled && pubkyProfile.automaticPaykitSession == session && + PublicPaykitService.pendingReconciliationMode() == .publishEndpoints + } case .removePublishedState: try await PublicPaykitService.removePublishedEndpoints() try await PublicPaykitService.syncPaykitApp() + PublicPaykitService.setCleanupPending(false) } - PublicPaykitService.setCleanupPending(false) } catch { Logger.warn("Failed to reconcile public Paykit state: \(error)", context: "AppScene") } @@ -1945,8 +1990,8 @@ struct AppScene: View { async let sessionRecovery: Void = pubkyProfile.restoreSessionIfNeeded() await currency.refresh() await sessionRecovery - if scenePhase == .active { - await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) + if scenePhase == .active, let session = pubkyProfile.automaticPaykitSession { + await PubkyService.republishIdentityIfNeeded(publicKey: session.publicKey) } await retryPendingPaykitEndpointRemoval() if PaykitFeatureFlags.isUIEnabled { diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index e4053c446..218db85ae 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -29,6 +29,7 @@ struct MainNavView: View { @State private var clipboardUri: String? @State private var didResumePendingPubkyProfileSetup = false @State private var isPreparingPendingContactDeepLink = false + @State private var retryPendingContactAfterDeletion = false init(canHandleDeepLinks: Bool = true) { self.canHandleDeepLinks = canHandleDeepLinks } @@ -41,12 +42,13 @@ struct MainNavView: View { canRoutePubkyContactLink( isPaykitUIActive: isPaykitUIActive, isPubkyInitialized: pubkyProfile.isInitialized, - hasLoadedContacts: contactsManager.hasLoaded + hasLoadedContacts: contactsManager.hasLoaded, + hasSignedInIdentity: pubkyProfile.isAuthenticated ) } private var canPrepareContactDeepLink: Bool { - !isPaykitUIActive || pubkyProfile.isInitialized + !isPaykitUIActive || (pubkyProfile.isInitialized && !pubkyProfile.isRestoringSession) } private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { @@ -440,6 +442,17 @@ struct MainNavView: View { guard canHandleDeepLinks, url != nil else { return } Task { await handlePendingDeepLink() } } + .onChange(of: pubkyProfile.isDeletingProfile) { _, isDeleting in + guard !isDeleting, canHandleDeepLinks, + let url = app.pendingDeepLinkURL, PubkyContactLink.matches(url) + else { return } + retryPendingContactAfterDeletion = true + Task { await handlePendingDeepLink() } + } + .onChange(of: isPreparingPendingContactDeepLink) { _, isPreparing in + guard !isPreparing, retryPendingContactAfterDeletion, !pubkyProfile.isDeletingProfile else { return } + Task { await handlePendingDeepLink() } + } .onChange(of: sheets.isPubkyAuthApprovalShownOrPending) { _, isShownOrPending in guard canHandleDeepLinks, !isShownOrPending else { return } Task { @@ -865,6 +878,7 @@ struct MainNavView: View { private func handlePendingDeepLink() async { guard !isPreparingPendingContactDeepLink else { return } + retryPendingContactAfterDeletion = false var contactsReady = true await prepareAndRoutePendingDeepLink { contactsReady = await loadContactsForPendingDeepLinkIfNeeded() @@ -885,21 +899,15 @@ struct MainNavView: View { pubkyProfile.isInitialized, !isPreparingPendingContactDeepLink, let url = app.pendingDeepLinkURL, - let contactPublicKey = PubkyContactLink.publicKey(from: url) + PubkyContactLink.publicKey(from: url) != nil else { return true } - let session = pubkyProfile.currentSession - let contactsOwnerPublicKey = pubkyProfile.publicKey ?? contactPublicKey + guard !pubkyProfile.isDeletingProfile else { return false } isPreparingPendingContactDeepLink = true defer { isPreparingPendingContactDeepLink = false } do { - if pubkyProfile.publicKey != nil { - try await contactsManager.refreshContacts(for: contactsOwnerPublicKey) - } else { - try await contactsManager.loadContactsIfNeeded(for: contactsOwnerPublicKey) - } - return pubkyProfile.currentSession == session && !Task.isCancelled + return try await preparePubkyContactLink(profile: pubkyProfile, contacts: contactsManager) } catch is CancellationError { return false } catch { @@ -933,9 +941,11 @@ struct MainNavView: View { if PubkyContactLink.matches(url) { guard let publicKey = try pubkyContactPublicKeyForRouting(from: url, isPaykitUIActive: isPaykitUIActive) else { return } guard pubkyProfile.initializationErrorMessage == nil, - contactsManager.hasLoaded + isContactDeepLinkReady else { throw ContactsManagerError.invalidPublicKey } + if routePublicContactIfSignedOut(publicKey: publicKey, profile: pubkyProfile, navigation: navigation) { return } + scannerManager.configure( app: app, contactsManager: contactsManager, diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 305af93c4..dd439fb65 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -86,6 +86,7 @@ class PubkyProfileManager: ObservableObject { @Published private var readOnlyProfile: (publicKey: String, profile: PubkyProfile?, revision: UUID)? @Published var isLoadingProfile = false @Published private(set) var isRestoringSession = false + @Published private(set) var isDeletingProfile = false @Published var isInitialized = false @Published var initializationErrorMessage: String? @Published var sessionRestorationFailed = false @@ -976,6 +977,20 @@ class PubkyProfileManager: ObservableObject { return true } + func performProfileDeletion(_ operation: () async throws -> Void) async rethrows { + guard !isDeletingProfile else { return } + isDeletingProfile = true + defer { isDeletingProfile = false } + await PrivatePaykitService.shared.beginProfileDeletion() + do { + try await operation() + } catch { + await PrivatePaykitService.shared.endProfileDeletion() + throw error + } + await PrivatePaykitService.shared.endProfileDeletion() + } + func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true) async throws { if cleanPrivatePaykitEndpoints { await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") @@ -1636,6 +1651,20 @@ class PubkyProfileManager: ObservableObject { return SignedInSession(publicKey: publicKey, revision: Self.sessionRevision) } + var automaticPaykitSession: SignedInSession? { + guard authState == .authenticated, !isDeletingProfile else { return nil } + return currentSession + } + + func storedIdentityGuard() -> (@MainActor () -> Bool)? { + guard !isAuthenticated, Self.sessionMutationCount == 0, (try? Self.hasStoredIdentity()) == true else { return nil } + let revision = Self.sessionRevision + // Restoration keeps this revision; identity replacement and sign-out invalidate it. + return { [weak self] in + self != nil && Self.sessionMutationCount == 0 && Self.sessionRevision == revision + } + } + var hasLocalSecretKeyForCurrentProfile: Bool { Self.hasLocalSecretKey(for: publicKey) } diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 26f20f2d6..30aff3e57 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -291,7 +291,6 @@ enum ContactPaymentsService { try await operations.syncPublicEndpoints(true, isChangeCurrent) guard isChangeCurrent() else { return } - operations.setPublicCleanupPending(false) if canUsePrivatePayments { operations.setPrivateCleanupPending(false) } @@ -373,7 +372,7 @@ enum ContactPaymentsService { do { try await operations.syncPublicEndpoints(state.sharesPublicEndpoints, isChangeCurrent) - if isChangeCurrent() { + if !state.sharesPublicEndpoints, isChangeCurrent() { operations.setPublicCleanupPending(state.publicCleanupPending) } } catch { diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index d133bc004..959761672 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1796,13 +1796,15 @@ final class PaykitPaymentRequestManager { func refresh( mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, - messagePriority: PaykitSdkOperationLock.Priority = .ordered + messagePriority: PaykitSdkOperationLock.Priority = .ordered, + canRefresh: @escaping @MainActor () -> Bool = { true } ) async -> Bool { await refresh( excludingProtectedRequestId: nil, mode: mode, forceFresh: forceFresh, - messagePriority: forceFresh ? .ordered : messagePriority + messagePriority: forceFresh ? .ordered : messagePriority, + canRefresh: canRefresh ) } @@ -1829,14 +1831,19 @@ final class PaykitPaymentRequestManager { excludingProtectedRequestId: PaykitPaymentRequest.ID?, mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, - messagePriority: PaykitSdkOperationLock.Priority = .ordered + messagePriority: PaykitSdkOperationLock.Priority = .ordered, + canRefresh: @escaping @MainActor () -> Bool = { true } ) async -> Bool { + guard canRefresh() else { return false } if let refreshTask { let generation = stateGeneration let refreshed = await refreshTask.task.value - guard generation == stateGeneration, !Task.isCancelled else { return false } + guard generation == stateGeneration, !Task.isCancelled, canRefresh() else { return false } if forceFresh || mode > refreshTask.mode { - return await refresh(excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, messagePriority: messagePriority) + return await refresh( + excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, + messagePriority: messagePriority, canRefresh: canRefresh + ) } return refreshed } @@ -1845,6 +1852,8 @@ final class PaykitPaymentRequestManager { let generation = refreshGeneration let task = Task { [weak self] in guard let self else { return false } + defer { if generation == refreshGeneration { refreshTask = nil } } + guard canRefresh() else { return false } let refreshed = await performRefresh( generation: generation, excludingProtectedRequestId: excludingProtectedRequestId, @@ -1852,7 +1861,6 @@ final class PaykitPaymentRequestManager { messagePriority: messagePriority ) guard generation == refreshGeneration else { return false } - refreshTask = nil return refreshed } refreshTask = (mode, task) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 2175c849b..a6e4bc9cb 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -322,15 +322,31 @@ enum PublicPaykitService { wallet: WalletViewModel, publish: Bool, isSessionCurrent: (@MainActor () -> Bool)? = nil, - appSyncPriority: PaykitSdkOperationLock.Priority = .ordered + appSyncPriority: PaykitSdkOperationLock.Priority = .ordered, + syncApp: (() async throws -> Void)? = nil, + applyEndpoints: ([Endpoint]) async throws -> Void = applyPublishedEndpointsLocked ) async throws { + if publish { wallet.preparePendingPublicPaykitInvoiceRefresh() } + var desiredSnapshot: (endpoints: [Endpoint], invoiceRevision: UUID)? try await syncPublishedEndpoints( publish: publish, isSessionCurrent: isSessionCurrent, - buildEndpoints: { try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) }, - syncApp: { try await syncPaykitApp(priority: appSyncPriority) }, - applyEndpoints: applyPublishedEndpointsLocked + buildEndpoints: { + let snapshot = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) + desiredSnapshot = snapshot + return snapshot.endpoints + }, + syncApp: syncApp ?? { try await syncPaykitApp(priority: appSyncPriority) }, + applyEndpoints: applyEndpoints ) + if let desiredSnapshot { + acknowledgePublishedEndpoints( + wallet: wallet, + endpoints: desiredSnapshot.endpoints, + invoiceRevision: desiredSnapshot.invoiceRevision, + isSessionCurrent: isSessionCurrent + ) + } } @MainActor @@ -374,17 +390,46 @@ enum PublicPaykitService { } @MainActor - static func syncCurrentPublishedEndpoints(wallet: WalletViewModel, isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws { - let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false) + static func syncCurrentPublishedEndpoints( + wallet: WalletViewModel, + isSessionCurrent: (@MainActor () -> Bool)? = nil, + syncApp: () async throws -> Void = { try await syncPaykitApp() }, + applyEndpoints: ([Endpoint]) async throws -> Void = applyPublishedEndpointsLocked + ) async throws { + wallet.preparePendingPublicPaykitInvoiceRefresh() + let (desiredEndpoints, invoiceRevision) = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false) try await syncPublishedEndpoints( publish: true, isSessionCurrent: isSessionCurrent, buildEndpoints: { desiredEndpoints }, - syncApp: { try await syncPaykitApp() }, - applyEndpoints: applyPublishedEndpointsLocked + syncApp: syncApp, + applyEndpoints: applyEndpoints + ) + acknowledgePublishedEndpoints( + wallet: wallet, + endpoints: desiredEndpoints, + invoiceRevision: invoiceRevision, + isSessionCurrent: isSessionCurrent ) } + @MainActor + private static func acknowledgePublishedEndpoints( + wallet: WalletViewModel, + endpoints: [Endpoint], + invoiceRevision: UUID, + isSessionCurrent: (@MainActor () -> Bool)? + ) { + // An older snapshot can finish after an invoice invalidation or its replacement. + guard let isSessionCurrent, !Task.isCancelled, isSessionCurrent(), + pendingReconciliationMode() == .publishEndpoints + else { return } + let publishedCurrentInvoice = !isLightningPaymentOptionEnabled() || + (wallet.publicPaykitInvoiceRevision == invoiceRevision && wallet.publicPaykitBolt11 == + (endpoints.first { $0.methodId == .bitcoinLightningBolt11 }?.value ?? "")) + setCleanupPending(!publishedCurrentInvoice) + } + static func removePublishedEndpoints() async throws { try await applyPublishedEndpoints([]) } @@ -518,7 +563,11 @@ enum PublicPaykitService { } @MainActor - private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool, requireEndpoint: Bool) async throws -> [Endpoint] { + private static func buildWalletEndpoints( + wallet: WalletViewModel, + refreshIfNeeded: Bool, + requireEndpoint: Bool + ) async throws -> (endpoints: [Endpoint], invoiceRevision: UUID) { let includeOnchain = isOnchainPaymentOptionEnabled() let includeLightning = isLightningPaymentOptionEnabled() @@ -528,19 +577,15 @@ enum PublicPaykitService { guard isNodeReady || lifecycleState == .running else { throw PublicPaykitError.walletNotReady } - - _ = try await wallet.refreshPublicPaykitEndpoints( - forceRefreshBolt11: includeLightning, - includeOnchain: includeOnchain, - includeLightning: includeLightning - ) } let publicEndpoints = try await wallet.refreshPublicPaykitEndpoints( - forceRefreshBolt11: false, + forceRefreshBolt11: refreshIfNeeded && includeLightning, includeOnchain: includeOnchain, includeLightning: includeLightning ) + // Keep the invoice revision tied to this snapshot across USDT preparation. + let invoiceRevision = wallet.publicPaykitInvoiceRevision var endpoints: [Endpoint] = [] let onchainAddress = publicEndpoints.onchainAddress.trimmingCharacters(in: .whitespacesAndNewlines) @@ -577,7 +622,7 @@ enum PublicPaykitService { throw PublicPaykitError.noSupportedEndpoint } - return endpoints + return (endpoints, invoiceRevision) } private static func isPayableEndpoint(_ endpoint: Endpoint) async -> Bool { diff --git a/Bitkit/Utilities/MainNavRouting.swift b/Bitkit/Utilities/MainNavRouting.swift index 5ce1ce596..706997378 100644 --- a/Bitkit/Utilities/MainNavRouting.swift +++ b/Bitkit/Utilities/MainNavRouting.swift @@ -3,9 +3,30 @@ import Foundation func canRoutePubkyContactLink( isPaykitUIActive: Bool, isPubkyInitialized: Bool, - hasLoadedContacts: Bool + hasLoadedContacts: Bool, + hasSignedInIdentity: Bool ) -> Bool { - !isPaykitUIActive || (isPubkyInitialized && hasLoadedContacts) + !isPaykitUIActive || (isPubkyInitialized && (!hasSignedInIdentity || hasLoadedContacts)) +} + +@MainActor +func preparePubkyContactLink( + profile: PubkyProfileManager, + contacts: ContactsManager, + hasStoredIdentity: () throws -> Bool = { try PubkyProfileManager.hasStoredIdentity() } +) async throws -> Bool { + guard profile.isInitialized, profile.initializationErrorMessage == nil, + !profile.isRestoringSession, !profile.isDeletingProfile, !Task.isCancelled + else { return false } + guard profile.isAuthenticated else { return (try? hasStoredIdentity()) == false } + guard let session = profile.currentSession else { return false } + do { + try await contacts.refreshContacts(for: session.publicKey) + } catch { + guard !profile.isDeletingProfile, profile.currentSession == session, !Task.isCancelled else { return false } + throw error + } + return !profile.isDeletingProfile && profile.currentSession == session && !Task.isCancelled } func pubkyContactPublicKeyForRouting(from url: URL, isPaykitUIActive: Bool) throws -> String? { @@ -16,6 +37,20 @@ func pubkyContactPublicKeyForRouting(from url: URL, isPaykitUIActive: Bool) thro return publicKey } +@MainActor +func routePublicContactIfSignedOut( + publicKey: String, + profile: PubkyProfileManager, + navigation: NavigationViewModel, + hasStoredIdentity: () throws -> Bool = { try PubkyProfileManager.hasStoredIdentity() } +) -> Bool { + guard !profile.isAuthenticated, !profile.isRestoringSession, !profile.isDeletingProfile, !Task.isCancelled, + (try? hasStoredIdentity()) == false + else { return false } + navigation.navigate(.addContact(publicKey: publicKey)) + return true +} + @MainActor func prepareAndRoutePendingDeepLink( preparation: () async -> Void, diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index d7e225225..250f88091 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -276,8 +276,10 @@ class AppViewModel: ObservableObject { handler: (URL) async -> Void ) async { guard isReady else { return } - promoteDeepLinkAfterPubkyAuthIfIdle() - guard let url = pendingDeepLinkURL else { return } + // A link held behind a sign-in link whose sheet has just closed is only promoted here. Its change of + // `pendingDeepLinkURL` routes it through the caller's own preparation, such as a contact link waiting for a + // restore or a deletion. + guard !promoteDeepLinkAfterPubkyAuthIfIdle(), let url = pendingDeepLinkURL else { return } if sheetViewModel.isPubkyAuthApprovalShownOrPending, !Self.isPubkyAuthDeepLink(url) { return } @@ -301,12 +303,14 @@ class AppViewModel: ObservableObject { promoteDeepLinkAfterPubkyAuthIfIdle() } - private func promoteDeepLinkAfterPubkyAuthIfIdle() { + @discardableResult + private func promoteDeepLinkAfterPubkyAuthIfIdle() -> Bool { guard pendingDeepLinkURL == nil, !sheetViewModel.isPubkyAuthApprovalShownOrPending, let next = deepLinkAfterPubkyAuth - else { return } + else { return false } deepLinkAfterPubkyAuth = nil pendingDeepLinkURL = next + return true } private static func isPubkyAuthDeepLink(_ url: URL) -> Bool { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index c40fa8938..3a074025a 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -33,6 +33,8 @@ class WalletViewModel: ObservableObject { @AppStorage("publicPaykitBolt11") var publicPaykitBolt11 = "" @AppStorage("publicPaykitBolt11PaymentHash") var publicPaykitBolt11PaymentHash = "" @AppStorage("publicPaykitBolt11ExpiresAt") var publicPaykitBolt11ExpiresAt = 0.0 + private(set) var publicPaykitInvoiceRevision = UUID() + private var pendingPublicPaykitInvoiceRefresh: (@MainActor () -> Bool)? @AppStorage("channelCount") var channelCount: Int = 0 // Keeping a cached version of this so we can better anticipate the receive flow UI // Send flow @@ -93,6 +95,7 @@ class WalletViewModel: ObservableObject { private let transferService: TransferService private let sheetViewModel: SheetViewModel private let feeEstimatesManager: FeeEstimatesManager + private let pubkyProfile: PubkyProfileManager? enum BlocktankPeerSimulation: String, CaseIterable { case none = "None" @@ -117,7 +120,8 @@ class WalletViewModel: ObservableObject { transferService: TransferService, sheetViewModel: SheetViewModel, feeEstimatesManager: FeeEstimatesManager, - onchainAttemptService: OnchainSendAttemptService = .shared + onchainAttemptService: OnchainSendAttemptService = .shared, + pubkyProfile: PubkyProfileManager? = nil ) { self.lightningService = lightningService self.coreService = coreService @@ -126,6 +130,7 @@ class WalletViewModel: ObservableObject { self.transferService = transferService self.sheetViewModel = sheetViewModel self.feeEstimatesManager = feeEstimatesManager + self.pubkyProfile = pubkyProfile BackupService.shared.usdtWallet = usdtWallet balanceManager = BalanceManager( lightningService: lightningService, @@ -157,12 +162,15 @@ class WalletViewModel: ObservableObject { } /// Convenience initializer for previews and testing - convenience init() { + convenience init(pubkyProfile: PubkyProfileManager? = nil) { let transferService = TransferService( lightningService: .shared, blocktankService: CoreService.shared.blocktank ) - self.init(transferService: transferService, sheetViewModel: SheetViewModel(), feeEstimatesManager: FeeEstimatesManager()) + self.init( + transferService: transferService, sheetViewModel: SheetViewModel(), + feeEstimatesManager: FeeEstimatesManager(), pubkyProfile: pubkyProfile + ) } func setWalletExistsState() throws { @@ -1228,7 +1236,7 @@ class WalletViewModel: ObservableObject { /// Refreshes cache and syncs all UI state including balance /// Use this for any event that may have changed balances or channel state - private func refreshAndSyncState() async { + func refreshAndSyncState() async { await updateBalanceState() syncState() } @@ -1393,6 +1401,9 @@ class WalletViewModel: ObservableObject { return ("", "") } + let initialInvoiceRevision = publicPaykitInvoiceRevision + let session = pubkyProfile?.currentSession + let wasSharing = sharesPublicPaykitEndpoints let receiveID = receiveRefreshID let commitID = receiveCommitID let previousAddress = onchainAddress @@ -1401,64 +1412,88 @@ class WalletViewModel: ObservableObject { onchainAddress = publicOnchainAddress } - if includeLightning, hasUsableChannels { - let hasReusableInvoice = await hasReusablePublicPaykitInvoice() - let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice - if shouldRefreshBolt11 { - do { - try await refreshPublicPaykitBolt11() - } catch let error as PublicPaykitError { - guard case .routeHintsUnavailable = error else { - throw error + for attempt in 0 ..< 2 { + let invoiceRevision = attempt == 0 ? initialInvoiceRevision : publicPaykitInvoiceRevision + let canCommitInvoice = { () async throws -> Bool in + if attempt > 0 { + guard await !PrivatePaykitService.shared.isDeletingProfile else { throw PubkyServiceError.sessionNotActive } + } + guard !Task.isCancelled, self.isPaykitUIActive, + attempt == 0 || self.pubkyProfile?.isDeletingProfile != true, + self.pubkyProfile?.currentSession == session && self.sharesPublicPaykitEndpoints == wasSharing + else { throw PubkyServiceError.sessionNotActive } + return self.publicPaykitInvoiceRevision == invoiceRevision + } + if attempt > 0 { + guard try await canCommitInvoice() else { continue } + } + if includeLightning, hasUsableChannels { + let hasReusableInvoice = await hasReusablePublicPaykitInvoice() + guard try await canCommitInvoice() else { continue } + let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice + if shouldRefreshBolt11 { + do { + guard try await refreshPublicPaykitBolt11(canCommitInvoice: canCommitInvoice) else { continue } + } catch let error as PublicPaykitError { + guard case .routeHintsUnavailable = error else { + throw error + } + Logger.warn( + "Public Paykit Lightning invoice has no route hints; publishing on-chain endpoint only", + context: "WalletViewModel" + ) } - Logger.warn("Public Paykit Lightning invoice has no route hints; publishing on-chain endpoint only", context: "WalletViewModel") } + } else if includeLightning { + guard try await canCommitInvoice() else { continue } + clearPublicPaykitBolt11() } - } else if includeLightning { - clearPublicPaykitBolt11() - } - return (publicOnchainAddress, includeLightning ? publicPaykitBolt11 : "") + return (publicOnchainAddress, includeLightning ? publicPaykitBolt11 : "") + } + throw PublicPaykitError.publicationFailed } func refreshPublicPaykitEndpointsOnForeground(isSessionCurrent: @escaping @MainActor () -> Bool) async { guard !Task.isCancelled, isSessionCurrent(), isPaykitUIActive, sharesPublicPaykitEndpoints else { return } do { try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self, isSessionCurrent: { - isSessionCurrent() && self.isPaykitUIActive && self.sharesPublicPaykitEndpoints + !Task.isCancelled && isSessionCurrent() && self.isPaykitUIActive && self.sharesPublicPaykitEndpoints }) } catch is CancellationError { return } catch PubkyServiceError.sessionNotActive { return } catch { + guard !Task.isCancelled, isSessionCurrent(), isPaykitUIActive, sharesPublicPaykitEndpoints else { return } + PublicPaykitService.setCleanupPending(true) Logger.warn("Failed to refresh public Paykit endpoints on foreground: \(error)", context: "WalletViewModel") } } - private func syncPublicPaykitEndpointsAfterChannelBecameUsable() async { - guard isPaykitUIActive else { return } - - do { - try await PublicPaykitService.syncPublishedEndpoints(wallet: self, publish: true) - } catch { - Logger.warn("Failed to refresh public Paykit endpoints after channel became usable: \(error)", context: "WalletViewModel") + func refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: String, forceRefreshLightning: Bool = false) async { + if forceRefreshLightning, !Task.isCancelled, isPaykitUIActive, sharesPublicPaykitEndpoints, + PublicPaykitService.isLightningPaymentOptionEnabled() + { + if pubkyProfile?.authState == .authenticated, pubkyProfile?.currentSession != nil { + pendingPublicPaykitInvoiceRefresh = nil + invalidatePublicPaykitInvoice() + PublicPaykitService.setCleanupPending(true) + } else if let profile = pubkyProfile, let isCurrent = profile.storedIdentityGuard() { + pendingPublicPaykitInvoiceRefresh = { [weak self, weak profile] in + self?.pubkyProfile === profile && isCurrent() + } + } } - } - - private func refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: String, forceRefreshLightning: Bool = false) async { + let isSessionCurrent = publicPaykitPublicationAdmission() await refreshAndSyncState() try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning, syncPublicPaykit: false, reportsError: false) - guard isPaykitUIActive else { return } + guard isPaykitUIActive, await !PrivatePaykitService.shared.isDeletingProfile else { return } if sharesPublicPaykitEndpoints { do { - if hasUsableChannels { - await syncPublicPaykitEndpointsAfterChannelBecameUsable() - } else { - try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) - } + try await publishPublicPaykitEndpoints(refreshIfNeeded: hasUsableChannels, isSessionCurrent: isSessionCurrent) } catch { Logger.warn("Failed to refresh public Paykit endpoints after channel availability changed: \(error)", context: "WalletViewModel") } @@ -1471,6 +1506,20 @@ class WalletViewModel: ObservableObject { ) } + func preparePendingPublicPaykitInvoiceRefresh() { + guard let isCurrent = pendingPublicPaykitInvoiceRefresh else { return } + guard isCurrent() else { + pendingPublicPaykitInvoiceRefresh = nil + return + } + guard !Task.isCancelled, isPaykitUIActive, sharesPublicPaykitEndpoints, + PublicPaykitService.isLightningPaymentOptionEnabled(), pubkyProfile?.automaticPaykitSession != nil + else { return } + pendingPublicPaykitInvoiceRefresh = nil + invalidatePublicPaykitInvoice() + PublicPaykitService.setCleanupPending(true) + } + private func hasReusablePublicPaykitInvoice() async -> Bool { guard !publicPaykitBolt11.isEmpty else { return false } guard publicPaykitBolt11ExpiresAt > Date().timeIntervalSince1970 + Self.publicPaykitInvoiceRefreshBufferSeconds else { return false } @@ -1482,9 +1531,11 @@ class WalletViewModel: ObservableObject { PublicPaykitService.hasLightningRouteHints(bolt11: publicPaykitBolt11) } - private func refreshPublicPaykitBolt11() async throws { + private func refreshPublicPaykitBolt11(canCommitInvoice: () async throws -> Bool) async throws -> Bool { let invoice = try await createInvoice(amountSats: nil, note: "") - guard case let .lightning(lightningInvoice) = try await decode(invoice: invoice) else { + let decodedInvoice = try await decode(invoice: invoice) + guard try await canCommitInvoice() else { return false } + guard case let .lightning(lightningInvoice) = decodedInvoice else { clearPublicPaykitBolt11() throw PublicPaykitError.invalidPayload } @@ -1496,6 +1547,7 @@ class WalletViewModel: ObservableObject { publicPaykitBolt11 = invoice publicPaykitBolt11PaymentHash = lightningInvoice.paymentHash.hex publicPaykitBolt11ExpiresAt = Double(lightningInvoice.timestampSeconds + lightningInvoice.expirySeconds) + return true } private func clearPublicPaykitBolt11() { @@ -1504,15 +1556,22 @@ class WalletViewModel: ObservableObject { publicPaykitBolt11ExpiresAt = 0 } - private func rotatePublicPaykitInvoiceIfNeeded(paymentHash: String) { + private func invalidatePublicPaykitInvoice() { + publicPaykitInvoiceRevision = UUID() + clearPublicPaykitBolt11() + } + + func rotatePublicPaykitInvoiceIfNeeded(paymentHash: String) { guard !publicPaykitBolt11PaymentHash.isEmpty, publicPaykitBolt11PaymentHash == paymentHash else { return } - clearPublicPaykitBolt11() + invalidatePublicPaykitInvoice() + if sharesPublicPaykitEndpoints { PublicPaykitService.setCleanupPending(true) } } func refreshBip21(forceRefreshBolt11: Bool = false, syncPublicPaykit: Bool = true, reportsError: Bool = true) async throws { + let isSessionCurrent = publicPaykitPublicationAdmission() let refreshID = UUID() let wasUpdating = isReceiveInvoiceUpdating receiveRefreshID = refreshID @@ -1586,9 +1645,11 @@ class WalletViewModel: ObservableObject { receiveCommitID = UUID() isReceiveInvoiceUpdating = false - if syncPublicPaykit, isPaykitUIActive, sharesPublicPaykitEndpoints { + if syncPublicPaykit, isPaykitUIActive, sharesPublicPaykitEndpoints, + await !PrivatePaykitService.shared.isDeletingProfile + { do { - try await syncReceivePublicPaykitEndpoints() + try await syncReceivePublicPaykitEndpoints(isSessionCurrent: isSessionCurrent) } catch { Logger.warn("Failed to refresh public paykit endpoints after receive refresh: \(error)", context: "WalletViewModel") } @@ -1627,8 +1688,27 @@ class WalletViewModel: ObservableObject { return !decoded.isExpired } - func syncReceivePublicPaykitEndpoints() async throws { - try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) + func syncReceivePublicPaykitEndpoints(isSessionCurrent: @escaping @MainActor () -> Bool) async throws { + guard await !PrivatePaykitService.shared.isDeletingProfile else { return } + try await publishPublicPaykitEndpoints(refreshIfNeeded: false, isSessionCurrent: isSessionCurrent) + } + + func publishPublicPaykitEndpoints(refreshIfNeeded: Bool, isSessionCurrent: @escaping @MainActor () -> Bool) async throws { + guard isSessionCurrent() else { return } + if refreshIfNeeded { + try await PublicPaykitService.syncPublishedEndpoints(wallet: self, publish: true, isSessionCurrent: isSessionCurrent) + } else { + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self, isSessionCurrent: isSessionCurrent) + } + } + + private func publicPaykitPublicationAdmission() -> @MainActor () -> Bool { + let session = pubkyProfile?.automaticPaykitSession + return { + guard let session else { return false } + return !Task.isCancelled && self.isPaykitUIActive && self.sharesPublicPaykitEndpoints && + self.pubkyProfile?.automaticPaykitSession == session + } } func receiveInvoiceTags(paymentId: String, searchByAddress: Bool = false) async -> [String]? { diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 650c678a1..eb583459b 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -148,9 +148,9 @@ struct EditProfileView: View { guard !isDeleting else { return } isDeleting = true - await PrivatePaykitService.shared.beginProfileDeletion() - await deleteProfileWithSessionRetry() - await PrivatePaykitService.shared.endProfileDeletion() + await pubkyProfile.performProfileDeletion { + await deleteProfileWithSessionRetry() + } isDeleting = false } diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 2b8fd312f..1363588e5 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -1394,7 +1394,7 @@ final class ContactPaymentsServiceTests: XCTestCase { func makeOperations(defaults: UserDefaults? = nil) -> ContactPaymentsService.Operations { ContactPaymentsService.Operations( - syncPublicEndpoints: { publish, isChangeCurrent in + syncPublicEndpoints: { @MainActor publish, isChangeCurrent in self.calls.append("public:\(publish)") self.publicPublicationValues.append(publish) await self.onSyncPublicEndpoints?(publish) @@ -1402,6 +1402,10 @@ final class ContactPaymentsServiceTests: XCTestCase { if self.publicPublicationFailures.contains(self.publicPublicationValues.count) { throw TestError.operationFailed } + if publish, !Task.isCancelled, isChangeCurrent() { + self.publicCleanupValues.append(false) + defaults?.set(false, forKey: PublicPaykitService.cleanupPendingKey) + } }, preparePrivateEndpoints: { contactPublicKeys, requiresImmediatePublication, isChangeCurrent in self.onPreparePrivateEndpoints?() diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 6e8f5cc94..9003989bd 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -334,6 +334,39 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestPresentation(request)) } + func testAutomaticRefreshPausesWithoutCancellingAdmittedWorkAndResumes() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + var canRefresh = true + await sdk.pauseNextPaymentRequestList() + let admitted = Task { await manager.refresh(canRefresh: { canRefresh }) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let waiting = expectation(description: "Fresh refresh waiting for admitted work") + let queued = Task { + waiting.fulfill() + return await manager.refresh(forceFresh: true, canRefresh: { canRefresh }) + } + await fulfillment(of: [waiting], timeout: 1) + canRefresh = false + await sdk.resumePaymentRequestList() + let admittedResult = await admitted.value + let queuedResult = await queued.value + let pausedResult = await manager.refresh(canRefresh: { canRefresh }) + XCTAssertTrue(admittedResult) + XCTAssertFalse(queuedResult) + XCTAssertFalse(pausedResult) + let paused = await sdk.snapshot() + XCTAssertEqual(paused.paymentRequestListCallCount, 1) + + let explicitResult = await manager.refresh(mode: .stored, forceFresh: true) + XCTAssertTrue(explicitResult, "Explicit proof refresh is not paused") + canRefresh = true + let resumedResult = await manager.refresh(canRefresh: { canRefresh }) + XCTAssertTrue(resumedResult) + let resumed = await sdk.snapshot() + XCTAssertEqual(resumed.paymentRequestListCallCount, 3) + } + func testFreshRefreshRereadsRequestsChangedAfterSnapshot() async throws { let record = try paymentRequestRecord() let sdk = PaymentRequestSdkMock(records: [record]) diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index bd4d4c081..afcd6f1b7 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -282,6 +282,36 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertEqual(routed, [signinURL, newerURL]) } + @MainActor + func testHeldContactLinkIsPromotedWithoutRoutingSoItsPreparationRuns() async throws { + let sheets = SheetViewModel() + let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel()) + let contactURL = try XCTUnwrap(URL(string: "bitkit://contact?pubky=pubky\(String(repeating: "y", count: 52))")) + XCTAssertNotNil(PubkyContactLink.publicKey(from: contactURL)) + let signinURL = try signinLink() + var routed: [URL] = [] + + // A contact link the app is holding while a sign-in link arrives waits behind it. + app.retainDeepLink(contactURL) + app.retainDeepLink(signinURL) + await app.routePendingDeepLinkIfReady(true) { url in + routed.append(url) + sheets.showSheet(.pubkyAuthApproval) + } + XCTAssertEqual(routed, [signinURL]) + XCTAssertNil(app.pendingDeepLinkURL, "The contact link stays held while the sign-in sheet is open") + + // When the sheet closes, the next call promotes the held contact link without routing it, so the view's + // restore or deletion preparation sees it before it opens. + sheets.hideSheet() + await app.routePendingDeepLinkIfReady(true) { routed.append($0) } + XCTAssertEqual(routed, [signinURL]) + XCTAssertEqual(app.pendingDeepLinkURL, contactURL) + + await app.routePendingDeepLinkIfReady(true) { routed.append($0) } + XCTAssertEqual(routed, [signinURL, contactURL]) + } + @MainActor func testNewerSignInLinkReplacesAnOlderPendingOne() async throws { let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) diff --git a/BitkitTests/PubkyContactLinkTests.swift b/BitkitTests/PubkyContactLinkTests.swift index d71bb57e5..55bec6d05 100644 --- a/BitkitTests/PubkyContactLinkTests.swift +++ b/BitkitTests/PubkyContactLinkTests.swift @@ -15,6 +15,32 @@ final class PubkyContactLinkTests: XCTestCase { } } + @MainActor + private final class SuspendedContactsManager: ContactsManager { + let started = XCTestExpectation(description: "Contact preparation started") + var finish: CheckedContinuation? + + override func refreshContacts(for publicKey: String) async throws { + await withCheckedContinuation { + finish = $0 + started.fulfill() + } + throw PubkyServiceError.sessionNotActive + } + } + + @MainActor + private final class SavedContactsManager: ContactsManager { + var savedContact: PubkyContact? + private(set) var refreshAttempts = 0 + + override func refreshContacts(for publicKey: String) async throws { + refreshAttempts += 1 + contacts = savedContact.map { [$0] } ?? [] + hasLoaded = true + } + } + func testAcceptsRawPrefixedAndEncodedKeys() throws { for value in [String(key.dropFirst(5)), key, key.uppercased(), key.replacingOccurrences(of: "pubky", with: "%70ubky")] { let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(value)")) @@ -105,7 +131,8 @@ final class PubkyContactLinkTests: XCTestCase { pubkyContactsAreReady: canRoutePubkyContactLink( isPaykitUIActive: true, isPubkyInitialized: profile.isInitialized, - hasLoadedContacts: contacts.hasLoaded + hasLoadedContacts: contacts.hasLoaded, + hasSignedInIdentity: profile.isAuthenticated ) ) { routedURL in XCTAssertEqual(routedURL, url) @@ -130,31 +157,167 @@ final class PubkyContactLinkTests: XCTestCase { } @MainActor - func testContactLinkWaitsForSavedContactsWithoutPubkyIdentity() async throws { - let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) - let profile = PubkyProfileManager() - let contacts = ContactsManager() - let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(key)")) - app.retainDeepLink(url) - profile.isInitialized = true - - func routePendingLink() async { + func testContactLinkDistinguishesSignedOutFromRestoringIdentity() async throws { + snapshotAppDefaultsDomain() + for restoringSession in [false, true] { + let navigation = NavigationViewModel() + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: navigation) + let profile = PubkyProfileManager() + let contacts = ContactsManager(contactRecords: { throw PubkyServiceError.sessionNotActive }) + let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(key)")) + app.retainDeepLink(url) + profile.isInitialized = true + let started = XCTestExpectation(description: "Session restoration started") + let (gate, finish) = AsyncStream.makeStream() + let restoration = Task { + guard restoringSession else { return } + await profile.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + started.fulfill() + for await _ in gate {} + return .restorationDeferred + } + } + if restoringSession { await fulfillment(of: [started], timeout: 2) } + XCTAssertEqual(profile.isRestoringSession, restoringSession) + let ready = try await preparePubkyContactLink(profile: profile, contacts: contacts, hasStoredIdentity: { restoringSession }) + XCTAssertEqual(ready, !restoringSession) await app.routePendingDeepLinkIfReady( true, - pubkyContactsAreReady: canRoutePubkyContactLink( + pubkyContactsAreReady: ready && canRoutePubkyContactLink( isPaykitUIActive: true, isPubkyInitialized: profile.isInitialized, - hasLoadedContacts: contacts.hasLoaded + hasLoadedContacts: contacts.hasLoaded, + hasSignedInIdentity: profile.isAuthenticated ) - ) { _ in } + ) { routedURL in + guard let publicKey = PubkyContactLink.publicKey(from: routedURL) else { return XCTFail("Expected contact key") } + XCTAssertTrue(routePublicContactIfSignedOut( + publicKey: publicKey, profile: profile, navigation: navigation, hasStoredIdentity: { restoringSession } + )) + } + XCTAssertEqual(app.pendingDeepLinkURL, restoringSession ? url : nil) + XCTAssertEqual(navigation.path, restoringSession ? [] : [.addContact(publicKey: key)]) + XCTAssertFalse(contacts.hasLoaded) + XCTAssertFalse(profile.isAuthenticated) + XCTAssertNil(profile.currentSession) + if !restoringSession { + await app.routePendingDeepLinkIfReady(true) { _ in XCTFail("Must not route twice") } + } + finish.finish() + await restoration.value + if restoringSession { + let deferredReady = try await preparePubkyContactLink(profile: profile, contacts: contacts, hasStoredIdentity: { true }) + XCTAssertFalse(deferredReady) + XCTAssertFalse(routePublicContactIfSignedOut( + publicKey: key, profile: profile, navigation: navigation, hasStoredIdentity: { true } + )) + } + profile.publicKey = key + XCTAssertFalse(routePublicContactIfSignedOut(publicKey: key, profile: profile, navigation: navigation)) + XCTAssertEqual(navigation.path, restoringSession ? [] : [.addContact(publicKey: key)]) } + } - await routePendingLink() - XCTAssertEqual(app.pendingDeepLinkURL, url) + @MainActor + func testStoredIdentityContactLinkRoutesSavedContactAfterRecovery() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + for restorationResult: PubkyProfileManager.SessionInitializationResult in [.restorationDeferred, .restorationFailed] { + let navigation = NavigationViewModel() + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: navigation) + let profile = PubkyProfileManager() + let contacts = SavedContactsManager() + contacts.savedContact = PubkyContact(publicKey: key, profile: .placeholder(publicKey: key)) + let scanner = ScannerManager() + scanner.configure(app: app, contactsManager: contacts, navigation: navigation, pubkyProfile: profile) + let url = try XCTUnwrap(URL(string: "bitkit://contact?pubky=\(key)")) + app.retainDeepLink(url) + await profile.initialize { restorationResult } + XCTAssertTrue(profile.isInitialized) + XCTAssertFalse(profile.isRestoringSession) + XCTAssertFalse(profile.isAuthenticated) + + func routePendingLink(hasStoredIdentity: () throws -> Bool) async throws { + let ready = try await preparePubkyContactLink(profile: profile, contacts: contacts, hasStoredIdentity: hasStoredIdentity) + await app.routePendingDeepLinkIfReady(true, pubkyContactsAreReady: ready) { routedURL in + guard let publicKey = PubkyContactLink.publicKey(from: routedURL) else { return XCTFail("Expected contact key") } + if !routePublicContactIfSignedOut( + publicKey: publicKey, profile: profile, navigation: navigation, hasStoredIdentity: hasStoredIdentity + ) { + await scanner.handleScan(publicKey, context: .main) + } + } + } - contacts.hasLoaded = true - await routePendingLink() - XCTAssertNil(app.pendingDeepLinkURL) + for unreadableCredentials in [false, true] { + try await routePendingLink { + if unreadableCredentials { throw PubkyServiceError.sessionNotActive } + return true + } + XCTAssertEqual(app.pendingDeepLinkURL, url) + XCTAssertEqual(navigation.path, []) + XCTAssertEqual(contacts.refreshAttempts, 0) + XCTAssertNil(contacts.loadErrorMessage) + XCTAssertFalse(routePublicContactIfSignedOut( + publicKey: key, profile: profile, navigation: navigation, + hasStoredIdentity: { + if unreadableCredentials { throw PubkyServiceError.sessionNotActive } + return true + } + )) + } + + profile.publicKey = "pubky" + String(repeating: "y", count: 52) + profile.authState = .authenticated + try await routePendingLink { true } + XCTAssertNil(app.pendingDeepLinkURL) + XCTAssertEqual(navigation.path, [.contactDetail(publicKey: key)]) + XCTAssertEqual(contacts.refreshAttempts, 1) + await app.routePendingDeepLinkIfReady(true) { _ in XCTFail("Must not route twice") } + } + } + + @MainActor + func testSignedOutContactPreparationDoesNotReadPrivateContacts() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set("Cached profile without credentials", forKey: "pubky_profile_name") + let profile = PubkyProfileManager() + profile.isInitialized = true + XCTAssertNotNil(profile.cachedName) + let contacts = ContactsManager(contactRecords: { + throw PubkyServiceError.sessionNotActive + }) + let ready = try await preparePubkyContactLink(profile: profile, contacts: contacts, hasStoredIdentity: { false }) + XCTAssertTrue(ready) + XCTAssertFalse(contacts.hasLoaded) + XCTAssertNil(contacts.loadErrorMessage) + let navigation = NavigationViewModel() + XCTAssertTrue(routePublicContactIfSignedOut( + publicKey: key, profile: profile, navigation: navigation, hasStoredIdentity: { false } + )) + XCTAssertEqual(navigation.path, [.addContact(publicKey: key)]) + XCTAssertFalse(profile.isAuthenticated) + } + + @MainActor + func testContactPreparationDropsErrorsAfterSessionChangeOrCancellation() async throws { + for cancel in [false, true] { + let profile = PubkyProfileManager() + profile.isInitialized = true + profile.publicKey = key + profile.authState = .authenticated + let contacts = SuspendedContactsManager() + let preparation = Task { try await preparePubkyContactLink(profile: profile, contacts: contacts) } + await fulfillment(of: [contacts.started], timeout: 2) + if cancel { + preparation.cancel() + } else { + profile.publicKey = "pubky" + String(repeating: "y", count: 52) + } + contacts.finish?.resume() + let ready = try await preparation.value + XCTAssertFalse(ready) + } } @MainActor diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 3602a37ab..d933eb191 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -11,6 +11,60 @@ final class PubkyProfileManagerTests: XCTestCase { case signOut } + @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() + 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 { + let manager = PubkyProfileManager() + manager.publicKey = "pubky" + String(repeating: "y", count: 52) + manager.authState = .authenticated + let original = manager.automaticPaykitSession + await manager.performProfileDeletion { + manager.publicKey = "pubky" + String(repeating: "o", count: 52) + XCTAssertNil(manager.automaticPaykitSession) + } + XCTAssertNotEqual(manager.automaticPaykitSession, original) + await manager.performProfileDeletion { manager.authState = .idle } + XCTAssertNil(manager.automaticPaykitSession) + } + @MainActor func testNavigationLookupReadsStoredIdentityWithoutCachedMetadata() async throws { let key = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index 7bef3dc9c..bf986362c 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -7,7 +7,13 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { override func setUp() { super.setUp() - stored = ["bolt11", "bip21", "onchainAddress", PublicPaykitService.publishingEnabledKey, PaykitFeatureFlags.uiEnabledKey].map { ( + stored = [ + "bolt11", "bip21", "onchainAddress", PublicPaykitService.publishingEnabledKey, + PublicPaykitService.cleanupPendingKey, PaykitFeatureFlags.uiEnabledKey, + "publicPaykitBolt11", "publicPaykitBolt11PaymentHash", "publicPaykitBolt11ExpiresAt", + PublicPaykitService.lightningPaymentOptionEnabledKey, + PrivatePaykitService.publishingEnabledKey, + ].map { ( $0, UserDefaults.standard.object(forKey: $0) ) } @@ -42,6 +48,869 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { XCTAssertFalse(wallet.isReceiveInvoiceUpdating) } + func testAutomaticPublicationCannotRestoreEndpointsAfterDeletionRemoval() async throws { + for route in AutomaticPublicationRoute.allCases { + let (profile, wallet) = publicationWallet(route: route) + let preparation = PublicationGate("Endpoint preparation") + let revocation = PublicationGate("Removal completed, revocation held") + var endpoints = ["ln-old"] + wallet.beforePublicEndpointRefresh = { + wallet.beforePublicEndpointRefresh = nil + await preparation.wait() + } + wallet.applyPublicEndpoints = { endpoints = $0.map(\.value) } + let publication = Task { try await route.refresh(wallet) } + await fulfillment(of: [preparation.started], timeout: 5) + let deletion = Task { + try await profile.performProfileDeletion { + try await PublicPaykitService.syncPublishedEndpoints( + publish: false, buildEndpoints: { [] }, syncApp: {}, + applyEndpoints: { endpoints = $0.map(\.value) } + ) + await revocation.wait() + } + } + await fulfillment(of: [revocation.started], timeout: 5) + XCTAssertTrue(endpoints.isEmpty) + preparation.release() + try await publication.value + XCTAssertTrue(endpoints.isEmpty, "\(route) restored removed endpoints") + XCTAssertEqual(wallet.hasCoherentReceiveInvoice, true) + revocation.release() + try await deletion.value + XCTAssertFalse(profile.isDeletingProfile) + } + } + + func testForcedChannelRefreshSurvivesFailedDeletionAndPublicationRetry() async throws { + let (profile, wallet) = publicationWallet(route: .channelUsable) + wallet.replaceEmptyPublicInvoice = true + var published: [String] = [] + do { + try await profile.performProfileDeletion { + await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) + XCTAssertTrue(wallet.publicPaykitBolt11.isEmpty) + XCTAssertTrue(PublicPaykitService.isCleanupPending) + throw PublicPaykitError.publicationFailed + } + } catch PublicPaykitError.publicationFailed {} + let session = try XCTUnwrap(profile.automaticPaykitSession) + do { + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, + applyEndpoints: { _ in throw PublicPaykitError.publicationFailed } + ) + XCTFail("Expected publication failure") + } catch PublicPaykitError.publicationFailed {} + XCTAssertTrue(PublicPaykitService.isCleanupPending) + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, + applyEndpoints: { published = $0.map(\.value) } + ) + XCTAssertEqual(published, ["ln-current"]) + XCTAssertFalse(PublicPaykitService.isCleanupPending) + } + + func testStartupChannelRefreshSurvivesRestorationAndPublicationRetry() async throws { + snapshotAppDefaultsDomain() + let owner = "pubky" + String(repeating: "y", count: 52) + enum FirstPublication: CaseIterable { case skipped, preparationFailed, publicationFailed } + for restoration in [PubkyProfileManager.SessionInitializationResult.restored(publicKey: owner), .restorationDeferred, .restorationFailed] { + for firstPublication in FirstPublication.allCases { + AdoptedPubkyReference.current = ("startup-test", owner) + let profile = StartupPublicationProfile() + let (_, wallet) = publicationWallet(route: .channelUsable, profile: profile) + profile.clearAuthenticatedStateForTesting() + wallet.replaceEmptyPublicInvoice = true + PublicPaykitService.setCleanupPending(false) + var published: [String] = [] + wallet.applyPublicEndpoints = { published = $0.map(\.value) } + let started = expectation(description: "Initial restoration held") + let (stream, continuation) = AsyncStream.makeStream() + let startup = Task { + await profile.initialize { + started.fulfill() + for await _ in stream {} + return restoration + } + } + await fulfillment(of: [started], timeout: 5) + await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) + XCTAssertTrue(published.isEmpty) + XCTAssertEqual(wallet.publicPaykitBolt11, "ln-public") + continuation.finish() + await startup.value + if profile.automaticPaykitSession == nil { + await profile.restoreSessionIfNeeded(hasStoredIdentity: { true }) { .restored(publicKey: owner) } + } + let session = try XCTUnwrap(profile.automaticPaykitSession) + let isCurrent = { profile.automaticPaykitSession == session } + switch firstPublication { + case .skipped: + do { + try await profile.performProfileDeletion { + do { + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: isCurrent, syncApp: {}, applyEndpoints: { published = $0.map(\.value) } + ) + XCTFail("Expected deletion to block publication") + } catch PubkyServiceError.sessionNotActive {} + throw PublicPaykitError.publicationFailed + } + } catch PublicPaykitError.publicationFailed {} + case .preparationFailed, .publicationFailed: + wallet.failPublicEndpointRefresh = firstPublication == .preparationFailed + do { + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: isCurrent, syncApp: {}, + applyEndpoints: { _ in throw PublicPaykitError.publicationFailed } + ) + XCTFail("Expected first publication to fail") + } catch {} + XCTAssertTrue(PublicPaykitService.isCleanupPending) + wallet.failPublicEndpointRefresh = false + } + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: isCurrent, syncApp: {}, applyEndpoints: { published = $0.map(\.value) } + ) + XCTAssertEqual(published, ["ln-current"], "\(restoration), \(firstPublication)") + XCTAssertFalse(PublicPaykitService.isCleanupPending) + } + } + } + + func testStartupChannelRefreshCannotTransferToAnotherIdentityOrIneligibleCaller() async throws { + snapshotAppDefaultsDomain() + enum State: CaseIterable { case replacement, sameKeyNewSession, cancelled, sharingDisabled, noStoredIdentity } + 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 (_, wallet) = publicationWallet(route: .channelUsable, profile: profile) + profile.clearAuthenticatedStateForTesting() + wallet.replaceEmptyPublicInvoice = true + wallet.applyPublicEndpoints = { _ in XCTFail("Startup must not publish") } + if state == .sharingDisabled { UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) } + await Task { + if state == .cancelled { withUnsafeCurrentTask { $0?.cancel() } } + await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) + }.value + if state == .replacement || state == .sameKeyNewSession { + do { + try await profile.signOut(performSessionCleanup: { throw PublicPaykitError.publicationFailed }) + } catch PublicPaykitError.publicationFailed {} + } + let restoredOwner = state == .replacement ? "pubky" + String(repeating: "z", count: 52) : owner + await profile.restoreSessionIfNeeded(hasStoredIdentity: { true }) { .restored(publicKey: restoredOwner) } + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + PublicPaykitService.setCleanupPending(false) + wallet.publicPaykitBolt11 = "ln-current-owner" + let revision = wallet.publicPaykitInvoiceRevision + let session = try XCTUnwrap(profile.automaticPaykitSession) + var published: [String] = [] + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, + applyEndpoints: { published = $0.map(\.value) } + ) + XCTAssertEqual(published, ["ln-current-owner"], "\(state)") + XCTAssertEqual(wallet.publicPaykitInvoiceRevision, revision, "\(state)") + XCTAssertFalse(PublicPaykitService.isCleanupPending, "\(state)") + } + } + + func testOlderPublicInvoiceCreationCannotRestoreForcedInvalidation() async throws { + let (profile, wallet) = publicationWallet(route: .channelUsable) + wallet.stubPublicEndpoints = false + let creation = PublicationGate("Public invoice creation") + wallet.invoiceCreation = { + wallet.invoiceCreation = nil + await creation.wait() + return Self.invoiceWithRouteHints + } + let older = Task { + try await wallet.refreshPublicPaykitEndpoints(includeOnchain: false) + } + await fulfillment(of: [creation.started], timeout: 5) + do { + try await profile.performProfileDeletion { + await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) + creation.release() + do { + _ = try await older.value + XCTFail("Expected superseded preparation") + } catch PubkyServiceError.sessionNotActive {} + XCTAssertTrue(wallet.publicPaykitBolt11.isEmpty) + XCTAssertTrue(PublicPaykitService.isCleanupPending) + throw PublicPaykitError.publicationFailed + } + } catch PublicPaykitError.publicationFailed {} + } + + func testEnablingContactPaymentsRetriesInvoiceSupersessionOnce() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key) + UserDefaults.standard.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) + for duringCreation in [false, true] { + for invalidations in duringCreation ? [1, 2] : [1] { + let (profile, wallet) = publicationWallet(route: .channelUsable) + wallet.stubPublicEndpoints = false + UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(false, forKey: ContactPaymentsService.confirmedPreferenceKey) + let session = try XCTUnwrap(profile.currentSession) + var preparations = 0 + var endpointRefreshes = 0 + wallet.beforePublicEndpointRefresh = { endpointRefreshes += 1 } + let invalidate = { + preparations += 1 + if !duringCreation || preparations <= invalidations { + wallet.publicPaykitBolt11PaymentHash = "paid" + wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid") + } + } + wallet.beforeReceiveAddress = { if !duringCreation { invalidate() } } + wallet.invoiceCreation = { + if duringCreation { invalidate() } + return Self.invoiceWithRouteHints + } + var published: [String] = [] + var removals = 0 + let operations = ContactPaymentsService.Operations( + syncPublicEndpoints: { publish, isCurrent in + try await PublicPaykitService.syncPublishedEndpoints( + wallet: wallet, publish: publish, isSessionCurrent: isCurrent, syncApp: {}, + applyEndpoints: { + if !publish { removals += 1 } + published = $0.map(\.value) + } + ) + }, + preparePrivateEndpoints: { _, _, _ in nil }, removePrivateEndpoints: { _ in }, + setPublicCleanupPending: PublicPaykitService.setCleanupPending, + setPrivateCleanupPending: PrivatePaykitService.setContactSharingCleanupPending + ) + do { + let applied = try await ContactPaymentsService.setEnabled( + true, pubkyProfile: profile, contactsManager: ContactsManager(), + operations: operations, privatePaymentAccess: { _ in false } + ) + XCTAssertEqual(invalidations, 1) + XCTAssertTrue(applied) + XCTAssertTrue(published.contains(Self.invoiceWithRouteHints)) + XCTAssertFalse(PublicPaykitService.isCleanupPending, "creation: \(duringCreation)") + } catch { + XCTAssertEqual(invalidations, 2, "\(error), creation: \(duringCreation)") + guard case PublicPaykitError.publicationFailed = error else { + XCTFail("Expected publication failure, got \(error)") + continue + } + XCTAssertEqual(preparations, 2) + XCTAssertTrue(published.isEmpty) + } + XCTAssertEqual(profile.currentSession, session) + XCTAssertEqual(ContactPaymentsService.isEnabled(), invalidations == 1) + XCTAssertEqual(removals, invalidations == 1 ? 0 : 1) + XCTAssertEqual(endpointRefreshes, 1) + XCTAssertEqual(wallet.addressCallCount, 1) + if !duringCreation { XCTAssertEqual(preparations, 1) } + } + } + } + + func testForcedPublicationUsesOneInvoiceRetryBudget() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key) + UserDefaults.standard.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) + for invalidatesRetry in [false, true] { + let (profile, wallet) = publicationWallet(route: .channelUsable) + wallet.stubPublicEndpoints = false + let session = try XCTUnwrap(profile.automaticPaykitSession) + var preparations = 0 + var creations = 0 + wallet.beforePublicEndpointRefresh = { preparations += 1 } + let invalidate = { + wallet.publicPaykitBolt11PaymentHash = "paid" + wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid") + } + wallet.beforeReceiveAddress = { invalidate() } + wallet.invoiceCreation = { + creations += 1 + if invalidatesRetry { invalidate() } + return Self.invoiceWithRouteHints + } + var published: [String] = [] + wallet.applyPublicEndpoints = { published = $0.map(\.value) } + do { + try await wallet.publishPublicPaykitEndpoints(refreshIfNeeded: true) { profile.automaticPaykitSession == session } + XCTAssertFalse(invalidatesRetry) + XCTAssertTrue(published.contains(Self.invoiceWithRouteHints)) + XCTAssertFalse(PublicPaykitService.isCleanupPending) + } catch PublicPaykitError.publicationFailed { + XCTAssertTrue(invalidatesRetry) + XCTAssertTrue(published.isEmpty) + XCTAssertTrue(wallet.publicPaykitBolt11.isEmpty) + XCTAssertTrue(PublicPaykitService.isCleanupPending) + } + XCTAssertEqual(preparations, 1) + XCTAssertEqual(wallet.addressCallCount, 1) + XCTAssertEqual(creations, 1) + XCTAssertEqual(profile.automaticPaykitSession, session) + } + } + + func testCurrentPublicationAcknowledgesRetriedInvoiceSnapshot() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key) + UserDefaults.standard.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) + let (profile, wallet) = publicationWallet(route: .channelUsable) + wallet.stubPublicEndpoints = false + wallet.invoiceCreation = { Self.invoiceWithRouteHints } + wallet.beforeReceiveAddress = { + wallet.beforeReceiveAddress = nil + wallet.publicPaykitBolt11PaymentHash = "paid" + wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid") + } + let session = try XCTUnwrap(profile.automaticPaykitSession) + var published: [String] = [] + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, + applyEndpoints: { published = $0.map(\.value) } + ) + XCTAssertTrue(published.contains(Self.invoiceWithRouteHints)) + XCTAssertFalse(PublicPaykitService.isCleanupPending) + } + + func testInvoiceSupersessionDoesNotRetryCreationOrDecodeFailures() async throws { + for failsCreation in [false, true] { + let (_, wallet) = publicationWallet(route: .channelUsable) + wallet.stubPublicEndpoints = false + var creations = 0 + wallet.invoiceCreation = { + creations += 1 + wallet.publicPaykitBolt11PaymentHash = "paid" + wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid") + if failsCreation { throw PublicPaykitError.walletNotReady } + return "invalid-invoice" + } + do { + _ = try await wallet.refreshPublicPaykitEndpoints(includeOnchain: false) + XCTFail("Expected invoice failure") + } catch { + if failsCreation { + guard case PublicPaykitError.walletNotReady = error else { return XCTFail("Unexpected error: \(error)") } + } else { + XCTAssertFalse(error is PubkyServiceError) + XCTAssertFalse(error is PublicPaykitError) + } + } + XCTAssertEqual(creations, 1) + XCTAssertTrue(wallet.publicPaykitBolt11.isEmpty) + } + } + + func testEnablingContactPaymentsRejectsDeletionBeforeOrDuringInvoiceRetry() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key) + for heldCreation in [1, 2] { + let (profile, wallet) = publicationWallet(route: .channelUsable) + wallet.stubPublicEndpoints = false + UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(false, forKey: ContactPaymentsService.confirmedPreferenceKey) + let session = try XCTUnwrap(profile.currentSession) + let creation = PublicationGate("Public invoice creation before deletion") + var creations = 0 + wallet.invoiceCreation = { + creations += 1 + if creations == 1 { + wallet.publicPaykitBolt11PaymentHash = "paid" + wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid") + } + if creations == heldCreation { await creation.wait() } + return Self.invoiceWithRouteHints + } + let operations = ContactPaymentsService.Operations( + syncPublicEndpoints: { publish, isCurrent in + try await PublicPaykitService.syncPublishedEndpoints( + wallet: wallet, publish: publish, isSessionCurrent: isCurrent, syncApp: {}, + applyEndpoints: { XCTAssertTrue($0.isEmpty, "Deletion must not publish replacement endpoints") } + ) + }, + preparePrivateEndpoints: { _, _, _ in nil }, removePrivateEndpoints: { _ in }, + setPublicCleanupPending: PublicPaykitService.setCleanupPending, + setPrivateCleanupPending: PrivatePaykitService.setContactSharingCleanupPending + ) + let change = Task { + try await ContactPaymentsService.setEnabled( + true, pubkyProfile: profile, contactsManager: ContactsManager(), + operations: operations, privatePaymentAccess: { _ in false } + ) + } + await fulfillment(of: [creation.started], timeout: 5) + await profile.performProfileDeletion { + XCTAssertEqual(profile.currentSession, session) + creation.release() + do { + _ = try await change.value + XCTFail("Expected deletion to reject invoice retry") + } catch PubkyServiceError.sessionNotActive { + } catch { + XCTFail("Unexpected error: \(error)") + } + XCTAssertTrue(wallet.publicPaykitBolt11.isEmpty) + XCTAssertFalse(ContactPaymentsService.isEnabled()) + XCTAssertEqual(creations, heldCreation) + } + } + } + + func testAdmittedPublicWriteCannotAcknowledgeNewerChannelRefresh() async throws { + snapshotAppDefaults(PublicPaykitService.onchainPaymentOptionEnabledKey) + UserDefaults.standard.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) + for refreshIfNeeded in [false, true] { + for invoice in ["ln-public", ""] { + let (profile, wallet) = publicationWallet(route: .channelUsable) + wallet.publicPaykitBolt11 = invoice + wallet.publicEndpointAddress = "bcrt1qreceive" + PublicPaykitService.setCleanupPending(false) + let session = try XCTUnwrap(profile.automaticPaykitSession) + let write = PublicationGate("Admitted public write") + wallet.applyPublicEndpoints = { _ in await write.wait() } + let publication = Task { + try await wallet.publishPublicPaykitEndpoints( + refreshIfNeeded: refreshIfNeeded, isSessionCurrent: { profile.automaticPaykitSession == session } + ) + } + await fulfillment(of: [write.started], timeout: 5) + do { + try await profile.performProfileDeletion { + await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) + throw PublicPaykitError.publicationFailed + } + } catch PublicPaykitError.publicationFailed {} + write.release() + try await publication.value + XCTAssertTrue(PublicPaykitService.isCleanupPending, "Invoice: \(invoice), refresh: \(refreshIfNeeded)") + } + } + } + + func testForcedChannelRefreshDoesNotInvalidateWithoutEligibleIdentityAndSharing() async throws { + enum State: CaseIterable { case signedOut, restoring, sharingDisabled, lightningDisabled, cancelled } + for state in State.allCases { + let (profile, wallet) = publicationWallet(route: .channelUsable) + PublicPaykitService.setCleanupPending(false) + switch state { + case .signedOut: profile.clearAuthenticatedStateForTesting() + case .restoring: profile.authState = .idle + case .sharingDisabled: UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + case .lightningDisabled: UserDefaults.standard.set(false, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey) + case .cancelled: break + } + do { + try await profile.performProfileDeletion { + await Task { + if state == .cancelled { withUnsafeCurrentTask { $0?.cancel() } } + await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) + }.value + throw PublicPaykitError.publicationFailed + } + } catch PublicPaykitError.publicationFailed {} + XCTAssertEqual(wallet.publicPaykitBolt11, "ln-public", "\(state)") + XCTAssertFalse(PublicPaykitService.isCleanupPending, "\(state)") + } + } + + func testSuspendedPublicInvoiceCreationCannotMutateChangedOwnerOrSharing() async throws { + enum State: CaseIterable { case replaced, sameKeyNewSession, sharingDisabled, uiDisabled, cancelled } + for state in State.allCases { + let (profile, wallet) = publicationWallet(route: .channelUsable) + wallet.stubPublicEndpoints = false + let creation = PublicationGate("Public invoice creation") + wallet.invoiceCreation = { + await creation.wait() + return Self.invoiceWithRouteHints + } + let preparation = Task { try await wallet.refreshPublicPaykitEndpoints(includeOnchain: false) } + await fulfillment(of: [creation.started], timeout: 5) + wallet.publicPaykitBolt11PaymentHash = "paid" + wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid") + switch state { + case .replaced: profile.publicKey = "pubky" + String(repeating: "z", count: 52) + case .sameKeyNewSession: + do { + try await profile.signOut(performSessionCleanup: { throw PublicPaykitError.publicationFailed }) + } catch PublicPaykitError.publicationFailed {} + case .sharingDisabled: UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + case .uiDisabled: UserDefaults.standard.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) + case .cancelled: preparation.cancel() + } + wallet.publicPaykitBolt11 = "ln-retained" + creation.release() + do { + _ = try await preparation.value + XCTFail("Expected superseded preparation") + } catch PubkyServiceError.sessionNotActive {} + XCTAssertEqual(wallet.publicPaykitBolt11, "ln-retained", "\(state)") + } + } + + func testLatePublicSnapshotRestoresPendingAfterReplacementWasAcknowledged() async throws { + for forceRefresh in [false, true] { + let (profile, wallet) = publicationWallet(route: .channelUsable) + let session = try XCTUnwrap(profile.automaticPaykitSession) + let preparation = PublicationGate("Older endpoint snapshot") + wallet.afterPublicEndpointSnapshot = { + wallet.afterPublicEndpointSnapshot = nil + await preparation.wait() + } + var published: [String] = [] + wallet.applyPublicEndpoints = { published += $0.map(\.value) } + let older = Task { + try await wallet.publishPublicPaykitEndpoints(refreshIfNeeded: forceRefresh) { profile.automaticPaykitSession == session } + } + await fulfillment(of: [preparation.started], timeout: 5) + wallet.publicPaykitBolt11PaymentHash = "paid" + wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid") + wallet.publicPaykitBolt11 = "ln-replacement" + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, + applyEndpoints: { published += $0.map(\.value) } + ) + XCTAssertFalse(PublicPaykitService.isCleanupPending) + preparation.release() + try await older.value + XCTAssertEqual(published, ["ln-replacement", "ln-public"]) + XCTAssertTrue(PublicPaykitService.isCleanupPending, "Forced: \(forceRefresh)") + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, + applyEndpoints: { published += $0.map(\.value) } + ) + XCTAssertEqual(published.last, "ln-replacement") + XCTAssertFalse(PublicPaykitService.isCleanupPending) + } + } + + func testSharingChangesPreservePublicPublicationAcknowledgement() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key) + for restoresSharing in [false, true] { + for replacesInvoice in [false, true] { + let (profile, wallet) = publicationWallet(route: .channelUsable) + let session = try XCTUnwrap(profile.automaticPaykitSession) + UserDefaults.standard.set(restoresSharing, forKey: PublicPaykitService.publishingEnabledKey) + PublicPaykitService.setCleanupPending(!replacesInvoice) + let contacts = ContactsManager() + let preparation = PublicationGate("Explicit endpoint snapshot") + wallet.afterPublicEndpointSnapshot = { + wallet.afterPublicEndpointSnapshot = nil + await preparation.wait() + } + var publications = 0 + var published: [String] = [] + let operations = ContactPaymentsService.Operations( + syncPublicEndpoints: { publish, isChangeCurrent in + publications += 1 + if restoresSharing, publications == 1 { throw PublicPaykitError.publicationFailed } + XCTAssertTrue(publish) + XCTAssertTrue(isChangeCurrent()) + XCTAssertTrue(UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)) + try await PublicPaykitService.syncPublishedEndpoints( + wallet: wallet, publish: publish, isSessionCurrent: isChangeCurrent, syncApp: {}, + applyEndpoints: { published += $0.map(\.value) } + ) + }, + preparePrivateEndpoints: { _, _, _ in nil }, + removePrivateEndpoints: { _ in }, + setPublicCleanupPending: PublicPaykitService.setCleanupPending, + setPrivateCleanupPending: PrivatePaykitService.setContactSharingCleanupPending + ) + let change = Task { + try await ContactPaymentsService.setEnabled( + true, pubkyProfile: profile, contactsManager: contacts, + operations: operations, privatePaymentAccess: { _ in false } + ) + } + await fulfillment(of: [preparation.started], timeout: 5) + if replacesInvoice { + wallet.publicPaykitBolt11PaymentHash = "paid" + wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid") + wallet.publicPaykitBolt11 = "ln-replacement" + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, + applyEndpoints: { published += $0.map(\.value) } + ) + XCTAssertFalse(PublicPaykitService.isCleanupPending) + } + preparation.release() + do { + let applied = try await change.value + XCTAssertFalse(restoresSharing) + XCTAssertTrue(applied) + } catch PublicPaykitError.publicationFailed { + XCTAssertTrue(restoresSharing) + } + XCTAssertEqual(published, replacesInvoice ? ["ln-replacement", "ln-public"] : ["ln-public"]) + XCTAssertEqual(PublicPaykitService.isCleanupPending, replacesInvoice, "Restore: \(restoresSharing)") + await ContactPaymentsService.reconcilePendingEndpoints { + guard PublicPaykitService.isCleanupPending else { return } + try? await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, + applyEndpoints: { published += $0.map(\.value) } + ) + } + XCTAssertEqual(published.last, replacesInvoice ? "ln-replacement" : "ln-public") + XCTAssertFalse(PublicPaykitService.isCleanupPending) + } + } + } + + private static let invoiceWithRouteHints = + "lnbcrt200n1p5hn4c8dqqnp4qwrgh4a03djj2sl34465uwnxhva0gtpjm4u8kvzgc5jergrkm9syypp55lwcgfpkdwuknmekjgted72n0ddl5qtaha7knk7c9n7yrjr4auassp5jgqw0a9w33e2ta4j7gyjrvsvu0lv844w895305nd8spnknq3f2hq9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcsqqqqlgqqqqqqgq2qd2gk64eg2kfxtdaryrlh98hvu97jdaxz2ma7aeyuy2uy9vkn9x5qft47p9taju297xnrehva20xcfml7wacuv737xv3xjjzyrtplcxqpfpu9dt" + + func testAutomaticPublicationRetainsStartingSessionAcrossPreparation() async throws { + enum EndState: CaseIterable { case signedOut, replaced, sessionChanged, restoring, sharingDisabled, cancelled } + for route in AutomaticPublicationRoute.allCases { + for endState in EndState.allCases { + let (profile, wallet) = publicationWallet(route: route) + let preparation = PublicationGate("Local receive preparation") + wallet.beforeReceiveAddress = { await preparation.wait() } + var writes = 0 + wallet.applyPublicEndpoints = { _ in writes += 1 } + let publication = Task { try await route.refresh(wallet) } + await fulfillment(of: [preparation.started], timeout: 5) + switch endState { + case .signedOut: profile.clearAuthenticatedStateForTesting() + case .replaced: profile.publicKey = "pubky" + String(repeating: "z", count: 52) + case .sessionChanged: + let session = profile.automaticPaykitSession + do { + try await profile.signOut { throw PublicPaykitError.publicationFailed } + XCTFail("Expected cleanup failure") + } catch PublicPaykitError.publicationFailed {} + XCTAssertNotEqual(profile.automaticPaykitSession, session) + XCTAssertEqual(profile.automaticPaykitSession?.publicKey, session?.publicKey) + case .restoring: profile.authState = .idle + case .sharingDisabled: UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + case .cancelled: publication.cancel() + } + preparation.release() + do { try await publication.value } catch is CancellationError {} + XCTAssertEqual(writes, 0, "\(route), \(endState)") + } + } + } + + func testDeletionRemovesAdmittedAutomaticWriteEvenWhenCallerIsCancelled() async throws { + for route in AutomaticPublicationRoute.allCases { + let (profile, wallet) = publicationWallet(route: route) + let write = PublicationGate("Admitted endpoint write") + let cleanup = expectation(description: "Cleanup attempting endpoint removal") + var events: [String] = [] + var endpoints: [String] = [] + wallet.applyPublicEndpoints = { + await write.wait() + endpoints = $0.map(\.value) + events.append("published") + } + let publication = Task { try await route.refresh(wallet) } + await fulfillment(of: [write.started], timeout: 5) + let deletion = Task { + try await profile.performProfileDeletion { + cleanup.fulfill() + try await PublicPaykitService.syncPublishedEndpoints( + publish: false, buildEndpoints: { [] }, syncApp: {}, + applyEndpoints: { + endpoints = $0.map(\.value) + events.append("removed") + } + ) + } + } + await fulfillment(of: [cleanup], timeout: 5) + XCTAssertTrue(events.isEmpty) + publication.cancel() + write.release() + try await publication.value + try await deletion.value + XCTAssertEqual(events, ["published", "removed"], "\(route)") + XCTAssertTrue(endpoints.isEmpty) + XCTAssertEqual(wallet.publicationRefreshModes, [route == .channelUsable]) + } + } + + private func publicationWallet( + route: AutomaticPublicationRoute, profile: PubkyProfileManager? = nil + ) -> (PubkyProfileManager, GatedReceiveWallet) { + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + UserDefaults.standard.set(true, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey) + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + let profile = profile ?? PubkyProfileManager() + profile.publicKey = "pubky" + String(repeating: "y", count: 52) + profile.authState = .authenticated + let wallet = GatedReceiveWallet(pubkyProfile: profile) + wallet.stubPublicEndpoints = true + wallet.publicPaykitBolt11 = "ln-public" + wallet.usableChannels = route == .channelUsable + return (profile, wallet) + } + + private enum AutomaticPublicationRoute: CaseIterable { + case receive, channelUsable, channelUnavailable + + func refresh(_ wallet: GatedReceiveWallet) async throws { + switch self { + case .receive: try await wallet.refreshBip21(forceRefreshBolt11: true) + case .channelUsable, .channelUnavailable: + await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test") + } + } + } + + func testDeletionDefersPublicationAfterInvoiceCreationWithoutDiscardingInvoice() async throws { + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + let wallet = GatedReceiveWallet() + wallet.invoiceAmountSats = 1000 + let refresh = Task { try await wallet.refreshBip21(forceRefreshBolt11: true) } + await fulfillment(of: [wallet.olderStarted], timeout: 5) + await PrivatePaykitService.shared.beginProfileDeletion() + wallet.releaseOlderInvoice() + do { + try await refresh.value + XCTAssertTrue(wallet.hasCoherentReceiveInvoice) + XCTAssertEqual(wallet.endpointSyncCount, 0) + } catch { + await PrivatePaykitService.shared.endProfileDeletion() + throw error + } + await PrivatePaykitService.shared.endProfileDeletion() + wallet.failPublicEndpointRefresh = true + for _ in 0 ..< 2 { + await wallet.refreshPublicPaykitEndpointsOnForeground { true } + XCTAssertTrue(PublicPaykitService.isCleanupPending) + XCTAssertEqual(PublicPaykitService.pendingReconciliationMode(), .publishEndpoints) + } + wallet.invoiceAmountSats = 2000 + try await wallet.refreshBip21(forceRefreshBolt11: true) + XCTAssertEqual(wallet.endpointSyncCount, 1) + } + + func testFailedPublicRefreshDoesNotMarkReconciliationAfterSessionOrSharingEnds() async throws { + enum EndState: CaseIterable { + case signedOut, identityChanged, sharingDisabled, cancelled + } + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + for endState in EndState.allCases { + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + PublicPaykitService.setCleanupPending(false) + let profile = PubkyProfileManager() + profile.publicKey = "pubky" + String(repeating: "y", count: 52) + profile.authState = .authenticated + let session = try XCTUnwrap(profile.currentSession) + let wallet = GatedReceiveWallet() + wallet.failPublicEndpointRefresh = true + wallet.beforePublicEndpointRefresh = { + switch endState { + case .signedOut: + profile.clearAuthenticatedStateForTesting() + case .identityChanged: + profile.publicKey = "pubky" + String(repeating: "z", count: 52) + case .sharingDisabled: + UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + case .cancelled: + withUnsafeCurrentTask { $0?.cancel() } + } + PublicPaykitService.setCleanupPending(false) + } + await Task { + await wallet.refreshPublicPaykitEndpointsOnForeground { profile.automaticPaykitSession == session } + }.value + XCTAssertFalse(PublicPaykitService.isCleanupPending) + } + } + + func testPaidPublicInvoiceRemainsPendingWhileDeletionDefersPublication() async { + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + let wallet = GatedReceiveWallet() + wallet.publicPaykitBolt11 = "ln-paid" + wallet.publicPaykitBolt11PaymentHash = "paid-hash" + 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() + } + + func testAdmittedPublicWriteCannotAcknowledgeNewerPaidInvoiceInvalidation() async throws { + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(true, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey) + let wallet = GatedReceiveWallet() + wallet.stubPublicEndpoints = true + wallet.publicPaykitBolt11 = "ln-paid" + wallet.publicPaykitBolt11PaymentHash = "paid-hash" + PublicPaykitService.setCleanupPending(true) + let admitted = expectation(description: "Public write admitted") + var finishWrite: CheckedContinuation? + var writtenInvoices: [String] = [] + let publication = Task { + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, + isSessionCurrent: { true }, + syncApp: {}, + applyEndpoints: { endpoints in + await withCheckedContinuation { + finishWrite = $0 + admitted.fulfill() + } + writtenInvoices.append(endpoints.first { $0.methodId == .bitcoinLightningBolt11 }?.value ?? "") + } + ) + } + await fulfillment(of: [admitted], timeout: 2) + wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid-hash") + finishWrite?.resume() + try await publication.value + XCTAssertEqual(writtenInvoices, ["ln-paid"]) + XCTAssertTrue(PublicPaykitService.isCleanupPending) + + wallet.publicPaykitBolt11 = "ln-replacement" + do { + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { true }, syncApp: {}, + applyEndpoints: { _ in throw PublicPaykitError.publicationFailed } + ) + XCTFail("Expected publication failure") + } catch PublicPaykitError.publicationFailed {} + XCTAssertTrue(PublicPaykitService.isCleanupPending) + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { true }, syncApp: {}, + applyEndpoints: { endpoints in + writtenInvoices.append(endpoints.first { $0.methodId == .bitcoinLightningBolt11 }?.value ?? "") + } + ) + XCTAssertEqual(writtenInvoices, ["ln-paid", "ln-replacement"]) + XCTAssertFalse(PublicPaykitService.isCleanupPending) + } + + func testPublicWriteDoesNotAcknowledgePendingWorkWithoutCurrentSession() async throws { + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + let wallet = GatedReceiveWallet() + wallet.stubPublicEndpoints = true + for hasSessionFence in [false, true] { + PublicPaykitService.setCleanupPending(true) + var currentSession = true + let fence: @MainActor () -> Bool = { currentSession } + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: hasSessionFence ? fence : nil, syncApp: {}, + applyEndpoints: { _ in currentSession = false } + ) + XCTAssertTrue(PublicPaykitService.isCleanupPending) + } + } + func testRefreshUsesCapturedInputsAndPreservesEditsMadeWhileWaiting() async throws { let wallet = GatedReceiveWallet() wallet.invoiceAmountSats = 1000 @@ -415,6 +1284,12 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } } +@MainActor +private final class StartupPublicationProfile: PubkyProfileManager { + override func loadProfile() async {} + override func checkAdoptedSource() async {} +} + @MainActor private final class GatedReceiveWallet: WalletViewModel { let olderStarted = XCTestExpectation(description: "Older invoice suspended") @@ -431,15 +1306,57 @@ private final class GatedReceiveWallet: WalletViewModel { var reusableInvoice: Bool? var gateEndpointSync = false + var endpointSyncCount = 0 + var failPublicEndpointRefresh = false + var stubPublicEndpoints = false + var publicEndpointAddress = "" + var replaceEmptyPublicInvoice = false + var invoiceCreation: (() async throws -> String)? + var beforePublicEndpointRefresh: (() async -> Void)? + var afterPublicEndpointSnapshot: (() async -> Void)? + var beforeReceiveAddress: (() async -> Void)? + var applyPublicEndpoints: (([PublicPaykitService.Endpoint]) async throws -> Void)? + var publicationRefreshModes: [Bool] = [] + var usableChannels: Bool? let endpointSyncStarted = XCTestExpectation(description: "Public endpoint sync suspended") private var endpointSync: CheckedContinuation? + override var hasUsableChannels: Bool { + usableChannels ?? super.hasUsableChannels + } + + override func refreshAndSyncState() async {} + override func waitForNodeToRun(timeoutSeconds: Double = 10.0) async -> Bool { + true + } + + override func publishPublicPaykitEndpoints(refreshIfNeeded: Bool, isSessionCurrent: @escaping @MainActor () -> Bool) async throws { + guard isSessionCurrent() else { return } + guard let applyPublicEndpoints else { + return try await super.publishPublicPaykitEndpoints(refreshIfNeeded: refreshIfNeeded, isSessionCurrent: isSessionCurrent) + } + publicationRefreshModes.append(refreshIfNeeded) + if refreshIfNeeded { + try await PublicPaykitService.syncPublishedEndpoints( + wallet: self, publish: true, isSessionCurrent: isSessionCurrent, syncApp: {}, applyEndpoints: applyPublicEndpoints + ) + } else { + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: self, isSessionCurrent: isSessionCurrent, syncApp: {}, applyEndpoints: applyPublicEndpoints + ) + } + } + override func isReceiveInvoiceReusable(_ invoice: String) async throws -> Bool { if let reusableInvoice { return reusableInvoice } return try await super.isReceiveInvoiceReusable(invoice) } - override func syncReceivePublicPaykitEndpoints() async throws { + override func syncReceivePublicPaykitEndpoints(isSessionCurrent: @escaping @MainActor () -> Bool) async throws { + if applyPublicEndpoints != nil { + return try await super.syncReceivePublicPaykitEndpoints(isSessionCurrent: isSessionCurrent) + } + endpointSyncCount += 1 if gateEndpointSync { await withCheckedContinuation { endpointSync = $0 @@ -448,6 +1365,26 @@ private final class GatedReceiveWallet: WalletViewModel { } } + override func refreshPublicPaykitEndpoints( + forceRefreshBolt11: Bool = false, + includeOnchain: Bool = true, + includeLightning: Bool = true + ) async throws -> (onchainAddress: String, bolt11: String) { + await beforePublicEndpointRefresh?() + if failPublicEndpointRefresh { throw TestError.failed } + if stubPublicEndpoints { + if replaceEmptyPublicInvoice, forceRefreshBolt11 || publicPaykitBolt11.isEmpty { + publicPaykitBolt11 = "ln-current" + } + let invoice = publicPaykitBolt11 + await afterPublicEndpointSnapshot?() + return (publicEndpointAddress, invoice) + } + return try await super.refreshPublicPaykitEndpoints( + forceRefreshBolt11: forceRefreshBolt11, includeOnchain: includeOnchain, includeLightning: includeLightning + ) + } + func releaseEndpointSync() { endpointSync?.resume() endpointSync = nil @@ -458,6 +1395,7 @@ private final class GatedReceiveWallet: WalletViewModel { override func persistPreActivityMetadata(paymentId: String, paymentHash: String?, address: String, tags: [String]) async {} override func canCreateReceiveLightningInvoice(amountSats: UInt64?) -> Bool { true } override func refreshReusableOnchainAddress() async throws -> String { + await beforeReceiveAddress?() if failNextAddress { failNextAddress = false; throw TestError.failed } addressCallCount += 1 if gateFirstAddress, addressCallCount == 1 { @@ -476,6 +1414,7 @@ private final class GatedReceiveWallet: WalletViewModel { } override func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { + if let invoiceCreation { return try await invoiceCreation() } if failNextInvoice { failNextInvoice = false; throw TestError.failed } if amountSats == 1000 { return await withCheckedContinuation { continuation in @@ -504,6 +1443,28 @@ private final class GatedReceiveWallet: WalletViewModel { private enum TestError: Error { case failed } } +@MainActor +private final class PublicationGate { + let started: XCTestExpectation + private var continuation: CheckedContinuation? + + init(_ description: String) { + started = XCTestExpectation(description: description) + } + + func wait() async { + await withCheckedContinuation { + continuation = $0 + started.fulfill() + } + } + + func release() { + continuation?.resume() + continuation = nil + } +} + @MainActor private final class TaggedReceiveWallet: WalletViewModel { let firstHashStarted = XCTestExpectation(description: "Older invoice hash suspended") diff --git a/BitkitTests/WalletViewModelReceiveTests.swift b/BitkitTests/WalletViewModelReceiveTests.swift index d5fbcf2db..b21102cd4 100644 --- a/BitkitTests/WalletViewModelReceiveTests.swift +++ b/BitkitTests/WalletViewModelReceiveTests.swift @@ -29,7 +29,10 @@ final class WalletViewModelReceiveTests: XCTestCase { snapshotAppDefaultsDomain() UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) - let wallet = ReceiveWalletSpy() + let profile = PubkyProfileManager() + profile.publicKey = "pubky" + String(repeating: "y", count: 52) + profile.authState = .authenticated + let wallet = ReceiveWalletSpy(pubkyProfile: profile) let address = "receive-\(UUID().uuidString)" wallet.onchainAddress = address wallet.bolt11 = "stale-invoice" @@ -50,6 +53,13 @@ final class WalletViewModelReceiveTests: XCTestCase { XCTAssertEqual(wallet.bip21, expectedBip21) XCTAssertEqual(wallet.metadataPersistenceCount, 2) XCTAssertEqual(wallet.publicEndpointRefreshCount, 1) + + profile.authState = .idle + try await wallet.refreshBip21() + + XCTAssertEqual(wallet.bip21, expectedBip21) + XCTAssertEqual(wallet.metadataPersistenceCount, 3) + XCTAssertEqual(wallet.publicEndpointRefreshCount, 1) } private final class ReceiveWalletSpy: WalletViewModel { diff --git a/changelog.d/next/954.fixed.md b/changelog.d/next/954.fixed.md new file mode 100644 index 000000000..2b577cd40 --- /dev/null +++ b/changelog.d/next/954.fixed.md @@ -0,0 +1 @@ +Pause automatic Paykit refresh and publication during profile deletion, then resume if the profile remains signed in. diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 35a21e8b7..585925c34 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -2,6 +2,30 @@ `delete-profile.xml` verifies visible progress and completion when deleting a disposable profile. Use a test identity that may be deleted. +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. + +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. + +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. + +Hold endpoint preparation before publication admission, or queue a public publication behind SDK work. Begin deletion, complete endpoint removal while session revocation remains held, then release the queued preparation/publication. The removed endpoints must stay absent. Separately, let a publication enter its SDK write before deletion: cleanup must wait for it and remove its endpoints afterward, even if its caller is cancelled. This requires controlled operation gates beyond the journey runner capabilities. + +On iOS, public publication and endpoint removal share an endpoint lock. A publication already holding that lock while waiting for SDK work must finish or stop before removal can complete. Hold preparation before that lock to exercise removal before a stale publication is released. Explicit manual sharing settings are outside these automatic-publication checks. + +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. + +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. + +While enabling contact payments, invalidate the public invoice during address preparation or invoice creation without changing the session. Enabling must re-prepare once and keep sharing enabled after publication. Repeated invalidation must stop with a publication failure, while creation or decoding errors must propagate without retry. Cancellation, identity changes, disabled sharing or UI, and active deletion must not admit replacement preparation. These checks require controlled preparation and invalidation gates beyond the journey runner capabilities. + +On iOS, these channel-refresh checks cover public endpoints. Private endpoint preparation retains its existing cleanup and reconciliation behavior. + +Hold initial session restoration before sign-in completes, make a Lightning channel usable or ready, then release restoration while public publication remains blocked. Restore readiness or connectivity without another channel event. Normal public maintenance must retain the forced invoice refresh for the restored identity, including after a deferred or failed restoration and a skipped or failed publication. Work retained for the stored identity or an existing signed-in session must not transfer to a replacement identity or a new session for the same key. These checks require restoration, channel and publication gates beyond the journey runner capabilities. + `signup-create-profile.xml` verifies that signup through staging.pubky.app can finish profile creation without a false Profile Error toast. Use a fresh, unfunded test wallet and keep the app process alive. The profile must use the same public key as the authorized signup. Delayed or failed session restoration requires network fault injection, which is not a journey-runner capability; see the PR manual checks. @@ -9,3 +33,5 @@ Delayed or failed session restoration requires network fault injection, which is For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. For the reported background-resume case, verify returning directly to Profile with the process still alive, then repeat after the OS recreates the process. A delay in session recovery must keep the saved identity on the profile loading/retry screen rather than show profile onboarding. + +On iOS, open a saved contact's link while a stored identity's session restoration is held, deferred or failed. The link must stay pending without opening Add Contact, reading private contacts or showing a QR error. Restore the session and verify that the retained link opens the saved contact once contacts are ready, without opening the link again. With no stored identity, including after successful profile deletion, the public contact must still open read-only without granting authentication. A cached profile name alone is not a stored identity. These checks require restoration and network-failure gates beyond the journey runner capabilities. diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index 9244c247a..1bece0e9c 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -5,6 +5,9 @@ Record the sequence so progress can be checked even when deletion completes quickly. Repeat with 62 imported contacts, once after preparation settles and once immediately after import. Measure confirmation to onboarding and check logs for any uncertain-write safety wait. + Cleanup may still wait for admitted operations and remote safety checks; no fixed duration is expected. + See the suite README for controlled automatic-admission, retained contact-link routing, + deletion-failure ordering and public endpoint publication retry checks. Open the existing profile from the Home header