From 1ae24c1b5456b5323c7a36a5390d7b32889e8543 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 18:54:27 -0500 Subject: [PATCH 01/10] fix: pause paykit work during profile deletion --- Bitkit/AppScene.swift | 119 ++++++++++++------ Bitkit/MainNavView.swift | 16 ++- Bitkit/Managers/PubkyProfileManager.swift | 20 +++ .../PaykitPaymentRequestService.swift | 20 ++- Bitkit/ViewModels/WalletViewModel.swift | 9 +- Bitkit/Views/Profile/EditProfileView.swift | 6 +- .../PaykitPaymentRequestServiceTests.swift | 33 +++++ BitkitTests/PubkyProfileManagerTests.swift | 54 ++++++++ BitkitTests/ReceiveInvoiceRefreshTests.swift | 25 ++++ changelog.d/next/954.fixed.md | 1 + journeys/profile/README.md | 2 + journeys/profile/delete-profile.xml | 2 + 12 files changed, 255 insertions(+), 52 deletions(-) create mode 100644 changelog.d/next/954.fixed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ee71aaa9e..6a3c95549 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -511,22 +511,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 refreshForegroundContacts() + await refreshForegroundPublicPaykitEndpoints() + await prepareSavedPaykitContacts() + await refreshIncomingPaykitPaymentRequests(onlyWhileActive: true) } } .task { await handlePendingPaykitSubscriptionNotification() } @@ -605,7 +601,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 +640,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 +658,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) } @@ -1319,11 +1319,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() @@ -1333,13 +1357,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() } ) } @@ -1350,7 +1374,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, @@ -1360,15 +1385,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 } @@ -1384,10 +1416,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 @@ -1438,7 +1470,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 { @@ -1448,7 +1482,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) { @@ -1459,7 +1494,6 @@ struct AppScene: View { case .refreshInboxAndMaintenance: mode = .full } - let session = pubkyProfile.currentSession startShopContactImport() if mode == .full { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) @@ -1471,7 +1505,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, @@ -1485,13 +1519,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 @@ -1506,7 +1542,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, @@ -1817,7 +1853,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 @@ -1827,7 +1863,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 @@ -1855,6 +1891,7 @@ struct AppScene: View { private func presentNextIncomingPaykitItem() async { guard scenePhase == .active, + pubkyProfile.automaticPaykitSession != nil, isPinVerified || !settings.pinEnabled else { return } await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( @@ -1867,7 +1904,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, @@ -1877,11 +1916,13 @@ 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) { + pubkyProfile.automaticPaykitSession == session + } case .removePublishedState: try await PublicPaykitService.removePublishedEndpoints() try await PublicPaykitService.syncPaykitApp() @@ -1941,8 +1982,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 6068d7b31..36aa5e34a 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 } @@ -433,6 +434,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() } + } .task(id: scenePhase == .active) { guard scenePhase == .active, canHandleDeepLinks, let url = app.pendingDeepLinkURL, PubkyContactLink.matches(url) @@ -851,6 +863,7 @@ struct MainNavView: View { private func handlePendingDeepLink() async { guard !isPreparingPendingContactDeepLink else { return } + retryPendingContactAfterDeletion = false var contactsReady = true await prepareAndRoutePendingDeepLink { contactsReady = await loadContactsForPendingDeepLinkIfNeeded() @@ -874,6 +887,7 @@ struct MainNavView: View { let contactPublicKey = PubkyContactLink.publicKey(from: url) else { return true } + guard !pubkyProfile.isDeletingProfile else { return false } let session = pubkyProfile.currentSession let contactsOwnerPublicKey = pubkyProfile.publicKey ?? contactPublicKey isPreparingPendingContactDeepLink = true @@ -885,7 +899,7 @@ struct MainNavView: View { } else { try await contactsManager.loadContactsIfNeeded(for: contactsOwnerPublicKey) } - return pubkyProfile.currentSession == session && !Task.isCancelled + return !pubkyProfile.isDeletingProfile && pubkyProfile.currentSession == session && !Task.isCancelled } catch is CancellationError { return false } catch { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 305af93c4..3d0245563 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,11 @@ class PubkyProfileManager: ObservableObject { return SignedInSession(publicKey: publicKey, revision: Self.sessionRevision) } + var automaticPaykitSession: SignedInSession? { + guard authState == .authenticated, !isDeletingProfile else { return nil } + return currentSession + } + var hasLocalSecretKeyForCurrentProfile: Bool { Self.hasLocalSecretKey(for: publicKey) } 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/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index c40fa8938..57faa97bc 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1437,7 +1437,7 @@ class WalletViewModel: ObservableObject { } private func syncPublicPaykitEndpointsAfterChannelBecameUsable() async { - guard isPaykitUIActive else { return } + guard isPaykitUIActive, await !PrivatePaykitService.shared.isDeletingProfile else { return } do { try await PublicPaykitService.syncPublishedEndpoints(wallet: self, publish: true) @@ -1450,7 +1450,7 @@ class WalletViewModel: ObservableObject { 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 { @@ -1586,7 +1586,9 @@ class WalletViewModel: ObservableObject { receiveCommitID = UUID() isReceiveInvoiceUpdating = false - if syncPublicPaykit, isPaykitUIActive, sharesPublicPaykitEndpoints { + if syncPublicPaykit, isPaykitUIActive, sharesPublicPaykitEndpoints, + await !PrivatePaykitService.shared.isDeletingProfile + { do { try await syncReceivePublicPaykitEndpoints() } catch { @@ -1628,6 +1630,7 @@ class WalletViewModel: ObservableObject { } func syncReceivePublicPaykitEndpoints() async throws { + guard await !PrivatePaykitService.shared.isDeletingProfile else { return } try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) } 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/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 1c287ac45..094403091 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -316,6 +316,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/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..49b9fe7c1 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -42,6 +42,29 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { XCTAssertFalse(wallet.isReceiveInvoiceUpdating) } + 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.invoiceAmountSats = 2000 + try await wallet.refreshBip21(forceRefreshBolt11: true) + XCTAssertEqual(wallet.endpointSyncCount, 1) + } + func testRefreshUsesCapturedInputsAndPreservesEditsMadeWhileWaiting() async throws { let wallet = GatedReceiveWallet() wallet.invoiceAmountSats = 1000 @@ -431,6 +454,7 @@ private final class GatedReceiveWallet: WalletViewModel { var reusableInvoice: Bool? var gateEndpointSync = false + var endpointSyncCount = 0 let endpointSyncStarted = XCTestExpectation(description: "Public endpoint sync suspended") private var endpointSync: CheckedContinuation? @@ -440,6 +464,7 @@ private final class GatedReceiveWallet: WalletViewModel { } override func syncReceivePublicPaykitEndpoints() async throws { + endpointSyncCount += 1 if gateEndpointSync { await withCheckedContinuation { endpointSync = $0 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..b10778ee9 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -2,6 +2,8 @@ `delete-profile.xml` verifies visible progress and completion when deleting a disposable profile. Use a test identity that may be deleted. +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. + `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. diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index 9244c247a..3eef8b04d 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -5,6 +5,8 @@ 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 and deletion-failure checks. Open the existing profile from the Home header From f6cc033a2b9cf507c511517fe4a2f3dc1897c961 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 02:44:40 -0500 Subject: [PATCH 02/10] fix: retain safe profile deletion follow-up work --- Bitkit/AppScene.swift | 4 +- Bitkit/MainNavView.swift | 18 +-- Bitkit/Services/PublicPaykitService.swift | 18 ++- Bitkit/Utilities/MainNavRouting.swift | 31 +++- Bitkit/ViewModels/WalletViewModel.swift | 5 +- BitkitTests/PubkyContactLinkTests.swift | 106 +++++++++++--- BitkitTests/ReceiveInvoiceRefreshTests.swift | 145 ++++++++++++++++++- journeys/profile/README.md | 6 + journeys/profile/delete-profile.xml | 3 +- 9 files changed, 298 insertions(+), 38 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 6a3c95549..da72f0d18 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -519,8 +519,8 @@ struct AppScene: View { .onChange(of: pubkyProfile.isDeletingProfile) { _, isDeleting in guard !isDeleting, isPaykitSceneActive else { return } Task { - await refreshForegroundContacts() await refreshForegroundPublicPaykitEndpoints() + await refreshForegroundContacts() await prepareSavedPaykitContacts() await refreshIncomingPaykitPaymentRequests(onlyWhileActive: true) } @@ -1926,8 +1926,8 @@ struct AppScene: View { 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") } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 36aa5e34a..61a13e5bf 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -42,7 +42,8 @@ struct MainNavView: View { canRoutePubkyContactLink( isPaykitUIActive: isPaykitUIActive, isPubkyInitialized: pubkyProfile.isInitialized, - hasLoadedContacts: contactsManager.hasLoaded + hasLoadedContacts: contactsManager.hasLoaded, + hasSignedInIdentity: pubkyProfile.isAuthenticated ) } @@ -884,22 +885,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 } guard !pubkyProfile.isDeletingProfile else { return false } - let session = pubkyProfile.currentSession - let contactsOwnerPublicKey = pubkyProfile.publicKey ?? contactPublicKey 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.isDeletingProfile && pubkyProfile.currentSession == session && !Task.isCancelled + return try await preparePubkyContactLink(profile: pubkyProfile, contacts: contactsManager) } catch is CancellationError { return false } catch { @@ -933,9 +927,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/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 2175c849b..420135f42 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -374,15 +374,27 @@ enum PublicPaykitService { } @MainActor - static func syncCurrentPublishedEndpoints(wallet: WalletViewModel, isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws { + static func syncCurrentPublishedEndpoints( + wallet: WalletViewModel, + isSessionCurrent: (@MainActor () -> Bool)? = nil, + syncApp: () async throws -> Void = { try await syncPaykitApp() }, + applyEndpoints: ([Endpoint]) async throws -> Void = applyPublishedEndpointsLocked + ) async throws { let desiredEndpoints = 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 ) + // An admitted write may finish after a payment invalidates its invoice. + guard let isSessionCurrent, !Task.isCancelled, isSessionCurrent(), + pendingReconciliationMode() == .publishEndpoints, + !isLightningPaymentOptionEnabled() || wallet.publicPaykitBolt11 == + (desiredEndpoints.first { $0.methodId == .bitcoinLightningBolt11 }?.value ?? "") + else { return } + setCleanupPending(false) } static func removePublishedEndpoints() async throws { diff --git a/Bitkit/Utilities/MainNavRouting.swift b/Bitkit/Utilities/MainNavRouting.swift index 5ce1ce596..c0cfff5cf 100644 --- a/Bitkit/Utilities/MainNavRouting.swift +++ b/Bitkit/Utilities/MainNavRouting.swift @@ -3,9 +3,29 @@ 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 +) async throws -> Bool { + guard profile.isInitialized, profile.initializationErrorMessage == nil, + !profile.isDeletingProfile, !Task.isCancelled + else { return false } + guard profile.isAuthenticated else { return true } + 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 +36,13 @@ func pubkyContactPublicKeyForRouting(from url: URL, isPaykitUIActive: Bool) thro return publicKey } +@MainActor +func routePublicContactIfSignedOut(publicKey: String, profile: PubkyProfileManager, navigation: NavigationViewModel) -> Bool { + guard !profile.isAuthenticated else { return false } + navigation.navigate(.addContact(publicKey: publicKey)) + return true +} + @MainActor func prepareAndRoutePendingDeepLink( preparation: () async -> Void, diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 57faa97bc..a2a6b398d 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1432,6 +1432,8 @@ class WalletViewModel: ObservableObject { } 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") } } @@ -1504,12 +1506,13 @@ class WalletViewModel: ObservableObject { publicPaykitBolt11ExpiresAt = 0 } - private func rotatePublicPaykitInvoiceIfNeeded(paymentHash: String) { + func rotatePublicPaykitInvoiceIfNeeded(paymentHash: String) { guard !publicPaykitBolt11PaymentHash.isEmpty, publicPaykitBolt11PaymentHash == paymentHash else { return } clearPublicPaykitBolt11() + if sharesPublicPaykitEndpoints { PublicPaykitService.setCleanupPending(true) } } func refreshBip21(forceRefreshBolt11: Bool = false, syncPublicPaykit: Bool = true, reportsError: Bool = true) async throws { diff --git a/BitkitTests/PubkyContactLinkTests.swift b/BitkitTests/PubkyContactLinkTests.swift index d71bb57e5..cd4b837de 100644 --- a/BitkitTests/PubkyContactLinkTests.swift +++ b/BitkitTests/PubkyContactLinkTests.swift @@ -15,6 +15,20 @@ 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 + } + } + 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 +119,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 +145,88 @@ 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 testSignedOutContactLinkDoesNotWaitForPrivateContacts() 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) + XCTAssertTrue(ready) await app.routePendingDeepLinkIfReady( true, pubkyContactsAreReady: 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)) + } + XCTAssertNil(app.pendingDeepLinkURL) + XCTAssertEqual(navigation.path, [.addContact(publicKey: key)]) + XCTAssertFalse(contacts.hasLoaded) + XCTAssertFalse(profile.isAuthenticated) + XCTAssertNil(profile.currentSession) + await app.routePendingDeepLinkIfReady(true) { _ in XCTFail("Must not route twice") } + finish.finish() + await restoration.value + profile.publicKey = key + XCTAssertFalse(routePublicContactIfSignedOut(publicKey: key, profile: profile, navigation: navigation)) + XCTAssertEqual(navigation.path, [.addContact(publicKey: key)]) } + } - await routePendingLink() - XCTAssertEqual(app.pendingDeepLinkURL, url) + @MainActor + func testSignedOutContactPreparationDoesNotReadPrivateContacts() async throws { + let profile = PubkyProfileManager() + profile.isInitialized = true + let contacts = ContactsManager(contactRecords: { + throw PubkyServiceError.sessionNotActive + }) + let ready = try await preparePubkyContactLink(profile: profile, contacts: contacts) + XCTAssertTrue(ready) + XCTAssertFalse(contacts.hasLoaded) + XCTAssertNil(contacts.loadErrorMessage) + } - contacts.hasLoaded = true - await routePendingLink() - XCTAssertNil(app.pendingDeepLinkURL) + @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/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index 49b9fe7c1..a5b39ac1b 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -7,7 +7,12 @@ 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, + ].map { ( $0, UserDefaults.standard.object(forKey: $0) ) } @@ -60,11 +65,133 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { 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 @@ -455,6 +582,9 @@ private final class GatedReceiveWallet: WalletViewModel { var reusableInvoice: Bool? var gateEndpointSync = false var endpointSyncCount = 0 + var failPublicEndpointRefresh = false + var stubPublicEndpoints = false + var beforePublicEndpointRefresh: (() async -> Void)? let endpointSyncStarted = XCTestExpectation(description: "Public endpoint sync suspended") private var endpointSync: CheckedContinuation? @@ -473,6 +603,19 @@ 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 { return ("", publicPaykitBolt11) } + return try await super.refreshPublicPaykitEndpoints( + forceRefreshBolt11: forceRefreshBolt11, includeOnchain: includeOnchain, includeLightning: includeLightning + ) + } + func releaseEndpointSync() { endpointSync?.resume() endpointSync = nil diff --git a/journeys/profile/README.md b/journeys/profile/README.md index b10778ee9..47c7c39a7 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -4,6 +4,12 @@ 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. + `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. diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index 3eef8b04d..1bece0e9c 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -6,7 +6,8 @@ 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 and deletion-failure checks. + 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 From b368a3b76f86a9dbfd99d6d5583deeb084d591ff Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 03:44:04 -0500 Subject: [PATCH 03/10] fix: fence automatic public publication after preparation --- Bitkit/AppScene.swift | 10 +- Bitkit/Services/PublicPaykitService.swift | 8 +- Bitkit/ViewModels/WalletViewModel.swift | 60 +++--- BitkitTests/ReceiveInvoiceRefreshTests.swift | 190 +++++++++++++++++- BitkitTests/WalletViewModelReceiveTests.swift | 12 +- journeys/profile/README.md | 4 + 6 files changed, 252 insertions(+), 32 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index da72f0d18..8e3e322ad 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, @@ -1921,7 +1924,8 @@ struct AppScene: View { switch PublicPaykitService.pendingReconciliationMode() { case .publishEndpoints: try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) { - pubkyProfile.automaticPaykitSession == session + !Task.isCancelled && pubkyProfile.automaticPaykitSession == session && + PublicPaykitService.pendingReconciliationMode() == .publishEndpoints } case .removePublishedState: try await PublicPaykitService.removePublishedEndpoints() diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 420135f42..c315f68dc 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -322,14 +322,16 @@ 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 { 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 + syncApp: syncApp ?? { try await syncPaykitApp(priority: appSyncPriority) }, + applyEndpoints: applyEndpoints ) } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index a2a6b398d..1a097142c 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -93,6 +93,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 +118,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 +128,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 +160,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 +1234,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() } @@ -1425,7 +1431,7 @@ class WalletViewModel: ObservableObject { 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 @@ -1438,17 +1444,8 @@ class WalletViewModel: ObservableObject { } } - private func syncPublicPaykitEndpointsAfterChannelBecameUsable() async { - guard isPaykitUIActive, await !PrivatePaykitService.shared.isDeletingProfile 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") - } - } - - private func refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: String, forceRefreshLightning: Bool = false) async { + func refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: String, forceRefreshLightning: Bool = false) async { + let isSessionCurrent = publicPaykitPublicationAdmission() await refreshAndSyncState() try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning, syncPublicPaykit: false, reportsError: false) @@ -1456,11 +1453,7 @@ class WalletViewModel: ObservableObject { 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") } @@ -1516,6 +1509,7 @@ class WalletViewModel: ObservableObject { } func refreshBip21(forceRefreshBolt11: Bool = false, syncPublicPaykit: Bool = true, reportsError: Bool = true) async throws { + let isSessionCurrent = publicPaykitPublicationAdmission() let refreshID = UUID() let wasUpdating = isReceiveInvoiceUpdating receiveRefreshID = refreshID @@ -1593,7 +1587,7 @@ class WalletViewModel: ObservableObject { 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") } @@ -1632,9 +1626,27 @@ class WalletViewModel: ObservableObject { return !decoded.isExpired } - func syncReceivePublicPaykitEndpoints() async throws { + func syncReceivePublicPaykitEndpoints(isSessionCurrent: @escaping @MainActor () -> Bool) async throws { guard await !PrivatePaykitService.shared.isDeletingProfile else { return } - try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) + 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/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index a5b39ac1b..cfddc0d9d 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -12,6 +12,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { PublicPaykitService.cleanupPendingKey, PaykitFeatureFlags.uiEnabledKey, "publicPaykitBolt11", "publicPaykitBolt11PaymentHash", "publicPaykitBolt11ExpiresAt", PublicPaykitService.lightningPaymentOptionEnabledKey, + PrivatePaykitService.publishingEnabledKey, ].map { ( $0, UserDefaults.standard.object(forKey: $0) @@ -47,6 +48,138 @@ 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 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) -> (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 = 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) @@ -585,15 +718,47 @@ private final class GatedReceiveWallet: WalletViewModel { var failPublicEndpointRefresh = false var stubPublicEndpoints = false var beforePublicEndpointRefresh: (() 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 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 { @@ -626,6 +791,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 { @@ -672,6 +838,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/journeys/profile/README.md b/journeys/profile/README.md index 47c7c39a7..d5440d76b 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -10,6 +10,10 @@ If public invoice replacement is deferred during deletion, make deletion fail an 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. + `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. From d7e0e1f1c847cc4fe2901b87bd0ad756f70d00bd Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 04:32:43 -0500 Subject: [PATCH 04/10] fix: retain contact links during session recovery --- Bitkit/MainNavView.swift | 2 +- Bitkit/Utilities/MainNavRouting.swift | 18 ++-- BitkitTests/PubkyContactLinkTests.swift | 111 +++++++++++++++++++++--- journeys/profile/README.md | 4 + 4 files changed, 119 insertions(+), 16 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 61a13e5bf..09f1b0d2e 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -48,7 +48,7 @@ struct MainNavView: View { } private var canPrepareContactDeepLink: Bool { - !isPaykitUIActive || pubkyProfile.isInitialized + !isPaykitUIActive || (pubkyProfile.isInitialized && !pubkyProfile.isRestoringSession) } private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { diff --git a/Bitkit/Utilities/MainNavRouting.swift b/Bitkit/Utilities/MainNavRouting.swift index c0cfff5cf..706997378 100644 --- a/Bitkit/Utilities/MainNavRouting.swift +++ b/Bitkit/Utilities/MainNavRouting.swift @@ -12,12 +12,13 @@ func canRoutePubkyContactLink( @MainActor func preparePubkyContactLink( profile: PubkyProfileManager, - contacts: ContactsManager + contacts: ContactsManager, + hasStoredIdentity: () throws -> Bool = { try PubkyProfileManager.hasStoredIdentity() } ) async throws -> Bool { guard profile.isInitialized, profile.initializationErrorMessage == nil, - !profile.isDeletingProfile, !Task.isCancelled + !profile.isRestoringSession, !profile.isDeletingProfile, !Task.isCancelled else { return false } - guard profile.isAuthenticated else { return true } + guard profile.isAuthenticated else { return (try? hasStoredIdentity()) == false } guard let session = profile.currentSession else { return false } do { try await contacts.refreshContacts(for: session.publicKey) @@ -37,8 +38,15 @@ func pubkyContactPublicKeyForRouting(from url: URL, isPaykitUIActive: Bool) thro } @MainActor -func routePublicContactIfSignedOut(publicKey: String, profile: PubkyProfileManager, navigation: NavigationViewModel) -> Bool { - guard !profile.isAuthenticated else { return false } +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 } diff --git a/BitkitTests/PubkyContactLinkTests.swift b/BitkitTests/PubkyContactLinkTests.swift index cd4b837de..55bec6d05 100644 --- a/BitkitTests/PubkyContactLinkTests.swift +++ b/BitkitTests/PubkyContactLinkTests.swift @@ -29,6 +29,18 @@ final class PubkyContactLinkTests: XCTestCase { } } + @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)")) @@ -145,7 +157,7 @@ final class PubkyContactLinkTests: XCTestCase { } @MainActor - func testSignedOutContactLinkDoesNotWaitForPrivateContacts() async throws { + func testContactLinkDistinguishesSignedOutFromRestoringIdentity() async throws { snapshotAppDefaultsDomain() for restoringSession in [false, true] { let navigation = NavigationViewModel() @@ -167,11 +179,11 @@ final class PubkyContactLinkTests: XCTestCase { } if restoringSession { await fulfillment(of: [started], timeout: 2) } XCTAssertEqual(profile.isRestoringSession, restoringSession) - let ready = try await preparePubkyContactLink(profile: profile, contacts: contacts) - XCTAssertTrue(ready) + 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, @@ -179,33 +191,112 @@ final class PubkyContactLinkTests: XCTestCase { ) ) { routedURL in guard let publicKey = PubkyContactLink.publicKey(from: routedURL) else { return XCTFail("Expected contact key") } - XCTAssertTrue(routePublicContactIfSignedOut(publicKey: publicKey, profile: profile, navigation: navigation)) + XCTAssertTrue(routePublicContactIfSignedOut( + publicKey: publicKey, profile: profile, navigation: navigation, hasStoredIdentity: { restoringSession } + )) } - XCTAssertNil(app.pendingDeepLinkURL) - XCTAssertEqual(navigation.path, [.addContact(publicKey: key)]) + XCTAssertEqual(app.pendingDeepLinkURL, restoringSession ? url : nil) + XCTAssertEqual(navigation.path, restoringSession ? [] : [.addContact(publicKey: key)]) XCTAssertFalse(contacts.hasLoaded) XCTAssertFalse(profile.isAuthenticated) XCTAssertNil(profile.currentSession) - await app.routePendingDeepLinkIfReady(true) { _ in XCTFail("Must not route twice") } + 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, [.addContact(publicKey: key)]) + XCTAssertEqual(navigation.path, restoringSession ? [] : [.addContact(publicKey: key)]) + } + } + + @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) + } + } + } + + 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) + 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 diff --git a/journeys/profile/README.md b/journeys/profile/README.md index d5440d76b..482bc3c72 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -14,6 +14,8 @@ Hold endpoint preparation before publication admission, or queue a public public 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. + `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. @@ -21,3 +23,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. From aae3dff9b7741ff18a4df6cf49977f784bc5b9f9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 08:23:59 -0500 Subject: [PATCH 05/10] fix: preserve public invoice refresh reconciliation --- Bitkit/Services/ContactPaymentsService.swift | 3 +- Bitkit/Services/PublicPaykitService.swift | 43 ++- Bitkit/ViewModels/WalletViewModel.swift | 32 ++- BitkitTests/ContactPaymentsServiceTests.swift | 6 +- BitkitTests/ReceiveInvoiceRefreshTests.swift | 268 +++++++++++++++++- journeys/profile/README.md | 6 + 6 files changed, 344 insertions(+), 14 deletions(-) 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/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index c315f68dc..4a80dbf9c 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -326,13 +326,27 @@ enum PublicPaykitService { syncApp: (() async throws -> Void)? = nil, applyEndpoints: ([Endpoint]) async throws -> Void = applyPublishedEndpointsLocked ) async throws { + let invoiceRevision = wallet.publicPaykitInvoiceRevision + var desiredEndpoints: [Endpoint]? try await syncPublishedEndpoints( publish: publish, isSessionCurrent: isSessionCurrent, - buildEndpoints: { try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) }, + buildEndpoints: { + let endpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) + desiredEndpoints = endpoints + return endpoints + }, syncApp: syncApp ?? { try await syncPaykitApp(priority: appSyncPriority) }, applyEndpoints: applyEndpoints ) + if let desiredEndpoints { + acknowledgePublishedEndpoints( + wallet: wallet, + endpoints: desiredEndpoints, + invoiceRevision: invoiceRevision, + isSessionCurrent: isSessionCurrent + ) + } } @MainActor @@ -382,6 +396,7 @@ enum PublicPaykitService { syncApp: () async throws -> Void = { try await syncPaykitApp() }, applyEndpoints: ([Endpoint]) async throws -> Void = applyPublishedEndpointsLocked ) async throws { + let invoiceRevision = wallet.publicPaykitInvoiceRevision let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false) try await syncPublishedEndpoints( publish: true, @@ -390,13 +405,29 @@ enum PublicPaykitService { syncApp: syncApp, applyEndpoints: applyEndpoints ) - // An admitted write may finish after a payment invalidates its invoice. + 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, - !isLightningPaymentOptionEnabled() || wallet.publicPaykitBolt11 == - (desiredEndpoints.first { $0.methodId == .bitcoinLightningBolt11 }?.value ?? "") + pendingReconciliationMode() == .publishEndpoints else { return } - setCleanupPending(false) + let publishedCurrentInvoice = !isLightningPaymentOptionEnabled() || + (wallet.publicPaykitInvoiceRevision == invoiceRevision && wallet.publicPaykitBolt11 == + (endpoints.first { $0.methodId == .bitcoinLightningBolt11 }?.value ?? "")) + setCleanupPending(!publishedCurrentInvoice) } static func removePublishedEndpoints() async throws { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1a097142c..107aed307 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -33,6 +33,7 @@ class WalletViewModel: ObservableObject { @AppStorage("publicPaykitBolt11") var publicPaykitBolt11 = "" @AppStorage("publicPaykitBolt11PaymentHash") var publicPaykitBolt11PaymentHash = "" @AppStorage("publicPaykitBolt11ExpiresAt") var publicPaykitBolt11ExpiresAt = 0.0 + private(set) var publicPaykitInvoiceRevision = UUID() @AppStorage("channelCount") var channelCount: Int = 0 // Keeping a cached version of this so we can better anticipate the receive flow UI // Send flow @@ -1399,6 +1400,13 @@ class WalletViewModel: ObservableObject { return ("", "") } + let invoiceRevision = publicPaykitInvoiceRevision + let session = pubkyProfile?.currentSession + let wasSharing = sharesPublicPaykitEndpoints + let canCommitInvoice = { + !Task.isCancelled && self.isPaykitUIActive && self.publicPaykitInvoiceRevision == invoiceRevision && + self.pubkyProfile?.currentSession == session && self.sharesPublicPaykitEndpoints == wasSharing + } let receiveID = receiveRefreshID let commitID = receiveCommitID let previousAddress = onchainAddress @@ -1409,10 +1417,11 @@ class WalletViewModel: ObservableObject { if includeLightning, hasUsableChannels { let hasReusableInvoice = await hasReusablePublicPaykitInvoice() + guard canCommitInvoice() else { throw PubkyServiceError.sessionNotActive } let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice if shouldRefreshBolt11 { do { - try await refreshPublicPaykitBolt11() + try await refreshPublicPaykitBolt11(canCommitInvoice: canCommitInvoice) } catch let error as PublicPaykitError { guard case .routeHintsUnavailable = error else { throw error @@ -1421,6 +1430,7 @@ class WalletViewModel: ObservableObject { } } } else if includeLightning { + guard canCommitInvoice() else { throw PubkyServiceError.sessionNotActive } clearPublicPaykitBolt11() } @@ -1445,6 +1455,13 @@ class WalletViewModel: ObservableObject { } func refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: String, forceRefreshLightning: Bool = false) async { + if forceRefreshLightning, !Task.isCancelled, isPaykitUIActive, sharesPublicPaykitEndpoints, + PublicPaykitService.isLightningPaymentOptionEnabled(), + pubkyProfile?.authState == .authenticated, pubkyProfile?.currentSession != nil + { + invalidatePublicPaykitInvoice() + PublicPaykitService.setCleanupPending(true) + } let isSessionCurrent = publicPaykitPublicationAdmission() await refreshAndSyncState() try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning, syncPublicPaykit: false, reportsError: false) @@ -1477,9 +1494,11 @@ class WalletViewModel: ObservableObject { PublicPaykitService.hasLightningRouteHints(bolt11: publicPaykitBolt11) } - private func refreshPublicPaykitBolt11() async throws { + private func refreshPublicPaykitBolt11(canCommitInvoice: () -> Bool) async throws { 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 canCommitInvoice() else { throw PubkyServiceError.sessionNotActive } + guard case let .lightning(lightningInvoice) = decodedInvoice else { clearPublicPaykitBolt11() throw PublicPaykitError.invalidPayload } @@ -1499,12 +1518,17 @@ class WalletViewModel: ObservableObject { publicPaykitBolt11ExpiresAt = 0 } + private func invalidatePublicPaykitInvoice() { + publicPaykitInvoiceRevision = UUID() + clearPublicPaykitBolt11() + } + func rotatePublicPaykitInvoiceIfNeeded(paymentHash: String) { guard !publicPaykitBolt11PaymentHash.isEmpty, publicPaykitBolt11PaymentHash == paymentHash else { return } - clearPublicPaykitBolt11() + invalidatePublicPaykitInvoice() if sharesPublicPaykitEndpoints { PublicPaykitService.setCleanupPending(true) } } 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/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index cfddc0d9d..06a5346a9 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -82,6 +82,261 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } } + 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 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 testAdmittedPublicWriteCannotAcknowledgeNewerChannelRefresh() async throws { + for invoice in ["ln-public", ""] { + let (profile, wallet) = publicationWallet(route: .channelUsable) + wallet.publicPaykitBolt11 = invoice + PublicPaykitService.setCleanupPending(false) + let session = try XCTUnwrap(profile.automaticPaykitSession) + let write = PublicationGate("Admitted public write") + let publication = Task { + try await PublicPaykitService.syncCurrentPublishedEndpoints( + wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, + applyEndpoints: { _ in await write.wait() } + ) + } + 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)") + } + } + + 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, sharingDisabled, 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) + switch state { + case .replaced: profile.publicKey = "pubky" + String(repeating: "z", count: 52) + case .sharingDisabled: UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + 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") + var snapshots = 0 + wallet.afterPublicEndpointSnapshot = { + snapshots += 1 + guard snapshots == (forceRefresh ? 2 : 1) else { return } + 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") + var snapshots = 0 + wallet.afterPublicEndpointSnapshot = { + snapshots += 1 + guard snapshots == 2 else { return } + 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 { @@ -717,7 +972,10 @@ private final class GatedReceiveWallet: WalletViewModel { var endpointSyncCount = 0 var failPublicEndpointRefresh = false var stubPublicEndpoints = false + 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] = [] @@ -775,7 +1033,14 @@ private final class GatedReceiveWallet: WalletViewModel { ) async throws -> (onchainAddress: String, bolt11: String) { await beforePublicEndpointRefresh?() if failPublicEndpointRefresh { throw TestError.failed } - if stubPublicEndpoints { return ("", publicPaykitBolt11) } + if stubPublicEndpoints { + if replaceEmptyPublicInvoice, forceRefreshBolt11 || publicPaykitBolt11.isEmpty { + publicPaykitBolt11 = "ln-current" + } + let invoice = publicPaykitBolt11 + await afterPublicEndpointSnapshot?() + return ("", invoice) + } return try await super.refreshPublicPaykitEndpoints( forceRefreshBolt11: forceRefreshBolt11, includeOnchain: includeOnchain, includeLightning: includeLightning ) @@ -810,6 +1075,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 diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 482bc3c72..1535c9639 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -16,6 +16,12 @@ On iOS, public publication and endpoint removal share an endpoint lock. A public Hold initial session restoration with public contact payments enabled, turn contact payments off, then release restoration. No automatic endpoint preparation or publication may resume, and pending cleanup must remain available. Repeat with publication waiting for SDK admission. If a write was already admitted, let it finish and verify it cannot clear the cleanup requested by disabling sharing. These checks require restoration and SDK-operation gates beyond the journey runner capabilities. +While deletion is held, make a Lightning channel usable or ready, then fail deletion while retaining the same identity. Public endpoint preparation must refresh the invoice rather than reuse its old route hints, even if an older invoice creation finishes after the channel change. A skipped or failed publication must leave reconciliation pending; an older admitted write must not acknowledge a newer channel refresh. Sign-out, identity replacement, cancellation or disabled sharing must not let older preparation refill the invalidated cache. Also hold an older public endpoint snapshot before publication, pay its invoice and publish the replacement, then release the older snapshot. The replacement must become pending again and be republished by normal reconciliation. These checks require channel, failure and publication gates beyond the journey runner capabilities. + +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. + +On iOS, these channel-refresh checks cover public endpoints. Private endpoint preparation retains its existing cleanup and reconciliation behavior. + `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. From aac9d0bdd1e5de3f4841fe975e23ec8149d4a526 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 08:51:28 -0500 Subject: [PATCH 06/10] fix: retain channel refresh through restoration --- Bitkit/Managers/PubkyProfileManager.swift | 9 ++ Bitkit/Services/PublicPaykitService.swift | 2 + Bitkit/ViewModels/WalletViewModel.swift | 29 ++++- BitkitTests/ReceiveInvoiceRefreshTests.swift | 120 ++++++++++++++++++- journeys/profile/README.md | 2 + 5 files changed, 156 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 3d0245563..dd439fb65 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1656,6 +1656,15 @@ class PubkyProfileManager: ObservableObject { 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/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 4a80dbf9c..777f29b66 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -326,6 +326,7 @@ enum PublicPaykitService { syncApp: (() async throws -> Void)? = nil, applyEndpoints: ([Endpoint]) async throws -> Void = applyPublishedEndpointsLocked ) async throws { + if publish { wallet.preparePendingPublicPaykitInvoiceRefresh() } let invoiceRevision = wallet.publicPaykitInvoiceRevision var desiredEndpoints: [Endpoint]? try await syncPublishedEndpoints( @@ -396,6 +397,7 @@ enum PublicPaykitService { syncApp: () async throws -> Void = { try await syncPaykitApp() }, applyEndpoints: ([Endpoint]) async throws -> Void = applyPublishedEndpointsLocked ) async throws { + wallet.preparePendingPublicPaykitInvoiceRefresh() let invoiceRevision = wallet.publicPaykitInvoiceRevision let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false) try await syncPublishedEndpoints( diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 107aed307..2e341b5ef 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -34,6 +34,7 @@ class WalletViewModel: ObservableObject { @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 @@ -1456,11 +1457,17 @@ class WalletViewModel: ObservableObject { func refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: String, forceRefreshLightning: Bool = false) async { if forceRefreshLightning, !Task.isCancelled, isPaykitUIActive, sharesPublicPaykitEndpoints, - PublicPaykitService.isLightningPaymentOptionEnabled(), - pubkyProfile?.authState == .authenticated, pubkyProfile?.currentSession != nil + PublicPaykitService.isLightningPaymentOptionEnabled() { - invalidatePublicPaykitInvoice() - PublicPaykitService.setCleanupPending(true) + 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() + } + } } let isSessionCurrent = publicPaykitPublicationAdmission() await refreshAndSyncState() @@ -1483,6 +1490,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 } diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index 06a5346a9..3813cb047 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -111,6 +111,113 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { 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 @@ -408,12 +515,14 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } } - private func publicationWallet(route: AutomaticPublicationRoute) -> (PubkyProfileManager, GatedReceiveWallet) { + 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 = PubkyProfileManager() + let profile = profile ?? PubkyProfileManager() profile.publicKey = "pubky" + String(repeating: "y", count: 52) profile.authState = .authenticated let wallet = GatedReceiveWallet(pubkyProfile: profile) @@ -953,6 +1062,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") @@ -993,6 +1108,7 @@ private final class GatedReceiveWallet: WalletViewModel { } 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) } diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 1535c9639..97b17dbaa 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -22,6 +22,8 @@ Repeat the held snapshot check while enabling contact payments and while a faile 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. From b8a3078027d5d1c600b7777073ea26e4874a5e7f Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 12:37:48 -0500 Subject: [PATCH 07/10] fix: retry superseded public invoice preparation --- Bitkit/ViewModels/WalletViewModel.swift | 63 +++++--- BitkitTests/ReceiveInvoiceRefreshTests.swift | 155 ++++++++++++++++++- journeys/profile/README.md | 2 + 3 files changed, 196 insertions(+), 24 deletions(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 2e341b5ef..3a074025a 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1401,13 +1401,9 @@ class WalletViewModel: ObservableObject { return ("", "") } - let invoiceRevision = publicPaykitInvoiceRevision + let initialInvoiceRevision = publicPaykitInvoiceRevision let session = pubkyProfile?.currentSession let wasSharing = sharesPublicPaykitEndpoints - let canCommitInvoice = { - !Task.isCancelled && self.isPaykitUIActive && self.publicPaykitInvoiceRevision == invoiceRevision && - self.pubkyProfile?.currentSession == session && self.sharesPublicPaykitEndpoints == wasSharing - } let receiveID = receiveRefreshID let commitID = receiveCommitID let previousAddress = onchainAddress @@ -1416,26 +1412,46 @@ class WalletViewModel: ObservableObject { onchainAddress = publicOnchainAddress } - if includeLightning, hasUsableChannels { - let hasReusableInvoice = await hasReusablePublicPaykitInvoice() - guard canCommitInvoice() else { throw PubkyServiceError.sessionNotActive } - let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice - if shouldRefreshBolt11 { - do { - try await refreshPublicPaykitBolt11(canCommitInvoice: canCommitInvoice) - } 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 { - guard canCommitInvoice() else { throw PubkyServiceError.sessionNotActive } - clearPublicPaykitBolt11() - } - return (publicOnchainAddress, includeLightning ? publicPaykitBolt11 : "") + return (publicOnchainAddress, includeLightning ? publicPaykitBolt11 : "") + } + throw PublicPaykitError.publicationFailed } func refreshPublicPaykitEndpointsOnForeground(isSessionCurrent: @escaping @MainActor () -> Bool) async { @@ -1515,10 +1531,10 @@ class WalletViewModel: ObservableObject { PublicPaykitService.hasLightningRouteHints(bolt11: publicPaykitBolt11) } - private func refreshPublicPaykitBolt11(canCommitInvoice: () -> Bool) async throws { + private func refreshPublicPaykitBolt11(canCommitInvoice: () async throws -> Bool) async throws -> Bool { let invoice = try await createInvoice(amountSats: nil, note: "") let decodedInvoice = try await decode(invoice: invoice) - guard canCommitInvoice() else { throw PubkyServiceError.sessionNotActive } + guard try await canCommitInvoice() else { return false } guard case let .lightning(lightningInvoice) = decodedInvoice else { clearPublicPaykitBolt11() throw PublicPaykitError.invalidPayload @@ -1531,6 +1547,7 @@ class WalletViewModel: ObservableObject { publicPaykitBolt11 = invoice publicPaykitBolt11PaymentHash = lightningInvoice.paymentHash.hex publicPaykitBolt11ExpiresAt = Double(lightningInvoice.timestampSeconds + lightningInvoice.expirySeconds) + return true } private func clearPublicPaykitBolt11() { diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index 3813cb047..eb081a5dc 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -246,6 +246,152 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } catch PublicPaykitError.publicationFailed {} } + func testEnablingContactPaymentsRetriesInvoiceSupersessionOnce() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key) + 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 + let invalidate = { + preparations += 1 + if 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)) + } 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) + } + } + } + + 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 { for invoice in ["ln-public", ""] { let (profile, wallet) = publicationWallet(route: .channelUsable) @@ -299,7 +445,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } func testSuspendedPublicInvoiceCreationCannotMutateChangedOwnerOrSharing() async throws { - enum State: CaseIterable { case replaced, sharingDisabled, cancelled } + enum State: CaseIterable { case replaced, sameKeyNewSession, sharingDisabled, uiDisabled, cancelled } for state in State.allCases { let (profile, wallet) = publicationWallet(route: .channelUsable) wallet.stubPublicEndpoints = false @@ -310,9 +456,16 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } 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" diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 97b17dbaa..585925c34 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -20,6 +20,8 @@ While deletion is held, make a Lightning channel usable or ready, then fail dele 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. From 21a8f59f498427f94dee99a57c8cde98e6abcd66 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 13:54:12 -0500 Subject: [PATCH 08/10] fix: acknowledge the prepared public invoice revision --- Bitkit/Services/PublicPaykitService.swift | 28 ++++---- BitkitTests/ReceiveInvoiceRefreshTests.swift | 74 ++++++++++++++------ 2 files changed, 68 insertions(+), 34 deletions(-) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 777f29b66..e0194f799 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -327,24 +327,23 @@ enum PublicPaykitService { applyEndpoints: ([Endpoint]) async throws -> Void = applyPublishedEndpointsLocked ) async throws { if publish { wallet.preparePendingPublicPaykitInvoiceRefresh() } - let invoiceRevision = wallet.publicPaykitInvoiceRevision - var desiredEndpoints: [Endpoint]? + var desiredSnapshot: (endpoints: [Endpoint], invoiceRevision: UUID)? try await syncPublishedEndpoints( publish: publish, isSessionCurrent: isSessionCurrent, buildEndpoints: { - let endpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) - desiredEndpoints = endpoints - return endpoints + 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 desiredEndpoints { + if let desiredSnapshot { acknowledgePublishedEndpoints( wallet: wallet, - endpoints: desiredEndpoints, - invoiceRevision: invoiceRevision, + endpoints: desiredSnapshot.endpoints, + invoiceRevision: desiredSnapshot.invoiceRevision, isSessionCurrent: isSessionCurrent ) } @@ -398,8 +397,7 @@ enum PublicPaykitService { applyEndpoints: ([Endpoint]) async throws -> Void = applyPublishedEndpointsLocked ) async throws { wallet.preparePendingPublicPaykitInvoiceRefresh() - let invoiceRevision = wallet.publicPaykitInvoiceRevision - let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false) + let (desiredEndpoints, invoiceRevision) = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false) try await syncPublishedEndpoints( publish: true, isSessionCurrent: isSessionCurrent, @@ -565,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() @@ -588,6 +590,8 @@ enum PublicPaykitService { 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) @@ -624,7 +628,7 @@ enum PublicPaykitService { throw PublicPaykitError.noSupportedEndpoint } - return endpoints + return (endpoints, invoiceRevision) } private static func isPayableEndpoint(_ endpoint: Endpoint) async -> Bool { diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index eb081a5dc..8ebe131d4 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -249,6 +249,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { 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) @@ -293,6 +294,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { 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 { @@ -309,6 +311,28 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } } + 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) @@ -393,28 +417,33 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { } func testAdmittedPublicWriteCannotAcknowledgeNewerChannelRefresh() async throws { - for invoice in ["ln-public", ""] { - let (profile, wallet) = publicationWallet(route: .channelUsable) - wallet.publicPaykitBolt11 = invoice - PublicPaykitService.setCleanupPending(false) - let session = try XCTUnwrap(profile.automaticPaykitSession) - let write = PublicationGate("Admitted public write") - let publication = Task { - try await PublicPaykitService.syncCurrentPublishedEndpoints( - wallet: wallet, isSessionCurrent: { profile.automaticPaykitSession == session }, syncApp: {}, - applyEndpoints: { _ in await write.wait() } - ) - } - await fulfillment(of: [write.started], timeout: 5) - do { - try await profile.performProfileDeletion { - await wallet.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "test", forceRefreshLightning: true) - throw PublicPaykitError.publicationFailed + 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 } + ) } - } catch PublicPaykitError.publicationFailed {} - write.release() - try await publication.value - XCTAssertTrue(PublicPaykitService.isCleanupPending, "Invoice: \(invoice)") + 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)") + } } } @@ -1240,6 +1269,7 @@ private final class GatedReceiveWallet: WalletViewModel { var endpointSyncCount = 0 var failPublicEndpointRefresh = false var stubPublicEndpoints = false + var publicEndpointAddress = "" var replaceEmptyPublicInvoice = false var invoiceCreation: (() async throws -> String)? var beforePublicEndpointRefresh: (() async -> Void)? @@ -1308,7 +1338,7 @@ private final class GatedReceiveWallet: WalletViewModel { } let invoice = publicPaykitBolt11 await afterPublicEndpointSnapshot?() - return ("", invoice) + return (publicEndpointAddress, invoice) } return try await super.refreshPublicPaykitEndpoints( forceRefreshBolt11: forceRefreshBolt11, includeOnchain: includeOnchain, includeLightning: includeLightning From 417bccab483a1f1c6f99333b292995d3257ad7a7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 16 Oct 2026 15:26:57 -0500 Subject: [PATCH 09/10] fix: prepare public payment endpoints once --- Bitkit/Services/PublicPaykitService.swift | 8 +-- BitkitTests/ReceiveInvoiceRefreshTests.swift | 54 +++++++++++++++++--- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index e0194f799..a6e4bc9cb 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -577,16 +577,10 @@ 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 ) diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index 8ebe131d4..bf986362c 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -258,9 +258,11 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { 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 preparations <= invalidations { + if !duringCreation || preparations <= invalidations { wallet.publicPaykitBolt11PaymentHash = "paid" wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid") } @@ -307,10 +309,54 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { 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) @@ -512,10 +558,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { let (profile, wallet) = publicationWallet(route: .channelUsable) let session = try XCTUnwrap(profile.automaticPaykitSession) let preparation = PublicationGate("Older endpoint snapshot") - var snapshots = 0 wallet.afterPublicEndpointSnapshot = { - snapshots += 1 - guard snapshots == (forceRefresh ? 2 : 1) else { return } wallet.afterPublicEndpointSnapshot = nil await preparation.wait() } @@ -557,10 +600,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { PublicPaykitService.setCleanupPending(!replacesInvoice) let contacts = ContactsManager() let preparation = PublicationGate("Explicit endpoint snapshot") - var snapshots = 0 wallet.afterPublicEndpointSnapshot = { - snapshots += 1 - guard snapshots == 2 else { return } wallet.afterPublicEndpointSnapshot = nil await preparation.wait() } From 4c026d2dd68356b13331cffc1e7e9423267c476c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 13:06:29 +0200 Subject: [PATCH 10/10] fix: let a held contact link pass its guards after sign-in --- Bitkit/ViewModels/AppViewModel.swift | 12 ++++++--- BitkitTests/PubkyAuthURLSchemeTests.swift | 30 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) 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/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())