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-rc50";
};
};
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.

17 changes: 13 additions & 4 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -932,19 +932,28 @@ 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 .publishReceiverMarker:
try await PublicPaykitService.syncLocalReceiverMarker(
publicSharingEnabled: false,
privateSharingEnabled: true
)
case .removePublishedState:
try await PublicPaykitService.removePublishedEndpoints()
try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false)
try await PublicPaykitService.syncLocalReceiverMarker(
publicSharingEnabled: false,
privateSharingEnabled: false
)
}
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
103 changes: 73 additions & 30 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -309,10 +309,15 @@ 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
do {
try await Task.detached {
try await PubkyService.signOut()
}.value
} catch {
Logger.warn("Failed to revoke incomplete Pubky profile session: \(error)", context: "PubkyProfileManager")
}
throw profileCreationError
}

Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager")
Expand Down Expand Up @@ -507,15 +512,19 @@ class PubkyProfileManager: ObservableObject {
try await completeAuthentication(
completeAuth: { _ = try await PubkyService.completeAuth() },
currentPublicKey: { await PubkyService.currentPublicKey() },
clearSessionAccess: { await PubkyService.clearSessionAccess() }
revokeSessionAccess: {
try await Task.detached {
try await PubkyService.signOut()
}.value
}
)
}

@discardableResult
private func completeAuthentication(
completeAuth: @escaping () async throws -> Void,
currentPublicKey: @escaping () async -> String?,
clearSessionAccess: @escaping () async -> Void
revokeSessionAccess: @escaping () async throws -> Void
) async throws -> String {
guard let attemptID = activeAuthAttemptID else {
throw CancellationError()
Expand Down Expand Up @@ -548,14 +557,14 @@ class PubkyProfileManager: ObservableObject {
await loadProfile()
return pk
} catch is CancellationError {
await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess)
await revokeCompletedAuthSessionIfNeeded(didCompleteAuth, revokeSessionAccess: revokeSessionAccess)
if activeAuthAttemptID == attemptID {
activeAuthAttemptID = nil
restoreAuthStateAfterAuthFlow()
}
throw CancellationError()
} catch let serviceError as PubkyServiceError {
await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess)
await revokeCompletedAuthSessionIfNeeded(didCompleteAuth, revokeSessionAccess: revokeSessionAccess)
guard activeAuthAttemptID == attemptID else {
throw CancellationError()
}
Expand All @@ -564,7 +573,7 @@ class PubkyProfileManager: ObservableObject {
restoreAuthStateAfterAuthFlow()
throw serviceError
} catch {
await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess)
await revokeCompletedAuthSessionIfNeeded(didCompleteAuth, revokeSessionAccess: revokeSessionAccess)
guard activeAuthAttemptID == attemptID else {
throw CancellationError()
}
Expand All @@ -575,9 +584,16 @@ class PubkyProfileManager: ObservableObject {
}
}

