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
4 changes: 2 additions & 2 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1912,7 +1912,7 @@ struct AppScene: View {

private func retryPendingPaykitEndpointRemoval() async {
guard let session = pubkyProfile.automaticPaykitSession else { return }
await ContactPaymentsService.reconcilePendingEndpoints {
await ContactPaymentsService.reconcilePendingEndpoints(reconcilePrivate: {
guard pubkyProfile.automaticPaykitSession == session else { return }
let privateCleanupPending = UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey)
await PrivatePaykitService.shared.retryPendingEndpointReconciliation(
Expand All @@ -1922,7 +1922,7 @@ struct AppScene: View {
if privateCleanupPending, !UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) {
PublicPaykitService.setCleanupPending(true)
}

}) {
if PublicPaykitService.isCleanupPending, pubkyProfile.automaticPaykitSession == session {
do {
switch PublicPaykitService.pendingReconciliationMode() {
Expand Down
10 changes: 5 additions & 5 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,7 @@ class PubkyProfileManager: ObservableObject {

func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true) async throws {
if cleanPrivatePaykitEndpoints {
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile")
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile", deferAppSync: true)
}
do {
try await Task.detached {
Expand Down Expand Up @@ -1410,9 +1410,9 @@ class PubkyProfileManager: ObservableObject {
}
}

static func removePrivatePaykitEndpoints(context: String) async throws {
static func removePrivatePaykitEndpoints(context: String, deferAppSync: Bool = false) async throws {
do {
try await PrivatePaykitService.shared.removePublishedEndpoints()
try await PrivatePaykitService.shared.removePublishedEndpoints(deferAppSync: deferAppSync)
PrivatePaykitService.setContactSharingCleanupPending(false)
} catch {
PrivatePaykitService.setContactSharingCleanupPending(true)
Expand All @@ -1421,9 +1421,9 @@ class PubkyProfileManager: ObservableObject {
}
}

static func removePrivatePaykitEndpointsBestEffort(context: String) async {
static func removePrivatePaykitEndpointsBestEffort(context: String, deferAppSync: Bool = false) async {
do {
try await removePrivatePaykitEndpoints(context: context)
try await removePrivatePaykitEndpoints(context: context, deferAppSync: deferAppSync)
PrivatePaykitService.setContactSharingCleanupPending(false)
} catch {
PrivatePaykitService.setContactSharingCleanupPending(true)
Expand Down
4 changes: 3 additions & 1 deletion Bitkit/Services/ContactPaymentsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,12 @@ enum ContactPaymentsService {
}

@MainActor
static func reconcilePendingEndpoints(_ reconcile: () async -> Void) async {
static func reconcilePendingEndpoints(reconcilePrivate: () async -> Void = {}, _ reconcile: () async -> Void) async {
guard !isOperationActive, !Task.isCancelled else { return }
isOperationActive = true
defer { releaseOperation() }
await reconcilePrivate()
guard await !PrivatePaykitService.shared.isDeletingProfile, !Task.isCancelled else { return }
await reconcile()
}

Expand Down
36 changes: 25 additions & 11 deletions Bitkit/Services/PrivatePaykitService+Contacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,19 +332,27 @@ extension PrivatePaykitService {
)
}

func removePublishedEndpoints(for publicKeys: [String]? = nil, isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws {
try await removePublishedEndpoints(for: publicKeys, isSessionCurrent: isSessionCurrent, operations: EndpointCleanupOperations(
linkedPeers: { try await PaykitSdkService.shared.linkedPeers() },
clearPaymentLists: { try await PaykitSdkService.shared.clearPrivatePaymentLists(to: $0, isSessionCurrent: isSessionCurrent) },
drainMessages: { await self.drainPendingPrivateMessages(reason: "cleanup", advancing: $0) },
pendingDrainKeys: { await self.pendingPrivateMessageDrainKeys($0) },
syncApp: { try await PublicPaykitService.syncPaykitApp() }
))
func removePublishedEndpoints(
for publicKeys: [String]? = nil, isSessionCurrent: (@MainActor () -> Bool)? = nil, deferAppSync: Bool = false
) async throws {
try await removePublishedEndpoints(
for: publicKeys,
isSessionCurrent: isSessionCurrent,
deferAppSync: deferAppSync,
operations: EndpointCleanupOperations(
linkedPeers: { try await PaykitSdkService.shared.linkedPeers() },
clearPaymentLists: { try await PaykitSdkService.shared.clearPrivatePaymentLists(to: $0, isSessionCurrent: isSessionCurrent) },
drainMessages: { await self.drainPendingPrivateMessages(reason: "cleanup", advancing: $0) },
pendingDrainKeys: { await self.pendingPrivateMessageDrainKeys($0) },
syncApp: { try await PublicPaykitService.syncPaykitApp() }
)
)
}

func removePublishedEndpoints(
for publicKeys: [String]? = nil,
isSessionCurrent: (@MainActor () -> Bool)? = nil,
deferAppSync: Bool = false,
operations: EndpointCleanupOperations
) async throws {
let revision = publicKeys != nil && isSessionCurrent != nil ? savedContactsRevision : nil
Expand All @@ -358,7 +366,8 @@ extension PrivatePaykitService {
try await withPublicationLock {
if let isSessionCurrent, await !isSessionCurrent() { throw PubkyServiceError.sessionNotActive }
try await removePublishedEndpointsLocked(
for: publicKeys, isSessionCurrent: isSessionCurrent, revision: revision, generation: generation, operations: operations
for: publicKeys, isSessionCurrent: isSessionCurrent, revision: revision, generation: generation,
deferAppSync: deferAppSync, operations: operations
)
}
} catch {
Expand All @@ -371,7 +380,7 @@ extension PrivatePaykitService {

private func removePublishedEndpointsLocked(
for publicKeys: [String]?, isSessionCurrent: (@MainActor () -> Bool)?, revision: Int?, generation: Int,
operations: EndpointCleanupOperations
deferAppSync: Bool, operations: EndpointCleanupOperations
) async throws {
let peers = try await operations.linkedPeers()
let linkedPublicKeys = Set(peers
Expand Down Expand Up @@ -461,7 +470,12 @@ extension PrivatePaykitService {
if let firstError {
throw firstError
}
try await operations.syncApp()
if deferAppSync {
// Final profile cleanup synchronizes the registry; retain repair if deletion stops before it.
PublicPaykitService.setCleanupPending(true)
} else {
try await operations.syncApp()
}
}

func privatePaymentListCleanupKeys(_ publicKeys: [String], linkedPublicKeys: Set<String>) -> [String] {
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Views/Profile/EditProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ struct EditProfileView: View {
}

private func performDeleteProfile() async throws {
await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "EditProfileView.deleteProfile")
await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "EditProfileView.deleteProfile", deferAppSync: true)
await contactsManager.deleteAllContactsBestEffort()
try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false)
navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro]
Expand Down
29 changes: 29 additions & 0 deletions BitkitTests/ContactPaymentsServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,35 @@ final class ContactPaymentsServiceTests: XCTestCase {
}
}

func testForegroundReconciliationRetainsRegistryRepairDuringDeletionOrCancellation() async {
snapshotAppDefaultsDomain()
for interruption in ["deletion", "deletionAfterPrivate", "cancelAfterPrivate"] {
PublicPaykitService.setCleanupPending(true)
if interruption == "deletion" { await PrivatePaykitService.shared.beginProfileDeletion() }
var registryUpdates = 0
let reconciliation = Task {
await ContactPaymentsService.reconcilePendingEndpoints(reconcilePrivate: {
if interruption == "deletionAfterPrivate" { await PrivatePaykitService.shared.beginProfileDeletion() }
if interruption == "cancelAfterPrivate" { withUnsafeCurrentTask { $0?.cancel() } }
}) {
registryUpdates += 1
PublicPaykitService.setCleanupPending(false)
}
}
await reconciliation.value
await PrivatePaykitService.shared.endProfileDeletion()
XCTAssertEqual(registryUpdates, 0, interruption)
XCTAssertTrue(PublicPaykitService.isCleanupPending, interruption)

await ContactPaymentsService.reconcilePendingEndpoints {
registryUpdates += 1
PublicPaykitService.setCleanupPending(false)
}
XCTAssertEqual(registryUpdates, 1, interruption)
XCTAssertFalse(PublicPaykitService.isCleanupPending, interruption)
}
}

func testEnablingWaitsForSuccessfulOrFailedDisable() async throws {
let ownerKey = try useLocalPubkySecretKey()
for (disableUI, removalFails) in [(false, false), (false, true), (true, false), (true, true)] {
Expand Down
56 changes: 50 additions & 6 deletions BitkitTests/PrivatePaykitServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2045,7 +2045,7 @@ final class PrivatePaykitServiceTests: XCTestCase {

func testGuardedCleanupChecksMembershipAndGenerationAfterAdmittedWithdrawal() async throws {
let key = "pubky" + String(repeating: "y", count: 52)
for change in ["none", "readd", "identity"] {
for (change, deferAppSync) in ["none", "readd", "identity"].flatMap({ change in [false, true].map { (change, $0) } }) {
let service = PrivatePaykitService()
var state = PrivatePaykitService.ContactState()
state.hasPublishedPrivatePaymentList = true
Expand All @@ -2062,7 +2062,7 @@ final class PrivatePaykitServiceTests: XCTestCase {
}, drainMessages: { _ in }, pendingDrainKeys: { _ in [] }, syncApp: {}
)
let cleanup = Task {
try await service.removePublishedEndpoints(for: [key], isSessionCurrent: { true }, operations: operations)
try await service.removePublishedEndpoints(for: [key], isSessionCurrent: { true }, deferAppSync: deferAppSync, operations: operations)
}
await fulfillment(of: [admitted], timeout: 2)
if change == "readd" { _ = await service.rememberSavedContacts([key], replacing: false) }
Expand Down Expand Up @@ -2127,10 +2127,53 @@ final class PrivatePaykitServiceTests: XCTestCase {
XCTAssertTrue(retained?.hasPublishedPrivatePaymentList == true)
}

func testDeferredCleanupLeavesDurableRegistryReconciliation() async throws {
let key = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
for sharingEnabled in [false, true] {
for deferAppSync in [false, true] {
UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey)
UserDefaults.standard.set(sharingEnabled, forKey: PrivatePaykitService.publishingEnabledKey)
PublicPaykitService.setCleanupPending(false)
let service = PrivatePaykitService()
var contact = PrivatePaykitService.ContactState()
contact.hasPublishedPrivatePaymentList = true
await service.setTestContactState(contact, publicKey: key)
PrivatePaykitService.markDeletedContactCleanupPending([key])
var withdrawals = 0
var registryUpdates = 0
try await service.removePublishedEndpoints(deferAppSync: deferAppSync, operations: .init(
linkedPeers: { [] },
clearPaymentLists: { keys in
XCTAssertEqual(keys, [key])
Comment thread
ben-kaufman marked this conversation as resolved.
withdrawals += 1
return .init(
queued: [], cleared: [.init(counterparty: key, outboundMessageId: 1, error: nil)],
failedToQueue: [], failedToDeliver: []
)
},
drainMessages: { _ in XCTFail("Delivered withdrawal needs no drain") },
pendingDrainKeys: { _ in [] },
syncApp: { registryUpdates += 1 }
))
XCTAssertEqual(withdrawals, 1)
XCTAssertEqual(registryUpdates, deferAppSync ? 0 : 1)
XCTAssertEqual(PublicPaykitService.isCleanupPending, deferAppSync)
XCTAssertFalse(PrivatePaykitService.pendingDeletedContactCleanupKeys().contains(key))
let restored = PrivatePaykitService()
let retained = await restored.testContactState(publicKey: key)
XCTAssertFalse(retained?.hasPublishedPrivatePaymentList == true)
XCTAssertEqual(PublicPaykitService.isCleanupPending, deferAppSync)
}
}
}

func testCleanupFailuresKeepRegistryReconciliationPending() async throws {
let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey)
for failureStage in ["lookup", "withdraw", "queue", "pending", "registry"] {
let cases = ["lookup", "withdraw", "queue", "pending", "cancel", "registry"].flatMap { stage in
(stage == "registry" ? [false] : [false, true]).map { (stage, $0) }
}
for (failureStage, deferAppSync) in cases {
let service = PrivatePaykitService()
var contactState = PrivatePaykitService.ContactState()
contactState.hasPublishedPrivatePaymentList = true
Expand All @@ -2145,6 +2188,7 @@ final class PrivatePaykitServiceTests: XCTestCase {
return []
},
clearPaymentLists: { _ in
if shouldFail, failureStage == "cancel" { throw CancellationError() }
if shouldFail, failureStage == "withdraw" { throw PrivatePaykitError.privateUnavailable }
if shouldFail, failureStage == "queue" {
return PrivatePaymentListDeliveryReport(
Expand All @@ -2170,7 +2214,7 @@ final class PrivatePaykitServiceTests: XCTestCase {
)

do {
try await service.removePublishedEndpoints(for: [publicKey], operations: operations)
try await service.removePublishedEndpoints(for: [publicKey], deferAppSync: deferAppSync, operations: operations)
XCTFail("Expected \(failureStage) failure")
} catch {
XCTAssertTrue(PublicPaykitService.isCleanupPending)
Expand Down Expand Up @@ -2264,7 +2308,7 @@ final class PrivatePaykitServiceTests: XCTestCase {
func testBatchCleanupRetainsOnlyFailedContacts() async throws {
let failed = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
let successful = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
for failQueue in [true, false] {
for (failQueue, deferAppSync) in [true, false].flatMap({ failQueue in [false, true].map { (failQueue, $0) } }) {
let service = PrivatePaykitService()
var contact = PrivatePaykitService.ContactState()
contact.hasPublishedPrivatePaymentList = true
Expand All @@ -2274,7 +2318,7 @@ final class PrivatePaykitServiceTests: XCTestCase {
PrivatePaykitService.markDeletedContactCleanupPending([failed, successful])
var batches = 0
do {
try await service.removePublishedEndpoints(for: [failed, successful], operations: .init(
try await service.removePublishedEndpoints(for: [failed, successful], deferAppSync: deferAppSync, operations: .init(
linkedPeers: { [] },
clearPaymentLists: { keys in
batches += 1
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/979.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Profile deletion avoids an intermediate Paykit capability update while retaining endpoint cleanup and failed-operation recovery.
4 changes: 4 additions & 0 deletions journeys/profile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

`delete-profile.xml` verifies visible progress and completion when deleting a disposable profile. Use a test identity that may be deleted.

With network fault injection, interrupt profile deletion after private endpoint withdrawal but before profile deletion or final App Registry synchronization. Verify the existing cleanup marker survives restart, normal reconciliation uses the current sharing preference, and failed withdrawal or registry publication remains pending until a successful retry. Successful deletion must still remove public endpoints, synchronize the App Registry with private sharing disabled, and sign out. Ordinary disconnect, contact deletion and sharing-setting changes must continue to synchronize immediately. Use the same disposable identity and record confirmation-to-onboarding time before and after; do not shorten uncertain-write safety waits or treat a small request reduction as a general latency guarantee.

While deletion is paused after private withdrawal, trigger foreground or network-restoration reconciliation. Verify it does not start public reconciliation or acknowledge the pending marker until deletion ends; already admitted work may complete.

During profile deletion, automatic contact refresh, request intake and endpoint publication must not start new work. Already admitted operations and explicit deletion cleanup still finish. After a failed deletion, normal automatic work must resume for the retained identity. Verifying SDK admission and a controlled cleanup failure requires operation gates beyond the journey runner capabilities; this remains a manual check rather than a timed deletion guarantee.

With a contact link held during deletion, let deletion succeed and verify that the retained link opens the public contact profile while signed out, without a QR error or a private contact-list read. For failed deletion, hold contact preparation until after cleanup fails, then release it and verify that the retained link retries once preparation is available without a repeated error loop. Controlling this ordering requires preparation and cleanup-failure gates beyond the journey runner capabilities.
Expand Down
1 change: 1 addition & 0 deletions journeys/profile/delete-profile.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Precondition: an onboarded disposable test wallet with Paykit enabled and a profile created
through Bitkit. The profile and its contacts will be deleted; do not use a personal identity.
Record the sequence so progress can be checked even when deletion completes quickly.
Start with zero contacts and record the deletion time without a fixed latency target.
Repeat with 62 imported contacts, once after preparation settles and once immediately after import.
Measure confirmation to onboarding and check logs for any uncertain-write safety wait.
Cleanup may still wait for admitted operations and remote safety checks; no fixed duration is expected.
Expand Down
Loading