From af281ba5b32d4515b49f654dd2163b8ff7954080 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 22:44:35 +0300 Subject: [PATCH 1/6] fix: recover paykit after clock changes --- Bitkit/Managers/PubkyProfileManager.swift | 25 +++---- .../PaykitPaymentRequestService.swift | 35 +++++---- Bitkit/Services/PaykitSubscription.swift | 11 ++- Bitkit/Services/PubkyService.swift | 33 +++++---- .../PaykitPaymentRequestServiceTests.swift | 74 +++++++++++++++++++ BitkitTests/PaykitSdkClientConfigTests.swift | 40 ++++++++++ BitkitTests/PubkyIdentityRepublishTests.swift | 14 ++++ BitkitTests/PubkyProfileManagerTests.swift | 41 ++++++---- changelog.d/next/796.fixed.md | 1 + journeys/README.md | 2 + journeys/paykit-clock-changes.md | 25 +++++++ 11 files changed, 240 insertions(+), 61 deletions(-) create mode 100644 changelog.d/next/796.fixed.md create mode 100644 journeys/paykit-clock-changes.md diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 02b1921d2..0a13f7be2 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -169,7 +169,11 @@ class PubkyProfileManager: ObservableObject { // MARK: - Initialization & Session Restoration /// Initializes Paykit and restores any persisted session. - func initialize() async { + func initialize( + initializeSession: @escaping @Sendable () async throws -> SessionInitializationResult = { + try await PubkyProfileManager.initializePersistedSession() + } + ) async { isInitialized = false initializationErrorMessage = nil sessionRestorationFailed = false @@ -177,7 +181,7 @@ class PubkyProfileManager: ObservableObject { let result: SessionInitializationResult do { result = try await Task.detached { - try await Self.initializePersistedSession() + try await initializeSession() }.value } catch { Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") @@ -196,7 +200,7 @@ class PubkyProfileManager: ObservableObject { Logger.info("Paykit session restored for \(pk)", context: "PubkyProfileManager") Task { await loadProfile() } case .restorationFailed: - clearAuthenticatedState() + clearAuthenticatedState(clearCachedProfile: false) sessionRestorationFailed = true } @@ -1087,11 +1091,11 @@ class PubkyProfileManager: ObservableObject { UserDefaults.standard.set(pending, forKey: Self.profileSetupPendingKey) } - private func clearAuthenticatedState() { + private func clearAuthenticatedState(clearCachedProfile: Bool = true) { publicKey = nil profile = nil authState = .idle - clearCachedProfileMetadata() + if clearCachedProfile { clearCachedProfileMetadata() } } private func activeSessionSecret() throws -> String { @@ -1224,10 +1228,7 @@ class PubkyProfileManager: ObservableObject { savedSessionSecret: savedSecret, storedSecretKeyHex: secretKeyHex, importSession: { try await PubkyService.importSession(secret: $0) }, - signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) }, - deleteSessionSecret: { - try? Keychain.delete(key: .paykitSession) - } + signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) } ) } @@ -1337,8 +1338,7 @@ class PubkyProfileManager: ObservableObject { signInWithSecretKey: (String) async throws -> String, publicKeyFromSecretKey: (String) throws -> String = { try PubkyProfileManager.publicKeyFromSecretKey($0) - }, - deleteSessionSecret: () -> Void + } ) async -> SessionInitializationResult { if let savedSessionSecret, !savedSessionSecret.isEmpty @@ -1371,8 +1371,7 @@ class PubkyProfileManager: ObservableObject { Logger.info("Re-signed in and restored session for \(publicKey)", context: "PubkyProfileManager") return .restored(publicKey: publicKey) } catch { - Logger.error("Re-sign-in failed, clearing session: \(error)", context: "PubkyProfileManager") - deleteSessionSecret() + Logger.warn("Re-sign-in failed, keeping saved session for retry: \(error)", context: "PubkyProfileManager") return .restorationFailed } } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index b1630d55c..173608dca 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1014,6 +1014,7 @@ final class PaykitPaymentRequestManager { String, PaykitSubscription.ID ) async throws -> Set + private let retryNow: @Sendable () -> ContinuousClock.Instant private let now: @Sendable () -> Date private let logWarning: @Sendable (String) -> Void private let isAvailable: @MainActor () -> Bool @@ -1021,7 +1022,7 @@ final class PaykitPaymentRequestManager { private var approvedPaymentRequestIds: Set = [] private var presentedRequestIds: Set = [] private var presentationRetryAttempts: [PaykitPaymentRequest.ID: Int] = [:] - private var presentationRetryDates: [PaykitPaymentRequest.ID: Date] = [:] + private var presentationRetryDeadlines: [PaykitPaymentRequest.ID: ContinuousClock.Instant] = [:] private var automaticPresentationDiagnosticReasons: [PaykitPaymentRequest.ID: Set] = [:] private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] @@ -1072,6 +1073,7 @@ final class PaykitPaymentRequestManager { ) }, now: @escaping @Sendable () -> Date = { Date() }, + retryNow: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, isAvailable: @escaping @MainActor () -> Bool = { PaykitFeatureFlags.isUIEnabled }, logWarning: @escaping @Sendable (String) -> Void = { Logger.warn($0, context: "PaykitPaymentRequest") @@ -1085,6 +1087,7 @@ final class PaykitPaymentRequestManager { self.inFlightPaymentRequestIds = inFlightPaymentRequestIds self.protectedRequestIdsForSubscriptionCancellation = protectedRequestIdsForSubscriptionCancellation self.now = now + self.retryNow = retryNow self.isAvailable = isAvailable self.logWarning = logWarning } @@ -1384,7 +1387,7 @@ final class PaykitPaymentRequestManager { pendingRequests.removeAll { $0.id == request.id } presentedRequestIds.remove(request.id) presentationRetryAttempts.removeValue(forKey: request.id) - presentationRetryDates.removeValue(forKey: request.id) + presentationRetryDeadlines.removeValue(forKey: request.id) if requestedPresentationId == request.id { presentationGeneration += 1 requestedPresentationId = nil @@ -1473,7 +1476,7 @@ final class PaykitPaymentRequestManager { else { return false } presentationGeneration += 1 presentationRetryAttempts.removeValue(forKey: request.id) - presentationRetryDates.removeValue(forKey: request.id) + presentationRetryDeadlines.removeValue(forKey: request.id) requestedPresentationId = request.id schedulePresentationRetry() return true @@ -1500,7 +1503,7 @@ final class PaykitPaymentRequestManager { presentedRequestIds = [] persistedPresentedRequestIds = [] presentationRetryAttempts = [:] - presentationRetryDates = [:] + presentationRetryDeadlines = [:] automaticPresentationDiagnosticReasons = [:] expiredRequestedPresentations = [] unavailableRequestedPresentations = [] @@ -1522,12 +1525,12 @@ final class PaykitPaymentRequestManager { } func requestsForPresentation() -> [PaykitPaymentRequest] { - let date = now() + let date = retryNow() if let requestedPresentationId { guard !processingRequestIds.contains(requestedPresentationId), let requestedRequest = pendingRequests.first(where: { $0.id == requestedPresentationId }), presentationRetryAttempts[requestedPresentationId, default: 0] <= Self.presentationRetryDelays.count, - presentationRetryDates[requestedPresentationId].map({ $0 <= date }) ?? true + presentationRetryDeadlines[requestedPresentationId].map({ $0 <= date }) ?? true else { return [] } return [requestedRequest] } @@ -1535,7 +1538,7 @@ final class PaykitPaymentRequestManager { return pendingRequests.filter { !presentedRequestIds.contains($0.id) && !processingRequestIds.contains($0.id) && - (presentationRetryDates[$0.id].map { $0 <= date } ?? true) + (presentationRetryDeadlines[$0.id].map { $0 <= date } ?? true) } } @@ -1644,7 +1647,7 @@ final class PaykitPaymentRequestManager { presentationRetryAttempts[request.id] = attempt + 1 delay = Self.presentationRetryDelays[attempt] } else if isRequestedPresentation { - presentationRetryDates.removeValue(forKey: request.id) + presentationRetryDeadlines.removeValue(forKey: request.id) requestedPresentationId = nil presentedRequestIds.insert(request.id) persistPresentedRequestIds() @@ -1654,7 +1657,7 @@ final class PaykitPaymentRequestManager { } else { delay = Self.automaticPresentationRetryDelay } - presentationRetryDates[request.id] = now().addingTimeInterval(delay) + presentationRetryDeadlines[request.id] = retryNow().advanced(by: .seconds(delay)) schedulePresentationRetry() return .retryScheduled } @@ -1683,7 +1686,7 @@ final class PaykitPaymentRequestManager { requestedPresentationId = nil } presentationRetryAttempts.removeValue(forKey: request.id) - presentationRetryDates.removeValue(forKey: request.id) + presentationRetryDeadlines.removeValue(forKey: request.id) automaticPresentationDiagnosticReasons.removeValue(forKey: request.id) schedulePresentationRetry() persistPresentedRequestIds() @@ -1801,7 +1804,7 @@ final class PaykitPaymentRequestManager { let currentRequestIds = Set(pendingRequests.map(\.id)) presentedRequestIds.formIntersection(currentRequestIds) presentationRetryAttempts = presentationRetryAttempts.filter { currentRequestIds.contains($0.key) } - presentationRetryDates = presentationRetryDates.filter { currentRequestIds.contains($0.key) } + presentationRetryDeadlines = presentationRetryDeadlines.filter { currentRequestIds.contains($0.key) } automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { currentRequestIds.contains($0.key) } persistPresentedRequestIds() discardExpiredRequests(handledRequestedExpirationId: handledRequestedExpirationId) @@ -1895,7 +1898,7 @@ final class PaykitPaymentRequestManager { pendingRequests.removeAll { $0.id == request.id } presentedRequestIds.remove(request.id) presentationRetryAttempts.removeValue(forKey: request.id) - presentationRetryDates.removeValue(forKey: request.id) + presentationRetryDeadlines.removeValue(forKey: request.id) automaticPresentationDiagnosticReasons.removeValue(forKey: request.id) schedulePresentationRetry() if requestedPresentationId == request.id { @@ -1940,7 +1943,7 @@ final class PaykitPaymentRequestManager { let requestIds = Set(pendingRequests.map(\.id)) presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } - presentationRetryDates = presentationRetryDates.filter { requestIds.contains($0.key) } + presentationRetryDeadlines = presentationRetryDeadlines.filter { requestIds.contains($0.key) } automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { requestIds.contains($0.key) } if requestedPresentationId.map({ !requestIds.contains($0) }) == true { presentationGeneration += 1 @@ -1972,11 +1975,11 @@ final class PaykitPaymentRequestManager { presentationRetryTask?.cancel() presentationRetryTask = nil - guard let nextRetry = presentationRetryDates.values.min() else { return } - let delay = max(0, nextRetry.timeIntervalSince(now())) + guard let nextRetry = presentationRetryDeadlines.values.min() else { return } + let delay = max(Duration.zero, retryNow().duration(to: nextRetry)) presentationRetryTask = Task { [weak self] in do { - try await Task.sleep(for: .seconds(delay)) + try await Task.sleep(for: delay) } catch { return } diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 73e5aa5e4..77e0c2b00 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -716,7 +716,9 @@ actor PaykitSubscriptionNotificationScheduler { subscription: subscription, period: period ) - guard !existingIdentifiers.contains(identifier) else { continue } + guard !pending.contains(where: { + $0.identifier == identifier && $0.trigger is UNCalendarNotificationTrigger + }) else { continue } let content = UNMutableNotificationContent() content.title = t("subscriptions__payment_due_title") content.body = t("subscriptions__payment_due_description") @@ -729,8 +731,11 @@ actor PaykitSubscriptionNotificationScheduler { "counterparty_receiver_path": subscription.counterpartyReceiverPath, "billing_period_starts_at": PaykitSubscriptionTimestamp.string(from: period.startsAt), ] - let interval = max(1, period.startsAt.timeIntervalSince(now)) - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let date = Date(timeIntervalSince1970: ceil(period.startsAt.timeIntervalSince1970)) + let components = calendar.dateComponents([.calendar, .timeZone, .year, .month, .day, .hour, .minute, .second], from: date) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) let request = UNNotificationRequest( identifier: identifier, content: content, diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index a08c8410c..e60d42dc1 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -349,14 +349,19 @@ actor PaykitSdkService { private var isRepublishingIdentity = false private var republishPublicKey: String? private var nextIdentityRepublishAt = Date.distantPast + private var lastIdentityRepublishAt = Date.distantPast private var sdk: PaykitSdk? private var activeAuthRequest: Paykit.PubkyAuthRequest? private var activeAuthRequestID: UUID? + private let sdkFactory: (() throws -> PaykitSdk)? + init( + sdkFactory: (() throws -> PaykitSdk)? = nil, bootstrapFactory: @escaping BootstrapFactory = PubkySessionBootstrap.withPubkyClientConfig(clientId:pubkyClient:) ) { self.bootstrapFactory = bootstrapFactory + self.sdkFactory = sdkFactory } func initialize() async throws { @@ -419,10 +424,11 @@ actor PaykitSdkService { try Paykit.pubkyPublicKeyFromSecret(localSecretKey: $0) } guard let identity = identity.flatMap(PubkyPublicKeyFormat.normalized), - identity != republishPublicKey || now >= nextIdentityRepublishAt + identity != republishPublicKey || now < lastIdentityRepublishAt || now >= nextIdentityRepublishAt else { return } republishPublicKey = identity + lastIdentityRepublishAt = now nextIdentityRepublishAt = now.addingTimeInterval(60) if try await bootstrap().republishIdentity(publicKey: identity) { nextIdentityRepublishAt = now.addingTimeInterval(30 * 60) @@ -458,7 +464,7 @@ actor PaykitSdkService { func importSession(secret: String, includeLocalSecret: Bool = true) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { - let previousPublicKey = await currentSdkStatePublicKey() + let previousPublicKey = try await currentSdkStatePublicKey() let localSecret = includeLocalSecret ? try sessionProvider.loadLocalSecretKey() : nil let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().importSession( @@ -475,7 +481,7 @@ actor PaykitSdkService { func signUp(secretKeyHex: String, homeserverPublicKey: String, signupCode: String?) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { - let previousPublicKey = await currentSdkStatePublicKey() + let previousPublicKey = try await currentSdkStatePublicKey() let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), @@ -508,7 +514,7 @@ actor PaykitSdkService { func activateRegisteredIdentity(_ result: PubkySessionBootstrapResult) async throws { try await operationLock.withLock { - let previousPublicKey = await currentSdkStatePublicKey() + let previousPublicKey = try await currentSdkStatePublicKey() do { try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) } catch { @@ -524,7 +530,7 @@ actor PaykitSdkService { func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { - let previousPublicKey = await currentSdkStatePublicKey() + let previousPublicKey = try await currentSdkStatePublicKey() let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().signIn( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), @@ -575,7 +581,7 @@ actor PaykitSdkService { clearActiveAuthRequest(ifCurrent: requestID) } - let previousPublicKey = await currentSdkStatePublicKey() + let previousPublicKey = try await currentSdkStatePublicKey() let sessionSecret = try await Self.completeAuthActivation( sessionSecret: result.sessionAccess.exportSessionSecret(), activate: { @@ -1105,7 +1111,7 @@ actor PaykitSdkService { return sdk } - let created = try PaykitSdk.withPaymentAdapterAndPubkyClientConfig( + let created = try sdkFactory?() ?? PaykitSdk.withPaymentAdapterAndPubkyClientConfig( stateStore: stateStore, sessionProvider: sessionProvider, paymentAdapter: paymentAdapter, @@ -1215,14 +1221,11 @@ actor PaykitSdkService { ) } - private func currentSdkStatePublicKey() async -> String? { - do { - return try await handle().identityStatus()?.publicKey - } catch { - try? Keychain.delete(key: .paykitSdkState) - resetRuntime() - return nil - } + private func currentSdkStatePublicKey() async throws -> String? { + // Read the persisted owner without restoring the grant we are about to replace. + sessionProvider.suspendStoredSessionAccess() + defer { sessionProvider.resumeStoredSessionAccess() } + return try await handle().identityStatus()?.publicKey } private nonisolated static func publicKeysMatch(_ lhs: String?, _ rhs: String) -> Bool { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 5ddcac728..8e2d4608d 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1428,6 +1428,50 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(period.endsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))) } + func testTravelAndDaylightSavingChangesPreserveUTCBillingReminders() async throws { + let original = NSTimeZone.default + defer { NSTimeZone.default = original } + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-03-13T09:00:00Z")) + let expected = try [ + XCTUnwrap(ISO8601DateFormatter().date(from: "2027-03-14T08:00:00Z")), + XCTUnwrap(ISO8601DateFormatter().date(from: "2027-03-15T08:00:00Z")), + ] + for zone in ["America/New_York", "Pacific/Kiritimati", "Pacific/Pago_Pago"] { + NSTimeZone.default = try XCTUnwrap(TimeZone(identifier: zone)) + let recurrence = PaymentRequestRecurrence( + every: 1, unit: "day", startsAt: "2027-03-13T08:00:00Z", + anchor: "2027-03-13T08:00:00Z", endsAt: "2027-03-16T08:00:00Z" + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + state: .activeRecurring, recurrence: recurrence + ))) + let periods = subscription.recurrence.upcomingPeriods(after: now, limit: 2) + XCTAssertEqual(periods.map(\.startsAt), expected, zone) + let center = PaykitSubscriptionNotificationCenterMock() + let scheduler = PaykitSubscriptionNotificationScheduler(center: center) + let identifier = PaykitSubscriptionNotificationIdentifier.identifier( + identity: "pubky_test", subscription: subscription, period: periods[0] + ) + try await center.add(UNNotificationRequest( + identifier: identifier, content: UNMutableNotificationContent(), + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 60, repeats: false) + )) + + for clock in [now, now.addingTimeInterval(-3600)] { + await scheduler.synchronize( + [subscription], acceptedAt: [subscription.id: now], pendingRequestIds: [], + payerIdentity: "pubky_test", notificationsEnabled: true, now: clock + ) + let requests = await center.pendingNotificationRequests() + let request = try XCTUnwrap(requests.first { $0.identifier == identifier }) + let trigger = try XCTUnwrap(request.trigger as? UNCalendarNotificationTrigger) + XCTAssertEqual(trigger.dateComponents.timeZone?.secondsFromGMT(), 0) + XCTAssertEqual(trigger.dateComponents.date, expected[0], zone) + XCTAssertFalse(trigger.repeats) + } + } + } + func testRecurrenceReturnsConsecutiveUpcomingPeriods() throws { let recurrence = PaymentRequestRecurrence( every: 1, @@ -1840,6 +1884,21 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.requestsForPresentation(), [request]) } + func testPresentationRetryUsesElapsedTimeAcrossWallClockChanges() async throws { + for shift in [TimeInterval(-30 * 86400), TimeInterval(30 * 86400)] { + let clock = PaymentRequestTestClock(Date()) + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()]), clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.requestsForPresentation().first) + manager.deferPresentation(request) + + clock.shiftWallClock(by: shift) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + clock.advance(by: 2) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + } + } + func testDeferredRequestUsesIncreasingPresentationBackoff() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let clock = PaymentRequestTestClock(now) @@ -3153,6 +3212,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { inFlightPaymentRequestIds: { _ in inFlightPaymentRequestIds }, protectedRequestIdsForSubscriptionCancellation: { _, _ in protectedRequestIdsForSubscriptionCancellation }, now: now, + retryNow: { clock.retryNow() }, isAvailable: { true }, logWarning: { _ in } ) @@ -3708,6 +3768,7 @@ private enum PaymentRequestSdkMockError: Error, Equatable { private final class PaymentRequestTestClock: @unchecked Sendable { private let lock = NSLock() private var date: Date + private var retryInstant = ContinuousClock.now private var invocations = 0 init(_ date: Date) { @@ -3731,6 +3792,19 @@ private final class PaymentRequestTestClock: @unchecked Sendable { lock.lock() defer { lock.unlock() } date = date.addingTimeInterval(interval) + retryInstant = retryInstant.advanced(by: .seconds(interval)) + } + + func shiftWallClock(by interval: TimeInterval) { + lock.lock() + defer { lock.unlock() } + date = date.addingTimeInterval(interval) + } + + func retryNow() -> ContinuousClock.Instant { + lock.lock() + defer { lock.unlock() } + return retryInstant } } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 3c461ba70..214bb4363 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -8,6 +8,38 @@ final class PaykitSdkClientConfigTests: XCTestCase { "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + "&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + func testIdentityReadFailurePreservesSavedStateAndSession() async throws { + let savedState = try Keychain.load(key: .paykitSdkState) + let savedSession = try Keychain.load(key: .paykitSession) + defer { + for (key, data) in [(KeychainEntryType.paykitSdkState, savedState), (.paykitSession, savedSession)] { + if let data { try? Keychain.upsert(key: key, data: data) } + else { try? Keychain.delete(key: key) } + } + } + let state = Data("saved contacts and identity".utf8) + let session = Data("saved grant".utf8) + for failure in [ + PaykitError.Identity(code: "identity_error", context: "restore Pubky grant session from platform provider"), + PaykitError.Storage(code: "storage_error", context: "unavailable"), + ] { + try Keychain.upsert(key: .paykitSdkState, data: state) + try Keychain.upsert(key: .paykitSession, data: session) + let sdk = IdentityReadFailureSdk(noPointer: .init()) + sdk.failure = failure + let service = PaykitSdkService(sdkFactory: { sdk }) + + do { + _ = try await service.signIn(secretKeyHex: "unused") + XCTFail("An unreadable stored identity must stop activation") + } catch { + XCTAssertEqual(String(describing: error), String(describing: failure)) + } + XCTAssertEqual(try Keychain.load(key: .paykitSdkState), state) + XCTAssertEqual(try Keychain.load(key: .paykitSession), session) + } + } + func testClientIDUsesBitkitOwnedDomain() { let expectedClientID = Env.network == .bitcoin ? "bitkit.to" : "staging.bitkit.to" @@ -165,3 +197,11 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } } + +private final class IdentityReadFailureSdk: PaykitSdk, @unchecked Sendable { + var failure: Error = PubkyServiceError.sessionNotActive + + override func identityStatus() async throws -> IdentityStatus? { + throw failure + } +} diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift index 597b12c01..4682bf6c8 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -6,6 +6,20 @@ final class PubkyIdentityRepublishTests: XCTestCase { private let publicKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private let now = Date(timeIntervalSince1970: 1000) + func testClockRollbackRetriesPublicationAndResumesThrottling() async { + for published in [true, false] { + let bootstrap = RepublishBootstrap(noPointer: .init()) + bootstrap.operation = { _ in published } + let service = PaykitSdkService { _, _ in bootstrap } + + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now.addingTimeInterval(30 * 86400)) + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now) + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now.addingTimeInterval(1)) + + XCTAssertEqual(bootstrap.publicKeys.count, 2) + } + } + func testSuccessfulPublicationIsThrottledAndReusesBootstrap() async { let bootstrap = RepublishBootstrap(noPointer: .init()) var factories = 0 diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 308c170fc..08b2e2513 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -4,6 +4,32 @@ import struct Paykit.PubkySessionBootstrapResult import XCTest final class PubkyProfileManagerTests: XCTestCase { + @MainActor + func testFailedRestorationPreservesCachedProfile() async { + let keys = ["pubky_profile_name", "pubky_profile_image_uri"] + let defaults = UserDefaults.standard + let saved = keys.map { defaults.object(forKey: $0) } + defer { + for (key, value) in zip(keys, saved) { + defaults.set(value, forKey: key) + } + } + defaults.set("Existing profile", forKey: keys[0]) + defaults.set("pubky://existing/avatar", forKey: keys[1]) + let manager = PubkyProfileManager() + + await manager.initialize { .restorationFailed } + + XCTAssertTrue(manager.isInitialized) + XCTAssertTrue(manager.sessionRestorationFailed) + XCTAssertEqual(manager.authState, .idle) + XCTAssertNil(manager.publicKey) + XCTAssertEqual(manager.cachedName, "Existing profile") + XCTAssertEqual(manager.cachedImageUri, "pubky://existing/avatar") + XCTAssertEqual(defaults.string(forKey: keys[0]), manager.cachedName) + XCTAssertEqual(defaults.string(forKey: keys[1]), manager.cachedImageUri) + } + @MainActor func testIdentityRestorationPreservesCredentialsForRetry() async throws { for failedStep in ["load", "signIn", "profile"] { @@ -775,9 +801,6 @@ final class PubkyProfileManagerTests: XCTestCase { publicKeyFromSecretKey: { _ in XCTFail("Public key should not be derived after successful saved-session import") return "pubky_unused" - }, - deleteSessionSecret: { - XCTFail("Session should not be deleted after successful import") } ) @@ -799,18 +822,13 @@ final class PubkyProfileManagerTests: XCTestCase { publicKeyFromSecretKey: { secretKey in XCTAssertEqual(secretKey, "local-secret") return "pubky_test" - }, - deleteSessionSecret: { - XCTFail("Session should not be deleted after successful re-sign-in") } ) XCTAssertEqual(result, .restored(publicKey: "pubky_test")) } - func testResolveSessionInitializationDeletesSavedSessionWhenReSignInFails() async { - var deletedSavedSession = false - + func testResolveSessionInitializationReportsFailedRestorationWhenReSignInFails() async { let result = await PubkyProfileManager.resolveSessionInitialization( savedSessionSecret: "stale-session", storedSecretKeyHex: "local-secret", @@ -823,13 +841,10 @@ final class PubkyProfileManagerTests: XCTestCase { publicKeyFromSecretKey: { _ in XCTFail("No public key should be derived when re-sign-in fails") return "pubky_unused" - }, deleteSessionSecret: { - deletedSavedSession = true } ) XCTAssertEqual(result, .restorationFailed) - XCTAssertTrue(deletedSavedSession) } func testResolveSessionInitializationReturnsNoSessionWhenNoCredentialsExist() async { @@ -847,8 +862,6 @@ final class PubkyProfileManagerTests: XCTestCase { publicKeyFromSecretKey: { _ in XCTFail("No public key should be derived without credentials") return "pubky_unused" - }, deleteSessionSecret: { - XCTFail("No saved session exists to delete") } ) diff --git a/changelog.d/next/796.fixed.md b/changelog.d/next/796.fixed.md new file mode 100644 index 000000000..f2384e110 --- /dev/null +++ b/changelog.d/next/796.fixed.md @@ -0,0 +1 @@ +Fixed Paykit recovery after device clock changes to preserve contacts and improve retry and billing reminder timing. diff --git a/journeys/README.md b/journeys/README.md index 43c55d61c..54d502320 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -155,6 +155,8 @@ journey PR, which is what made this file conflict on every merge. | LNURL pay, withdraw, channel and auth, and Lightning Addresses | the `bitkit-docker` `lnurl-server` on local regtest, reached by a simulator app built with `E2E_BUILD`, whose default `E2E_BACKEND=local` targets the host at `127.0.0.1`; it issues memo invoices, so a check that needs a description-hash invoice needs another endpoint | | Deep links handed to the app | `xcrun simctl openurl ""`; only `bitkit://pubky-auth/setup`, `bitkit://contact?pubky=`, web URLs, Pubky callbacks and payment URIs route — there is no screen or sheet router — [pubky-auth](pubky-auth/README.md), [deeplinks](deeplinks), [Not ported](#not-ported) | +Device-clock fault injection requires a separate manual run: [Paykit clock changes](paykit-clock-changes.md). + ## Not ported **`deeplinks/screen-deeplink.xml` and `sheet-deeplink.xml`.** These Android journeys exercise `bitkit://screen/...` routing with a diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md new file mode 100644 index 000000000..3784bf271 --- /dev/null +++ b/journeys/paykit-clock-changes.md @@ -0,0 +1,25 @@ +# Paykit clock changes — manual fault injection + +Device-clock control is not provided by the journey runner's capability table. Run these checks on disposable test identities and test wallets using a device or isolated environment whose clock can be changed without changing the developer host clock. + +## Setup + +Create a fresh wallet and a matching Pubky identity, save a contact, and link a second test identity for private payments. Record the profile, contact, receiving address, and wallet balance. Cover both a local-secret identity and a Ring-authorized session. Enable notifications and accept a recurring subscription with a known UTC billing boundary. + +## Clock skew and recovery + +1. Move the test clock one month forward. Relaunch Bitkit and attempt a Paykit operation. An authentication failure is allowed; the app must not treat it as authorization to erase the saved identity, contacts, or wallet. +2. Attempt to restore the session while the clock is wrong. Restore the correct clock and retry, then relaunch. If a grant has expired or been revoked, authorize the same identity again in Ring. Do not sign out or reset the wallet as part of recovery. +3. Verify that the original contact, profile, receiving address, and balance are still present, and that private payment requests can be exchanged again. A new payment must still require normal approval. +4. Repeat with a backward clock change. After correcting the clock, verify that identity publication and payment-request presentation retry normally instead of waiting for the old future timestamp. +5. Separately verify that explicitly signing out and switching identities retains the normal isolation between identities. + +## Travel, daylight saving, and reminders + +1. Keep automatic date/time enabled and change only the timezone between America/New_York, Pacific/Kiritimati, and Pacific/Pago_Pago. Authentication and the subscription's UTC billing boundary must remain unchanged; local date/time labels may change. +2. Include a subscription spanning a daylight-saving transition. Verify the agreed UTC boundary rather than assuming the local wall-clock hour stays constant. +3. Schedule a reminder, then move the clock backward before it is due. It must not announce that payment is due while the device's current time is before that billing boundary. +4. Restore the correct clock and verify reminders still work. On Android, WorkManager delivery is best effort and may be delayed by retry backoff or OS scheduling; this check does not require exact delivery to the second. +5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. + +These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. From 182657aae01c0fc6423c464e39a18d2e0ab28dd3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 23:08:28 +0300 Subject: [PATCH 2/6] fix: isolate pubky cache when identity changes --- Bitkit/Components/Header.swift | 2 +- Bitkit/Managers/PubkyProfileManager.swift | 15 +++ Bitkit/Services/PubkyService.swift | 1 + BitkitTests/PaykitSdkClientConfigTests.swift | 125 +++++++++++++++++++ journeys/paykit-clock-changes.md | 3 +- 5 files changed, 144 insertions(+), 2 deletions(-) diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index d8106aa55..67e7d5a57 100644 --- a/Bitkit/Components/Header.swift +++ b/Bitkit/Components/Header.swift @@ -119,7 +119,7 @@ struct Header: View { return } - if pubkyProfile.isAuthenticated || pubkyProfile.cachedName != nil { + if pubkyProfile.isAuthenticated { navigation.navigate(.profile) } else if pubkyProfile.initializationErrorMessage != nil { navigation.navigate(.profile) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 0a13f7be2..2517e4f0c 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -195,6 +195,7 @@ class PubkyProfileManager: ObservableObject { clearAuthenticatedState() Logger.debug("No saved paykit session found", context: "PubkyProfileManager") case let .restored(pk): + reloadCachedProfileMetadata() publicKey = pk authState = .authenticated Logger.info("Paykit session restored for \(pk)", context: "PubkyProfileManager") @@ -444,6 +445,7 @@ class PubkyProfileManager: ObservableObject { try await activateIdentity(registeredSession) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + reloadCachedProfileMetadata() self.publicKey = publicKey authState = .authenticated setProfileSetupPending(true) @@ -718,6 +720,7 @@ class PubkyProfileManager: ObservableObject { Self.notifyAppStateBackupChanged() activeAuthAttemptID = nil + reloadCachedProfileMetadata() publicKey = pk authState = .completingAuthentication Logger.info("Pubky auth completed for \(pk)", context: "PubkyProfileManager") @@ -1079,6 +1082,18 @@ class PubkyProfileManager: ObservableObject { UserDefaults.standard.set(profile.imageUrl, forKey: Self.cachedImageUriKey) } + static func clearCachedIdentityMetadata() { + UserDefaults.standard.removeObject(forKey: cachedNameKey) + UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + ContactsManager.restoreContactProfileOverrides(nil) + } + + private func reloadCachedProfileMetadata() { + profile = nil + cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) + cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) + } + private func clearCachedProfileMetadata() { cachedName = nil cachedImageUri = nil diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index e60d42dc1..58e48deb0 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1192,6 +1192,7 @@ actor PaykitSdkService { try persistSessionAccess(result.sessionAccess, shouldStoreLocalSecret: shouldStoreLocalSecret) sessionProvider.setLiveSessionAccess(result.sessionAccess) if !Self.publicKeysMatch(previousPublicKey, result.publicKey) { + if previousPublicKey != nil { await PubkyProfileManager.clearCachedIdentityMetadata() } try? Keychain.delete(key: .paykitSdkState) } resetRuntime() diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 214bb4363..bf995e84f 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -40,6 +40,82 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } + @MainActor + func testIdentityActivationSeparatesCacheAndPreservesSameOwnerOrLegacyBackup() async throws { + let defaults = UserDefaults.standard + let metadataKeys = ["pubky_profile_name", "pubky_profile_image_uri"] + let savedMetadata = metadataKeys.map { defaults.object(forKey: $0) } + let savedOverrides = ContactsManager.backupContactProfileOverrides() + let credentialKeys: [KeychainEntryType] = [ + .paykitSdkState, .paykitSession, .pubkySecretKey, .paykitReceiverNoiseSecretKey, + .bip39Mnemonic(index: 0), .bip39Passphrase(index: 0), + ] + let savedCredentials = try credentialKeys.map { try Keychain.load(key: $0) } + defer { + for (key, value) in zip(metadataKeys, savedMetadata) { + defaults.set(value, forKey: key) + } + ContactsManager.restoreContactProfileOverrides(savedOverrides) + for (key, data) in zip(credentialKeys, savedCredentials) { + if let data { try? Keychain.upsert(key: key, data: data) } + else { try? Keychain.delete(key: key) } + } + } + let mnemonic = Array(repeating: "abandon", count: 11).joined(separator: " ") + " about" + try Keychain.upsert(key: .bip39Mnemonic(index: 0), data: Data(mnemonic.utf8)) + try Keychain.delete(key: .bip39Passphrase(index: 0)) + let noiseBytes = try PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( + mnemonic: mnemonic, passphrase: nil, network: Env.networkName, receiverPath: PaykitReceiverPath.wallet + ) + try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: noiseBytes) + let originalKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let differentKey = "5" + String(originalKey.dropFirst()) + let overrides = [originalKey: PubkyProfileData(name: "Private label", bio: "", image: nil, links: [], tags: [])] + for previousKey in [originalKey, "pubky\(originalKey)", differentKey, nil] { + for restoreOnStartup in [false, true] { + defaults.set("Original profile", forKey: metadataKeys[0]) + defaults.set("pubky://original/avatar", forKey: metadataKeys[1]) + ContactsManager.restoreContactProfileOverrides(overrides) + let manager = UnavailableProfileManager() + await manager.initialize { .restorationFailed } + let sdk = CacheActivationSdk(noPointer: .init()) + sdk.previousKey = previousKey + let service = PaykitSdkService(sdkFactory: { sdk }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } + let session = CacheActivationSession(noPointer: .init()) + session.noiseBytes = noiseBytes + let result = PubkySessionBootstrapResult(sessionAccess: session, publicKey: "pubky\(originalKey)") + + if restoreOnStartup { + try await service.activateRegisteredIdentity(result) + await manager.initialize { .restored(publicKey: "pubky\(originalKey)") } + } else { + manager.setActiveAuthAttemptIDForTesting(UUID()) + try await manager.completeAuthenticationForTesting( + completeAuth: { + try await service.activateRegisteredIdentity(result) + return "new-session" + }, + currentPublicKey: { "pubky\(originalKey)" }, + discardSessionAccess: { _ in XCTFail("Activation must succeed") } + ) + } + + XCTAssertEqual(manager.publicKey, "pubky\(originalKey)") + if previousKey == differentKey { + XCTAssertNil(manager.displayName) + XCTAssertNil(manager.displayImageUri) + XCTAssertNil(defaults.string(forKey: metadataKeys[0])) + XCTAssertNil(defaults.string(forKey: metadataKeys[1])) + XCTAssertNil(ContactsManager.backupContactProfileOverrides()) + } else { + XCTAssertEqual(manager.displayName, "Original profile") + XCTAssertEqual(manager.displayImageUri, "pubky://original/avatar") + XCTAssertEqual(ContactsManager.backupContactProfileOverrides(), overrides) + } + } + } + } + func testClientIDUsesBitkitOwnedDomain() { let expectedClientID = Env.network == .bitcoin ? "bitkit.to" : "staging.bitkit.to" @@ -205,3 +281,52 @@ private final class IdentityReadFailureSdk: PaykitSdk, @unchecked Sendable { throw failure } } + +@MainActor +private final class UnavailableProfileManager: PubkyProfileManager { + override func loadProfile() async {} +} + +private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { + var previousKey: String? + + override func identityStatus() async throws -> IdentityStatus? { + IdentityStatus(publicKey: previousKey, liveSessionAvailable: false) + } + + override func initialize() async throws -> InitializationReport { + InitializationReport(identity: IdentityStatus(publicKey: previousKey, liveSessionAvailable: false)) + } +} + +private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked Sendable { + override func republishIdentity(publicKey _: String) async throws -> Bool { + true + } +} + +private final class CacheActivationSession: PubkySessionAccess, @unchecked Sendable { + var noiseBytes = Data() + + override func exportSessionSecret() -> String { + "new-session" + } + + override func exportLocalSecretKey() -> PubkyLocalSecretKey? { + nil + } + + override func exportReceiverNoiseSecretKey() -> ReceiverNoiseSecretKey { + let key = CacheActivationNoiseKey(noPointer: .init()) + key.bytes = noiseBytes + return key + } +} + +private final class CacheActivationNoiseKey: ReceiverNoiseSecretKey, @unchecked Sendable { + var bytes = Data() + + override func exportBytes() -> Data { + bytes + } +} diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index 3784bf271..be49f593b 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -12,7 +12,8 @@ Create a fresh wallet and a matching Pubky identity, save a contact, and link a 2. Attempt to restore the session while the clock is wrong. Restore the correct clock and retry, then relaunch. If a grant has expired or been revoked, authorize the same identity again in Ring. Do not sign out or reset the wallet as part of recovery. 3. Verify that the original contact, profile, receiving address, and balance are still present, and that private payment requests can be exchanged again. A new payment must still require normal approval. 4. Repeat with a backward clock change. After correcting the clock, verify that identity publication and payment-request presentation retry normally instead of waiting for the old future timestamp. -5. Separately verify that explicitly signing out and switching identities retains the normal isolation between identities. +5. After a failed restoration, open the profile from the home header and authorize the same identity through Ring without signing out. The recovery flow must remain reachable and preserve the profile and contact labels. +6. Repeat failed restoration, then authorize a different identity through Ring. Even if its profile cannot load, the previous identity's name, avatar, and contact labels must not appear. Also verify normal explicit sign-out and identity switching. ## Travel, daylight saving, and reminders From 797a528c228be027612ecbb49e130418a83e66ae Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 17:15:19 +0300 Subject: [PATCH 3/6] fix: retry saved paykit sessions after connection loss --- Bitkit/AppScene.swift | 4 + Bitkit/Managers/PubkyProfileManager.swift | 132 ++++++++++++++----- Bitkit/Services/PubkyService.swift | 98 +++++++++----- BitkitTests/PaykitSdkClientConfigTests.swift | 101 ++++++++++++++ BitkitTests/PubkyProfileManagerTests.swift | 120 ++++++++++++++++- changelog.d/next/796.fixed.md | 2 +- journeys/paykit-clock-changes.md | 12 ++ 7 files changed, 399 insertions(+), 70 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index e3a92c646..65c462fb6 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -945,9 +945,11 @@ struct AppScene: View { } if wallet.walletExists == true { Task { + async let sessionRecovery: Void = network.isConnected ? pubkyProfile.restoreSessionIfNeeded() : () await clearDeliveredNotifications() await LightningService.shared.reconnectPeers() try? await wallet.sync() + await sessionRecovery await retryPendingPaykitEndpointRemoval() await wallet.refreshPublicPaykitEndpointsOnForeground() if PaykitFeatureFlags.isUIEnabled { @@ -1371,7 +1373,9 @@ struct AppScene: View { // Refresh currency rates when network is restored - critical for UI // to display balances (MoneyText returns "0" if rates are nil) Task { + async let sessionRecovery: Void = pubkyProfile.restoreSessionIfNeeded() await currency.refresh() + await sessionRecovery if scenePhase == .active { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 2517e4f0c..7184fe12b 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -159,6 +159,18 @@ class PubkyProfileManager: ObservableObject { private var activeAuthAttemptID: UUID? private var isSignupInFlight = false + private var initializationTask: Task? + private static var sessionRevision = UUID() + private static var sessionMutationCount = 0 + + private static func beginSessionMutation() { + sessionRevision = UUID() + sessionMutationCount += 1 + } + + private static func endSessionMutation() { + sessionMutationCount -= 1 + } init() { cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) @@ -174,6 +186,41 @@ class PubkyProfileManager: ObservableObject { try await PubkyProfileManager.initializePersistedSession() } ) async { + if let initializationTask { + await initializationTask.value + return + } + guard Self.sessionMutationCount == 0, activeAuthAttemptID == nil else { return } + let task = Task { + defer { initializationTask = nil } + guard Self.sessionMutationCount == 0, activeAuthAttemptID == nil else { return } + await initializeSessionState(initializeSession: initializeSession) + } + initializationTask = task + await task.value + } + + /// Retry saved credentials after startup or connectivity failures, without interrupting authorization. + func restoreSessionIfNeeded( + hasStoredIdentity: () throws -> Bool = { try PubkyProfileManager.hasStoredIdentity() }, + initializeSession: @escaping @Sendable () async throws -> SessionInitializationResult = { + try await PubkyProfileManager.initializePersistedSession() + } + ) async { + if let initializationTask { await initializationTask.value } + guard publicKey == nil, authState == .idle, activeAuthAttemptID == nil, Self.sessionMutationCount == 0 else { return } + do { + guard try hasStoredIdentity() else { return } + await initialize(initializeSession: initializeSession) + } catch { + Logger.warn("Unable to read saved Pubky identity for recovery: \(error)", context: "PubkyProfileManager") + } + } + + private func initializeSessionState( + initializeSession: @escaping @Sendable () async throws -> SessionInitializationResult + ) async { + let revision = Self.sessionRevision isInitialized = false initializationErrorMessage = nil sessionRestorationFailed = false @@ -184,12 +231,20 @@ class PubkyProfileManager: ObservableObject { try await initializeSession() }.value } catch { + guard revision == Self.sessionRevision else { + isInitialized = true + return + } Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") authState = .idle initializationErrorMessage = error.localizedDescription return } + guard revision == Self.sessionRevision else { + isInitialized = true + return + } switch result { case .noSession: clearAuthenticatedState() @@ -280,6 +335,8 @@ class PubkyProfileManager: ObservableObject { try await Task.detached { try Keychain.loadString(key: .pubkySecretKey) }.value } ) async throws { + Self.beginSessionMutation() + defer { Self.endSessionMutation() } if isProfileSetupPending, let publicKey { try await createProfile( publicKey: publicKey, @@ -437,7 +494,11 @@ class PubkyProfileManager: ObservableObject { ) async throws { guard !isSignupInFlight else { throw PubkySignupError.inProgress } isSignupInFlight = true - defer { isSignupInFlight = false } + Self.beginSessionMutation() + defer { + isSignupInFlight = false + Self.endSessionMutation() + } setProfileSetupPending(false) let registeredSession = try await registerIdentity() @@ -574,6 +635,8 @@ class PubkyProfileManager: ObservableObject { // MARK: - Auth Flow (Ring) func cancelAuthentication() async { + Self.beginSessionMutation() + defer { Self.endSessionMutation() } activeAuthAttemptID = nil do { @@ -625,6 +688,7 @@ class PubkyProfileManager: ObservableObject { } func startAuthentication() async throws { + Self.sessionRevision = UUID() let attemptID = UUID() activeAuthAttemptID = attemptID authState = .authenticating @@ -699,6 +763,8 @@ class PubkyProfileManager: ObservableObject { guard let attemptID = activeAuthAttemptID else { throw CancellationError() } + Self.beginSessionMutation() + defer { Self.endSessionMutation() } var completedSessionSecret: String? do { @@ -914,6 +980,8 @@ class PubkyProfileManager: ObservableObject { // MARK: - Sign Out static func clearLocalState() async { + beginSessionMutation() + defer { Self.endSessionMutation() } do { try await PubkyService.forgetSessionAccess() } catch { @@ -1002,6 +1070,8 @@ class PubkyProfileManager: ObservableObject { } private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws { + Self.beginSessionMutation() + defer { Self.endSessionMutation() } let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) @@ -1198,30 +1268,37 @@ class PubkyProfileManager: ObservableObject { try await PubkyService.importExternalSession(secret: $0) } ) async throws { + await beginSessionMutation() do { - try await forgetSessionAccess() - } catch { - Logger.warn("Failed to forget existing Pubky session before restore: \(error)", context: "PubkyProfileManager") - } - - switch backup?.kind { - case .none: - // Backups without pubky state do not carry recoverable pubky credentials. - try? deleteKeychainValue(.paykitSession) - try? deleteKeychainValue(.pubkySecretKey) - case .localSeed: - let secretKeyHex = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString) - try persistKeychainString(.pubkySecretKey, secretKeyHex) - try? deleteKeychainValue(.paykitSession) - _ = try await signInWithSecretKey(secretKeyHex) - case .externalSession: - guard let sessionSecret = backup?.sessionSecret, - !sessionSecret.isEmpty - else { - throw PubkyServiceError.authFailed("Missing session secret in backup") + do { + try await forgetSessionAccess() + } catch { + Logger.warn("Failed to forget existing Pubky session before restore: \(error)", context: "PubkyProfileManager") + } + + switch backup?.kind { + case .none: + // Backups without pubky state do not carry recoverable pubky credentials. + try? deleteKeychainValue(.paykitSession) + try? deleteKeychainValue(.pubkySecretKey) + case .localSeed: + let secretKeyHex = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString) + try persistKeychainString(.pubkySecretKey, secretKeyHex) + try? deleteKeychainValue(.paykitSession) + _ = try await signInWithSecretKey(secretKeyHex) + case .externalSession: + guard let sessionSecret = backup?.sessionSecret, + !sessionSecret.isEmpty + else { + throw PubkyServiceError.authFailed("Missing session secret in backup") + } + _ = try await importExternalSession(sessionSecret) } - _ = try await importExternalSession(sessionSecret) + } catch { + await endSessionMutation() + throw error } + await endSessionMutation() } private func cancelPendingAuthSetup() async { @@ -1235,16 +1312,7 @@ class PubkyProfileManager: ObservableObject { } private nonisolated static func initializePersistedSession() async throws -> SessionInitializationResult { - try await PubkyService.initialize() - - let savedSecret = try Keychain.loadString(key: .paykitSession) - let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey) - return await resolveSessionInitialization( - savedSessionSecret: savedSecret, - storedSecretKeyHex: secretKeyHex, - importSession: { try await PubkyService.importSession(secret: $0) }, - signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) } - ) + try await PaykitSdkService.shared.restorePersistedSession() } private nonisolated static func notifyAppStateBackupChanged() { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 58e48deb0..2b88c73e0 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -367,25 +367,43 @@ actor PaykitSdkService { func initialize() async throws { Task { await republishIdentityIfNeeded() } try await operationLock.withLock { - var sdk = try handle() + try await initializeLocked() + } + } + + private func initializeLocked() async throws { + var sdk = try handle() + do { + _ = try await sdk.initialize() + } catch { + guard try sessionProvider.canDeferStaleSession(error: error) else { throw error } + + Logger.warn("Deferring stale Paykit session restoration until SDK setup completes", context: "PaykitSdkService") + sessionProvider.suspendStoredSessionAccess() + resetRuntime() do { + sdk = try handle() _ = try await sdk.initialize() } catch { - guard try sessionProvider.canDeferStaleSession(error: error) else { throw error } - - Logger.warn("Deferring stale Paykit session restoration until SDK setup completes", context: "PaykitSdkService") - sessionProvider.suspendStoredSessionAccess() - resetRuntime() - do { - sdk = try handle() - _ = try await sdk.initialize() - } catch { - sessionProvider.resumeStoredSessionAccess() - throw error - } sessionProvider.resumeStoredSessionAccess() + throw error } - await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) + sessionProvider.resumeStoredSessionAccess() + } + await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) + } + + /// Keep credential reads and fallback activation atomic with sign-out and identity changes. + func restorePersistedSession() async throws -> PubkyProfileManager.SessionInitializationResult { + Task { await republishIdentityIfNeeded() } + return try await operationLock.withLock { + try await initializeLocked() + return try await PubkyProfileManager.resolveSessionInitialization( + savedSessionSecret: Keychain.loadString(key: .paykitSession), + storedSecretKeyHex: Keychain.loadString(key: .pubkySecretKey), + importSession: { try await self.importSessionLocked(secret: $0).publicKey }, + signInWithSecretKey: { try await self.signInLocked(secretKeyHex: $0).publicKey } + ) } } @@ -464,21 +482,25 @@ actor PaykitSdkService { func importSession(secret: String, includeLocalSecret: Bool = true) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { - let previousPublicKey = try await currentSdkStatePublicKey() - let localSecret = includeLocalSecret ? try sessionProvider.loadLocalSecretKey() : nil - let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() - let result = try await bootstrap().importSession( - sessionSecret: secret, - localSecretKey: localSecret, - receiverNoiseSecretKey: receiverNoiseSecretKey, - requiredCapabilities: Self.requiredCapabilities() - ) - try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: includeLocalSecret) - markWalletBackupDataChanged() - return result + try await importSessionLocked(secret: secret, includeLocalSecret: includeLocalSecret) } } + private func importSessionLocked(secret: String, includeLocalSecret: Bool = true) async throws -> PubkySessionBootstrapResult { + let previousPublicKey = try await currentSdkStatePublicKey() + let localSecret = includeLocalSecret ? try sessionProvider.loadLocalSecretKey() : nil + let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() + let result = try await bootstrap().importSession( + sessionSecret: secret, + localSecretKey: localSecret, + receiverNoiseSecretKey: receiverNoiseSecretKey, + requiredCapabilities: Self.requiredCapabilities() + ) + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: includeLocalSecret) + markWalletBackupDataChanged() + return result + } + func signUp(secretKeyHex: String, homeserverPublicKey: String, signupCode: String?) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = try await currentSdkStatePublicKey() @@ -530,19 +552,23 @@ actor PaykitSdkService { func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { - let previousPublicKey = try await currentSdkStatePublicKey() - let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() - let result = try await bootstrap().signIn( - localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), - receiverNoiseSecretKey: receiverNoiseSecretKey, - requiredCapabilities: Self.requiredCapabilities() - ) - try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) - markWalletBackupDataChanged() - return result + try await signInLocked(secretKeyHex: secretKeyHex) } } + private func signInLocked(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { + let previousPublicKey = try await currentSdkStatePublicKey() + let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() + let result = try await bootstrap().signIn( + localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + receiverNoiseSecretKey: receiverNoiseSecretKey, + requiredCapabilities: Self.requiredCapabilities() + ) + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + markWalletBackupDataChanged() + return result + } + func startAuth() async throws -> String { try await operationLock.withLock { let request = try await bootstrap().startSignInAuth(capabilities: Self.requiredCapabilities()) diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index bf995e84f..06d7fb9f2 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -116,6 +116,61 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } + func testSessionRecoveryCannotReactivateCredentialsAfterForget() async throws { + let keys: [KeychainEntryType] = [ + .paykitSdkState, .paykitSession, .pubkySecretKey, .paykitReceiverNoiseSecretKey, + .bip39Mnemonic(index: 0), .bip39Passphrase(index: 0), + ] + let saved = try keys.map { try Keychain.load(key: $0) } + defer { + for (key, data) in zip(keys, saved) { + if let data { try? Keychain.upsert(key: key, data: data) } + else { try? Keychain.delete(key: key) } + } + } + let secret = String(repeating: "01", count: 32) + let mnemonic = Array(repeating: "abandon", count: 11).joined(separator: " ") + " about" + try Keychain.upsert(key: .bip39Mnemonic(index: 0), data: Data(mnemonic.utf8)) + try Keychain.delete(key: .bip39Passphrase(index: 0)) + let noise = try PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( + mnemonic: mnemonic, passphrase: nil, network: Env.networkName, receiverPath: PaykitReceiverPath.wallet + ) + try Keychain.upsert(key: .paykitSession, data: Data("saved-session".utf8)) + try Keychain.upsert(key: .pubkySecretKey, data: Data(secret.utf8)) + try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: noise) + let started = expectation(description: "import started") + let (stream, continuation) = AsyncStream.makeStream() + let bootstrap = RecoveryBootstrap(noPointer: .init()) + bootstrap.importSessionOperation = { + started.fulfill() + for await _ in stream {} + throw PubkyServiceError.authFailed("expired session") + } + let session = CacheActivationSession(noPointer: .init()) + session.noiseBytes = noise + bootstrap.result = try PubkySessionBootstrapResult( + sessionAccess: session, publicKey: PubkyProfileManager.publicKeyFromSecretKey(secret) + ) + let sdk = RecoverySdk(noPointer: .init()) + let forgotEarly = expectation(description: "forget cannot interleave with recovery") + forgotEarly.isInverted = true + sdk.onForget = { forgotEarly.fulfill() } + let service = PaykitSdkService(sdkFactory: { sdk }, bootstrapFactory: { _, _ in bootstrap }) + let recovery = Task { try await service.restorePersistedSession() } + await fulfillment(of: [started], timeout: 2) + let forget = Task { try await service.forgetSessionAccess() } + await fulfillment(of: [forgotEarly], timeout: 0.1) + sdk.onForget = {} + continuation.finish() + let result = try await recovery.value + try await forget.value + XCTAssertEqual(result, .restored(publicKey: bootstrap.result.publicKey)) + XCTAssertNil(try Keychain.load(key: .paykitSession)) + XCTAssertNil(try Keychain.load(key: .pubkySecretKey)) + let afterForget = try await service.restorePersistedSession() + XCTAssertEqual(afterForget, .noSession) + } + func testClientIDUsesBitkitOwnedDomain() { let expectedClientID = Env.network == .bitcoin ? "bitkit.to" : "staging.bitkit.to" @@ -330,3 +385,49 @@ private final class CacheActivationNoiseKey: ReceiverNoiseSecretKey, @unchecked bytes } } + +private final class RecoverySdk: PaykitSdk, @unchecked Sendable { + var onForget: () -> Void = {} + + override func identityStatus() async throws -> IdentityStatus? { + IdentityStatus(publicKey: nil, liveSessionAvailable: false) + } + + override func initialize() async throws -> InitializationReport { + InitializationReport(identity: IdentityStatus(publicKey: nil, liveSessionAvailable: false)) + } + + override func backupStateRevision() async throws -> String { + "unchanged" + } + + override func forgetSessionAccess() async throws -> IdentityStatus { + onForget() + try Keychain.delete(key: .paykitSession) + try Keychain.delete(key: .pubkySecretKey) + return IdentityStatus(publicKey: nil, liveSessionAvailable: false) + } +} + +private final class RecoveryBootstrap: PubkySessionBootstrap, @unchecked Sendable { + var importSessionOperation: () async throws -> Void = {} + var result: PubkySessionBootstrapResult! + + override func importSession( + sessionSecret _: String, localSecretKey _: PubkyLocalSecretKey?, + receiverNoiseSecretKey _: ReceiverNoiseSecretKey, requiredCapabilities _: String + ) async throws -> PubkySessionBootstrapResult { + try await importSessionOperation() + return result + } + + override func signIn( + localSecretKey _: PubkyLocalSecretKey, receiverNoiseSecretKey _: ReceiverNoiseSecretKey, requiredCapabilities _: String + ) async throws -> PubkySessionBootstrapResult { + result + } + + override func republishIdentity(publicKey _: String) async throws -> Bool { + true + } +} diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 08b2e2513..cd4fa5fdf 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -16,7 +16,7 @@ final class PubkyProfileManagerTests: XCTestCase { } defaults.set("Existing profile", forKey: keys[0]) defaults.set("pubky://existing/avatar", forKey: keys[1]) - let manager = PubkyProfileManager() + let manager = RecoveryProfileManager() await manager.initialize { .restorationFailed } @@ -28,6 +28,119 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertEqual(manager.cachedImageUri, "pubky://existing/avatar") XCTAssertEqual(defaults.string(forKey: keys[0]), manager.cachedName) XCTAssertEqual(defaults.string(forKey: keys[1]), manager.cachedImageUri) + + manager.sessionRestorationFailed = false + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { .restored(publicKey: "existing-identity") } + XCTAssertEqual(manager.publicKey, "existing-identity") + XCTAssertEqual(manager.cachedName, "Existing profile") + XCTAssertEqual(manager.cachedImageUri, "pubky://existing/avatar") + } + + @MainActor + func testRecoveryWaitsForStartupAndCoalescesConnectivityEvents() async { + let manager = RecoveryProfileManager() + let started = expectation(description: "startup started") + let (stream, continuation) = AsyncStream.makeStream() + let startup = Task { + await manager.initialize { + started.fulfill() + for await _ in stream {} + throw PubkyServiceError.authFailed("offline") + } + } + await fulfillment(of: [started], timeout: 2) + let restored = expectation(description: "one recovery") + restored.assertForOverFulfill = true + let retries = (0 ..< 2).map { _ in + Task { + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + restored.fulfill() + return .restored(publicKey: "existing-identity") + } + } + } + continuation.finish() + await startup.value + for retry in retries { + await retry.value + } + await fulfillment(of: [restored], timeout: 2) + XCTAssertEqual(manager.publicKey, "existing-identity") + XCTAssertEqual(manager.authState, .authenticated) + XCTAssertNil(manager.initializationErrorMessage) + } + + @MainActor + func testRecoverySkipsMissingUnreadableAndAuthorizingIdentities() async { + let manager = RecoveryProfileManager() + await manager.restoreSessionIfNeeded(hasStoredIdentity: { false }) { + XCTFail("No saved identity to restore") + return .noSession + } + await manager.restoreSessionIfNeeded(hasStoredIdentity: { throw PubkyServiceError.authFailed("keychain unavailable") }) { + XCTFail("Unreadable credentials must not be interpreted as an absent identity") + return .noSession + } + manager.setActiveAuthAttemptIDForTesting(UUID()) + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + XCTFail("Recovery must not interrupt Ring authorization") + return .noSession + } + XCTAssertNotNil(manager.activeAuthAttemptIDForTesting) + } + + @MainActor + func testLateRecoveryDoesNotReplaceRingIdentity() async throws { + let manager = RecoveryProfileManager() + let started = expectation(description: "recovery started") + let (stream, continuation) = AsyncStream.makeStream() + let recovery = Task { + await manager.initialize { + started.fulfill() + for await _ in stream {} + return .restored(publicKey: "old-identity") + } + } + await fulfillment(of: [started], timeout: 2) + manager.setActiveAuthAttemptIDForTesting(UUID()) + _ = try await manager.completeAuthenticationForTesting( + completeAuth: { "new-session" }, + currentPublicKey: { "new-identity" }, + discardSessionAccess: { _ in XCTFail("Successful authorization must be retained") } + ) + continuation.finish() + await recovery.value + XCTAssertEqual(manager.publicKey, "new-identity") + XCTAssertEqual(manager.authState, .completingAuthentication) + } + + @MainActor + func testBackupReplacementSuppressesRecoveryAndDiscardsLateResult() async throws { + let manager = RecoveryProfileManager() + let started = expectation(description: "recovery started") + let (stream, continuation) = AsyncStream.makeStream() + let recovery = Task { + await manager.initialize { + started.fulfill() + for await _ in stream {} + return .restored(publicKey: "old-identity") + } + } + await fulfillment(of: [started], timeout: 2) + try await PubkyProfileManager.restoreSessionBackupState( + nil, + deleteKeychainValue: { _ in }, + forgetSessionAccess: { + continuation.finish() + await recovery.value + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + XCTFail("Recovery must not run while replacing credentials") + return .restored(publicKey: "old-identity") + } + } + ) + XCTAssertNil(manager.publicKey) + XCTAssertEqual(manager.authState, .idle) } @MainActor @@ -1229,3 +1342,8 @@ private func XCTAssertThrowsErrorAsync( XCTFail("Expected expression to throw", file: file, line: line) } catch {} } + +@MainActor +private final class RecoveryProfileManager: PubkyProfileManager { + override func loadProfile() async {} +} diff --git a/changelog.d/next/796.fixed.md b/changelog.d/next/796.fixed.md index f2384e110..8ce24c149 100644 --- a/changelog.d/next/796.fixed.md +++ b/changelog.d/next/796.fixed.md @@ -1 +1 @@ -Fixed Paykit recovery after device clock changes to preserve contacts and improve retry and billing reminder timing. +Fixed Paykit recovery after connection loss or device clock changes to preserve profiles and contacts, retry saved sessions, and improve billing reminder timing. diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index be49f593b..0c8127d40 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -24,3 +24,15 @@ Create a fresh wallet and a matching Pubky identity, save a contact, and link a 5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. + +## Connection loss and saved identity recovery + +Network fault injection is not provided by the journey capability table. Use a disposable wallet with a saved local identity, then repeat with a Ring-authorized identity. + +1. Record the profile name, public key, contacts, receiving address, and wallet balance while online. +2. Disable both Wi-Fi and mobile data on the test device, force-stop Bitkit, and reopen it. Wait for session restoration to fail. The cached name must remain, and the app must not advertise Pubky signup for this existing identity. +3. Re-enable connectivity while leaving Bitkit open. Verify that the same identity and contact list recover without scanning Ring again, signing out, or restarting the app when the saved grant is still valid. For an expired or revoked grant, reauthorization remains required. +4. Repeat the failed startup and restore connectivity while Bitkit is backgrounded. Return to the foreground from a profile/contact screen and verify the same recovery. Resume must work from any screen, not only Home. +5. Start a Ring authorization while recovery is pending. Verify that automatic restoration does not replace that attempt. Explicit sign-out or wallet reset must not be undone by a pending restoration. + +Both platforms retry automatically on connectivity restoration and app resume. A valid saved session must recover without a new authorization; expired or revoked grants still require Ring. From a4b4d35ebfbaa359dc4539f83f09ab378bdea231 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 15:44:30 +0100 Subject: [PATCH 4/6] chore: format paykit recovery code --- Bitkit/Managers/PubkyProfileManager.swift | 4 +++- BitkitTests/PaykitSdkClientConfigTests.swift | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 7184fe12b..953cf5d78 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -207,7 +207,9 @@ class PubkyProfileManager: ObservableObject { try await PubkyProfileManager.initializePersistedSession() } ) async { - if let initializationTask { await initializationTask.value } + if let initializationTask { + await initializationTask.value + } guard publicKey == nil, authState == .idle, activeAuthAttemptID == nil, Self.sessionMutationCount == 0 else { return } do { guard try hasStoredIdentity() else { return } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 06d7fb9f2..29e5710fe 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -124,8 +124,11 @@ final class PaykitSdkClientConfigTests: XCTestCase { let saved = try keys.map { try Keychain.load(key: $0) } defer { for (key, data) in zip(keys, saved) { - if let data { try? Keychain.upsert(key: key, data: data) } - else { try? Keychain.delete(key: key) } + if let data { + try? Keychain.upsert(key: key, data: data) + } else { + try? Keychain.delete(key: key) + } } } let secret = String(repeating: "01", count: 32) From d4dd1ce5e67f3dfed5a384a2b63898195b93059c Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 18:27:38 +0100 Subject: [PATCH 5/6] fix: keep paykit recovery available --- Bitkit/Managers/PubkyProfileManager.swift | 78 +++++++-- Bitkit/Services/PubkyService.swift | 23 ++- BitkitTests/PaykitSdkClientConfigTests.swift | 164 +++++++++++++++++++ BitkitTests/PubkyProfileManagerTests.swift | 148 +++++++++++++++++ 4 files changed, 397 insertions(+), 16 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 953cf5d78..898bae47e 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -146,6 +146,11 @@ class PubkyProfileManager: ObservableObject { case restorationFailed } + private enum SessionInitializationMode { + case userVisible + case automaticRecovery + } + @Published var authState: PubkyAuthState = .idle @Published var profile: PubkyProfile? @Published var publicKey: String? @@ -185,6 +190,13 @@ class PubkyProfileManager: ObservableObject { initializeSession: @escaping @Sendable () async throws -> SessionInitializationResult = { try await PubkyProfileManager.initializePersistedSession() } + ) async { + await initialize(mode: .userVisible, initializeSession: initializeSession) + } + + private func initialize( + mode: SessionInitializationMode, + initializeSession: @escaping @Sendable () async throws -> SessionInitializationResult ) async { if let initializationTask { await initializationTask.value @@ -194,7 +206,7 @@ class PubkyProfileManager: ObservableObject { let task = Task { defer { initializationTask = nil } guard Self.sessionMutationCount == 0, activeAuthAttemptID == nil else { return } - await initializeSessionState(initializeSession: initializeSession) + await initializeSessionState(mode: mode, initializeSession: initializeSession) } initializationTask = task await task.value @@ -213,19 +225,22 @@ class PubkyProfileManager: ObservableObject { guard publicKey == nil, authState == .idle, activeAuthAttemptID == nil, Self.sessionMutationCount == 0 else { return } do { guard try hasStoredIdentity() else { return } - await initialize(initializeSession: initializeSession) + await initialize(mode: .automaticRecovery, initializeSession: initializeSession) } catch { Logger.warn("Unable to read saved Pubky identity for recovery: \(error)", context: "PubkyProfileManager") } } private func initializeSessionState( + mode: SessionInitializationMode, initializeSession: @escaping @Sendable () async throws -> SessionInitializationResult ) async { let revision = Self.sessionRevision - isInitialized = false - initializationErrorMessage = nil - sessionRestorationFailed = false + if case .userVisible = mode { + isInitialized = false + initializationErrorMessage = nil + sessionRestorationFailed = false + } let result: SessionInitializationResult do { @@ -239,7 +254,9 @@ class PubkyProfileManager: ObservableObject { } Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") authState = .idle - initializationErrorMessage = error.localizedDescription + if case .userVisible = mode { + initializationErrorMessage = error.localizedDescription + } return } @@ -247,19 +264,24 @@ class PubkyProfileManager: ObservableObject { isInitialized = true return } + initializationErrorMessage = nil switch result { case .noSession: clearAuthenticatedState() + sessionRestorationFailed = false Logger.debug("No saved paykit session found", context: "PubkyProfileManager") case let .restored(pk): reloadCachedProfileMetadata() publicKey = pk authState = .authenticated + sessionRestorationFailed = false Logger.info("Paykit session restored for \(pk)", context: "PubkyProfileManager") Task { await loadProfile() } case .restorationFailed: clearAuthenticatedState(clearCachedProfile: false) - sessionRestorationFailed = true + if case .userVisible = mode { + sessionRestorationFailed = true + } } isInitialized = true @@ -746,7 +768,9 @@ class PubkyProfileManager: ObservableObject { @discardableResult func completeAuthentication() async throws -> String { try await completeAuthentication( - completeAuth: { try await PubkyService.completeAuth() }, + completeAuth: { willActivate in + try await PubkyService.completeAuth(willActivate: willActivate) + }, currentPublicKey: { await PubkyService.currentPublicKey() }, discardSessionAccess: { sessionSecret in await Task.detached { @@ -758,19 +782,30 @@ class PubkyProfileManager: ObservableObject { @discardableResult private func completeAuthentication( - completeAuth: @escaping () async throws -> String, + completeAuth: @escaping (@escaping @MainActor @Sendable () throws -> Void) async throws -> String, currentPublicKey: @escaping () async -> String?, discardSessionAccess: @escaping (String) async -> Void ) async throws -> String { guard let attemptID = activeAuthAttemptID else { throw CancellationError() } - Self.beginSessionMutation() - defer { Self.endSessionMutation() } var completedSessionSecret: String? + var mutationBegun = false + defer { + if mutationBegun { + Self.endSessionMutation() + } + } do { - completedSessionSecret = try await completeAuth() + completedSessionSecret = try await completeAuth { + try Task.checkCancellation() + guard self.activeAuthAttemptID == attemptID else { + throw CancellationError() + } + Self.beginSessionMutation() + mutationBegun = true + } try Task.checkCancellation() guard activeAuthAttemptID == attemptID else { throw CancellationError() @@ -923,7 +958,24 @@ class PubkyProfileManager: ObservableObject { discardSessionAccess: @escaping (String) async -> Void ) async throws -> String { try await completeAuthentication( - completeAuth: completeAuth, + completeAuth: { willActivate in + try willActivate() + return try await completeAuth() + }, + currentPublicKey: currentPublicKey, + discardSessionAccess: discardSessionAccess + ) + } + + func completeAuthenticationForTesting( + completeAuthWithActivationBoundary: @escaping ( + @escaping @MainActor @Sendable () throws -> Void + ) async throws -> String, + currentPublicKey: @escaping () async -> String?, + discardSessionAccess: @escaping (String) async -> Void + ) async throws -> String { + try await completeAuthentication( + completeAuth: completeAuthWithActivationBoundary, currentPublicKey: currentPublicKey, discardSessionAccess: discardSessionAccess ) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 2b88c73e0..080d9a3fc 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -76,8 +76,10 @@ enum PubkyService { } /// Step 2: Long-poll until Ring approves. Returns the raw session secret. - static func completeAuth() async throws -> String { - try await PaykitSdkService.shared.completeAuth() + static func completeAuth( + willActivate: @escaping @MainActor @Sendable () throws -> Void + ) async throws -> String { + try await PaykitSdkService.shared.completeAuth(willActivate: willActivate) } /// Cancel an in-progress auth relay poll started by `startAuth`. @@ -579,7 +581,9 @@ actor PaykitSdkService { } } - func completeAuth() async throws -> String { + func completeAuth( + willActivate: @escaping @MainActor @Sendable () throws -> Void + ) async throws -> String { guard let request = activeAuthRequest else { throw PubkyServiceError.invalidAuthUrl } @@ -607,11 +611,24 @@ actor PaykitSdkService { clearActiveAuthRequest(ifCurrent: requestID) } + try await willActivate() + try Task.checkCancellation() + guard activeAuthRequestID == requestID, activeAuthRequest != nil else { + throw CancellationError() + } let previousPublicKey = try await currentSdkStatePublicKey() + try Task.checkCancellation() + guard activeAuthRequestID == requestID, activeAuthRequest != nil else { + throw CancellationError() + } let sessionSecret = try await Self.completeAuthActivation( sessionSecret: result.sessionAccess.exportSessionSecret(), activate: { try await self.activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: false) + try Task.checkCancellation() + guard self.activeAuthRequestID == requestID, self.activeAuthRequest != nil else { + throw CancellationError() + } }, discardSessionAccess: { sessionSecret in await Task.detached { diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 29e5710fe..8acc1f034 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -40,6 +40,114 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } + @MainActor + func testCanceledAuthActivationBlocksRecoveryUntilDiscardFinishes() async throws { + let keys: [KeychainEntryType] = [ + .paykitSdkState, .paykitSession, .pubkySecretKey, .paykitReceiverNoiseSecretKey, + .bip39Mnemonic(index: 0), .bip39Passphrase(index: 0), + ] + let saved = try keys.map { try Keychain.load(key: $0) } + defer { + for (key, data) in zip(keys, saved) { + if let data { + try? Keychain.upsert(key: key, data: data) + } else { + try? Keychain.delete(key: key) + } + } + } + + let mnemonic = Array(repeating: "abandon", count: 11).joined(separator: " ") + " about" + try Keychain.upsert(key: .bip39Mnemonic(index: 0), data: Data(mnemonic.utf8)) + try Keychain.delete(key: .bip39Passphrase(index: 0)) + let noiseBytes = try PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( + mnemonic: mnemonic, passphrase: nil, network: Env.networkName, receiverPath: PaykitReceiverPath.wallet + ) + try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: noiseBytes) + try? Keychain.delete(key: .paykitSdkState) + try? Keychain.delete(key: .paykitSession) + try? Keychain.delete(key: .pubkySecretKey) + + let session = CacheActivationSession(noPointer: .init()) + session.noiseBytes = noiseBytes + let authRequest = CanceledActivationAuthRequest(noPointer: .init()) + authRequest.result = PubkySessionBootstrapResult(sessionAccess: session, publicKey: "pubky_test") + let bootstrap = CanceledActivationBootstrap(noPointer: .init()) + bootstrap.request = authRequest + let sdk = CanceledActivationSdk(noPointer: .init()) + let activationStarted = expectation(description: "activation started after credentials persisted") + let (activationStream, activationContinuation) = AsyncStream.makeStream() + sdk.initializeOperation = { + activationStarted.fulfill() + for await _ in activationStream {} + } + let discardStarted = expectation(description: "abandoned session discard started") + let (discardStream, discardContinuation) = AsyncStream.makeStream() + sdk.signOutOperation = { + discardStarted.fulfill() + for await _ in discardStream {} + } + let service = PaykitSdkService(sdkFactory: { sdk }, bootstrapFactory: { _, _ in bootstrap }) + _ = try await service.startAuth() + + let manager = UnavailableProfileManager() + manager.isInitialized = true + manager.setActiveAuthAttemptIDForTesting(UUID()) + manager.authState = .authenticating + let authentication = Task { + try await manager.completeAuthenticationForTesting( + completeAuthWithActivationBoundary: { willActivate in + try await service.completeAuth(willActivate: willActivate) + }, + currentPublicKey: { try? await service.currentPublicKey() }, + discardSessionAccess: { sessionSecret in + await service.discardCompletedAuthSession(sessionSecret: sessionSecret) + } + ) + } + + await fulfillment(of: [activationStarted], timeout: 2) + XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "new-session") + manager.setActiveAuthAttemptIDForTesting(nil) + manager.authState = .idle + await service.cancelAuth() + + let recoveryCount = TestAsyncCallCounter() + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + await recoveryCount.increment() + return .restored(publicKey: "existing-identity") + } + let countDuringActivation = await recoveryCount.value + XCTAssertEqual(countDuringActivation, 0) + + activationContinuation.finish() + await fulfillment(of: [discardStarted], timeout: 2) + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + await recoveryCount.increment() + return .restored(publicKey: "existing-identity") + } + let countDuringDiscard = await recoveryCount.value + XCTAssertEqual(countDuringDiscard, 0) + + discardContinuation.finish() + do { + _ = try await authentication.value + XCTFail("Expected cancellation") + } catch is CancellationError { + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + await recoveryCount.increment() + return .restored(publicKey: "existing-identity") + } + let countAfterDiscard = await recoveryCount.value + XCTAssertEqual(countAfterDiscard, 1) + XCTAssertEqual(manager.publicKey, "existing-identity") + XCTAssertNil(try Keychain.load(key: .paykitSession)) + } + @MainActor func testIdentityActivationSeparatesCacheAndPreservesSameOwnerOrLegacyBackup() async throws { let defaults = UserDefaults.standard @@ -357,6 +465,54 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { } } +private final class CanceledActivationSdk: PaykitSdk, @unchecked Sendable { + var initializeOperation: () async -> Void = {} + var signOutOperation: () async -> Void = {} + + override func identityStatus() async throws -> IdentityStatus? { + IdentityStatus(publicKey: nil, liveSessionAvailable: false) + } + + override func initialize() async throws -> InitializationReport { + await initializeOperation() + return InitializationReport(identity: IdentityStatus(publicKey: nil, liveSessionAvailable: false)) + } + + override func signOut() async throws -> IdentityStatus { + await signOutOperation() + try? Keychain.delete(key: .paykitSession) + return IdentityStatus(publicKey: nil, liveSessionAvailable: false) + } +} + +private final class CanceledActivationBootstrap: PubkySessionBootstrap, @unchecked Sendable { + var request: Paykit.PubkyAuthRequest! + + override func startSignInAuth(capabilities _: String) async throws -> Paykit.PubkyAuthRequest { + request + } + + override func republishIdentity(publicKey _: String) async throws -> Bool { + true + } +} + +private final class CanceledActivationAuthRequest: Paykit.PubkyAuthRequest, @unchecked Sendable { + var result: PubkySessionBootstrapResult! + + override func authorizationUrl() async throws -> String { + "pubkyauth://test" + } + + override func complete( + localSecretKey _: PubkyLocalSecretKey?, + receiverNoiseSecretKey _: ReceiverNoiseSecretKey, + requiredCapabilities _: String + ) async throws -> PubkySessionBootstrapResult { + result + } +} + private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked Sendable { override func republishIdentity(publicKey _: String) async throws -> Bool { true @@ -434,3 +590,11 @@ private final class RecoveryBootstrap: PubkySessionBootstrap, @unchecked Sendabl true } } + +private actor TestAsyncCallCounter { + private(set) var value = 0 + + func increment() { + value += 1 + } +} diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index cd4fa5fdf..af16bebd2 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -70,6 +70,52 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(manager.initializationErrorMessage) } + @MainActor + func testAutomaticRecoveryKeepsUsableStateWhileRetryFails() async { + let manager = RecoveryProfileManager() + manager.isInitialized = true + let started = expectation(description: "recovery started") + let (retryStream, retryContinuation) = AsyncStream.makeStream() + let recovery = Task { + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + started.fulfill() + for await _ in retryStream {} + throw PubkyServiceError.authFailed("offline") + } + } + + await fulfillment(of: [started], timeout: 2) + XCTAssertTrue(manager.isInitialized) + XCTAssertNil(manager.initializationErrorMessage) + XCTAssertFalse(manager.sessionRestorationFailed) + + retryContinuation.finish() + await recovery.value + XCTAssertTrue(manager.isInitialized) + XCTAssertNil(manager.initializationErrorMessage) + XCTAssertFalse(manager.sessionRestorationFailed) + } + + @MainActor + func testAutomaticRecoveryDoesNotRepeatFailureNotificationAndClearsStaleErrorOnSuccess() async { + let manager = RecoveryProfileManager() + manager.isInitialized = true + + for _ in 0 ..< 2 { + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { .restorationFailed } + XCTAssertTrue(manager.isInitialized) + XCTAssertNil(manager.initializationErrorMessage) + XCTAssertFalse(manager.sessionRestorationFailed) + } + + manager.isInitialized = false + manager.initializationErrorMessage = "offline" + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { .restored(publicKey: "existing-identity") } + XCTAssertTrue(manager.isInitialized) + XCTAssertNil(manager.initializationErrorMessage) + XCTAssertEqual(manager.publicKey, "existing-identity") + } + @MainActor func testRecoverySkipsMissingUnreadableAndAuthorizingIdentities() async { let manager = RecoveryProfileManager() @@ -677,6 +723,100 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testCanceledAuthenticationDoesNotBlockRecoveryWhileRelayPollFinishes() async { + let manager = RecoveryProfileManager() + manager.setActiveAuthAttemptIDForTesting(UUID()) + manager.authState = .authenticating + let pollStarted = expectation(description: "relay poll started") + let (pollStream, pollContinuation) = AsyncStream.makeStream() + let authentication = Task { + try await manager.completeAuthenticationForTesting( + completeAuthWithActivationBoundary: { _ in + pollStarted.fulfill() + for await _ in pollStream {} + throw CancellationError() + }, + currentPublicKey: { nil }, + discardSessionAccess: { _ in XCTFail("No session was activated") } + ) + } + + await fulfillment(of: [pollStarted], timeout: 2) + manager.setActiveAuthAttemptIDForTesting(nil) + manager.authState = .idle + + let recoveryCount = AsyncCallCounter() + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + await recoveryCount.increment() + return .restored(publicKey: "existing-identity") + } + let completedRecoveryCount = await recoveryCount.value + XCTAssertEqual(completedRecoveryCount, 1) + XCTAssertEqual(manager.publicKey, "existing-identity") + + pollContinuation.finish() + do { + _ = try await authentication.value + XCTFail("Expected cancellation") + } catch is CancellationError { + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + XCTAssertEqual(manager.publicKey, "existing-identity") + } + + @MainActor + func testCanceledAuthenticationBlocksRecoveryUntilCompletedSessionIsDiscarded() async { + let manager = RecoveryProfileManager() + manager.setActiveAuthAttemptIDForTesting(UUID()) + manager.authState = .authenticating + let cleanupStarted = expectation(description: "completed session cleanup started") + let (cleanupStream, cleanupContinuation) = AsyncStream.makeStream() + let authentication = Task { + try await manager.completeAuthenticationForTesting( + completeAuthWithActivationBoundary: { willActivate in + try willActivate() + manager.setActiveAuthAttemptIDForTesting(nil) + manager.authState = .idle + return "late-session" + }, + currentPublicKey: { nil }, + discardSessionAccess: { sessionSecret in + XCTAssertEqual(sessionSecret, "late-session") + cleanupStarted.fulfill() + for await _ in cleanupStream {} + } + ) + } + + await fulfillment(of: [cleanupStarted], timeout: 2) + let recoveryCount = AsyncCallCounter() + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + await recoveryCount.increment() + return .restored(publicKey: "existing-identity") + } + let recoveryCountBeforeCleanup = await recoveryCount.value + XCTAssertEqual(recoveryCountBeforeCleanup, 0) + + cleanupContinuation.finish() + do { + _ = try await authentication.value + XCTFail("Expected cancellation") + } catch is CancellationError { + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + await recoveryCount.increment() + return .restored(publicKey: "existing-identity") + } + let recoveryCountAfterCleanup = await recoveryCount.value + XCTAssertEqual(recoveryCountAfterCleanup, 1) + XCTAssertEqual(manager.publicKey, "existing-identity") + } + @MainActor func testSupersededAuthenticationPreservesNewAttempt() async { let manager = PubkyProfileManager() @@ -1343,6 +1483,14 @@ private func XCTAssertThrowsErrorAsync( } catch {} } +private actor AsyncCallCounter { + private(set) var value = 0 + + func increment() { + value += 1 + } +} + @MainActor private final class RecoveryProfileManager: PubkyProfileManager { override func loadProfile() async {} From 003030e64f01a7e8dc9f854801582812651e2077 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 19:09:36 +0100 Subject: [PATCH 6/6] fix: cancel abandoned Ring authorization --- Bitkit/Views/Profile/PubkyChoiceView.swift | 5 +++++ Bitkit/Views/Profile/PubkyRingAuthView.swift | 5 +++++ journeys/paykit-clock-changes.md | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 026b37af4..6c4eb9c49 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -55,6 +55,11 @@ struct PubkyChoiceView: View { isLoadingAfterAuth: $isLoadingAfterAuth ) } + .onDisappear { + guard isWaitingForRing, pubkyProfile.authState == .authenticating else { return } + isWaitingForRing = false + Task { await pubkyProfile.cancelAuthentication() } + } .alert(t("profile__ring_not_installed_title"), isPresented: $showRingNotInstalledDialog) { Button(t("profile__ring_download")) { if let url = URL(string: pubkyRingAppStoreUrl) { diff --git a/Bitkit/Views/Profile/PubkyRingAuthView.swift b/Bitkit/Views/Profile/PubkyRingAuthView.swift index a4f956e8f..318c96f7d 100644 --- a/Bitkit/Views/Profile/PubkyRingAuthView.swift +++ b/Bitkit/Views/Profile/PubkyRingAuthView.swift @@ -137,6 +137,11 @@ struct PubkyRingAuthView: View { isLoadingAfterAuth: $isLoadingAfterAuth ) } + .onDisappear { + guard isWaitingForRing, pubkyProfile.authState == .authenticating else { return } + isWaitingForRing = false + Task { await pubkyProfile.cancelAuthentication() } + } .alert(t("profile__ring_not_installed_title"), isPresented: $showRingNotInstalledDialog) { Button(t("profile__ring_download")) { if let url = URL(string: pubkyRingAppStoreUrl) { diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index 0c8127d40..a47e12c57 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -33,6 +33,6 @@ Network fault injection is not provided by the journey capability table. Use a d 2. Disable both Wi-Fi and mobile data on the test device, force-stop Bitkit, and reopen it. Wait for session restoration to fail. The cached name must remain, and the app must not advertise Pubky signup for this existing identity. 3. Re-enable connectivity while leaving Bitkit open. Verify that the same identity and contact list recover without scanning Ring again, signing out, or restarting the app when the saved grant is still valid. For an expired or revoked grant, reauthorization remains required. 4. Repeat the failed startup and restore connectivity while Bitkit is backgrounded. Return to the foreground from a profile/contact screen and verify the same recovery. Resume must work from any screen, not only Home. -5. Start a Ring authorization while recovery is pending. Verify that automatic restoration does not replace that attempt. Explicit sign-out or wallet reset must not be undone by a pending restoration. +5. Start a Ring authorization while recovery is pending. Use Back before approving, then retry or foreground the app. The abandoned relay poll must not block recovery. Start another authorization and verify that automatic restoration does not replace it. Explicit sign-out or wallet reset must not be undone by a pending restoration. Both platforms retry automatically on connectivity restoration and app resume. A valid saved session must recover without a new authorization; expired or revoked grants still require Ring.