Repository navigation
fix: stop deletion after incomplete cleanup - #1124
ben-kaufman wants to merge 7 commits into
Conversation
|
Triage: 3.1.0, with its Android twin synonymdev/bitkit-android#1690. |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. The Edit Profile deletion path now uses the throwing private endpoint withdrawal and the throwing deleteAllContacts(), a failed contact read is no longer treated as an empty list, and the best-effort wrappers on this path are gone. Reviewed 76e0f80, full tier, stacked on #1122 (fix/profile-delete-session-recovery), so the delta against that base is what I read.
What I checked, and 5 candidates I ruled out
Read in full: ContactsManager.deleteAllContacts and isMissingContactsDataError, PubkyProfileManager.deleteProfile(for:cleanPrivatePaykitEndpoints:) and both removePrivatePaykitEndpoints overloads, EditProfileView.deleteProfile / performDeleteProfile, PaykitSdkService.contactRecords / removeContacts, and remove_contacts_and_block_peers / contact_records in paykit-rs.
Call sites traced: deleteAllContacts (only EditProfileView), deleteAllContactsBestEffort (no callers left), removePrivatePaykitEndpointsBestEffort (now only AppReset.wipe, out of this PR's scope), pubkyProfile.deleteProfile (only EditProfileView, with cleanPrivatePaykitEndpoints: false).
CI: SwiftFormat green; Run Tests still running when this was posted.
Ruled out
- A missing-storage read now blocks deletion:
contactRecordsis a local SDK storage transaction, so a 404-looking message there is not evidence of an empty list; the list loader keeps its ownmissingDataIsEmptypath, so only deletion changed. deleteProfile(cleanPrivatePaykitEndpoints: true)now throws where it used to continue: no caller passestrue(the default) today, so no flow changes behaviour through it.- Session switch between cleanup steps:
requireCurrentSessionis still checked after each step in the extractedperformDeleteProfile, and the view's catch ignores errors when the public key changed. - Contacts the SDK retains by design (active recurring request, peer lease, published marker) block deletion on every retry: the stated intent of the PR and its journey; the existing
PreservesContactsNotRemovedByTheBulkOperationtest pins it. - Parity with bitkit-android#1690: both stop on endpoint failure before touching contacts, both propagate the contact read error, both keep unacknowledged contacts and overrides; iOS additionally clears local-only overrides on full success, which Android gets from the store reset in
signOut.
Not built here: iOS does not build on this reviewer's Linux host, so this is reasoned from code and CI.
Merge confidence: 4/5, no findings, but the change is reasoned from code rather than built here, and the unit test job had not finished.
Reviewer model: claude-opus-5-5
Reasoning effort: Unknown (not exposed to the reviewing session)
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: no findings.
Checked and clean:
- When cleanup is incomplete, the user gets the existing Retry / Sign out / Cancel alert. Retry is idempotent after a late
signOutfailure, because an absent profile reads asNone. - No path locks the user out of deletion or sign-in for good. Private endpoints are now withdrawn before the profile delete, and the cleanup flag is cleared on any successful withdrawal (
PubkyProfileManager.swift:1498), so the user's private lists come back right after Cancel. profile-cleanup-failure.xml's fault point is right: contact removal goes through the shared-state storage adapter (PubkyShared,put_locked), so a homeserver proxy can fail it.- With #1122 removing the automatic replay, each stop is final until a manual Retry.
- Twin synonymdev/bitkit-android#1690 keeps its cleanup flag set after a stop; that's raised there.
Device gate (partial): a062618 — not run yet. profile-cleanup-failure.xml needs a homeserver fault proxy. The partial-removal stop can be driven without one, using a contact with an active recurring payment request on two devices. Device results will follow in a separate review.
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: None.
QA for a062618: iPhone 17 and iPhone SE simulators, iOS 26.5; bitkit-core 0.8.0-rc11 and Paykit 0.1.0. A debug fixture provisioned disposable native identities and displayed call/state evidence. The real deletion workflow and SDK ran against a local homeserver fault proxy; no SDK result was replaced.
Test 1: Manual 1 / J1 — private cleanup fails ✅ Failure appears before contact or profile deletion.
A native shared-state read returned 503 during private cleanup. After Cancel, the inspector and call trace confirmed the session, contacts, overrides and sharing settings remained intact, with no contact or profile deletion entered.
endpoint-2x.2x.mp4
Private cleanup failure and retained state, at 2× speed.
Test 2: Manual 1 / J1 — contact write is unconfirmed ✅ Local evidence survives and profile deletion is not entered.
The proxy accepted the native write-ahead record, then rejected shared-state publication with 503. The SDK reported an unconfirmed write. The failure dialog appeared, both local contacts and overrides remained, and the profile-delete counter stayed at zero.
contact-final-2x.2x.mp4
Unconfirmed native contact cleanup, at 2× speed.
Test 3: Manual 1 / J1 — partial native removal ✅ Only the acknowledged contact and override disappear.
The native SDK retained a contact with a published public marker and acknowledged the other removal. The remaining contact and override survived; the app showed failure without entering profile deletion. The marker was provisioned through the native SDK.
partial-2x.2x.mp4
Partial removal and retained evidence, at 2× speed.
Test 4: Manual 2 — profile deletion returns 404 ✅ Failure preserves the session and sharing settings.
The request trace confirmed profile GET 200 followed by DELETE 404. The app kept its failure UI, authenticated session and both sharing settings. Earlier acknowledged contact removals remained applied.
profile404-2x.2x.mp4
Rejected profile deletion and retained session, at 2× speed.
Test 5: J1 — Cancel and cold restart ✅ Normal app startup restores the same identity without replaying profile deletion.
After unconfirmed contact cleanup and Cancel, a normal app launch restored the same identity on Home. A fresh UI snapshot also confirmed the Profile screen. The native trace recorded a successful profile read and no profile DELETE during the 60-second observation. A separate cold profile-manager initialization after Test 4 also restored its identity without replay.
normal-startup-final-2x.2x.mp4
Cold launch through the normal app, at 2× speed.
Check 1: focused native regression checks ✅ The unmodified head passes the affected suites and formatting.
ContactsManagerTests and PubkyProfileManagerTests passed. SwiftFormat 0.64.0 passed the changed Swift files, and git diff --check passed. This does not replace the full hosted suite: its existing SendConfirmation lifecycle failure remains outside these focused checks.
Full review of d1745fb..a062618, all seven changed files. The manager's default cleanup entry point and local missing-file deletion error had source/unit coverage rather than separate device cases. Active-recurring and peer-lease retention variants were not separately driven; the published-marker case exercised the native partial-result branch. Manual Retry/Disconnect ownership and cross-device mutation safety remain deferred by this PR.
Twin: bitkit-android#1690 fix: stop deletion after incomplete cleanup matches the cleanup/error propagation behavior in the reviewed source; Android's local finalization differs.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full review of the complete PR diff against merge base d1745fb, at a062618. No saved assessment baseline was available.
No new actionable code findings.
Incomplete private-endpoint withdrawal, a failed contact read, a partial contact removal, and an unconfirmed profile-deletion error stop Edit Profile deletion before sign-out. Contacts and overrides that were not acknowledged as removed stay in place. Confirmed profile absence still completes: PaykitSdkService.deletePaykitProfile returns when fetchPaykitProfile is nil, and sign-out follows that return.
Parity with bitkit-android#1690 at 2daec90 matches on stopping deletion after incomplete cleanup and on keeping the session after a 404-like profile deletion error. When the remote contact list is empty, Android returns without clearing local-only contacts; iOS still clears that local-only state after the empty read succeeds.
bitkit-ios#1122 at ca10ace was checked only for the no-replay contract this branch is based on, not as a full review. Profile-deletion sources at that head match this PR's base. Profile deletion still runs only from the Edit Profile confirmation and its Retry action. Retry and Sign out are still not bound to the sign-in that started the attempt. That limitation remains bitkit-android#1687.
Validation: inspected PubkyProfileManagerTests and ContactsManagerTests at this head and did not execute them. SwiftFormat passed. Unit tests failed in SendConfirmationLifecycleTests.testPendingWalletSwitchResumesAfterTags (confirm versus utxoSelection). That file is outside this diff and was not re-run here.
Device testing: not performed in this review. An earlier simulator pass at this same head reports the failure cases; those results are that reviewer's.
Suggested additional test cases
- iOS simulator. Disposable signed-in profile with one saved contact and private sharing on, and no homeserver fault. Confirm deletion from Edit Profile. Expected: no failure dialog, the session ends, the app navigates to profile choice or intro, and the contact is gone.
Reviewer model: grok-4.7
Reasoning effort: high
|
Device gate (partial):
Still to check before the final line: after a refused-connection attempt, the next deletion in the same process failed in 1.4 s with no fault active and no new homeserver connection, and background refreshes failed with the same error until a relaunch. It may predate this PR; the base build hasn't been run yet. The faulted attempt logged the same error (below); the unfaulted one followed at 10:27:59 → 10:28:00.732 UTC with no proxy CONNECT in between. |
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Re-reviewed the full PR at 7e172c6 after the comparison base changed from d1745fb to 7f54d7b. No inherited coverage. Merge base is 7f54d7b.
No new actionable code findings.
Incomplete private-endpoint withdrawal, a failed contact read, a partial contact removal, and an unconfirmed profile-deletion error stop Edit Profile deletion before sign-out. Contacts and overrides that were not acknowledged as removed stay in place. Confirmed profile absence still completes: PaykitSdkService.deletePaykitProfile returns when fetchPaykitProfile is nil, and sign-out follows that return. Cancel and a later cold start leave profile deletion unstarted. Retry and Sign out still use the current session rather than the attempt that failed. That ownership gap remains bitkit-android#1687.
Parity with bitkit-android#1690 at 2daec90 matches on those stop and preserve outcomes. bitkit-ios#1122 at 7f54d7b was checked only for that no-replay contract, not as a full review.
Validation: inspected PubkyProfileManagerTests.swift and ContactsManagerTests.swift and did not execute them. SwiftFormat passed at this revision. Unit tests were still running. Device testing: not performed in this review. A partial simulator pass at a062618 covers journey actions 1, 2, 3, 7 and 8 only.
Recommended before device testing
Wait for the unit-test run at this revision to finish.
Suggested additional test cases
- iOS simulator. Disposable signed-in profile, one saved contact, private sharing on, and no homeserver fault. Confirm deletion from Edit Profile. Expected: no failure dialog, the session ends, the app opens profile choice or intro, and the contact is gone.
- iOS simulator. Same disposable profile, with a proxy that refuses the private-endpoint connection once and then allows it. Confirm deletion, cancel the failure dialog, clear the fault, and confirm deletion again without relaunching. Expected: the second attempt opens a new homeserver connection, and the first transport error does not block a later successful deletion.
Reviewer model: grok-4.7
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Approving. The code scan at a062618 had no findings, the only new commit is a merge of the parent branch, and the device gate passed at 7e172c6.
Device gate: 7e172c6, iPhone 17 simulator, a disposable profile with a saved contact on staging, through a local homeserver fault proxy. profile-cleanup-failure.xml actions 1–3, 7 and 8 were driven twice.
- After the private-endpoint withdrawal fails, deletion stops with contacts and profile kept, and "Unable to Delete Profile" offers Retry, Disconnect and Cancel.
- The base
7f54d7b, on the same steps, went pastprivateUnavailableand deleted the contacts and the profile, which is the bug this PR fixes. - The follow-up
acquire Pubky write lockfailure I noted earlier is not from this PR. It reproduces on base too (head 2 of 2, base 1 of 1), clears within about 60 s without a relaunch, and nothing in Bitkit holds the lock. The earlier "until relaunch" reading came from a run that re-applied the fault. Paykit 0.1.1-rc2 now logs two causes:- HTTP 405: pubky-sdk 0.16.2 caches a failed
/infofetch as an empty feature list for 60 s and falls back to the legacy LOCK route, which the homeserver rejects. - 423
shared_state_busy: a lock granted just as the tunnel was cut keeps its 60 s server lease because the unlock never arrived.
- HTTP 405: pubky-sdk 0.16.2 caches a failed
- Perf: not relevant.
Run Tests was still pending at 7e172c6 when I posted; confirm it's green before merge.
There was a problem hiding this comment.
Verdict:
Findings: None in the code inspected.
QA for c18e747: iPhone 17 simulator, iOS 26.5. A debug fixture provisioned disposable identities and contacts and displayed call counters and state; the real deletion workflow and native SDK ran against a controlled homeserver. The fixture did not replace SDK results.
Test 1: Private cleanup failure ✅ A shared-state read returned 503 and deletion stopped before contacts or profile were deleted.
The request trace confirmed the 503; the inspector showed the session, two contacts, two overrides and sharing settings retained.
private-2x.2x.mp4
Private cleanup failure, at 2× speed.
Test 2: Unconfirmed contact removal ✅ An unconfirmed native write preserved both contacts and stopped profile deletion.
The request trace showed the write-ahead record accepted and shared-state publication rejected with 503. The inspector showed both contacts and overrides retained.
unknown-2x.2x.mp4
Unconfirmed contact cleanup, at 2× speed.
Test 3: Partial removal ✅ The retained contact and override survived and profile deletion did not begin.
A native public marker retained one contact; the inspector showed one contact and override remaining after the other was removed.
partial-2x.2x.mp4
Partial removal, at 2× speed.
Test 4: Profile deletion returns 404 ✅ Failure preserved the session and sharing settings.
The request trace showed profile GET 200 followed by DELETE 404. Previously acknowledged contact removals remained applied.
profile404-2x.2x.mp4
Rejected profile deletion, at 2× speed.
Test 5: Cancel and normal restart ✅ The normal app restored the same profile without replaying deletion during the observed startup.
After an unconfirmed contact write and Cancel, normal startup restored the identity; the request trace showed no profile DELETE during the observation.
normal-2x.2x.mp4
Cancel and normal startup, at 2× speed.
Test 6: Recovery after connection failure ✅ A second deletion completed after connectivity returned without an app relaunch.
The fixture showed failure before cleanup, then the success route and cleared session and contacts after the connection was restored.
refusal2-2x.2x.mp4
Connection interruption and recovery, at 2× speed.
Test 7: Successful deletion with a saved contact ⏭️ The normal app completed deletion, but contact presence immediately before the attempt was not established.
The trace showed profile DELETE 204 and the normal app opened the profile introduction. The earlier unconfirmed contact write could have reconciled on restart. A separate fixture run started with two contacts and reached its success route after cleanup, but it does not prove the normal-app precondition.
success-2x.2x.mp4
Normal-app deletion, at 2× speed.
recovery-2x.2x.mp4
Fixture cleanup and success, at 2× speed.
The earlier authenticated review covered these fault paths at a062618; the Paykit dependency changed since then. The later device report covers private cleanup and restart at 7e172c6. Current-head hosted tests and formatting passed.
Reviewed the deletion interactions and scanned the main production changes in a062618..c18e747; this did not exhaust every changed test, journey, localization, or dependency implementation. The device cases did not exercise the unrelated merged USDT, backup, or payment changes.
Twin: bitkit-android#1690 matches the stop-on-incomplete-cleanup behavior; local finalization differs.
Test fixture
Not pushed. Provisions disposable identities and observes native calls without replacing their results.
diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 961935aa..12b4fcb1 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -184,7 +184,9 @@ struct BitkitApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
- if ProcessInfo.processInfo.arguments.contains("-contact-import-ui-test") {
+ if ProcessInfo.processInfo.arguments.contains("-profile-cleanup-probe") { // test hook
+ ProfileCleanupProbeView() // test hook
+ } else if ProcessInfo.processInfo.arguments.contains("-contact-import-ui-test") {
ContactImportUITestFixture()
} else if ProcessInfo.processInfo.arguments.contains("-profile-recovery-ui-test") {
ProfileRecoveryUITestFixture()
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 7be9cb33..1bc3ad58 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1847,9 +1847,22 @@ actor PaykitSdkService {
private nonisolated static func config() throws -> PaykitSdkConfig {
var config = try Paykit.defaultConfig(appId: "bitkit")
config.publicContactSharing = .privateOnly
+ #if DEBUG
+ if ProcessInfo.processInfo.arguments.contains("-retain-public-contact") { // test hook
+ config.publicContactSharing = .enabled // test hook: permit native marker provisioning
+ }
+ #endif
return config
}
+ #if DEBUG
+ func reviewPublishContact(_ key: String) async throws { // test hook: stage real native retained contact
+ try await withStateRevisionTracking { sdk in // test hook
+ _ = try await sdk.publishPublicContact(publicKey: key) // test hook
+ }
+ }
+ #endif
+
nonisolated static var clientID: String {
switch Env.network {
case .bitcoin: "bitkit.to"
diff --git a/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift b/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift
new file mode 100644
index 0000000..20f6834
--- /dev/null
+++ b/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift
@@ -0,0 +1,145 @@
+#if DEBUG
+import Paykit
+import SwiftUI
+
+// test hook: provisions disposable native data and observes real SDK operations; no result is replaced.
+@MainActor
+final class ProfileCleanupProbe: ObservableObject {
+ static let shared = ProfileCleanupProbe()
+ @Published var status = "Preparing disposable identity"
+ @Published var reads = 0
+ @Published var removals = 0
+ @Published var deletes = 0
+ @Published var evidence = ""
+ @Published var owner = ""
+ @Published var ready = false
+
+ func note(_ message: String) {
+ Logger.info("CLEANUP_PROBE \(message)", context: "ProfileCleanupProbe")
+ }
+}
+
+// test hook: all deletion decisions remain in EditProfileView and the existing managers.
+struct ProfileCleanupProbeView: View {
+ @StateObject private var probe = ProfileCleanupProbe.shared
+ @StateObject private var profile = PubkyProfileManager(profileDeleter: { key in
+ await MainActor.run {
+ ProfileCleanupProbe.shared.deletes += 1
+ ProfileCleanupProbe.shared.note("profile delete entered")
+ }
+ try await PubkyService.deletePaykitProfile(expectedIdentity: key)
+ })
+ @StateObject private var contacts = ContactsManager(contactRecords: {
+ await MainActor.run {
+ ProfileCleanupProbe.shared.reads += 1
+ ProfileCleanupProbe.shared.note("contact read entered")
+ }
+ return try await PubkyService.contactRecords()
+ }, removeContactRecords: { keys in
+ await MainActor.run {
+ ProfileCleanupProbe.shared.removals += 1
+ ProfileCleanupProbe.shared.note("contact remove entered count=\(keys.count)")
+ }
+ let removed = try await PubkyService.removeContacts(publicKeys: keys)
+ await MainActor.run { ProfileCleanupProbe.shared.note("contact remove returned count=\(removed.count)") }
+ return removed
+ })
+ @StateObject private var app = AppViewModel()
+ @StateObject private var navigation = NavigationViewModel()
+
+ var body: some View {
+ VStack(spacing: 4) {
+ CaptionText(probe.status).accessibilityIdentifier("CleanupStatus")
+ CaptionText("Reads \(probe.reads), removals \(probe.removals), deletes \(probe.deletes)")
+ .accessibilityIdentifier("CleanupCounters")
+ CaptionText(probe.evidence).accessibilityIdentifier("CleanupEvidence")
+ Button("Inspect evidence") { inspect() }.accessibilityIdentifier("CleanupInspect")
+ if probe.ready {
+ NavigationStack(path: $navigation.path) {
+ EditProfileView()
+ .navigationDestination(for: Route.self) { _ in CaptionText("Deletion success route") }
+ }
+ }
+ }
+ .environmentObject(profile)
+ .environmentObject(contacts)
+ .environmentObject(app)
+ .environmentObject(navigation)
+ .environment(KeyboardManager()) // test hook: normal Edit Profile environment dependency
+ .preferredColorScheme(.dark)
+ .task { await provision() }
+ }
+
+ private func inspect() {
+ let overrides = ContactsManager.backupContactProfileOverrides()?.count ?? 0
+ let publicSharing = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)
+ let privateSharing = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)
+ probe.evidence = "Session \(profile.currentSession == nil ? "absent" : "retained"); contacts \(contacts.contacts.count); overrides \(overrides); sharing \(publicSharing)/\(privateSharing); deleting \(profile.isDeletingProfile); route \(navigation.path.count)"
+ probe.note(probe.evidence)
+ }
+
+ private func provision() async {
+ guard !probe.ready else { return }
+ probe.note("setup e2e=\(Env.isE2E) localHost=\(Env.pubkyLocalTestnetHost ?? "nil")") // test hook
+ do {
+ if ProcessInfo.processInfo.arguments.contains("-prepare-wallet"), try Keychain.loadString(key: .bip39Mnemonic(index: 0)) == nil { // test hook
+ _ = try StartupHandler.createNewWallet(bip39Passphrase: nil) // test hook: disposable unfunded wallet for normal AppScene startup
+ }
+ if ProcessInfo.processInfo.arguments.contains("-resume-cleanup") { // test hook: invoke real startup without reseeding evidence
+ await profile.initialize()
+ guard profile.currentSession != nil else { throw PubkyServiceError.sessionNotActive }
+ let records = try await PubkyService.contactRecords()
+ contacts.contacts = records.map { PubkyContact(publicKey: PubkyPublicKeyFormat.normalized($0.publicKey) ?? $0.publicKey, profile: .placeholder(publicKey: $0.publicKey)) }
+ probe.owner = profile.publicKey ?? ""
+ probe.status = "Restored through normal profile initialization"
+ inspect()
+ probe.ready = true
+ probe.note("resumed owner=\(probe.owner)")
+ return
+ }
+ if ProcessInfo.processInfo.arguments.contains("-new-cleanup-identity") { // test hook: discard failed test provisioning only
+ await PaykitSdkService.shared.clearState()
+ try Keychain.delete(key: .pubkySecretKey)
+ try Keychain.delete(key: .paykitSession)
+ }
+ let key: String
+ if let stored = try Keychain.loadString(key: .pubkySecretKey) {
+ key = stored
+ _ = try await PubkyService.signIn(secretKeyHex: key)
+ } else {
+ var bytes = [UInt8](repeating: 0, count: 32)
+ guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { return }
+ key = bytes.map { String(format: "%02x", $0) }.joined()
+ try Keychain.upsert(key: .pubkySecretKey, data: Data(key.utf8))
+ let homeserver = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "00", count: 32))
+ _ = try await PubkyService.signUp(secretKeyHex: key, homeserverZ32: homeserver)
+ let publicKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: key)
+ try await PubkyService.publishPaykitProfile(Paykit.PaykitProfile(displayName: "Disposable cleanup identity", imageUri: nil, extraJson: nil), expectedIdentity: publicKey)
+ let peerKeys = try ["11", "22"].map { try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: $0, count: 32)) }
+ _ = try await PubkyService.saveContacts(updates: peerKeys.enumerated().map { ContactUpdate(publicKey: $0.element, label: "Peer \($0.offset + 1)") }, expectedIdentity: publicKey)
+ }
+ let publicKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: key)
+ profile.publicKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey
+ profile.authState = .authenticated
+ profile.profile = PubkyProfile(publicKey: profile.publicKey!, name: "Disposable cleanup identity", bio: "Local testnet", imageUrl: nil, links: [], status: nil)
+ if ProcessInfo.processInfo.arguments.contains("-retain-public-contact") { // test hook
+ let peer = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "22", count: 32))
+ try await PaykitSdkService.shared.reviewPublishContact(peer) // test hook: real native publication
+ }
+ let records = try await PubkyService.contactRecords()
+ contacts.contacts = records.map { PubkyContact(publicKey: PubkyPublicKeyFormat.normalized($0.publicKey) ?? $0.publicKey, profile: .placeholder(publicKey: $0.publicKey)) }
+ ContactsManager.restoreContactProfileOverrides(Dictionary(uniqueKeysWithValues: contacts.contacts.map { ($0.publicKey, PubkyProfileData.from(profile: $0.profile)) }))
+ UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey)
+ UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey)
+ probe.owner = profile.publicKey!
+ probe.note("ready owner=\(probe.owner) contacts=\(records.count)")
+ probe.status = "Authenticated local testnet identity"
+ inspect()
+ probe.ready = true
+ } catch {
+ probe.status = "Provisioning failed: \(error)"
+ probe.note(probe.status)
+ }
+ }
+}
+#endif
private: [{'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/profile.json', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/profile.json', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143518, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143518, 'delayed_ms': 1, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state.bin', 'status': 503}]
unknown: [{'at': 1793143564, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143564, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143564, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 1, 'held': False, 'method': 'PUT', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state.bin', 'status': 503}]
profile404: [{'at': 1793143683, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143683, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143683, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143683, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143684, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143684, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143684, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143684, 'delayed_ms': 1, 'held': False, 'method': 'DELETE', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/profile.json', 'status': 404}]
normal: [{'at': 1793143980, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143980, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143980, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143980, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143981, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143981, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143981, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143981, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}]
success: [{'at': 1793144015, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144016, 'delayed_ms': 0, 'held': False, 'method': 'DELETE', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/profile.json', 'status': 204}, {'at': 1793144016, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144016, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144016, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144017, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144017, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144017, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}]
Reviewed by gpt-6.1-terra-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Re-reviewed the full PR at c18e747 after the merge base changed since the saved assessment. No inherited coverage. Merge base is 162ea52.
No new actionable code findings.
Incomplete private-endpoint withdrawal, a failed contact read, a partial contact removal, and an unconfirmed profile-deletion error stop Edit Profile deletion before sign-out. Contacts and overrides that were not acknowledged as removed stay in place. Confirmed profile absence still completes: PaykitSdkService.deletePaykitProfile returns when fetchPaykitProfile is nil, and sign-out follows that return. Cancel and a later cold start leave profile deletion unstarted. Retry and Sign out use the session that is current when they are tapped. That ownership gap remains bitkit-android#1687.
Parity with bitkit-android#1690 at 2daec90 matches on those stop and preserve outcomes. After the remote contact list is fully removed, or the read succeeds and is empty, iOS clears remaining local-only contact state before profile deletion; Android removes only the acknowledged remote contacts. bitkit-ios#1122 at 162ea52 was checked only for the no-replay contract: deleteProfileWithSessionRetry is absent, and profile deletion still runs from the Edit Profile confirmation and its Retry action.
Validation: inspected PubkyProfileManagerTests.swift and ContactsManagerTests.swift and did not execute them. SwiftFormat and unit tests passed at this revision.
Device testing: not performed in this review. A simulator pass at this revision reports the failure and recovery cases; contact presence immediately before the successful deletion was not established. An earlier partial journey pass at 7e172c6 covers actions 1–3, 7 and 8 only.
Ready for device testing
Suggested additional test cases
- iOS simulator. Disposable signed-in profile, one saved contact still present, private sharing on, and no homeserver fault. Confirm deletion from Edit Profile. Expected: no failure dialog, the session ends, the app opens profile choice or intro, and that contact is gone.
Reviewer model: grok-4.7
Reasoning effort: high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of c26d7b13 against merge base f3df6cda after the comparison base changed. The complete PR diff and affected callers/tests were inspected; no coverage was inherited.
No new actionable code findings.
Cleanup failures stop subsequent deletion, and unacknowledged contacts/overrides remain available. Confirmed profile absence still completes through the service's optional profile result. Targeted comparisons with Android #1690 at 2daec90 and iOS #1122 at f3df6cda checked cleanup/error propagation and the absence of automatic deletion replay; neither comparison was a separate full review. Paykit contracts were checked at 01de5dd.
Existing concern: Retry and Disconnect still use the sign-in current when tapped; the ownership gap tracked in #1687 remains outside this repair.
Validation: hosted tests and SwiftFormat passed on merge 3d572813, containing the exact reviewed head/base. Logs confirm the new cleanup-retention and deletion-acknowledgement cases passed. Locally, diff whitespace checks and journey XML validation passed; native tests were inspected but not executed because Xcode/Swift are unavailable.
Device testing: not performed in this review. Earlier fault-injection results at c18e747 remain revision-bound; J1 needs its controlled proxy on this composition.
Suggested additional test cases
- iOS: use a disposable authenticated profile with private sharing enabled and one saved contact confirmed present immediately before deletion, with no injected fault. Delete from Edit Profile; expect the session and contact to clear, profile choice/intro navigation, and no failure dialog.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: None.
QA for c26d7b1: iPhone 17 simulator, iOS 26.5; Paykit 0.1.1-rc2. Disposable native identities and contacts were provisioned with a debug fixture. Deletion ran through the normal app and native SDK against a local test homeserver, with no injected fault or substituted SDK result.
Test 1: Successful deletion with saved contacts ✅ Deletion clears the contacts, ends the session and opens the profile introduction without a failure dialog.
The normal Contacts screen showed two saved contacts before deletion. An observation-only log at deletion entry confirmed contacts=2, privateSharing=true and an active session. After acknowledged cleanup, the log showed zero contacts and overrides. The native request trace recorded profile GET 200 followed by DELETE 204; the final log confirmed the session had ended. This closes the success-case gap in the earlier review.
Saved contacts in the normal app before this attempt.
success-short-2x.2x.mp4
Normal Edit Profile confirmation through the profile introduction, at 2× speed.
Check 1: Earlier passing fault and recovery cases ✅ The reached cleanup code and Paykit dependency are unchanged.
I retained the c18e747 device results for private withdrawal failure, unconfirmed contact writes, partial removal, profile DELETE 404, Cancel/cold restart and connection recovery. Those cases were not rerun: the contiguous delta leaves their cleanup services, deletion workflow, startup contracts and Paykit pin unchanged. The earlier success case was unverified and was driven above.
Reviewed c18e747..c26d7b1, focusing on the unchanged seven-file cleanup repair and its interactions with the merged parent. This was not a separate full review of the inherited Blocktank, notification or payment features; the device case did not exercise them. The manager's default cleanup entry and local missing-file error remain covered by source/unit evidence, rather than separate device cases. Manual Retry/Disconnect ownership and cross-device conditional cleanup remain explicitly deferred by this PR.
Twin: bitkit-android#1690 fix: stop deletion after incomplete cleanup matches stopping after incomplete cleanup and propagating unconfirmed deletion errors; local finalization differs.
Test fixture and observation hooks
Not pushed. The debug fixture provisions disposable native identities and contacts. Added log lines observe the normal deletion workflow; no deletion decision or SDK result is replaced.
diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 5f028be3..6f6187a4 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -190,7 +190,9 @@ struct BitkitApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
- if ProcessInfo.processInfo.arguments.contains("-contact-import-ui-test") {
+ if ProcessInfo.processInfo.arguments.contains("-profile-cleanup-probe") { // test hook
+ ProfileCleanupProbeView() // test hook
+ } else if ProcessInfo.processInfo.arguments.contains("-contact-import-ui-test") {
ContactImportUITestFixture()
} else if ProcessInfo.processInfo.arguments.contains("-profile-recovery-ui-test") {
ProfileRecoveryUITestFixture()
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 3813f6b7..40aa8926 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1944,9 +1944,22 @@ actor PaykitSdkService {
private nonisolated static func config() throws -> PaykitSdkConfig {
var config = try Paykit.defaultConfig(appId: "bitkit")
config.publicContactSharing = .privateOnly
+ #if DEBUG
+ if ProcessInfo.processInfo.arguments.contains("-retain-public-contact") { // test hook
+ config.publicContactSharing = .enabled // test hook: permit native marker provisioning
+ }
+ #endif
return config
}
+ #if DEBUG
+ func reviewPublishContact(_ key: String) async throws { // test hook: stage real native retained contact
+ try await withStateRevisionTracking { sdk in // test hook
+ _ = try await sdk.publishPublicContact(publicKey: key) // test hook
+ }
+ }
+ #endif
+
nonisolated static var clientID: String {
switch Env.network {
case .bitcoin: "bitkit.to"
diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift
index 092006c0..7df2a4ae 100644
--- a/Bitkit/Views/Profile/EditProfileView.swift
+++ b/Bitkit/Views/Profile/EditProfileView.swift
@@ -181,6 +181,11 @@ struct EditProfileView: View {
)
}
navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro]
+ #if DEBUG
+ if ProcessInfo.processInfo.environment["E2E"] == "true" { // test hook: observe real sign-out
+ Logger.info("CLEANUP_SUCCESS completed session=\(pubkyProfile.currentSession != nil) contacts=\(contactsManager.contacts.count)", context: "EditProfileView")
+ }
+ #endif
}
static func performDeleteProfile(
@@ -189,10 +194,20 @@ struct EditProfileView: View {
contactsManager: ContactsManager,
removePrivateEndpoints: () async throws -> Void
) async throws {
+ #if DEBUG
+ if ProcessInfo.processInfo.environment["E2E"] == "true" { // test hook: observe real normal UI preconditions
+ Logger.info("CLEANUP_SUCCESS before contacts=\(contactsManager.contacts.count) privateSharing=\(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)) session=\(pubkyProfile.currentSession != nil)", context: "EditProfileView")
+ }
+ #endif
try pubkyProfile.requireCurrentSession(session)
try await removePrivateEndpoints()
try pubkyProfile.requireCurrentSession(session)
try await contactsManager.deleteAllContacts()
+ #if DEBUG
+ if ProcessInfo.processInfo.environment["E2E"] == "true" { // test hook: observe acknowledged native cleanup
+ Logger.info("CLEANUP_SUCCESS after contacts=\(contactsManager.contacts.count) overrides=\(ContactsManager.backupContactProfileOverrides()?.count ?? 0)", context: "EditProfileView")
+ }
+ #endif
try pubkyProfile.requireCurrentSession(session)
try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session)
}
diff --git a/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift b/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift
new file mode 100644
index 0000000..20f6834
--- /dev/null
+++ b/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift
@@ -0,0 +1,145 @@
+#if DEBUG
+import Paykit
+import SwiftUI
+
+// test hook: provisions disposable native data and observes real SDK operations; no result is replaced.
+@MainActor
+final class ProfileCleanupProbe: ObservableObject {
+ static let shared = ProfileCleanupProbe()
+ @Published var status = "Preparing disposable identity"
+ @Published var reads = 0
+ @Published var removals = 0
+ @Published var deletes = 0
+ @Published var evidence = ""
+ @Published var owner = ""
+ @Published var ready = false
+
+ func note(_ message: String) {
+ Logger.info("CLEANUP_PROBE \(message)", context: "ProfileCleanupProbe")
+ }
+}
+
+// test hook: all deletion decisions remain in EditProfileView and the existing managers.
+struct ProfileCleanupProbeView: View {
+ @StateObject private var probe = ProfileCleanupProbe.shared
+ @StateObject private var profile = PubkyProfileManager(profileDeleter: { key in
+ await MainActor.run {
+ ProfileCleanupProbe.shared.deletes += 1
+ ProfileCleanupProbe.shared.note("profile delete entered")
+ }
+ try await PubkyService.deletePaykitProfile(expectedIdentity: key)
+ })
+ @StateObject private var contacts = ContactsManager(contactRecords: {
+ await MainActor.run {
+ ProfileCleanupProbe.shared.reads += 1
+ ProfileCleanupProbe.shared.note("contact read entered")
+ }
+ return try await PubkyService.contactRecords()
+ }, removeContactRecords: { keys in
+ await MainActor.run {
+ ProfileCleanupProbe.shared.removals += 1
+ ProfileCleanupProbe.shared.note("contact remove entered count=\(keys.count)")
+ }
+ let removed = try await PubkyService.removeContacts(publicKeys: keys)
+ await MainActor.run { ProfileCleanupProbe.shared.note("contact remove returned count=\(removed.count)") }
+ return removed
+ })
+ @StateObject private var app = AppViewModel()
+ @StateObject private var navigation = NavigationViewModel()
+
+ var body: some View {
+ VStack(spacing: 4) {
+ CaptionText(probe.status).accessibilityIdentifier("CleanupStatus")
+ CaptionText("Reads \(probe.reads), removals \(probe.removals), deletes \(probe.deletes)")
+ .accessibilityIdentifier("CleanupCounters")
+ CaptionText(probe.evidence).accessibilityIdentifier("CleanupEvidence")
+ Button("Inspect evidence") { inspect() }.accessibilityIdentifier("CleanupInspect")
+ if probe.ready {
+ NavigationStack(path: $navigation.path) {
+ EditProfileView()
+ .navigationDestination(for: Route.self) { _ in CaptionText("Deletion success route") }
+ }
+ }
+ }
+ .environmentObject(profile)
+ .environmentObject(contacts)
+ .environmentObject(app)
+ .environmentObject(navigation)
+ .environment(KeyboardManager()) // test hook: normal Edit Profile environment dependency
+ .preferredColorScheme(.dark)
+ .task { await provision() }
+ }
+
+ private func inspect() {
+ let overrides = ContactsManager.backupContactProfileOverrides()?.count ?? 0
+ let publicSharing = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)
+ let privateSharing = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)
+ probe.evidence = "Session \(profile.currentSession == nil ? "absent" : "retained"); contacts \(contacts.contacts.count); overrides \(overrides); sharing \(publicSharing)/\(privateSharing); deleting \(profile.isDeletingProfile); route \(navigation.path.count)"
+ probe.note(probe.evidence)
+ }
+
+ private func provision() async {
+ guard !probe.ready else { return }
+ probe.note("setup e2e=\(Env.isE2E) localHost=\(Env.pubkyLocalTestnetHost ?? "nil")") // test hook
+ do {
+ if ProcessInfo.processInfo.arguments.contains("-prepare-wallet"), try Keychain.loadString(key: .bip39Mnemonic(index: 0)) == nil { // test hook
+ _ = try StartupHandler.createNewWallet(bip39Passphrase: nil) // test hook: disposable unfunded wallet for normal AppScene startup
+ }
+ if ProcessInfo.processInfo.arguments.contains("-resume-cleanup") { // test hook: invoke real startup without reseeding evidence
+ await profile.initialize()
+ guard profile.currentSession != nil else { throw PubkyServiceError.sessionNotActive }
+ let records = try await PubkyService.contactRecords()
+ contacts.contacts = records.map { PubkyContact(publicKey: PubkyPublicKeyFormat.normalized($0.publicKey) ?? $0.publicKey, profile: .placeholder(publicKey: $0.publicKey)) }
+ probe.owner = profile.publicKey ?? ""
+ probe.status = "Restored through normal profile initialization"
+ inspect()
+ probe.ready = true
+ probe.note("resumed owner=\(probe.owner)")
+ return
+ }
+ if ProcessInfo.processInfo.arguments.contains("-new-cleanup-identity") { // test hook: discard failed test provisioning only
+ await PaykitSdkService.shared.clearState()
+ try Keychain.delete(key: .pubkySecretKey)
+ try Keychain.delete(key: .paykitSession)
+ }
+ let key: String
+ if let stored = try Keychain.loadString(key: .pubkySecretKey) {
+ key = stored
+ _ = try await PubkyService.signIn(secretKeyHex: key)
+ } else {
+ var bytes = [UInt8](repeating: 0, count: 32)
+ guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { return }
+ key = bytes.map { String(format: "%02x", $0) }.joined()
+ try Keychain.upsert(key: .pubkySecretKey, data: Data(key.utf8))
+ let homeserver = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "00", count: 32))
+ _ = try await PubkyService.signUp(secretKeyHex: key, homeserverZ32: homeserver)
+ let publicKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: key)
+ try await PubkyService.publishPaykitProfile(Paykit.PaykitProfile(displayName: "Disposable cleanup identity", imageUri: nil, extraJson: nil), expectedIdentity: publicKey)
+ let peerKeys = try ["11", "22"].map { try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: $0, count: 32)) }
+ _ = try await PubkyService.saveContacts(updates: peerKeys.enumerated().map { ContactUpdate(publicKey: $0.element, label: "Peer \($0.offset + 1)") }, expectedIdentity: publicKey)
+ }
+ let publicKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: key)
+ profile.publicKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey
+ profile.authState = .authenticated
+ profile.profile = PubkyProfile(publicKey: profile.publicKey!, name: "Disposable cleanup identity", bio: "Local testnet", imageUrl: nil, links: [], status: nil)
+ if ProcessInfo.processInfo.arguments.contains("-retain-public-contact") { // test hook
+ let peer = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "22", count: 32))
+ try await PaykitSdkService.shared.reviewPublishContact(peer) // test hook: real native publication
+ }
+ let records = try await PubkyService.contactRecords()
+ contacts.contacts = records.map { PubkyContact(publicKey: PubkyPublicKeyFormat.normalized($0.publicKey) ?? $0.publicKey, profile: .placeholder(publicKey: $0.publicKey)) }
+ ContactsManager.restoreContactProfileOverrides(Dictionary(uniqueKeysWithValues: contacts.contacts.map { ($0.publicKey, PubkyProfileData.from(profile: $0.profile)) }))
+ UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey)
+ UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey)
+ probe.owner = profile.publicKey!
+ probe.note("ready owner=\(probe.owner) contacts=\(records.count)")
+ probe.status = "Authenticated local testnet identity"
+ inspect()
+ probe.ready = true
+ } catch {
+ probe.status = "Provisioning failed: \(error)"
+ probe.note(probe.status)
+ }
+ }
+}
+#endif[2026-10-28 20:06:05.832 UTC] INFOℹ️: CLEANUP_SUCCESS before contacts=2 privateSharing=true session=true - EditProfileView [EditProfileView.swift: performDeleteProfile(session:pubkyProfile:contactsManager:removePrivateEndpoints:) line: 199]
[2026-10-28 20:06:06.518 UTC] INFOℹ️: Deleted all contacts - ContactsManager [ContactsManager.swift: deleteAllContacts() line: 991]
[2026-10-28 20:06:06.518 UTC] INFOℹ️: CLEANUP_SUCCESS after contacts=0 overrides=0 - EditProfileView [EditProfileView.swift: performDeleteProfile(session:pubkyProfile:contactsManager:removePrivateEndpoints:) line: 208]
[2026-10-28 20:06:09.419 UTC] INFOℹ️: CLEANUP_SUCCESS completed session=false contacts=0 - EditProfileView [EditProfileView.swift: performDeleteProfile(session:) line: 186]
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: None.
QA for a2b2186: iPhone 17 simulator, iOS 26.5; Paykit 0.1.1-rc2 and bitkit-core 0.8.0-rc11. Disposable authenticated identities ran against a controlled test homeserver. The subscription peer used a separate native SDK handle and identity, with real encrypted-link handshake, proposal, acceptance and cancellation. No SDK result was substituted.
Payer subscription prevents profile removal ✅ The subscription warning appears before cleanup; cancellation and a fresh confirmation permit deletion.
Native logs confirm an accepted payer subscription. Before and after refusal, the inspector showed three saved contacts, three overrides, the session, both sharing settings and one private receiving endpoint at the peer. Contact/profile cleanup counters stayed at zero. After native cancellation, a new explicit confirmation cleared the contacts, overrides and session and reached the success route.
payer-repeat2x.2x.mp4
Payer subscription prevents profile removal, at 2× speed.
Payee subscription without a saved contact prevents profile removal ✅ The subscription blocks deletion without its saved contact; cancellation and a fresh confirmation permit deletion.
Native logs confirm an accepted payee subscription with no saved contact for its counterparty. Both unrelated contacts, their overrides, the session, sharing settings and the peer’s private receiving endpoint survived refusal. Native cancellation followed by a new explicit confirmation cleared the contacts and session and reached the success route.
payee2x.2x.mp4
Payee subscription without a saved contact prevents profile removal, at 2× speed.
Unavailable subscription state prevents cleanup ✅ An unavailable subscription read stops all cleanup and preserves the session and local evidence.
The proxy returned 503 for the native shared-state read in the subscription preflight. The generic failure dialog appeared, with no private-withdrawal entry and zero contact/profile cleanup calls in the call log. Cancel left both contacts and overrides, the session and sharing settings intact.
subscription-read2x.2x.mp4
Unavailable subscription state prevents cleanup, at 2× speed.
Private withdrawal failure preserves contacts and profile ✅ A failed private withdrawal stops contact and profile deletion.
An explicit test barrier let the subscription preflight succeed before the proxy returned 503 during private withdrawal. The app showed failure with no contact read, contact removal or profile deletion. Cancel retained both contacts and overrides, the session and sharing settings.
private-repeat2x.2x.mp4
Private withdrawal failure preserves contacts and profile, at 2× speed.
Unconfirmed contact removal preserves evidence ✅ An unconfirmed native write preserves local contacts and prevents profile deletion.
The proxy acknowledged the native write-ahead record with 201, then rejected shared-state publication with 503. The app reported an unconfirmed write. Both local contacts and overrides remained, the session and sharing settings survived, and the profile-delete counter stayed at zero.
unknown-final2x.2x.mp4
Unconfirmed contact removal preserves evidence, at 2× speed.
Partial contact removal retains unacknowledged contacts ✅ The unacknowledged contact and its override remain; profile deletion does not begin.
A public contact marker was provisioned through the native SDK. Bulk removal acknowledged one contact and retained the other. After Cancel, the inspector showed one contact and one override, with the session and sharing settings retained and zero profile-delete calls.
partial2x.2x.mp4
Partial contact removal retains unacknowledged contacts, at 2× speed.
Rejected profile deletion retains the session ✅ A rejected profile DELETE preserves the session and sharing settings.
The native request trace recorded profile GET 200 followed by DELETE 404. The failure dialog remained honest: the session and both sharing settings survived, while the earlier acknowledged contact removals stayed applied.
profile404-repeat2x.2x.mp4
Rejected profile deletion retains the session, at 2× speed.
Cancel and restart do not repeat deletion ✅ Normal startup restores the same identity without replaying profile deletion.
After unconfirmed contact cleanup and Cancel, a cold launch through the normal AppScene restored the same identity on Home and opened its Profile screen. The complete homeserver proxy log over more than 60 seconds recorded a profile GET 200 and no profile DELETE; the app log independently confirmed restoration.
restart2x.2x.mp4
Cancel and restart do not repeat deletion, at 2× speed.
Deletion recovers after private cleanup failure ✅ A fresh confirmation completes deletion after the private-cleanup fault is cleared, without relaunching.
After the failed private withdrawal, I cleared its 503 fault and explicitly confirmed deletion again in the same process. Native cleanup completed, the inspector showed zero contacts and overrides, both sharing settings were cleared, and the session ended. This repeat uses an HTTP transport fault; the earlier refused-connection result remains in the linked prior report.
recovery-repeat2x.2x.mp4
Deletion recovers after private cleanup failure, at 2× speed.
Check: Native regression and translations ✅ The unmodified head builds and the affected suites pass.
xcodebuild built the exact head. PaykitSdkIdentityCheckTests, PubkyProfileManagerTests and ContactsManagerTests passed, with no failures or skips in the native result. SwiftFormat passed all six changed Swift files; translation validation and git diff --check passed. The full hosted suite was not rerun locally.
Reviewed all 23 changed files in c26d7b1..a2b2186, the reached deletion callers, and the pinned native subscription-enumeration contracts. The manager’s alternate entry, empty strict batches, malformed/unknown expiries, other-app records, cancellation and session interleavings have source/unit coverage rather than separate device cases; Ring unlink was not driven separately. Original-attempt Retry/Disconnect ownership and cross-device atomic cleanup remain explicitly deferred by this PR.
The earlier fault/recovery report and saved-contact success report informed these cases. The subscription extension and cleanup cases above were driven at this head.
Twin: bitkit-android#1690 fix: stop deletion after incomplete cleanup matches subscription refusal before cleanup and stopping after incomplete cleanup. iOS additionally checks an empty strict batch; local finalization differs.
Debug fixture and observations
Not pushed. The fixture provisions disposable native state and observes the normal Edit Profile deletion workflow. Its success destination displays the navigation result; the restart case uses the normal AppScene. Optional explicit-release barriers place request faults after preflight or private cleanup. The patch changes neither the deletion decision nor any native SDK result.
diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 5f028be3..6f6187a4 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -190,7 +190,9 @@ struct BitkitApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
- if ProcessInfo.processInfo.arguments.contains("-contact-import-ui-test") {
+ if ProcessInfo.processInfo.arguments.contains("-profile-cleanup-probe") { // test hook
+ ProfileCleanupProbeView() // test hook
+ } else if ProcessInfo.processInfo.arguments.contains("-contact-import-ui-test") {
ContactImportUITestFixture()
} else if ProcessInfo.processInfo.arguments.contains("-profile-recovery-ui-test") {
ProfileRecoveryUITestFixture()
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 5e3ee269..a4808458 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1975,9 +1975,110 @@ actor PaykitSdkService {
return config
}
+ #if DEBUG
+ private var reviewPeer: PaykitSdk?
+ private var reviewPeerProvider: PaykitSdkSessionProvider?
+ private var reviewPeerKey: String?
+ private var reviewRequestID: String?
+
+ func reviewSetupSubscription(role: String) async throws -> String {
+ try await withStateRevisionTracking { sdk in // test hook: native setup only
+ guard let owner = try await sdk.identityStatus()?.publicKey else { throw PubkyServiceError.sessionNotActive }
+ let config = Self.makePubkyClientConfig(localTestnetHost: Env.pubkyLocalTestnetHost)
+ let bootstrap = try PubkySessionBootstrap.withPubkyClientConfig(clientId: "cleanup-peer", pubkyClient: config)
+ var bytes = [UInt8](repeating: 0, count: 32)
+ guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { throw PubkyServiceError.sessionNotActive }
+ let secret = try Self.localSecretKey(fromHex: bytes.map { String(format: "%02x", $0) }.joined())
+ let homeserver = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "00", count: 32))
+ let signed = try await bootstrap.signUp(localSecretKey: secret, homeserverPublicKey: homeserver, signupCode: nil, requiredCapabilities: Self.requiredCapabilities())
+ let access = try PubkySessionAccess(clientId: "cleanup-peer", sessionSecret: signed.sessionAccess.exportSessionSecret(), localSecretKey: secret, paykitIdentitySecretKey: secret.derivePaykitIdentitySecretKey(keyGeneration: 1))
+ let provider = PaykitSdkSessionProvider(loadSessionSecret: { access.exportSessionSecret() }, deleteKeychainValue: { _ in })
+ provider.setLiveSessionAccess(access)
+ let peerConfig = try Paykit.defaultConfig(appId: "cleanup-peer")
+ let peer = try PaykitSdk.withPubkySharedStateAndClientConfig(sessionProvider: provider, config: peerConfig, pubkyClient: config)
+ _ = try await peer.initialize()
+ _ = try await peer.publishPaykitNoiseKeyAuthorization()
+ let caps = PaykitAppCapabilities(privatePayments: true, paymentRequests: true, receipts: false, outgoingPayments: true)
+ _ = try await peer.publishPaykitApp(displayName: "Disposable subscription peer", capabilities: caps)
+ _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: caps)
+ let peerKey = signed.publicKey
+ for _ in 0..<12 {
+ _ = try await sdk.ensureLinkWithPeer(counterparty: peerKey, maxAdvanceSteps: 1)
+ _ = try await peer.ensureLinkWithPeer(counterparty: owner, maxAdvanceSteps: 1)
+ if try await sdk.linkedPeers().contains(where: { PubkyPublicKeyFormat.matches($0.counterparty, peerKey) && $0.state == .linked }), try await peer.linkedPeers().contains(where: { PubkyPublicKeyFormat.matches($0.counterparty, owner) && $0.state == .linked }) { break }
+ }
+ _ = try await sdk.enqueuePrivatePaymentListWithReceivingDetails(counterparty: peerKey, receivingDetails: [PrivateReceivingDetail(identifier: "btc-regtest-p2wpkh", payload: PaymentPayload(text: "{\"value\":\"bcrt1quwla2z9mehsklxscwhw27m9jzp9sqayx5yedlw\"}"))]) // test hook
+ _ = try await sdk.processOutboundPrivateMessages(counterparty: peerKey) // test hook
+ _ = try await peer.receivePrivateMessages(counterparty: owner) // test hook
+ let terms = try PaymentRequestTerms(
+ amount: PaymentRequestAmount(value: "0.00001", asset: "btc"),
+ paymentReference: PaymentReference(text: "disposable-cleanup-\(UUID().uuidString)"),
+ proposalExpiresAt: nil,
+ recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: "2026-10-01T00:00:00Z", anchor: "2026-10-01T00:00:00Z", endsAt: nil),
+ acceptedPaymentEndpointIdentifiers: ["btc-regtest-p2wpkh"], paymentEndpoints: nil,
+ requiredAppId: nil, conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}")
+ )
+ let proposer = role == "payer" ? peer : sdk
+ let acceptor = role == "payer" ? sdk : peer
+ let proposerOther = role == "payer" ? owner : peerKey
+ let acceptorOther = role == "payer" ? peerKey : owner
+ let proposed = try await proposer.proposePaymentRequest(counterparty: proposerOther, terms: terms)
+ _ = try await proposer.processOutboundPrivateMessages(counterparty: proposerOther)
+ _ = try await acceptor.receivePrivateMessages(counterparty: acceptorOther)
+ guard let incoming = try await acceptor.paymentRequestsWith(counterparty: acceptorOther).first(where: { $0.paymentRequestId == proposed.paymentRequestId }), let item = incoming.proposalStreamItemId else { throw PubkyServiceError.profileNotFound }
+ _ = try await acceptor.claimAndAcceptPaymentRequest(counterparty: acceptorOther, paymentRequestId: proposed.paymentRequestId, expectedProposalStreamItemId: item)
+ _ = try await acceptor.processOutboundPrivateMessages(counterparty: acceptorOther)
+ _ = try await proposer.receivePrivateMessages(counterparty: proposerOther)
+ self.reviewPeer = peer
+ self.reviewPeerProvider = provider
+ self.reviewPeerKey = peerKey
+ self.reviewRequestID = proposed.paymentRequestId
+ let records = try await sdk.paymentRequests()
+ guard let active = records.first(where: { $0.paymentRequestId == proposed.paymentRequestId }), active.state == .activeRecurring else { throw PubkyServiceError.profileNotFound }
+ // test hook: payer case has its subscription contact plus unrelated contacts; payee case deliberately has no subscription contact.
+ if role == "payer" { _ = try await sdk.saveContactsAndUnblockPeers(updates: [ContactUpdate(publicKey: peerKey, label: "Subscription peer")]) }
+ return "Native \(String(describing: active.localRole)) \(active.state); subscription contact \(role == "payer" ? "saved" : "absent")"
+ }
+ }
+
+ func reviewEndSubscription() async throws -> String {
+ try await withStateRevisionTracking { sdk in // test hook: real native cancellation
+ guard let peer = self.reviewPeer, let peerKey = self.reviewPeerKey, let requestID = self.reviewRequestID, let owner = try await sdk.identityStatus()?.publicKey else { throw PubkyServiceError.sessionNotActive }
+ _ = try await sdk.cancelPaymentRequest(counterparty: peerKey, paymentRequestId: requestID, reason: "Disposable test ended")
+ _ = try await sdk.processOutboundPrivateMessages(counterparty: peerKey)
+ _ = try await peer.receivePrivateMessages(counterparty: owner)
+ _ = try await peer.processOutboundPrivateMessages(counterparty: owner)
+ _ = try await sdk.receivePrivateMessages(counterparty: peerKey)
+ let records = try await sdk.paymentRequests()
+ guard let ended = records.first(where: { $0.paymentRequestId == requestID }), ended.state != .activeRecurring else { throw PubkyServiceError.profileNotFound }
+ return "Native subscription \(ended.state)"
+ }
+ }
+
+ func reviewNativeEvidence() async throws -> String {
+ try await withStateRevisionTracking { sdk in // test hook: native observations only
+ let records = try await sdk.paymentRequests()
+ let contacts = try await sdk.contactRecords()
+ var endpointCount = -1
+ if let peer = self.reviewPeer, let owner = try await sdk.identityStatus()?.publicKey {
+ _ = try await peer.receivePrivateMessages(counterparty: owner)
+ endpointCount = try await peer.currentPrivatePaymentLists(counterparty: owner).flatMap(\.paymentEndpoints).count
+ }
+ return "Native contacts \(contacts.count); active \(records.filter { $0.state == .activeRecurring }.count); private endpoints at peer \(endpointCount)"
+ }
+ }
+
+ func reviewPublishContact(_ key: String) async throws { // test hook
+ try await withStateRevisionTracking { sdk in _ = try await sdk.publishPublicContact(publicKey: key) }
+ }
+ #endif
+
private nonisolated static func config() throws -> PaykitSdkConfig {
var config = try Paykit.defaultConfig(appId: "bitkit")
config.publicContactSharing = .privateOnly
+ #if DEBUG
+ if ProcessInfo.processInfo.arguments.contains("-retain-public-contact") { config.publicContactSharing = .enabled } // test hook
+ #endif
return config
}
diff --git a/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift b/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift
new file mode 100644
index 00000000..5ab5bc62
--- /dev/null
+++ b/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift
@@ -0,0 +1,166 @@
+#if DEBUG
+import Paykit
+import SwiftUI
+
+// test hook: provisions disposable native data and observes real SDK operations; no result is replaced.
+@MainActor
+final class ProfileCleanupProbe: ObservableObject {
+ static let shared = ProfileCleanupProbe()
+ @Published var status = "Preparing disposable identity"
+ @Published var reads = 0
+ @Published var removals = 0
+ @Published var deletes = 0
+ @Published var evidence = ""
+ @Published var owner = ""
+ @Published var ready = false
+
+ func waitForRelease(_ step: String) async throws { // test hook: deterministic barrier, explicit tester release
+ guard ProcessInfo.processInfo.arguments.contains("-hold-" + step) else { return }
+ let release = Env.appStorageUrl.appendingPathComponent("review-release-" + step)
+ note("holding before " + step)
+ while !FileManager.default.fileExists(atPath: release.path) { try await Task.sleep(for: .milliseconds(100)) }
+ note("released before " + step)
+ }
+
+ func note(_ message: String) {
+ Logger.info("CLEANUP_PROBE \(message)", context: "ProfileCleanupProbe")
+ }
+}
+
+// test hook: all deletion decisions remain in EditProfileView and the existing managers.
+struct ProfileCleanupProbeView: View {
+ @StateObject private var probe = ProfileCleanupProbe.shared
+ @StateObject private var profile = PubkyProfileManager(profileDeleter: { key in
+ await MainActor.run {
+ ProfileCleanupProbe.shared.deletes += 1
+ ProfileCleanupProbe.shared.note("profile delete entered")
+ }
+ try await PubkyService.deletePaykitProfile(expectedIdentity: key)
+ })
+ @StateObject private var contacts = ContactsManager(contactRecords: {
+ await MainActor.run {
+ ProfileCleanupProbe.shared.reads += 1
+ ProfileCleanupProbe.shared.note("contact read entered")
+ }
+ return try await PubkyService.contactRecords()
+ }, removeContactRecords: { keys in
+ await MainActor.run {
+ ProfileCleanupProbe.shared.removals += 1
+ ProfileCleanupProbe.shared.note("contact remove entered count=\(keys.count)")
+ }
+ let removed = try await PubkyService.removeContactsForProfile(publicKeys: keys)
+ await MainActor.run { ProfileCleanupProbe.shared.note("contact remove returned count=\(removed.count)") }
+ return removed
+ })
+ @StateObject private var app = AppViewModel()
+ @StateObject private var navigation = NavigationViewModel()
+
+ var body: some View {
+ VStack(spacing: 4) {
+ CaptionText(probe.status).accessibilityIdentifier("CleanupStatus")
+ CaptionText("Reads \(probe.reads), removals \(probe.removals), deletes \(probe.deletes)")
+ .accessibilityIdentifier("CleanupCounters")
+ CaptionText(probe.evidence).accessibilityIdentifier("CleanupEvidence")
+ Button("Inspect evidence") { inspect() }.accessibilityIdentifier("CleanupInspect")
+ Button("End subscription") { Task {
+ do { probe.status = try await PaykitSdkService.shared.reviewEndSubscription(); inspect() } catch { probe.status = "End failed: \(error)" }
+ } }.accessibilityIdentifier("CleanupEnd") // test hook
+ if probe.ready {
+ NavigationStack(path: $navigation.path) {
+ EditProfileView()
+ .navigationDestination(for: Route.self) { _ in CaptionText("Deletion success route") }
+ }
+ }
+ }
+ .environmentObject(profile)
+ .environmentObject(contacts)
+ .environmentObject(app)
+ .environmentObject(navigation)
+ .environment(KeyboardManager()) // test hook: normal Edit Profile environment dependency
+ .preferredColorScheme(.dark)
+ .task { await provision() }
+ }
+
+ private func inspect() {
+ let overrides = ContactsManager.backupContactProfileOverrides()?.count ?? 0
+ let publicSharing = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)
+ let privateSharing = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)
+ probe.evidence = "Session \(profile.currentSession == nil ? "absent" : "retained"); contacts \(contacts.contacts.count); overrides \(overrides); sharing \(publicSharing)/\(privateSharing); deleting \(profile.isDeletingProfile); route \(navigation.path.count)"
+ probe.note(probe.evidence)
+ Task { // test hook: observe native state without changing deletion decisions
+ if let native = try? await PaykitSdkService.shared.reviewNativeEvidence() {
+ probe.status = native
+ probe.note(native)
+ }
+ }
+ }
+
+ private func provision() async {
+ guard !probe.ready else { return }
+ probe.note("setup e2e=\(Env.isE2E) localHost=\(Env.pubkyLocalTestnetHost ?? "nil")") // test hook
+ do {
+ if ProcessInfo.processInfo.arguments.contains("-prepare-wallet"), try Keychain.loadString(key: .bip39Mnemonic(index: 0)) == nil { // test hook
+ _ = try StartupHandler.createNewWallet(bip39Passphrase: nil) // test hook: disposable unfunded wallet for normal AppScene startup
+ }
+ if ProcessInfo.processInfo.arguments.contains("-resume-cleanup") { // test hook: invoke real startup without reseeding evidence
+ await profile.initialize()
+ guard profile.currentSession != nil else { throw PubkyServiceError.sessionNotActive }
+ let records = try await PubkyService.contactRecords()
+ contacts.contacts = records.map { PubkyContact(publicKey: PubkyPublicKeyFormat.normalized($0.publicKey) ?? $0.publicKey, profile: .placeholder(publicKey: $0.publicKey)) }
+ probe.owner = profile.publicKey ?? ""
+ probe.status = "Restored through normal profile initialization"
+ inspect()
+ probe.ready = true
+ probe.note("resumed owner=\(probe.owner)")
+ return
+ }
+ if ProcessInfo.processInfo.arguments.contains("-new-cleanup-identity") { // test hook: discard failed test provisioning only
+ await PaykitSdkService.shared.clearState()
+ try Keychain.delete(key: .pubkySecretKey)
+ try Keychain.delete(key: .paykitSession)
+ }
+ let key: String
+ if let stored = try Keychain.loadString(key: .pubkySecretKey) {
+ key = stored
+ _ = try await PubkyService.signIn(secretKeyHex: key)
+ } else {
+ var bytes = [UInt8](repeating: 0, count: 32)
+ guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { return }
+ key = bytes.map { String(format: "%02x", $0) }.joined()
+ try Keychain.upsert(key: .pubkySecretKey, data: Data(key.utf8))
+ let homeserver = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "00", count: 32))
+ _ = try await PubkyService.signUp(secretKeyHex: key, homeserverZ32: homeserver)
+ let publicKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: key)
+ try await PubkyService.publishPaykitProfile(Paykit.PaykitProfile(displayName: "Disposable cleanup identity", imageUri: nil, extraJson: nil), expectedIdentity: publicKey)
+ let peerKeys = try ["11", "22"].map { try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: $0, count: 32)) }
+ _ = try await PubkyService.saveContacts(updates: peerKeys.enumerated().map { ContactUpdate(publicKey: $0.element, label: "Peer \($0.offset + 1)") }, expectedIdentity: publicKey)
+ }
+ let publicKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: key)
+ profile.publicKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey
+ profile.authState = .authenticated
+ profile.profile = PubkyProfile(publicKey: profile.publicKey!, name: "Disposable cleanup identity", bio: "Local testnet", imageUrl: nil, links: [], status: nil)
+ if ProcessInfo.processInfo.arguments.contains("-retain-public-contact") { // test hook
+ let peer = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "22", count: 32))
+ try await PaykitSdkService.shared.reviewPublishContact(peer) // test hook: real native publication
+ }
+ if let role = ProcessInfo.processInfo.environment["CLEANUP_ROLE"] { // test hook
+ probe.status = try await PaykitSdkService.shared.reviewSetupSubscription(role: role) // test hook
+ probe.note(probe.status) // test hook
+ }
+ let records = try await PubkyService.contactRecords()
+ contacts.contacts = records.map { PubkyContact(publicKey: PubkyPublicKeyFormat.normalized($0.publicKey) ?? $0.publicKey, profile: .placeholder(publicKey: $0.publicKey)) }
+ ContactsManager.restoreContactProfileOverrides(Dictionary(uniqueKeysWithValues: contacts.contacts.map { ($0.publicKey, PubkyProfileData.from(profile: $0.profile)) }))
+ UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey)
+ UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey)
+ probe.owner = profile.publicKey!
+ probe.note("ready owner=\(probe.owner) contacts=\(records.count)")
+ if ProcessInfo.processInfo.environment["CLEANUP_ROLE"] == nil { probe.status = "Authenticated local testnet identity" }
+ inspect()
+ probe.ready = true
+ } catch {
+ probe.status = "Provisioning failed: \(error)"
+ probe.note(probe.status)
+ }
+ }
+}
+#endif
diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift
index 0db9252a..bce43723 100644
--- a/Bitkit/Views/Profile/EditProfileView.swift
+++ b/Bitkit/Views/Profile/EditProfileView.swift
@@ -162,6 +162,9 @@ struct EditProfileView: View {
try await performDeleteProfile(session: session)
} catch PubkyServiceError.activeSubscription {
guard !Task.isCancelled, pubkyProfile.currentSession == session else { return }
+ #if DEBUG
+ ProfileCleanupProbe.shared.note("specific subscription warning") // test hook
+ #endif
app.toast(type: .error, title: t("profile__delete_active_subscription"))
} catch {
guard !Task.isCancelled, pubkyProfile.publicKey == session.publicKey else { return }
@@ -192,9 +195,21 @@ struct EditProfileView: View {
contactsManager: ContactsManager,
removePrivateEndpoints: () async throws -> Void
) async throws {
+ #if DEBUG
+ ProfileCleanupProbe.shared.note("attempt before contacts=\(contactsManager.contacts.count) privateSharing=\(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey))") // test hook
+ #endif
try await pubkyProfile.validateProfileDeletion(for: session)
+ #if DEBUG
+ ProfileCleanupProbe.shared.note("private withdrawal entered") // test hook
+ #endif
+ #if DEBUG
+ try await ProfileCleanupProbe.shared.waitForRelease("private") // test hook
+ #endif
try await removePrivateEndpoints()
try pubkyProfile.requireCurrentSession(session)
+ #if DEBUG
+ try await ProfileCleanupProbe.shared.waitForRelease("contacts") // test hook
+ #endif
try await contactsManager.deleteAllContacts()
try pubkyProfile.requireCurrentSession(session)
try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session)Observed app/native state:
[2026-10-29 07:34:48.421 UTC] INFOℹ️: CLEANUP_PROBE Native Optional(Paykit.PaymentRequestLocalRole.payer) activeRecurring; subscription contact saved - ProfileCleanupProbe [ProfileCleanupProbe.swift: note(_:) line: 18]
[2026-10-29 07:37:30.183 UTC] INFOℹ️: CLEANUP_PROBE Native Optional(Paykit.PaymentRequestLocalRole.payee) activeRecurring; subscription contact absent - ProfileCleanupProbe [ProfileCleanupProbe.swift: note(_:) line: 18]
[2026-10-29 07:35:27.490 UTC] INFOℹ️: CLEANUP_PROBE specific subscription warning - ProfileCleanupProbe [ProfileCleanupProbe.swift: note(_:) line: 18]
[2026-10-29 07:34:50.935 UTC] INFOℹ️: CLEANUP_PROBE Native contacts 3; active 1; private endpoints at peer 1 - ProfileCleanupProbe [ProfileCleanupProbe.swift: note(_:) line: 18]
[2026-10-29 07:37:32.146 UTC] INFOℹ️: CLEANUP_PROBE Native contacts 2; active 1; private endpoints at peer 1 - ProfileCleanupProbe [ProfileCleanupProbe.swift: note(_:) line: 18]
[2026-10-29 07:55:36.522 UTC] INFOℹ️: CLEANUP_PROBE contact remove returned count=1 - ProfileCleanupProbe [ProfileCleanupProbe.swift: note(_:) line: 26]
[2026-10-29 07:59:44.631 UTC] INFOℹ️: CLEANUP_PROBE Session retained; contacts 1; overrides 1; sharing true/true; deleting false; route 0 - ProfileCleanupProbe [ProfileCleanupProbe.swift: note(_:) line: 26]
[2026-10-29 07:42:30.242 UTC] ERROR❌: Failed to delete profile: Transport(code: "transport_error", context: "write encrypted Pubky shared state could not be confirmed") - EditProfileView [EditProfileView.swift: deleteProfile() line: 171]
[2026-10-29 07:36:54.299 UTC] INFOℹ️: CLEANUP_PROBE Session absent; contacts 0; overrides 0; sharing false/false; deleting false; route 1 - ProfileCleanupProbe [ProfileCleanupProbe.swift: note(_:) line: 18]
Reviewed by gpt-6.1-sol-high
Twin: synonymdev/bitkit-android#1690
Dependency: #1122
This PR stops profile deletion after incomplete payment-link or contact cleanup instead of discarding the cleanup error and local contact evidence.
Description
Out of Scope
Design
Existing Profile › Profile Edit in the Bitkit handoff. This changes failure handling; it adds no visual design.
Preview
N/A — existing error UI is reused.
QA Notes
Setup
Use this branch's normal released dependencies: Core 0.8.0-rc11, Paykit 0.1.1-rc2 and VSS 0.5.26-rc1. Component tests use synthetic identities and injected service failures; no real profile, wallet or funds are needed. For J1, provision a disposable authenticated identity and a homeserver fault proxy that can hold/fail private endpoint withdrawal, lose a contact-removal acknowledgement, and return a partial contact-removal acknowledgement, or fail profile deletion with a 404-like error without confirming the outcome. Also prepare active recurring subscriptions in both payer/payee roles, including one without a saved contact and an unrelated saved contact. Record endpoint/contact state, confirm removal, then end the subscription and explicitly confirm again. Those controls are unavailable in the standard journey fixture. The committed
journeys/profile/profile-cleanup-failure.xmldescribes the sequence; do not use a personal profile or reset another worker's wallet.Journeys
profile-cleanup-failure.xml— incomplete cleanup preserves retained evidence and prevents later profile deletion. The added subscription cases must refuse before withdrawal or any contact removal and permit a new explicit confirmation after the subscription ends; earlier J1 evidence does not cover this extension.Manual Tests
1. External reviewer reported native authenticated private-cleanup, unconfirmed contact-write and partial-removal cases at
a0626189; failure appeared before profile deletion and retained evidence stayed intact. See the scoped review evidence below.2. External reviewer reported profile GET 200 followed by DELETE 404 at
a0626189; failure UI, session and sharing settings remained. See the scoped review evidence below.Automated Checks
PubkyProfileManagerTests.swift— the actual view workflow stops after private/contact cleanup failure, retains the session/evidence and releases deletion admission.ContactsManagerTests.swift— unavailable contact reads preserve local evidence; existing partial-removal coverage remains in place.PaykitSdkIdentityCheckTests.swiftand manager tests — both roles, orphan/non-Bitkit records, unknown/invalid/expired subscription ends, strict empty/nonempty batches, read failure/cancellation and early workflow refusal.Evidence
Current signed head
a2b218613ecdeb0a45cd9364d723204147b27044is based on live #1122f3df6cdad712d31a5a223b746e0211860900745d. All seven actual-base commits verify locally and reportverified=true/reason=validon GitHub; the PR remains ready (draft=false). Current-head hosted native tests and formatting passed on syntheticb87f0c25, whose tree equals the published head. The native result contains 2,832 unit passes and 39 UI execution passes, zero failures/retries, and five explicit fixture/hardware skips; four integration suites are also excluded. Complete logs and all 2,932 nested result references were inspected; the uploaded archive digest/CRC and 8,950 sealed evidence files verify. Hosted runtime issues are zero. The 286 distinct SQLite duplicate-symbol warnings match the prior result at the same actual base and unchanged build inputs. A separatexcodebuild -version | headprobe raised a Brokenpipe exception; the cache action and subsequent tests succeeded. This is retained as a tooling event, not an app crash. Hosted SwiftFormat is 0/821, with 93 configured skips; nine migration jobs were skipped and receive no pass credit.Final build/unit/UI validation on iPhone 16 / iOS 26.2 build 23C54 passed 2,832 unit tests and 39 UI executions, with three explicit Trezor skips and zero failures. All 1,647 frozen inputs matched. The repository's integration exclusions and expired local-fork case were not counted as passes. Complete 22,880-line native logs, all 2,874 nested execution summaries and 2,927 reference IDs were inspected; 3,011 sealed evidence hashes were verified. Twelve QoS observations (SDK construction and LDK status/cache) and two AppIntents notices remain disclosed with actual-base source/pin comparisons; no timing-cause or warning-free claim is made. SwiftFormat 0.64.0 reported 0/821, 93 configured exclusions; translations reported zero errors and 15 actual-base warnings. Two independent risk-scoped source reviews are clean. The CLI collapses 30 launch variants and misses two interleaved native result lines; complete native/XCResult counts reconcile. Authenticated subscription acceptance remains the explicit gap below.
The new subscription checks preserve the existing original-session validation and SDK operation lock. Unknown or malformed expiry remains active; a failed subscription read propagates its error. Generic bulk contact removal retains its existing behavior. Profile cleanup uses the strict batch, so a known active subscription cannot silently filter its contact and delete unrelated contacts. Final profile deletion retains the native expected-revision CAS. Cross-device atomicity across separate SDK calls is not claimed.
The controlled native before test failed for both payer and payee orphan subscriptions: the profile mutation ran when it should have refused (four assertion instances). The repaired focused SDK/manager/contact suites passed 251 cases with no failures/skips. The early workflow test verifies refusal before endpoint/contact cleanup and preserved session/sharing state. These tests inject native records and failures; they are not authenticated runtime acceptance.
Previous composition
c26d7b13, parentf3df6cda, passed 2,828 units and 39 UI executions locally, with three explicit Trezor skips. Hosted native tests and formatter passed on its exact tree: 2,828 units + 39 UI passes, five explicit fixture/hardware skips, no failures/retries, format0/821. Complete logs and checksum/CRC-verified nested artifacts were inspected. Duplicate-symbol and runtime diagnostics remain revision-scoped in the recorded evidence. These prior results do not validate the new subscription branch.Gaps
The complete J1 matrix, including the new active-subscription cases, has not been independently driven by this worker against the authenticated fault fixture. The required pre-created active payer/payee/orphan records and acknowledged endpoint/contact mutation controls are not available in the standard journey setup. No skipped/component case is counted as runtime success. External manual 1/2 results below remain at their tested revision. Native contact CAS, safe uncertain remote retry and historical incident causation are not established.
Models used
gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh),gpt-6.1-sol(reasoning:high)Prior hosted failure
At
a0626189, run40165956252 failed the three-second pending-wallet route appearance assertion while later path/preparation assertions and 39 UI executions passed. Complete logs/nestedXCResult and the separate exact iOS 26.2 diagnostic pass are retained. Keyboard overlap did not establish causation. No deadline was weakened; later green compositions are not a causal repair claim.External authenticated review evidence — October26
Review5653598069 reports five controlled cases at
a0626189, using iPhone 17/SE simulators on iOS 26.5, Core 0.8.0-rc11 and Paykit 0.1.0. A debug fixture provisioned disposable native identities and displayed call/state evidence; the deletion workflow/SDK used a local homeserver proxy. Private-read503, accepted contact write with rejected shared-state publication, partial removal and GET200/DELETE404 preserved appropriate session/contact/sharing evidence and prevented later deletion. Cancel plus cold launch restored the same identity with no profile DELETE during the reported 60-second observation. Videos/traces are linked in that review.These are attributed external manual 1/2 results, not current-head author runs or proof of the added subscription cases, safe manual retry or native cross-device CAS. The earlier approval does not waive an unresolved finding or failed check.