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
183 changes: 107 additions & 76 deletions Bitkit/Managers/ContactsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,18 @@

@MainActor
class ContactsManager: ObservableObject {
@Published var contacts: [PubkyContact] = []
private var contactsRevision = 0
private var loadGeneration = 0
private let contactRecords: @Sendable () async throws -> [ContactRecord]

init(contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords) {

Check warning on line 121 in Bitkit/Managers/ContactsManager.swift

View workflow job for this annotation

GitHub Actions / Run Tests

converting non-sendable function value to '@sendable () async throws -> [ContactRecord]' may introduce data races
self.contactRecords = contactRecords
}

@Published var contacts: [PubkyContact] = [] {
didSet { contactsRevision += 1 }
}

@Published var isLoading = false
@Published var hasLoaded = false
@Published var loadErrorMessage: String?
Expand All @@ -136,6 +147,7 @@
}

func reset() {
loadGeneration += 1
contacts = []
isLoading = false
hasLoaded = false
Expand Down Expand Up @@ -173,97 +185,107 @@
return
}

loadGeneration += 1
let generation = loadGeneration
isLoading = true
loadErrorMessage = nil
defer { isLoading = false }
defer {
if generation == loadGeneration { isLoading = false }
}

Logger.info("Loading contacts for \(PubkyPublicKeyFormat.redacted(publicKey))", context: "ContactsManager")

do {
let records = try await Task.detached {
try await PubkyService.contactRecords()
}.value

Logger.debug("Loaded \(records.count) SDK contact records", context: "ContactsManager")
while generation == loadGeneration {
try Task.checkCancellation()
let revision = contactsRevision
do {
let records = try await contactRecords()

Logger.debug("Loaded \(records.count) SDK contact records", context: "ContactsManager")

let loadedResult: (contacts: [PubkyContact], failures: Int,
missingFailures: Int, firstError: Error?) = await withTaskGroup(of: Result<PubkyContact, Error>.self) { group in
let overrides = Self.loadContactProfileOverrides()
for record in records {
group.addTask {
do {
let contact = try await Self.contact(from: record, overrides: overrides, includePlaceholder: true)
return .success(contact)
} catch {
Logger.warn(
"Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(record.publicKey))': \(error)",
context: "ContactsManager"
)
return .failure(error)
}
}
}

let loadedResult: (contacts: [PubkyContact], failures: Int,
missingFailures: Int, firstError: Error?) = await withTaskGroup(of: Result<PubkyContact, Error>.self) { group in
let overrides = Self.loadContactProfileOverrides()
for record in records {
group.addTask {
do {
let contact = try await Self.contact(from: record, overrides: overrides, includePlaceholder: true)
return .success(contact)
} catch {
Logger.warn(
"Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(record.publicKey))': \(error)",
context: "ContactsManager"
)
return .failure(error)
var results: [PubkyContact] = []
var failures = 0
var missingFailures = 0
var firstError: Error?

for await result in group {
switch result {
case let .success(contact):
results.append(contact)
case let .failure(error):
failures += 1
if Self.isMissingContactsDataError(error) {
missingFailures += 1
}
firstError = firstError ?? error
}
}

return (results, failures, missingFailures, firstError)
}

var results: [PubkyContact] = []
var failures = 0
var missingFailures = 0
var firstError: Error?
guard contactsRevision == revision else { continue }

for await result in group {
switch result {
case let .success(contact):
results.append(contact)
case let .failure(error):
failures += 1
if Self.isMissingContactsDataError(error) {
missingFailures += 1
}
firstError = firstError ?? error
if !records.isEmpty, loadedResult.contacts.isEmpty {
if loadedResult.failures == loadedResult.missingFailures {
contacts = []
hasLoaded = true
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
Logger.info("Contacts storage entries were missing, treating list as empty", context: "ContactsManager")
return
}
throw loadedResult.firstError ?? PubkyServiceError.profileNotFound
}

return (results, failures, missingFailures, firstError)
}
contacts = loadedResult.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
hasLoaded = true
await PrivatePaykitService.shared
.pruneUnsavedContactState(savedPublicKeys: records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) })

if !records.isEmpty, loadedResult.contacts.isEmpty {
if loadedResult.failures == loadedResult.missingFailures {
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
if loadedResult.failures > 0 {
Logger.warn(
"Skipped \(loadedResult.failures) unreadable contacts while loading list",
context: "ContactsManager"
)
}

Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager")
return
} catch {
guard contactsRevision == revision else { continue }
if Self.isMissingContactsDataError(error) {
contacts = []
hasLoaded = true
Logger.info("Contacts storage entries were missing, treating list as empty", context: "ContactsManager")
loadErrorMessage = nil
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
Logger.info("Contacts storage missing, treating list as empty", context: "ContactsManager")
return
}
throw loadedResult.firstError ?? PubkyServiceError.profileNotFound
}

contacts = loadedResult.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
await PrivatePaykitService.shared
.pruneUnsavedContactState(savedPublicKeys: records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) })
hasLoaded = true

if loadedResult.failures > 0 {
Logger.warn(
"Skipped \(loadedResult.failures) unreadable contacts while loading list",
context: "ContactsManager"
)
}

Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager")
} catch {
if Self.isMissingContactsDataError(error) {
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
contacts = []
hasLoaded = true
loadErrorMessage = nil
Logger.info("Contacts storage missing, treating list as empty", context: "ContactsManager")
return
}

Logger.error("Failed to load contacts: \(error)", context: "ContactsManager")
if contacts.isEmpty {
loadErrorMessage = error.localizedDescription
Logger.error("Failed to load contacts: \(error)", context: "ContactsManager")
if contacts.isEmpty {
loadErrorMessage = error.localizedDescription
}
throw error
}
throw error
}
}

Expand Down Expand Up @@ -297,7 +319,12 @@
}

let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey)
_ = try await PubkyService.saveContact(publicKey: prefixedKey, label: profile.name, receiverPaths: receiverPaths)
_ = try await PubkyService.saveContact(
publicKey: prefixedKey,
label: profile.name,
receiverPaths: receiverPaths,
restorePrivateConnection: true
)

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

Expand Down Expand Up @@ -342,7 +369,12 @@
do {
let profile = try await resolveContactProfile(publicKey: key, includePlaceholder: true)
let receiverPaths = try await Self.relevantReceiverPaths(for: key)
_ = try await PubkyService.saveContact(publicKey: key, label: profile.name, receiverPaths: receiverPaths)
_ = try await PubkyService.saveContact(
publicKey: key,
label: profile.name,
receiverPaths: receiverPaths,
restorePrivateConnection: true
)
return .success(PubkyContact(publicKey: key, profile: profile))
} catch is CancellationError {
return .failure(CancellationError())
Expand Down Expand Up @@ -423,12 +455,11 @@
try await Task.detached {
_ = try await PubkyService.removeContact(publicKey: prefixedKey)
}.value
await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey)
contacts.removeAll { $0.publicKey == prefixedKey }
Self.removeContactProfileOverride(publicKey: prefixedKey)
await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey)

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

