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 @@ -1169,7 +1169,7 @@
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = exactVersion;
version = "0.1.0-rc31";
version = "0.1.0-rc33";
};
};
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.

35 changes: 27 additions & 8 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ struct AppScene: View {
if authState == .authenticated, let pk = pubkyProfile.publicKey {
Task {
try? await contactsManager.loadContacts(for: pk)
await refreshPrivateOnlyPaykitReceiverMarker()
if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true {
await retryPendingPaykitEndpointRemoval()
}
Expand Down Expand Up @@ -632,6 +633,7 @@ struct AppScene: View {
await retryPendingPaykitEndpointRemoval()
}
guard PaykitFeatureFlags.isUIEnabled else { return }
await refreshPrivateOnlyPaykitReceiverMarker()
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
await PrivatePaykitService.shared.prepareSavedContacts(
contactsManager.contacts.map(\.publicKey),
Expand Down Expand Up @@ -671,8 +673,11 @@ struct AppScene: View {
await retryPendingPaykitEndpointRemoval()
await wallet.refreshPublicPaykitEndpointsOnForeground()
if PaykitFeatureFlags.isUIEnabled {
await refreshPrivateOnlyPaykitReceiverMarker()
let contactPublicKeys = contactsManager.contacts.map(\.publicKey)
await PrivatePaykitService.shared.refreshSavedContactEndpoints(
for: contactsManager.contacts.map(\.publicKey),
for: contactPublicKeys,
savedPublicKeys: contactPublicKeys,
wallet: wallet
)
}
Expand All @@ -681,17 +686,31 @@ struct AppScene: View {
}
}

private func refreshPrivateOnlyPaykitReceiverMarker() async {
let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)
let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)
guard privateSharingEnabled, !publicSharingEnabled else { return }
guard await PubkyService.currentPublicKey() != nil else { return }

do {
try await PublicPaykitService.syncLocalReceiverMarker()
} catch {
Logger.warn("Failed to refresh private Paykit receiver marker: \(error)", context: "AppScene")
}
}

private func retryPendingPaykitEndpointRemoval() async {
if PublicPaykitService.isCleanupPending {
if UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) {
PublicPaykitService.setCleanupPending(false)
} else {
do {
do {
if UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) {
try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet)
} else {
try await PublicPaykitService.removePublishedEndpoints()
PublicPaykitService.setCleanupPending(false)
} catch {
Logger.warn("Failed to retry public Paykit endpoint cleanup: \(error)", context: "AppScene")
try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false)
}
PublicPaykitService.setCleanupPending(false)
} catch {
Logger.warn("Failed to reconcile public Paykit state: \(error)", context: "AppScene")
}
}

Expand Down
3 changes: 3 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,9 @@ struct MainNavView: View {
contacts: contactsManager.contacts
) {
navigation.navigate(route)
if case let .contactDetail(publicKey) = route {
await contactsManager.refreshContactReceiverPaths(publicKey: publicKey, wallet: wallet)
}
clipboardUri = nil
return
}
Expand Down
57 changes: 51 additions & 6 deletions Bitkit/Managers/ContactsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,8 @@ class ContactsManager: ObservableObject {
try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true)
}

try await Task.detached {
_ = try await PubkyService.saveContact(publicKey: prefixedKey, label: profile.name)
}.value
let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey)
_ = try await PubkyService.saveContact(publicKey: prefixedKey, label: profile.name, receiverPaths: receiverPaths)

Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager")

Expand All @@ -291,6 +290,29 @@ class ContactsManager: ObservableObject {
contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}

func refreshContactReceiverPaths(publicKey: String, wallet: WalletViewModel) async {
guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey),
let contact = contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) })
else { return }