private func clearCompletedAuthSessionIfNeeded(_ didCompleteAuth: Bool, clearSessionAccess: @escaping () async -> Void) async {
private func revokeCompletedAuthSessionIfNeeded(
_ didCompleteAuth: Bool,
revokeSessionAccess: @escaping () async throws -> Void
) async {
guard didCompleteAuth else { return }
await clearSessionAccess()
do {
try await revokeSessionAccess()
} catch {
Logger.warn("Failed to revoke canceled Pubky auth session: \(error)", context: "PubkyProfileManager")
}
}

func finalizeAuthentication() {
Expand Down Expand Up @@ -614,12 +630,12 @@ class PubkyProfileManager: ObservableObject {
func completeAuthenticationForTesting(
completeAuth: @escaping () async throws -> Void,
currentPublicKey: @escaping () async -> String?,
clearSessionAccess: @escaping () async -> Void
revokeSessionAccess: @escaping () async throws -> Void
) async throws -> String {
try await completeAuthentication(
completeAuth: completeAuth,
currentPublicKey: currentPublicKey,
clearSessionAccess: clearSessionAccess
revokeSessionAccess: revokeSessionAccess
)
}
#endif
Expand Down Expand Up @@ -666,11 +682,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 +769,43 @@ 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(
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)
}
}

func refreshSessionIfPossible(after error: Error) async -> Bool {
await Self.refreshSessionIfPossible(
after: error,
Expand Down Expand Up @@ -871,8 +914,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 +924,7 @@ class PubkyProfileManager: ObservableObject {
try await PubkyService.importExternalSession(secret: $0)
}
) async throws {
await clearSessionAccess()
try await forgetSessionAccess()

switch backup?.kind {
case .none:
Expand Down
27 changes: 26 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,25 @@ 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 error = await prepareSavedContacts(
Array(savedKeys),
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
52 changes: 21 additions & 31 deletions Bitkit/Services/PubkyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,8 @@ enum PubkyService {
try await PaykitSdkService.shared.signOut()
}

static func forceSignOut() async {
await PaykitSdkService.shared.forceSignOut()
}

static func clearSessionAccess() async {
await PaykitSdkService.shared.clearSessionAccess()
static func forgetSessionAccess() async throws {
try await PaykitSdkService.shared.forgetSessionAccess()
}
}

Expand Down Expand Up @@ -801,25 +797,11 @@ actor PaykitSdkService {
resetRuntime()
}

func forceSignOut() async {
await operationLock.withLock {
sessionProvider.clearLiveSessionAccess()
try? Keychain.delete(key: .paykitSession)
try? Keychain.delete(key: .pubkySecretKey)
clearStateLocked()
}
}

func clearSessionAccess() async {
await operationLock.withLock {
sessionProvider.clearLiveSessionAccess()
try? Keychain.delete(key: .paykitSession)
try? Keychain.delete(key: .pubkySecretKey)
activeAuthRequest = nil
activeAuthRequestID = nil
resetRuntime()
markWalletBackupDataChanged()
func forgetSessionAccess() async throws {
try await withStateRevisionTracking { sdk in
_ = try await sdk.forgetSessionAccess()
}
resetRuntime()
}

func clearState() async {
Expand Down Expand Up @@ -1008,7 +990,10 @@ actor PaykitSdkService {
}

private func bootstrap() throws -> PubkySessionBootstrap {
try PubkySessionBootstrap.withPubkyClientConfig(pubkyClient: pubkyClientConfig)
try PubkySessionBootstrap.withPubkyClientConfig(
clientId: Self.clientID,
pubkyClient: pubkyClientConfig
)
}

nonisolated static func makePubkyClientConfig(localTestnetHost: String?) -> PubkyClientConfig {
Expand All @@ -1019,15 +1004,19 @@ actor PaykitSdkService {

private nonisolated static func config() throws -> PaykitSdkConfig {
var config = try Paykit.defaultConfig(receiverPath: PaykitReceiverPath.wallet)
config.profileNamespace = switch Env.network {
case .bitcoin: "bitkit.to"
default: "staging.bitkit.to"
}
config.profileNamespace = clientID
config.endpointManagementScope = .managedOnly
config.encryptedLinkRecoveryMarkers = .enabled
config.publicContactSharing = .localOnly
return config
}

nonisolated static var clientID: String {
switch Env.network {
case .bitcoin: "bitkit.to"
default: "staging.bitkit.to"
}
}
}

private final class PaykitSdkOperationLock: @unchecked Sendable {
Expand Down Expand Up @@ -1149,6 +1138,7 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke
}

return try PubkySessionAccess(
clientId: PaykitSdkService.clientID,
sessionSecret: sessionSecret,
localSecretKey: loadLocalSecretKey(),
receiverNoiseSecretKey: loadOrDeriveReceiverNoiseSecretKey()
Expand Down Expand Up @@ -1179,8 +1169,8 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke

func clearSessionAccess() throws {
clearLiveSessionAccess()
try? Keychain.delete(key: .paykitSession)
try? Keychain.delete(key: .pubkySecretKey)
try Keychain.delete(key: .pubkySecretKey)
try Keychain.delete(key: .paykitSession)
}

func loadLocalSecretKey() throws -> PubkyLocalSecretKey? {
Expand Down
Loading
Loading