Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,7 @@
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = exactVersion;
version = "0.1.0-rc46";
version = "0.1.0-rc51";
};
};
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 8 additions & 4 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -933,19 +933,23 @@ struct AppScene: View {
private func retryPendingPaykitEndpointRemoval() async {
if PublicPaykitService.isCleanupPending {
do {
if UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) {
switch PublicPaykitService.pendingReconciliationMode() {
case .publishEndpoints:
try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet)
} else {
case .removePublishedState:
try await PublicPaykitService.removePublishedEndpoints()
try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false)
try await PublicPaykitService.syncLocalReceiverMarker(
publicSharingEnabled: false,
privateSharingEnabled: false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch hardcodes privateSharingEnabled: false, but it is reached whenever PublicPaykitService.pendingReconciliationMode() returns .removePublishedState, and that helper only looks at PublicPaykitService.publishingEnabledKey. In the private-only state (sharesPublicPaykitEndpoints false, sharesPrivatePaykitEndpoints true) we therefore call syncLocalReceiverMarker with both flags false, which makes isDiscoverable false and drives PaykitSdkService.syncLocalReceiverMarker into sdk.removePaykitReceiverMarker() — tearing down the marker for a user whose private sharing is still on. That state is reachable and deliberately supported: ContactPaymentsService.restore writes sharesPublicEndpoints=false alongside restoresPrivateEndpoints=true, and refreshPrivateOnlyPaykitReceiverMarker exists in this same file purely to keep the marker published for it. markPaykitReconciliationPendingAfterFailedSignOut also sets the public pending flag when only private sharing is enabled, so a failed sign-out routes exactly this state here, and the private reconciliation that runs afterwards never republishes the marker. Could we pass the real private flag here, for example privateSharingEnabled: UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey), or drop both arguments so syncLocalReceiverMarker() reads the stored flags itself?

)
}
PublicPaykitService.setCleanupPending(false)
} catch {
Logger.warn("Failed to reconcile public Paykit state: \(error)", context: "AppScene")
}
}

await PrivatePaykitService.shared.retryPendingEndpointRemoval(
await PrivatePaykitService.shared.retryPendingEndpointReconciliation(
wallet: wallet,
savedPublicKeys: contactsManager.contacts.map(\.publicKey)
)
Expand Down
161 changes: 130 additions & 31 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -309,10 +309,9 @@ class PubkyProfileManager: ObservableObject {
profile = createdProfile
cacheProfileMetadata(createdProfile)
} catch {
try? Keychain.delete(key: .pubkySecretKey)
try? Keychain.delete(key: .paykitSession)
await PubkyService.forceSignOut()
throw error
let profileCreationError = error
await discardAbandonedSession()
throw profileCreationError
}

Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager")
Expand Down Expand Up @@ -365,6 +364,7 @@ class PubkyProfileManager: ObservableObject {
Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager")
}

Self.clearPaykitSharingAfterProfileDeletion()
try await signOut(cleanPrivatePaykitEndpoints: false)
}

Expand Down Expand Up @@ -507,24 +507,26 @@ class PubkyProfileManager: ObservableObject {
try await completeAuthentication(
completeAuth: { _ = try await PubkyService.completeAuth() },
currentPublicKey: { await PubkyService.currentPublicKey() },
clearSessionAccess: { await PubkyService.clearSessionAccess() }
discardSessionAccess: {
await self.discardAbandonedSession()
}
)
}