contacts.removeAll { $0.publicKey == prefixedKey }
}

func deleteAllContacts() async throws {
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 @@ -1127,6 +1127,7 @@
"contacts__import_select_none" = "Select none";
"contacts__import_friends_count" = "{count} friends";
"contacts__import_selected_count" = "{count} selected";
"contacts__delete_active_subscription" = "End active subscriptions before deleting this contact.";
"contacts__delete_title" = "Delete {name}?";
"contacts__delete_description" = "Are you sure you want to delete {name} from your contacts?";
"contacts__delete_confirm" = "Yes, Delete";
Expand Down
31 changes: 29 additions & 2 deletions Bitkit/Services/PaykitPaymentRequestService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -539,8 +539,14 @@ struct PaykitPaymentRequestService {
logIntakeFailures(intakeReports)
let synchronizationDate = now()
let records = try await sdk.paymentRequests()
let blockedPeers = try await sdk.linkedPeers().filter { $0.state == .blocked }
let availableRecords = records.filter { record in
!blockedPeers.contains {
PubkyPublicKeyFormat.matches($0.counterparty, record.counterparty) && $0.counterpartyReceiverPath == record.counterpartyReceiverPath
}
}
var rejections: [IncomingPaykitPaymentRequestRejection] = []
let incoming = records.compactMap { record in
let incoming = availableRecords.compactMap { record in
switch PaykitPaymentRequest.parseIncoming(record: record, now: synchronizationDate) {
case let .success(request):
return request
Expand All @@ -566,7 +572,7 @@ struct PaykitPaymentRequestService {
let history = records.compactMap {
PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate)
}
let subscriptions = records.compactMap { PaykitSubscription(record: $0) }
let subscriptions = availableRecords.compactMap { PaykitSubscription(record: $0) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Deleting a contact hides an accepted subscription instead of ending it, and re-adding the contact re-presents every missed period.

Once the peer is blocked, the subscription drops out of subscriptions, so cancel() throws requestUnavailable (1447). The SDK also refuses cancel on a blocked peer, so this cannot be fixed by cancelling afterwards. subscriptionAcceptedAt (1738-1743) is never pruned, while dismissedSubscriptionPaymentIds.formIntersection(activeRecurringRequestIds) (1755) drops the hidden subscription's dismissals.

After an explicit re-add unblocks the peer, requests(through:acceptedAt:) regenerates every unpaid period since the original acceptance, including dismissed ones, and each is presented. Nothing pays without a confirm. Before this change the subscription stayed visible and cancellable.

Fix: cancel (or warn about) the counterparty's active payer subscriptions before blockPeer, and move the acceptance anchor to the re-add date on unblock.

Same in synonymdev/bitkit-android#1372.

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.

Deletion now refuses to proceed while this contact has an active subscription, whether we are payer or payee, and shows "End active subscriptions before deleting this contact." The check runs before endpoint withdrawal or blocking. Canceled or naturally ended subscriptions allow deletion. This keeps the existing cancellation and in-flight proof safeguards intact. Covered in PaykitContactLifecycleTests.swift and the mirrored subscription-deletion journey.

Commit: 75e8c9c.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Works. One LOW refinement: the guard counts fixed-term subscriptions whose endsAt is in the future (PubkyService.swift:659), but canCancel(at:) returns false whenever recurrence.endsAt != nil (PaykitSubscription.swift:478). A payer who accepted a fixed-term request can neither cancel it nor delete the contact until the term ends, while the refusal tells them to end the subscription. Bitkit creates requests with endsAt: nil, so this needs a proposal from another Paykit client, and isProposalActionable(at:) accepts those. Either allow cancelling fixed-term subscriptions or name the end date in the refusal. Same in synonymdev/bitkit-android#1372.

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.

Added the fixed-term end date to the deletion refusal. Open-ended subscriptions keep the cancel-first message.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Resolved in 8d62210. The refusal now names the end date.

return PaykitPaymentRequestSnapshot(
incoming: incoming,
history: history,
Expand Down Expand Up @@ -807,6 +813,13 @@ struct PaykitPaymentRequestService {
)
}

func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws {
guard try await !sdk.linkedPeers().contains(where: {
$0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) &&
$0.counterpartyReceiverPath == request.counterpartyReceiverPath
}) else { throw PaykitPaymentRequestError.requestUnavailable }
}

func accept(_ request: PaykitPaymentRequest) async throws {
guard !request.isExpired(at: now()) else {
throw PaykitPaymentRequestError.requestExpired
Expand Down Expand Up @@ -1444,17 +1457,31 @@ final class PaykitPaymentRequestManager {
refreshTask = nil
}

func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws {
do {
try await service.ensurePaymentAllowed(request)
} catch {
approvedPaymentRequestIds.remove(request.id)
throw error
}
}

func prepareForPayment(
_ request: PaykitPaymentRequest,
consumePrivatePaymentList: () async throws -> Void = {}
) async throws {
do {
if isApprovedForPayment(request) {
try await ensurePaymentAllowed(request)
return
}
try await perform(
request,
resultingState: .accepted,
markApprovedForPayment: true,
preservePending: !request.requiresAcceptance
) {
try await ensurePaymentAllowed($0)
try await consumePrivatePaymentList()
if $0.requiresAcceptance {
try await service.accept($0)
Expand Down
3 changes: 2 additions & 1 deletion Bitkit/Services/PrivatePaykitService+Contacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ extension PrivatePaykitService {
)
for receiverPath in cleanupReceiverPaths {
do {
let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey, receiverPath: receiverPath)
guard let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey, receiverPath: receiverPath)
else { continue }
Comment on lines +194 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Skipped cleanup loses retry If blocking leaves a previously published private list in place, this continue treats its skipped remote clear as successful cleanup. The app then erases the local publication state and retry marker, so it has no cleanup work left to perform before an explicit re-add unblocks the peer.

Knowledge Base Used: Paykit payment modes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is not conditional. PaykitSdkService.removeContact blocks every known receiver path before ContactsManager calls removeSavedContact, so clearPrivatePaymentList returns nil on every deletion, and the deleted contact keeps the last private payment list we sent them. Before this PR the clear was delivered. Keeping the cleanup pending would not help either: blockPeer drops the encrypted link, and the peer stays blocked until an explicit re-add. The clear needs to run before the paths are blocked.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You’re right — I missed the ordering across the two services. ContactsManager.removeContact invokes PaykitSdkService.removeContact before PrivatePaykitService.removeSavedContact, and the former blocks every known receiver path. Therefore, clearPrivatePaymentList necessarily returns nil during this cleanup; retaining the retry marker would not make it deliverable while the peer remains blocked.

The cleanup must occur before the SDK contact removal/blocking (or otherwise explicitly clear the list before blocking). Given that ordering, the continue here is intentional, and my “skipped cleanup loses retry” comment does not apply. Retracting it.

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.

Correct about the ordering. Deletion now attempts empty-list delivery to linked receivers inside the existing SDK operation lock before blocking them, so another publication cannot run between withdrawal and revocation. A delivery failure still allows local blocking and contact removal. Post-block cleanup continues to skip network delivery. Tests cover the ordering and failed withdrawal. This preserves the withdrawal attempt, but it cannot retract invoices already shared and can still wait on the SDK network timeout.

Commit: 75e8c9c.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Resolved. The best-effort clear now runs before blockPeer under operationLock. When it cannot be delivered, the contact keeps the last list, which is the accepted limitation.

if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty {
throw PrivatePaykitError.privateUnavailable
}
Expand Down
Loading
Loading