do {
let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey)
_ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name, receiverPaths: receiverPaths)
await PrivatePaykitService.shared.refreshSavedContactEndpoints(
for: [prefixedKey],
savedPublicKeys: contacts.map(\.publicKey),
wallet: wallet
)
} catch is CancellationError {
return
} catch {
Logger.warn(
"Failed to refresh contact receiver paths for \(PubkyPublicKeyFormat.redacted(prefixedKey)): \(error)",
context: "ContactsManager"
)
}
}

// MARK: - Import Contacts

func importContacts(publicKeys: [String]) async throws {
Expand All @@ -302,8 +324,11 @@ class ContactsManager: ObservableObject {
group.addTask { [self] in
do {
let profile = try await resolveContactProfile(publicKey: key, includePlaceholder: true)
_ = try await PubkyService.saveContact(publicKey: key, label: profile.name)
let receiverPaths = try await Self.relevantReceiverPaths(for: key)
_ = try await PubkyService.saveContact(publicKey: key, label: profile.name, receiverPaths: receiverPaths)
return .success(PubkyContact(publicKey: key, profile: profile))
} catch is CancellationError {
return .failure(CancellationError())
} catch {
Logger.warn("Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(key))': \(error)", context: "ContactsManager")
return .failure(error)
Expand All @@ -328,6 +353,8 @@ class ContactsManager: ObservableObject {
return (results, failures, firstError)
}

try Task.checkCancellation()

if !prefixedKeys.isEmpty, loadedResult.contacts.isEmpty {
throw loadedResult.firstError ?? PubkyServiceError.profileNotFound
}
Expand Down Expand Up @@ -379,12 +406,12 @@ class ContactsManager: ObservableObject {
try await Task.detached {
_ = try await PubkyService.removeContact(publicKey: prefixedKey)
}.value
await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey)
Self.removeContactProfileOverride(publicKey: prefixedKey)

Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager")

contacts.removeAll { $0.publicKey == prefixedKey }
await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey)
}

func deleteAllContacts() async throws {
Expand Down Expand Up @@ -423,13 +450,17 @@ class ContactsManager: ObservableObject {

if let firstError {
if !deletedKeys.isEmpty {
contacts.removeAll { deletedKeys.contains($0.publicKey) }
await PrivatePaykitService.shared.removeSavedContacts(publicKeys: Array(deletedKeys))
for publicKey in deletedKeys {
Self.removeContactProfileOverride(publicKey: publicKey)
}
contacts.removeAll { deletedKeys.contains($0.publicKey) }
}
throw firstError
}

// All remote deletes succeeded, so clear any local-only contacts too.
await PrivatePaykitService.shared.removeSavedContacts(publicKeys: Array(deletedKeys))
Self.clearContactProfileOverrides()
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
contacts.removeAll()
Expand Down Expand Up @@ -593,6 +624,20 @@ class ContactsManager: ObservableObject {
throw PubkyServiceError.profileNotFound
}

private nonisolated static func relevantReceiverPaths(for publicKey: String) async throws -> [String] {
do {
return try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey)
} catch is CancellationError {
throw CancellationError()
} catch {
Logger.warn(
"Failed to discover Paykit receivers for '\(PubkyPublicKeyFormat.redacted(publicKey))': \(error)",
context: "ContactsManager"
)
return [PaykitReceiverPath.wallet]
}
}

private nonisolated static let contactProfileOverridesKey = "pubkyContactProfileOverrides"

private nonisolated static func contact(
Expand Down
21 changes: 17 additions & 4 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ class PubkyProfileManager: ObservableObject {
}

func deleteProfile() async throws {
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile")
try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.deleteProfile")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Private Cleanup Blocks Deletion

When private Paykit endpoint removal fails during profile deletion, this new throwing call exits before deletePaykitProfile() and signOut(cleanPrivatePaykitEndpoints: false) run. A transient cleanup error can leave the user signed in with the profile, public endpoints, and receiver marker still active instead of deleting the profile and recording cleanup for retry.

Suggested change
try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.deleteProfile")
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping this blocking is intentional. If deletion continues after private cleanup fails, clearLocalState() removes the SDK state and credentials needed to retry clearing private details that may already be shared. Normal deletion keeps the profile in place so the user can retry; the forced wallet wipe path remains best effort.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That's a solid rationale — preserving credentials for retry is more important than a clean deletion path here. Withdrawing the concern.

Shall I remember this as a rule: "prefer throwing cleanup over best-effort when failure would wipe the credentials needed to retry that same cleanup"?

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

do {
try await Task.detached {
try await PubkyService.deletePaykitProfile()
Expand Down Expand Up @@ -684,13 +684,26 @@ class PubkyProfileManager: ObservableObject {
}

static func removePublicPaykitEndpoints(context: String) async throws {
var firstError: Error?
do {
try await PublicPaykitService.removePublishedEndpoints()
} catch PubkyServiceError.sessionNotActive {
Logger.debug("Skipping public Paykit endpoint cleanup because no session is active", context: context)
} catch {
Logger.warn("Failed to remove public Paykit endpoints before clearing session: \(error)", context: context)
throw error
firstError = error
}

do {
try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false, privateSharingEnabled: false)
} catch PubkyServiceError.sessionNotActive {
Logger.debug("Skipping Paykit receiver marker cleanup because no session is active", context: context)
} catch {
firstError = firstError ?? error
}

if let firstError {
Logger.warn("Failed to remove public Paykit state before clearing session: \(firstError)", context: context)
throw firstError
}
}

Expand Down Expand Up @@ -730,7 +743,7 @@ class PubkyProfileManager: ObservableObject {
private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws {
try await Task.detached {
if cleanPrivatePaykitEndpoints {
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut")
try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Private Cleanup Blocks Sign-Out

When private endpoint cleanup fails here, the detached sign-out task throws before public Paykit cleanup, server sign-out, local key removal, and clearAuthenticatedState() run. A network or homeserver error in private cleanup can keep the user locally authenticated with stale receiver state, even though cleanup pending state was meant to allow retry.

Suggested change
try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut")
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional for the same reason as deletion. Normal sign-out must keep the session and private SDK state available until the receiver-scoped clears are delivered. Otherwise local sign-out would erase the retry state and could strand shared private details. Forced wallet wipe remains the best-effort escape path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Understood — the throw acts as a gate to keep session and retry state intact until all receiver-scoped clears succeed, which is consistent with the deletion path. That makes sense. I'll drop this comment.

}
await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut")
do {
Expand Down
13 changes: 12 additions & 1 deletion Bitkit/Managers/ScannerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class ScannerManager: ObservableObject {
private var navigation: NavigationViewModel?
private var pubkyProfile: PubkyProfileManager?
private var sheets: SheetViewModel?
private var wallet: WalletViewModel?

func configure(
app: AppViewModel,
Expand All @@ -26,7 +27,8 @@ class ScannerManager: ObservableObject {
settings: SettingsViewModel? = nil,
navigation: NavigationViewModel? = nil,
pubkyProfile: PubkyProfileManager? = nil,
sheets: SheetViewModel? = nil
sheets: SheetViewModel? = nil,
wallet: WalletViewModel? = nil
) {
self.app = app
self.contactsManager = contactsManager
Expand All @@ -35,6 +37,7 @@ class ScannerManager: ObservableObject {
self.navigation = navigation
self.pubkyProfile = pubkyProfile
self.sheets = sheets
self.wallet = wallet
}

func handleScan(_ uri: String, context: ScannerContext) async {
Expand Down Expand Up @@ -109,6 +112,14 @@ class ScannerManager: ObservableObject {
sheets?.hideSheetIfActive(sheetId, reason: reason)
}
navigation.navigate(route)
if case let .contactDetail(publicKey) = route,
let contactsManager,
let wallet
{
Task {
await contactsManager.refreshContactReceiverPaths(publicKey: publicKey, wallet: wallet)
}
}
return true
}

Expand Down
Loading
Loading