@discardableResult
private func completeAuthentication(
completeAuth: @escaping () async throws -> Void,
currentPublicKey: @escaping () async -> String?,
clearSessionAccess: @escaping () async -> Void
discardSessionAccess: @escaping () async -> Void
) async throws -> String {
guard let attemptID = activeAuthAttemptID else {
throw CancellationError()
}
var didCompleteAuth = false

do {
try await completeAuth()
didCompleteAuth = true
try await completeAuth()
try Task.checkCancellation()
guard activeAuthAttemptID == attemptID else {
throw CancellationError()
Expand All @@ -548,14 +550,20 @@ class PubkyProfileManager: ObservableObject {
await loadProfile()
return pk
} catch is CancellationError {
await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess)
await discardCompletedAuthSessionIfNeeded(
Comment thread
ben-kaufman marked this conversation as resolved.
didCompleteAuth,
discardSessionAccess: discardSessionAccess
)
if activeAuthAttemptID == attemptID {
activeAuthAttemptID = nil
restoreAuthStateAfterAuthFlow()
}
throw CancellationError()
} catch let serviceError as PubkyServiceError {
await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess)
await discardCompletedAuthSessionIfNeeded(
didCompleteAuth,
discardSessionAccess: discardSessionAccess
)
guard activeAuthAttemptID == attemptID else {
throw CancellationError()
}
Expand All @@ -564,7 +572,10 @@ class PubkyProfileManager: ObservableObject {
restoreAuthStateAfterAuthFlow()
throw serviceError
} catch {
await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess)
await discardCompletedAuthSessionIfNeeded(
didCompleteAuth,
discardSessionAccess: discardSessionAccess
)
guard activeAuthAttemptID == attemptID else {
throw CancellationError()
}
Expand All @@ -575,9 +586,43 @@ class PubkyProfileManager: ObservableObject {
}
}

private func clearCompletedAuthSessionIfNeeded(_ didCompleteAuth: Bool, clearSessionAccess: @escaping () async -> Void) async {
private func discardCompletedAuthSessionIfNeeded(
_ didCompleteAuth: Bool,
discardSessionAccess: @escaping () async -> Void
) async {
guard didCompleteAuth else { return }
await clearSessionAccess()
await discardSessionAccess()
}

private func discardAbandonedSession() async {
await discardAbandonedSession(
revokeSessionAccess: {
try await Task.detached {
try await PubkyService.signOut()
}.value
},
forgetSessionAccess: {
try await Task.detached {
try await PubkyService.forgetSessionAccess()
}.value
}
)
}

private func discardAbandonedSession(
revokeSessionAccess: @escaping () async throws -> Void,
forgetSessionAccess: @escaping () async throws -> Void
) async {
do {
try await revokeSessionAccess()
} catch {
Logger.warn("Failed to revoke abandoned Pubky session: \(error)", context: "PubkyProfileManager")
do {
try await forgetSessionAccess()
} catch {
Logger.warn("Failed to forget abandoned Pubky session access: \(error)", context: "PubkyProfileManager")
}
}
}

func finalizeAuthentication() {
Expand Down Expand Up @@ -614,12 +659,22 @@ class PubkyProfileManager: ObservableObject {
func completeAuthenticationForTesting(
completeAuth: @escaping () async throws -> Void,
currentPublicKey: @escaping () async -> String?,
clearSessionAccess: @escaping () async -> Void
discardSessionAccess: @escaping () async -> Void
) async throws -> String {
try await completeAuthentication(
completeAuth: completeAuth,
currentPublicKey: currentPublicKey,
clearSessionAccess: clearSessionAccess
discardSessionAccess: discardSessionAccess
)
}

func discardAbandonedSessionForTesting(
revokeSessionAccess: @escaping () async throws -> Void,
forgetSessionAccess: @escaping () async throws -> Void
) async {
await discardAbandonedSession(
revokeSessionAccess: revokeSessionAccess,
forgetSessionAccess: forgetSessionAccess
)
}
#endif
Expand Down Expand Up @@ -666,11 +721,17 @@ class PubkyProfileManager: ObservableObject {
// MARK: - Sign Out

static func clearLocalState() async {
do {
try await PubkyService.forgetSessionAccess()
} catch {
Logger.warn("Failed to forget local Pubky session access: \(error)", context: "PubkyProfileManager")
}
await clearLocalAppState()
}

private static func clearLocalAppState() async {
await PrivatePaykitService.shared.closeAndClear()
await PrivatePaykitAddressReservationStore.shared.clearContactAssignments()
await PubkyService.forceSignOut()
try? Keychain.delete(key: .paykitSession)
try? Keychain.delete(key: .pubkySecretKey)
await PubkyImageCache.shared.clear()
UserDefaults.standard.removeObject(forKey: cachedNameKey)
UserDefaults.standard.removeObject(forKey: cachedImageUriKey)
Expand Down Expand Up @@ -747,22 +808,56 @@ class PubkyProfileManager: ObservableObject {
}

private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws {
try await Task.detached {
if cleanPrivatePaykitEndpoints {
try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut")
}
await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut")
do {
let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)
let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)

do {
try await Task.detached {
if cleanPrivatePaykitEndpoints {
try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut")
}
await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut")
try await PubkyService.signOut()
} catch {
Logger.warn("Server sign out failed, forcing local sign out: \(error)", context: "PubkyProfileManager")
}
await Self.clearLocalState()
}.value
await Self.clearLocalAppState()
}.value
} catch {
Self.markPaykitReconciliationPendingAfterFailedSignOut(
Comment thread
ben-kaufman marked this conversation as resolved.
publicSharingEnabled: publicSharingEnabled,
privateSharingEnabled: privateSharingEnabled
)
throw error
}

clearAuthenticatedState()
}

static func markPaykitReconciliationPendingAfterFailedSignOut(
publicSharingEnabled: Bool,
privateSharingEnabled: Bool,
setPublicReconciliationPending: (Bool) -> Void = PublicPaykitService.setCleanupPending,
setPrivateReconciliationPending: (Bool) -> Void = PrivatePaykitService.setContactSharingCleanupPending
) {
if publicSharingEnabled || privateSharingEnabled {
setPublicReconciliationPending(true)
}
if privateSharingEnabled {
setPrivateReconciliationPending(true)
}
}

static func clearPaykitSharingAfterProfileDeletion(
defaults: UserDefaults = .standard,
setPublicReconciliationPending: (Bool) -> Void = PublicPaykitService.setCleanupPending
) {
let hadPublishedState = defaults.bool(forKey: PublicPaykitService.publishingEnabledKey) ||
defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)
defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey)
defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey)
if hadPublishedState {
setPublicReconciliationPending(true)
}
}

func refreshSessionIfPossible(after error: Error) async -> Bool {
await Self.refreshSessionIfPossible(
after: error,
Expand Down Expand Up @@ -871,8 +966,8 @@ class PubkyProfileManager: ObservableObject {
deleteKeychainValue: (KeychainEntryType) throws -> Void = {
try Keychain.delete(key: $0)
},
clearSessionAccess: @escaping () async -> Void = {
await PubkyService.clearSessionAccess()
forgetSessionAccess: @escaping () async throws -> Void = {
try await PubkyService.forgetSessionAccess()
},
signInWithSecretKey: @escaping (String) async throws -> String = {
try await PubkyService.signIn(secretKeyHex: $0)
Expand All @@ -881,7 +976,11 @@ class PubkyProfileManager: ObservableObject {
try await PubkyService.importExternalSession(secret: $0)
}
) async throws {
await clearSessionAccess()
do {
try await forgetSessionAccess()
} catch {
Logger.warn("Failed to forget existing Pubky session before restore: \(error)", context: "PubkyProfileManager")
}

switch backup?.kind {
case .none:
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Models/PubkyAuthRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ struct PubkyAuthPermission {
struct PubkyAuthRequest {
let rawUrl: String
let kind: Paykit.PubkyAuthRequestKind
let clientID: String
let relay: String
let capabilities: String
let permissions: [PubkyAuthPermission]
Expand All @@ -75,6 +76,7 @@ struct PubkyAuthRequest {
return PubkyAuthRequest(
rawUrl: url,
kind: details.kind,
clientID: details.clientId,
relay: details.relayUrl ?? "",
capabilities: capabilities,
permissions: permissions,
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@
"pubky_auth__title" = "Authorize";
"pubky_auth__description_prefix" = "A service is requesting permission to access and edit your ";
"pubky_auth__description_suffix" = " data.";
"pubky_auth__requester" = "Requester ID: {clientId}";
"pubky_auth__requested_permissions" = "REQUESTED PERMISSIONS";
"pubky_auth__watch_only_account_default_name" = "{service} account";
"pubky_auth__watch_only_account_fallback_name" = "Paykit server account";
Expand Down
30 changes: 29 additions & 1 deletion Bitkit/Services/PrivatePaykitService+Contacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import Paykit
// MARK: - Saved Contacts

extension PrivatePaykitService {
enum FullCleanupReconciliationMode: Equatable {
case restoreSavedContacts
case removePublishedState
}

static func fullCleanupReconciliationMode(defaults: UserDefaults = .standard) -> FullCleanupReconciliationMode {
return defaults.bool(forKey: publishingEnabledKey) ? .restoreSavedContacts : .removePublishedState
}

@discardableResult
func prepareSavedContacts(
_ publicKeys: [String],
Expand Down Expand Up @@ -255,9 +264,28 @@ extension PrivatePaykitService {
}
}

func retryPendingEndpointRemoval(wallet _: WalletViewModel, savedPublicKeys publicKeys: [String]) async {
func retryPendingEndpointReconciliation(wallet: WalletViewModel, savedPublicKeys publicKeys: [String]) async {
let savedKeys = Set(normalizedSavedContactKeys(publicKeys))
let isFullCleanupPending = UserDefaults.standard.bool(forKey: Self.cleanupPendingKey)
if isFullCleanupPending,
Self.fullCleanupReconciliationMode() == .restoreSavedContacts
{
let restoreKeys = savedKeys.union(knownSavedContactKeys)
guard !restoreKeys.isEmpty else { return }

let error = await prepareSavedContacts(
Comment thread
ben-kaufman marked this conversation as resolved.
Array(restoreKeys),
wallet: wallet,
requireImmediatePublication: true
)
if let error {
Logger.warn("Failed to reconcile private Paykit endpoints: \(error)", context: "PrivatePaykit")
} else {
Self.setContactSharingCleanupPending(false)
}
return
}

let cleanupKeys = isFullCleanupPending
? Set(knownSavedContactKeys).union(state.contacts.keys).union(Self.pendingDeletedContactCleanupKeys())
: Set(pendingPrivateEndpointRemovalKeys(savedPublicKeys: publicKeys))
Expand Down
Loading
Loading