Repository navigation
fix: bind profile removal to its original owner - #1143
ben-kaufman wants to merge 36 commits into
Conversation
|
Placed in the 3.1.0 milestone: its Android twin synonymdev/bitkit-android#1693 and its parent in the stack, #1125, are both in 3.1.0. This PR closes no issue, so it carries the milestone itself. |
talosmachina
left a comment
There was a problem hiding this comment.
No findings in the paths I reviewed. Binds profile removal, Disconnect and wallet wipe to a reserved admission scope with a durable profile-removal-v1.json obligation, makes credential, keychain and preference cleanup acknowledged rather than best effort, and gates wallet startup on an unresolved wipe. Reviewed 60b1485b, oversized tier (76 files, +7,577/-2,360, stacked on #1125 at 6c0a1607).
What I checked, and what I did not
Read in full: ProfileOperationAdmission.swift, ProfileRemovalStore.swift, ProfileRemovalRecoveryView.swift, AppReset.swift
Read as diff plus callers: Keychain.swift, SharedPubkyKeychain.swift, PrivatePaykitAddressReservationStore.swift, BackupService.swift, PrivatePaykitService+Backup.swift, PrivatePaykitService+State.swift, the removal flow in PubkyProfileManager.swift (prepareRemoval, confirmRemoval, finishPendingRemoval, runRemoval, clearLocalState), the gating in AppScene.swift and MainNavView.swift, and the ContactsManager removal/import paths
Call sites traced: every AppReset.wipe( caller, every admission.capture() / scheduleAfterTransition / reserveSessionTransition site, contactPublicKeyForAttribution
CI: SwiftFormat green on this head; Run Tests pending at review time; e2e jobs skipped
Ruled out
- Wipe marker deleted by its own wipe: the marker lives at
<network>/wallet0/profile-removal-v1.json, whilewipeStorageremoves onlywallet0/ldkandwipeAllDatabaseswipes the core DB tables in place, so the obligation survives untiladmission.completeremoves it. - Wallet wipe downgraded to profile recovery:
ProfileRemovalStore.beginrefuses a.profilerecord over a pending.walletWipeone. - Keychain wipe deletes other apps' or test-run credentials: enumeration is scoped to
Env.keychainGroupwith the unit-test prefix guard kept, and shared-group deletion is filtered to theownSourceApp:prefix. - Unreadable reservation ledger silently replaced:
clearContactAssignmentsForRemovalthrows when the stored ledger was unreadable or differs from memory, so local completion fails closed instead of overwriting it. - Wallet starts while a wipe is pending:
setupTask,handleWalletExistsChangeandstartWalletall return early onblocksWalletStartup, andmainContentswaps in the recovery view.
Not reviewed: the remaining payment, invoice, contacts and SDK changes in PubkyService.swift, PrivatePaykitService+Payments.swift, PrivatePaykitService+Contacts.swift, ContactPaymentsService.swift, the view changes outside recovery, the tests and the translations. At this size a line-by-line review of the whole diff is not reliable.
Not built: Swift does not build on this reviewer's Linux host, so this review is static and relies on CI for compilation and tests.
Merge confidence: 3/5, oversized tier, so the remainder above was not read.
Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed (the review run's metadata does not report it)
There was a problem hiding this comment.
Verdict:
Findings:
- MEDIUM — Preserve incoming payment attribution during cleanup (
Bitkit/Services/PrivatePaykitService+Payments.swift:35): a private Lightning invoice paid while removal is reserved or recovery is pending is saved without its contact because the local lookup returns nil. Local Disconnect clears the invoice mapping before a later sync can recover it. The same admission gate blocks reserved-address attribution for on-chain receipts, and completion clears the assignment history.
QA for 60b1485:
Test 1: Controlled local recovery ✅ The isolated UI fixture retained its marker and watermark through failure and cancellation, then cleared them after explicit completion; XCTest assertions checked the fixture state.
recovery-retry-2x.2x.mp4
Test 2: J1 profile removal recovery ⏭️ The complete authenticated journey was not driven; scoped lost-acknowledgement and replacement controls were unavailable.
Test 3: J2 profile cleanup failure ⏭️ The complete authenticated journey was not driven; the required scoped withdrawal, contact and profile fault controls were unavailable.
Test 4: Manual 1 teardown ownership ⏭️ The full installed replacement and wipe interleavings were not driven; the PR reports scheduler-component coverage.
Test 5: Manual 2 durable cleanup failures ⏭️ The full credential and storage failure and restart boundaries were not driven; Test 1 uses controlled fixture state.
Check 1: Hosted tests ⚠️ The current-head hosted run failed two unit tests; both passed in a focused local repeat.
Hosted test run failed ContactPaymentsServiceTests.testContactPaymentsEnableWinsOverAnOlderDisableStillRemoving and ProfileDestinationViewTests.testCreateWaitsForTheOriginalTransitionReleaseWithoutAnotherBusyEvent. The repeat does not establish why CI failed.
The review covered the full PR range 6c0a160..60b1485, with targeted tracing of ownership, cleanup and receipt attribution; the complete diff was not reviewed line by line. No device case drove payment attribution or the authenticated fault journeys. The earlier source review covers additional cleanup paths.
Pair PR synonymdev/bitkit-android#1693 implements the same ownership and recovery goal; Android additionally offers Review, while iOS offers local Disconnect only.
Reviewer model: GPT-6
Reasoning effort: Not exposed (run metadata did not expose it).
Reviewed by gpt-6.1-terra-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: one MEDIUM, inline at PubkyProfileManager.swift:1560. It is the iOS form of the MEDIUM I raised on synonymdev/bitkit-android#1693: if Disconnect fails partway, it can't be retried, and the only way out removes the profile on this device alone.
Checked and clean:
- A profile switch can't make removal apply to the wrong identity.
- A restart doesn't replay the removal automatically.
- A re-run of the recovery wipe tolerates the database and keychain state it finds.
- PIN and startup gating hold.
- The DEBUG fault selector doesn't reach release builds.
Not raised as a finding: an unreadable reservation ledger blocks a local Disconnect until a wallet reset. That fail-closed behaviour is intended and tested here, and only external corruption of the ledger can reach it.
Device gate: waits for the MEDIUM.
There was a problem hiding this comment.
Verdict:
Findings: No new findings in the delta. The earlier receipt-attribution finding is unchanged. The separate Disconnect retry finding is also open.
QA for 91379a5:
Test 1: Controlled local recovery ✅ The previously recorded fixture retained its marker and watermark through failure and cancellation, then cleared them after explicit completion.
The earlier test report covers this unchanged fixture and includes the recording.
Test 2: J1 profile removal recovery ⏭️ The complete journey remains unrun.
A linked disposable peer and a paid regtest request established consumed version 0. A post-commit 502 led to recovery; the version survived a credential-erasure fault, and the final marker survived a later completion fault. The app returned to profile setup after explicit completion. State and proxy logs were checked directly. Remote edits and reconnect were not driven, and a post-commit 502 is not a dropped connection.
Test 3: J2 profile cleanup failure ⏭️ The complete journey remains unrun.
A pre-storage profile DELETE returned 404, recovery appeared, and a fresh GET still returned 200; proxy logs showed no additional DELETE after cancellation and restart. The withdrawal and partial contact-removal faults were not driven.
Test 4: Manual 1 teardown ownership ⏭️ One same-key replacement completion was exercised; the remaining teardown boundaries were not driven.
A local test hook held the old completion after teardown and opened profile creation. After a new session for the same key was active, releasing the completion returned false from its ownership check and left the new profile open. The hook's result and the final profile screen were checked separately; the recording ends during replacement setup and has visible stutters.
owner2-2x.2x.mp4
Test 5: Manual 2 durable cleanup failures ⏭️ The final-marker boundary was exercised; the full storage-failure matrix remains unrun.
The same recovery record survived the injected final-marker failure and a restart after credential cleanup, then disappeared after explicit completion. The record was compared on disk before and after the fault; the recording ends at the final confirmation, and the subsequent screen and marker removal were checked separately.
manual2-2x.2x.mp4
Check 1: Hosted tests ✅ The exact-head native test run passed.
Native CI passed at this head. These tests do not establish the unrun authenticated fault matrix.
This review covered the delta 60b1485..91379a5 and the earlier open attribution finding. The delta changes only test synchronization; no device case drove payment attribution. The earlier source review covers additional cleanup paths.
Twin synonymdev/bitkit-android#1693 shares the ownership and recovery goal; Android additionally offers Review, while iOS offers local Disconnect only.
Reviewer model: gpt-6.1-terra
Reasoning effort: high
Reviewed by gpt-6.1-terra-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — an old receipt can write after wallet wipe.
Findings:
- HIGH —
Bitkit/Utilities/ProfileOperationAdmission.swift:74: a receipt rejected from wipe admission still runs untracked, so its old-wallet activity can be written after database cleanup. The inline comment includes the controlled reproduction. - The earlier Disconnect retry finding remains open; the author has explained the remote reconciliation constraint.
QA for f1fa5ee:
Test 1: Controlled local recovery ✅ The marker and watermark survived failure and cancellation until explicit completion.
The earlier report records this unchanged fixture.
Test 2: Receipt attribution during cleanup ✅ A regtest reserved-address receipt retained its amount and contact key through local completion and restart.
The current-head report describes the stored-row and restart checks. It does not establish immediate physical persistence or power-loss atomicity.
Test 3: Receipt starting during wallet wipe ❌ An old receipt wrote an activity after wipe drained and cleared the test database.
A focused test reserved wipe, held an incoming invoice receipt in its resolver, drained and persisted wipe, cleared the isolated Core activity database, then released the receipt. The assertion that the database remained empty failed. The assertion result was checked in the test output; the recording shows the test host.
wipe-2x.2x.mp4
Test 4: J1 profile removal recovery ⏭️ The complete authenticated journey remains unrun.
The previous partial report exercised a post-commit error and credential failure, but not a dropped acknowledgement, remote edits and reconnect.
Test 5: J2 profile cleanup failure ⏭️ The complete authenticated journey remains unrun.
The previous partial report exercised a profile DELETE 404 and restart; withdrawal and partial contact-removal faults remain untested.
Test 6: Teardown ownership boundaries ⏭️ The full boundary matrix remains unrun.
The previous partial report exercised one held completion across same-key replacement, but not every SDK, publication, wipe and local-completion boundary.
Test 7: Durable cleanup failures ⏭️ The full storage-failure matrix remains unrun.
A preferences-flush fault retained the same marker across restart; after removing the fault, explicit Disconnect cleared it. The marker was compared on disk. The recording has visible stutters. The previous partial report exercised credential and final-marker failures; the complete storage and consumed-watermark matrix remains incomplete.
manual2-2x.2x.mp4
Check 1: Hosted tests ✅ The exact-head native test run passed.
Native CI includes receipt and admission regression coverage; it does not establish the unrun authenticated journeys or the new wipe interleaving.
The review covered 91379a5..f1fa5ee and the two earlier open findings. The authenticated fault journeys and the full teardown and storage matrices were not driven to completion.
Twin synonymdev/bitkit-android#1693 shares the ownership and recovery goal; Android additionally offers Review, while iOS offers local Disconnect only.
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: Full review of the complete PR diff against merge base 6c0a160, at f1fa5ee. No saved assessment baseline was available, so nothing was inherited.
2 actionable findings — resolve or provide an evidence-backed rebuttal.
Captured receipt leases keep invoice and reserved-address contacts through pending profile cleanup and check them again at the Core write. The earlier lost-contact thread is addressed at this head. A receipt that starts after a wallet wipe is reserved is not part of that lease: it still writes the activity after drain. A pending backup restore can also rewrite wallet data while wipe recovery is unresolved, because only some startup paths gained the new gate. The open Disconnect retry thread still matches the code and this PR's stated local-only contract; moving the marker would pass the SDK admission boundary the author described, so this review does not file a second copy.
Native CI completed successfully on this head. I confirmed that run's SHA and result only; I did not re-run the suite or read its logs. Device testing: not performed in this review. Compared with bitkit-android#1693 at 7e2fd14: Android still offers Review after an uncertain removal, and this PR deliberately offers local Disconnect only. Android's live-versus-receipt contact split matches the profile-cleanup behavior traced here; Android activity writes across wallet wipe were not followed. Parent bitkit-ios#1125 at 6c0a160 was comparison context, not a full review.
Suggested additional test cases
- iOS, warm wallet with
walletExistsalready true. Reserve or persist a wallet wipe, hold an inbound payment sync on the Core write until activities are deleted, then release it. Expected: the old activity is not inserted, including into a replacement wallet that reuses the default wallet id. - iOS, with a pending
paykitPendingBackupRestorekeychain item, a durable wallet-wipe marker, andwalletExists == true. Foreground the app or reconnect the network. Expected: no VSS or legacy restore writes until the wipe obligation is cleared.
Findings
- [HIGH] Stop untracked receipt writes during wallet wipe — inline at
Bitkit/Utilities/ProfileOperationAdmission.swift:74. - [MEDIUM] Block backup restore while wallet-wipe recovery is pending — Trigger: the running app already has
walletExists == true, a wallet-wipe recovery record, and a pending restore gate (paykitPendingBackupRestore, or a keychain read thathasPendingWalletRestore()treats as pending). The scene becomes active or the network reconnects.
Mechanism: handleScenePhaseChange and handleNetworkChange call retryPendingWalletRestoreIfNeeded() without blocksWalletStartup. restoreWalletBackupAndStart applies the VSS or legacy backup before startWallet, which is the call that returns on the new gate.
Consequence: this path would rewrite wallet backup data while wipe recovery is unresolved, including overlapping a confirmed wipe that then clears the marker.
Expected: restore retries should use the same gate as wallet startup and wait until the wipe obligation is gone.
Evidence basis: source analysis at f1fa5ee. A focused issue search for this restore-during-wipe mechanism found no existing ticket. I did not execute the path.
Source. Summary placement: The restore retries in handleScenePhaseChange and handleNetworkChange are outside the PR diff. The new blocksWalletStartup returns were added on setupTask, handleWalletExistsChange, and startWallet only.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict:
Findings: No new findings. The receipt/wipe and restore-retry fixes in 1f1bead0 hold at 86fb0c5 in source and the targeted native regressions. The existing Disconnect retry discussion remains open: recovery is still local-only, and the proposed Paykit follow-up has not been agreed.
QA for 86fb0c5:
Test 1: receipt writes versus wallet wipe ✅ Rejected admission stops the write, and wipe drains admitted work.
Checked the native XCTest assertions against a temporary bitkit-core database: reserved, persisted and unreadable wipe state; held payment enumeration; cancellation; and replacement-wallet operations. Nine selected receipt/activity tests executed successfully. The video shows the test host; the storage and ordering results come from XCTest assertions, not the splash screen. This was controlled component execution.
receipt-wipe-2x.2x.mp4
Native receipt/activity regression run, 2×.
Test 2: restore admission versus wallet wipe ✅ Pending retries reject before backup reads, and actual restore tails drain.
Both selected native restore regressions passed. Assertions cover reserved/persisted/unreadable wipe, pending-state read failure, live and cancelled held restore writes, invalidated completion and a fresh replacement restore. The video shows the test host; assertions establish these results. This does not establish the separate application network-reconnect action.
restore-wipe-2x.2x.mp4
Native restore regression run, 2×.
Test 3: controlled local recovery ✅ Failure and cancellation preserve recovery until explicit completion.
The actual confirmation/recovery UI test passed twice using the isolated DEBUG fixture. XCTest checked the same pending UUID, retained credentials and watermark 42, cancellation, zero remote cleanup calls, and explicit local completion. This fixture does not establish authenticated remote behavior. The recording retry still has visible stutters.
local-recovery-2x.2x.mp4
Controlled Profile recovery UI, 2×.
Test 4: J1 profile removal recovery ⏭️ Native fault/recovery steps ran; literal network reconnect remains unverified.
A genuine native peer payment established consumed-list version 0. The profile DELETE committed upstream with HTTP 204, then the scoped proxy dropped its acknowledgement. The app retained recovery, offered a separate local confirmation, and cancellation did no cleanup. An independent fixture client subsequently restored/edited the remote profile; the request journal showed no automatic second DELETE during the ensuing recovery/cold-launch sequence.
The same UUID survived the storage faults in Test 7. Profile recovery and ordinary Receive remained available after credentials were absent. The held old completion against a same-key replacement is covered in Test 6. Request journals, exact credential queries and stored-state comparisons establish the details that the screen recording cannot show.
Background/foreground was exercised. A device-scoped transport restart did not establish the application's network-change callback; reboot restored input but does not prove network reconnect. J1 action 10 and therefore the complete journey remain unrun. A separate decoded-QR/nonreuse check was not performed. The recording has visible stutters.
j1-2x.2x.mp4
Native lost-acknowledgement and recovery sequence, 2×.
Test 5: J2 profile cleanup failure ✅ Reused the authenticated report for unchanged cleanup paths.
I relied on the author's authenticated J2 report, attributed there to the installed candidate whose recovery fixes are preserved in 0eef45d1. It covers private-withdrawal refusal, unknown/partial contact cleanup, a profile DELETE returning HTTP 404 before storage, cancellation and cold restart without replay.
The reached profile, contact, credential, removal and recovery-view files are byte-identical from 0eef45d through 86fb0c5; the Paykit and bitkit-core pins are unchanged. The dependency delta updates LDK to 0.7.0-rc.78. This is inherited evidence, with no new J2 device execution or video claimed.
Test 6: Manual 1 teardown ownership boundaries ⏭️ One installed same-key completion case passed; the full boundary matrix remains unrun.
A temporary DEBUG-only gate held the result after actual profile deletion, grant revocation and local cleanup. Normal native profile creation then obtained a new grant/session for the same public key and published Replacement. Releasing the old result made the unchanged production ownership guard return false; Profile stayed on Replacement. The gate receipt, native request journal and fresh profile GET corroborated the UI.
This closes the installed completion case that earlier attempts did not reach. It does not cover every admitted Paykit SDK operation, publication and local-cleanup stage crossed with identity replacement or wallet wipe. The earlier reported scheduler tests remain component evidence; the complete Manual 1 matrix is unrun. The recording has visible stutters.
owner-2x.2x.mp4
Real removal, same-key native replacement and released old completion, 2×.
Test 7: Manual 2 durable cleanup failures ✅ The exact recovery obligation survived each exercised failure and cold launch.
The same pending UUID survived session deletion, root deletion, shared-keychain enumeration/deletion and unreadable reservation storage. Exact in-process Keychain queries checked credential presence/absence; cold-launch state samples checked the marker and genuine consumed-list version 0. That watermark survived the credential and reservation refusals.
Preference-flush and final-marker-completion faults also retained the same recovery UUID across cold launch after credentials were absent. At those later stages the preference writes had already cleared the watermark and identity; the result does not establish preference rollback or physical-disk/power-loss atomicity. After removing the final fault and separately confirming local Disconnect, the marker was absent, mappings/history and watermark were cleared, and reserved receive indexes remained. The edited remote profile was retained, with no automatic cleanup replay.
The video shows the confirmations and cold launches. Scoped fault receipts, credential queries and stored-state comparisons establish the invisible boundaries. The long recording has visible stutters.
manual2-2x.2x.mp4
Sequential native storage-fault/cold-recovery checks, 2×.
Test 8: Manual 3 receipt attribution through profile cleanup ✅ A real receipt retained its historical contact through local cleanup and cold resync.
With session erasure faulted and profile cleanup pending, a fresh 5,000-sat regtest payment arrived at the native peer's reserved address. The UI showed the receipt. After explicit local Disconnect and cold restart/resync, the bitkit-core activity retained the exact same ID, amount and historical contact key, verified by matching SQLite rows. Profile assignments/history were empty, the recovery marker was absent, and the reserved index remained. The contact-key claim comes from stored-row comparison; a deleted contact name is not displayed. The recording has visible stutters.
manual3-2x.2x.mp4
Native reserved-address receipt, local cleanup and cold resync, 2×.
Check 1: build and CI. Built 86fb0c5 for the iOS simulator and executed the selected tests. Xcode 27 promoted an existing implicit-strong-capture diagnostic to an error; the local build required an OTHER_SWIFT_FLAGS= override. Production source and dependency pins were unchanged for Tests 1–4, 7 and 8; Test 6 used the disclosed temporary completion gate. The exact-head native CI run reports success; I verified its head and conclusion, without auditing its complete test log here.
Coverage: Delta f1fa5eea..86fb0c5, the prior open receipt/restore/Disconnect feedback, and reached parent-merge interactions. Inherited Blocktank, notification, payment and translation changes were not separately audited or fully device-tested in this pass.
Twin: bitkit-android#1693 fix: bind profile removal to its original owner matches the ownership/recovery goal. Android also offers remote Review; this iOS implementation deliberately offers local-only recovery.
Reviewed by gpt-6.1-sol-high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of bitkit-ios#1143 at 08f29903, against merge base ef1286d8. The comparison base moved from 6c0a160 to ef1286d and no coverage baseline was supplied, so the whole pull request was reviewed again with no inherited coverage.
No new actionable code findings.
Removal, unlink, and Disconnect stay bound to the confirmation that started them. The recovery record is written before destructive work and removed only after credential, preference, and reservation acknowledgement. Active subscriptions block delete and unlink, including a recheck inside the confirmed profile deletion. Pending profile cleanup keeps ordinary receive attribution from the pre-cleanup snapshot, and wallet wipe waits for admitted receipt and restore work before clearing storage. Wallet backup capture fails closed while a removal record is pending.
The same removal, subscription, and local-completion behavior matches bitkit-android#1693 at e1cc3b17. iOS recovery offers local Disconnect only, as stated in journeys/profile/profile-removal-recovery.xml. This change is stacked on bitkit-ios#1125 at ef1286d; the assessment covers the delta from that merge base.
Hosted unit/UI and SwiftFormat succeeded on this revision. This review did not re-run them. Device testing: not performed. Authenticated J1 and J2 still need a disposable wallet and the fault-capable Pubky/Homegate setup in the PR; standard staging does not provide those faults. BITKIT-001 and BITKIT-002 did not apply.
Earlier findings reconciled at this revision: 1 still present.
Existing concerns
- [MEDIUM] Disconnect cannot retry remote cleanup after a failed withdrawal or revoke — still unresolved; already tracked.
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 the pinned PR at fc4b5b7 against 6306930, following the comparison-base change. No coverage was inherited.
2 actionable findings — resolve or provide an evidence-backed rebuttal.
Checked removal/recovery, original-owner admission, credential cleanup, subscription refusal, receipt attribution, backup/wipe interactions and affected UI completion. Comparison checks covered the corresponding contracts at Android c228adb, iOS dependency #1125 at the reviewed base, and Paykit d56e051; these were not full companion reviews.
Validation: local translation validation passed (0 errors, 17 warnings), as did the diff whitespace check. The matching CI unit/UI run passed with five skipped tests. Native tests were not run locally because this Linux environment lacks Xcode. Device testing: not performed in this review.
Authenticated J1–J3 and the authored scheduler/storage fault cases still require compatible owned services and fault injection; controlled UI/component tests do not establish those runtime outcomes.
Earlier findings reconciled at this revision: 3 still present, 5 verified fixed.
Findings
- [MEDIUM] Deleting a contact makes later profile removal fail its private withdrawal — inline at
Bitkit/Services/PrivatePaykitService+Contacts.swift:507. Still present from an earlier unpublished review. - [LOW] Wallet reset cannot finish when the reservation ledger is unreadable — inline at
Bitkit/Utilities/AppReset.swift:176. Still present from an earlier unpublished review.
Existing concerns
- [MEDIUM] Disconnect cannot retry an uncertain private withdrawal — still unresolved; already tracked.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
There was a problem hiding this comment.
Delta review of f9890dc51 (blocked-peer cleanup, wipe reservations by scope), plus the five parent merges since my last review. No HIGH or MEDIUM; one LOW inline on the journey.
Checked at f9890dc51:
- Scope plumbing.
.walletWipeis passed only by AppReset.swift:176.resumeWiperequirespending.scope == .walletWipe(AppReset.swift:51), andbeginrefuses a.profilerecord over a pending wipe (ProfileRemovalStore.swift:79). Neither scope can take the other's path. - Wallet-wipe ordering.
clearLocalAppStateforgets the session and the shared credentials beforePrivatePaykitAddressReservationStore.shared.clear(), so a throw leaves the ledger intact.clear()resetsstoredLedgerWasUnreadable, and the wipe validatesledger == .emptywith defaults removed. The profile scope still fails closed on an unreadable ledger. - Blocked-peer exclusion. It matches state exactly: linked, linking and recovery-required peers still need an exact counterparty and
outboundMessageIdacknowledgement. When every peer is blocked there is no SDK call and no drain. The session, revision and admission guards are unchanged, so a replacement identity's peers can't classify the old identity's keys. - Merges.
- 91eb82b and fc4b5b7:
checkMutationAdmission()resolves tocheckCurrent()+checkWipeOwnership(). Equivalent or stricter, sincecheckCurrent()already callsTask.checkCancellation(). - The incoming abandoned-wipe test is kept, renamed
testAbandonedWipeCleanupRetainsAdmissionAndCannotRecreateOldKeyState. - The other three merges change only fixtures or tests.
- 91eb82b and fc4b5b7:
- perf: not relevant (one set filter over an already-fetched list).
- Android twin (synonymdev/bitkit-android#1693). Its profile-removal path still builds cleanup keys without excluding
BLOCKED(PrivatePaykitRepo.kt:2257-2272). It also doesn't carry the three new journey actions. Both are parity notes for a follow-up, not findings here.
Device gate (partial): f9890dc51 — not run yet; the new profile-cleanup-failure.xml actions 14-16 (two linked simulators, withdrawal failure through the CONNECT proxy) are still to run. The device result will follow.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up. Re-reviewed the changes since fc4b5b7, including affected code paths, at f9890dc. Unchanged coverage is inherited from the baseline review. The previous head is an ancestor of this head, the merge base is unchanged, the delta is one commit, and the 112-file PR inventory matches the baseline with no package pin changes.
1 actionable finding — resolve or provide an evidence-backed rebuttal.
The delta has no production defect. The wallet-wipe branch discards an unreadable reservation ledger only after credential erasure, validates its absence, and stays idempotent across the wipe's retries. The profile branch keeps its strict refusal and exact-bytes validation. The blocked-peer filter is sound against Paykit d56e051: peers are unique per counterparty, a blocked peer has no Encrypted Link, the SDK parks unsent lists until a fresh handshake, and other peers still need exact acknowledgements. The one finding concerns the J2 journey premise, not product behaviour.
Parity: source comparison against Android c228adb matches for wipe order (reservations clear after the keychain wipe, with no ledger decode) and for ordinary contact deletion of a blocked peer. One difference is left for the Android review: Android's acknowledgement-required profile removal does not exclude blocked peers from its cleanup keys, so a blocked peer with retained published-list state would refuse removal there, while iOS now clears it locally.
Validation: no Swift toolchain was available, so this review compiled and ran nothing; conclusions are from source analysis and inspection of the new tests. The author reports 3,363 passed executions at this head, which this review did not reproduce. SwiftFormat passed; Run Tests was still in progress at the last check. Companion Appium coverage was inherited and not re-read.
Recommended before device testing: wait for the Run Tests result at this head, and correct J2 actions 14–15 as described in the finding.
Device testing: not performed in this review.
Earlier findings reconciled at this revision: 1 still present, 6 verified fixed.
Findings
- [LOW] Fix J2 action 15: deletion now clears the state it expects profile removal to clear — inline at
journeys/profile/profile-cleanup-failure.xml:42.
Existing concerns
- [MEDIUM] Disconnect cannot retry an uncertain private withdrawal — still unresolved; already tracked.
Reviewer model: claude-opus-5-5
Reasoning effort: high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up review at 065a4c6f. I re-reviewed the changes since f9890dc5, including the affected code paths, and inherited unchanged coverage from the baseline review. Git confirms the previous head is an ancestor of this head and the base and merge base are unchanged. The PR still has the same 112 changed files.
No new actionable code findings.
No new actionable code findings in the follow-up. The only change is journeys/profile/profile-cleanup-failure.xml, which rewrites the J2 blocked-peer description and actions 14-15. Production code and native tests are identical to the baseline.
The new journey wording matches the code at this revision:
- Contact deletion:
PubkyService.removeContactlogs a failed withdrawal, then still blocks the peer and removes the contact.removeSavedContactsthen filters out the blocked peer, so no further withdrawal is attempted, and clears the published-list flag and pending cleanup key. - Later profile removal: the blocked peer stays excluded, while other linked peers still need exact acknowledgements.
- Paykit d56e051b:
block_peerparks unfinished outbound messages and clears the encrypted link. Outbound processing skips blocked peers, andremove_contactdeletes only the contact record, so the blocked peer record survives for that filter. - Android c228adb7: a targeted check found the same blocked-peer filter in
clearPrivatePaymentLists. This was not a full Android review.
Validation: the XML parses and git diff --check passes on the delta. Both were run locally. Native tests were inspected, not run, in this pass. At the last CI check on this head, SwiftFormat had passed and Run Tests was still in progress.
Recommended before device testing: wait for Run Tests to finish on this head.
Device testing: not performed in this review. The rewritten J2 actions were not executed, and they need the controlled fault fixtures named in the journey.
Earlier findings reconciled at this revision: 1 still present, 5 verified fixed.
Existing concerns
- [MEDIUM] Disconnect cannot retry an uncertain private withdrawal — still unresolved; already tracked.
Reviewer model: claude-opus-5-5
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Device gate at f9890dc51. Head is now 065a4c6f2, a journey-only change (profile-cleanup-failure.xml). It rewords actions 14-15 to the behaviour my earlier LOW described, and that thread is resolved.
- Action 14: PASS. A withdrawal failure was forced with the homeserver CONNECT proxy refusing the payee's host:
Failed to withdraw private endpoints before contact deletionat 14:23:01, thenremoveContactfinished at 14:23:06. The contact was removed, and later messages from that peer fail withpolicy_error, which shows it is blocked. Right after deletionprivatePaykitCacheState.contacts = {}and there was noprivatePaykitDeletedContactCleanupKeys, with no second delivery attempt. This also passes the 065a4c6 wording. - Action 15: FAIL, see the inline comment. The blocked peer got no new withdrawal (no
clearPrivatePaymentLists, no tunnels to its host). Profile deletion then stopped aftersyncPaykitAppon every attempt that followed a contact deletion: two identities, five runs. A control on a fresh identity with no contacts deleted cleanly. - Not run: action 16 and the force-quit variant of 15. Action 16 depends on the same deletion finishing. I stopped when the head moved.
- Perf:
removeContacttook 19.7-21.5 s. Profile deletion took about 30 s from confirm to the failure screen. No crash reports.
Evidence is in bitkit-ios/.ai/gate_ios_1143_1793887896/ (app logs, lldb throw traces, proxy log, screenshots).
Reviewer model: claude-opus-5-5 (coordinator), device gate by a claude-opus-5-5 subagent
Reasoning effort: Unknown (not exposed in session metadata)
| try await validateRemoteRemovalOwner(live) | ||
| } | ||
| if !live.appAcknowledged { | ||
| try await removalServices.syncApp() |
There was a problem hiding this comment.
MEDIUM: profile deletion can't finish once any contact has been deleted.
Contact deletion blocks the peer (removeContactsAndBlockPeers / blockPeer, PubkyService.swift:1281-1306), and profile removal never withdraws a blocked peer's list. This syncApp() then calls publishPaykitApp with privatePayments: false, and that call throws. Because completePublicRemoval now treats the throw as fatal, deletion stops at "Finish local cleanup", where only Disconnect is offered. The contacts and the remote profile both stay.
On the base branch (fix/paykit-sdk-wipe-queue) the same app sync went through removePublicPaykitEndpoints after the profile was already deleted. A failure there only marked cleanup pending, so this is a regression introduced here.
Repro at f9890dc (the head is now 065a4c6, which changes only the journey), on Debug simulators against staging:
- Use a fresh identity with no contacts and delete the profile. It finishes:
syncPaykitApp14:38:39.6 →removeContactsForProfile→deleteConfirmedProfile→ keys deleted → Pubky choice screen. - On the same identity, add a contact, wait for the link, then delete the contact with no fault ("Removed contact" 14:44:15).
- Delete the profile. It stops right after
Finished Paykit operation syncPaykitApp(14:45:36), with no error logged, and shows the recovery screen with Disconnect only. - Steps 2-3 failed the same way at 14:49, 14:54 and 14:59, and on a second identity at 14:26 and 14:31. An lldb
swift_willThrowtrace puts the throw inPaykitSdk.publishPaykitApp(displayName:capabilities:)underPaykitSdkService.syncPaykitApp.
I couldn't read the error text. From paykit-rs v0.1.1-rc4 source, the likely cause is require_app_capability_downgrade_safe (paykit-sdk/src/runtime/app_removal.rs:177-290). It refuses to drop private_payments while a counterparty still holds a shared private list or a PrivatePaymentList message is pending or failed. That fits a blocked peer whose list was never withdrawn.
Two other problems show up here:
- "Finished Paykit operation syncPaykitApp" is logged even though the call threw.
- The failure isn't logged anywhere, so from the logs alone the stop looks like a hang.
Possible fixes:
- Withdraw the list before blocking at contact deletion, or clear blocked peers' shared lists in the SDK.
- Keep the post-delete best-effort behaviour for the app downgrade.
Action 16 ("acknowledging it allows cleanup to finish") will hit the same wall.
There was a problem hiding this comment.
The failure remains open. Released RC4 does reject capability downgrade for active private-list work and Shared/UncertainClear history, but the exact SDK cause in this run is still unconfirmed. I saw that the error text was unavailable. If your existing artifacts captured the app-owned private-list state at action 15, could you share only whether it was never attempted, attempted/pending, or possibly shared/uncertain clear? If that was not captured, please say unavailable. No keys, tokens, raw lists, or new rerun needed. We are preserving the withdrawal/unknown-outcome checks while tracing this.
There was a problem hiding this comment.
Reproduced at 065a4c6 on two staging identities after refusing only the peer’s connection during contact deletion. The native error is now captured: policy_error, private_payments cannot be disabled while app-owned work is active. Contact deletion cleared the local published flag and pending key; later profile deletion stopped at registry sync with Disconnect only. For the state question: withdrawal was attempted and failed; the exact retained native Shared/UncertainClear classification was not exported. A no-fault control completed on both identities, so the broader every-deleted-contact wording is not established. Recordings and storage checks are in this review; leaving the finding open.
There was a problem hiding this comment.
The private-list state is unavailable. The gate did not export the native classification and the error text was already freed before lldb read it.
What the logs do show:
- Action 15 identity (Payer1213): the withdrawal was attempted and failed. The proxy cut the tunnel to the peer's host, and
Failed to withdraw private endpoints before contact deletionwas logged at 14:23:01. - Third1143 case ("no fault"):
removeContactfinished at 14:44:02 with no withdrawal warning. The counterparty was Payer1213, which an hour earlier had been through a failed profile deletion, a Disconnect and a sign-in again. I did not confirm that its homeserver accepted the clear. So the case only shows that the app logged no failure, not that the counterparty acknowledged the clear.
ovi-reviewer's narrower scope stands: the failure is reproduced after a withdrawal fails during contact deletion. My "any contact has been deleted" wording is not established.
There was a problem hiding this comment.
43c72a5 now logs which profile-removal step failed and whether the queued operation completed, deferred or failed. Thanks for capturing the native policy_error on both identities after a failed withdrawal. The controls reached onboarding when withdrawal succeeded. The app still uses RC4, so the blocked-withdrawal case stays open. The next check is to repeat J2 actions 15–16 when the SDK fix is released and integrated, and verify that the original withdrawal is acknowledged before profile removal finishes. SDK change.
There was a problem hiding this comment.
Agreed. The thread stays open until pubky/paykit-rs#267 is released and integrated. I'll repeat J2 actions 15–16 then, and check that the original withdrawal is acknowledged before profile removal finishes. Confirmed at 43c72a5: the new logging rethrows the same error and doesn't change the failure path.
There was a problem hiding this comment.
@ben-kaufman Confirmed at 43c72a5: after a genuine failed private withdrawal during contact deletion, local published/pending state cleared, but profile deletion still stopped at syncPaykitApp and offered only Disconnect. The new failed-operation and policy-category logs correctly identify the failing step. The original recovery UUID remained; the native Shared/UncertainClear classification was not exported. RC4 is still pinned, so this finding remains open pending the Paykit fix being released and integrated, followed by the native completion check. Recordings are in this review.
There was a problem hiding this comment.
@ben-kaufman Reproduced at 0f2b654 with the logging-only hook: a linked contact’s withdrawal failed under a connection refusal, contact deletion completed, then connectivity was restored. The native cleanup report omitted that blocked contact and matched the remaining linked peer’s clear/acknowledgement pair (message 3). The later capability update still failed with policy and retained identity/recovery. A fresh no-fault control completed deletion; a separate same-key profile recreation with a re-added Linking peer also hit policy. Paykit remains RC4 and pubky/paykit-rs#267 Retain explicit empty-list withdrawals when blocking peers is not integrated, so this finding remains open.
There was a problem hiding this comment.
@ben-kaufman Reproduced at bb26b03 with the logging-only hook: a linked contact’s withdrawal failed under a peer-only connection refusal, contact deletion completed and access was restored. Cleanup skipped that blocked peer and matched the remaining linked peer’s clear/acknowledgement pair at message 4, then the capability update failed with policy and retained the original identity/recovery after cold launch. A same-key profile recreation with a re-added Linking peer also failed with policy after an exact remaining-peer ACK at message 7, without an active refusal. At the reviewed head, Paykit remained RC4 and pubky/paykit-rs#267 Retain explicit empty-list withdrawals when blocking peers was not integrated. The new Send startup checks pass; this existing cleanup finding remains open. Recordings and hook disclosures are in this review.
There was a problem hiding this comment.
@ben-kaufman Reproduced at e9354eb with logging-only tracing: after a linked contact withdrawal failed, deleting that contact cleared its local pending state. Profile cleanup skipped the blocked peer and matched the remaining linked peer clear/ACK at message 8, then failed the capability update with policy. The original identity and recovery UUID remained. The backup correction passes locally, including ten repeats, and in hosted CI. RC4 remains pinned, so this existing finding stays open. The re-added-contact result and recordings are in this review.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — profile deletion still fails after an unsuccessful contact withdrawal.
Reaudit: diff, 7 files, plus earlier findings.
Findings: No new finding. The existing MEDIUM at PubkyProfileManager.swift:1633 remains. I reproduced it on two staging identities and captured the native policy_error: disabling private_payments is refused while app-owned work is active. The no-fault control completed twice, so the failure is narrower than every deleted contact.
QA for 065a4c6: iPhone 17 Pro 2× simulators, iOS 26.5, Xcode 27.0; unfunded regtest wallets and staging profiles.
Contact deletion clears blocked-peer cleanup state ✅ Carried from f9890dc.
Checked by @jvsena42’s manual run: failed withdrawal still removed and blocked the contact, cleared its published flag and pending key, and made no second delivery attempt. The relevant production and test files are unchanged.
Ordinary profile deletion finishes after contact deletion ✅ Both no-fault controls reached profile onboarding.
Local identity and recovery state cleared; checked in the UI and stored state. Remote profile absence was not independently queried. The recording shows one of the two runs.
ordinary-delete-control2.2x.mp4
Profile deletion finishes after a failed contact withdrawal ❌ Registry synchronization stops deletion.
After the peer connection was refused, contact deletion cleared local cleanup state, but profile deletion showed “Finish local cleanup” with Disconnect only. The native policy error was checked in the debugger. The recording contains stutter.
failed-withdrawal-repeat.2x.mp4
Corrupt reservations stay strict for profiles and clear during wallet reset ✅ Manual 4 passed within the storage-fault scope.
- Profile-only cleanup refused malformed and version-2 reservation records and retained their exact bytes and recovery UUID across restart.
- In full Reset, separate session-acknowledgement and shared-credential deletion errors retained the ledger, injected consumed-version data and wipe UUID after restart. Removing each fault allowed Retry to finish; cold readback found no ledger, safety state or marker.
- Ledger and safety values were checked in storage. Watermarks were injected, not consumed by a payment. An immediate preference sample lagged after one reset; no power-loss atomicity is claimed.
| Malformed reservation data | Unsupported reservation version |
|---|---|
malformed-ledger.2x.mp4 |
unsupported-ledger.2x.mp4 |
A remaining peer acknowledgement allows profile deletion to finish ⏭️ Blocked by the reproduced app failure.
The combined J2 action 16 completion remains unrun: the blocked-peer registry downgrade still prevents its required final success. Component acknowledgement results do not prove that combined journey.
Pending cleanup survives a real network reconnect ⏭️ No actual application network-change event.
The simulator configuration-service PID changed and recovery stayed visible with the same UUID, but the app log recorded no new network-change event. The required real reconnect was not produced.
network-attempt.2x.mp4
Wrong native acknowledgements and open-ended cancellation retain safety ⏭️ Full authenticated matrix unrun.
The available native fixtures cannot emit another peer’s or message’s signed acknowledgement or stage every open-ended cancellation combination. Existing component checks and bounded native cases do not prove the full matrix.
Every delayed native callback rejects replacement and wipe ⏭️ Full native timing matrix unrun.
The available barriers do not hold every SDK, publication, adoption, receipt, indexing and restore tail across every replacement and wipe boundary.
Unchanged evidence retained:
- 630be75 native review: credential faults, receipt safety, fixed-term local Disconnect and bounded native callback cases.
- 429a8c4 review: original-binding retirement, restored Profile ownership and public/app/logout continuation.
- fc4b5b7 review: component, recovery/import UI and running-node wipe coverage.
The current native CI run is failing; those failed tests were not rerun here. XML parsing and the delta whitespace check passed. Device coverage is bounded to the cases above.
Pair PR: bitkit-android#1693 fix: bind profile removal to its original owner, at c228adb, shares the original-owner/local-only recovery contract. Its strict cleanup still includes retained blocked-peer targets, so that behavior differs; Android was source-checked only.
Reviewed by gpt-6.1-sol-high
jvsena42
left a comment
There was a problem hiding this comment.
Delta review at 43c72a58f (since 065a4c6f2). The delta adds no new findings. The MEDIUM at PubkyProfileManager.swift:1633 stays open until the SDK fix (pubky/paykit-rs#267) is integrated, so this review requests changes.
25ac19c48 (failure reporting): this commit only changes logging.
completePublicRemovalnow logs the failure category forsyncApp()and rethrows the same error.appAcknowledgedis still set only after a successful sync.- The DEBUG-only
withSdklog reports Failed, Deferred or Completed. Release behaviour is unchanged, and no path returns success when the SDK failed. - The log lines carry only the category and operation name. They contain no keys or error payloads.
- The MEDIUM's failure path is unchanged. A
policy_errorfrompublishPaykitAppstill stops deletion at "Finish local cleanup", which is what your reply says.
Merge 43c72a58f (saved-contact payment routing): every file matches the union of both sides, except PrivatePaykitService+Payments.swift and the tests.
- The incoming saved-contact routing is intact:
- private path check →
beginContactPaymentwith the updated-list retry; - no public fallback for a saved contact with a private path.
- private path check →
- This PR's
profileAdmission.withOperationbinding is intact. The one added line,profileAdmission.checkCurrent()insavedContact(_:), only tightens it: a removal reservation that lands between the SDK read and the cache write now throws instead of re-caching the contact. - The new
testSavedContactLookupCannotCacheTheContactAfterProfileRemovalReservesAdmissionreaches that path. Theacknowledged: []fixture fix is complete.
perf: not relevant (one in-memory admission check per saved-contact lookup).
profile-cleanup-failure.xml will run again once the SDK fix is integrated.
Reviewer model: claude-opus-5-5 (coordinator); delta review claude-fable-5-1
Reasoning effort: Not exposed (no setting in session metadata)
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of 43c72a58 against 814a837b after the comparison base changed, with no inherited coverage. Included targeted parity checks against Android c228adb7 and composition with iOS #1125 at its supplied pin.
1 actionable finding — resolve or provide an evidence-backed rebuttal.
Validation: Native CI passed on merge commit 0cdaa5ab, combining the exact reviewed head and base; five tests were skipped. Locally, 37 adapted Linux tests passed for admission, leases, session transitions and recovery storage. Translation validation returned zero errors and 17 warnings.
Device testing: not performed in this review; no local iOS build or visual verification. Authenticated J1–J3 and the manual fault cases remain unverified here. They require owned Pubky/Homegate services, companion grants and transport/storage fault controls; a portable equivalent of the author’s service fixture is needed for reproduction. Native contact compare-and-swap remains outside this PR’s guarantee.
Earlier findings reconciled at this revision: 3 still present, 4 verified fixed.
Findings
- [MEDIUM] Contact screens stay inert when opened before a session is available — inline at
Bitkit/Views/Contacts/AddContactView.swift:258. Still present from an earlier unpublished review.
Existing concerns
- [MEDIUM] Disconnect cannot retry an uncertain private withdrawal — still unresolved; already tracked.
- [MEDIUM] Profile deletion stops after a failed contact withdrawal — still unresolved; already tracked.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — two existing app failures reproduced at 43c72a5.
The PR moved to 0f2b654 after this run; these results are for 43c72a5.
Reaudit: diff, 49 changed files plus open findings. No new findings.
Findings:
- MEDIUM:
Bitkit/Managers/PubkyProfileManager.swift:1634— after a failed private withdrawal during contact deletion, profile removal still fails its Paykit app capability downgrade and offers only local Disconnect. The new failure/category logging works; the removal failure remains in the existing thread. - MEDIUM:
Bitkit/Views/Contacts/AddContactView.swift:258— following a public contact link after local Disconnect exposes an enabled Save button that silently returns. I reproduced the signed-out branch of the existing finding; delayed session restoration was not driven.
QA for 43c72a5:
Built the reviewed source with normal signing and warnings-as-errors settings. Tested on iOS 26.5 simulators using staging profiles and regtest funds. Eight cases pass, including five explicitly carried from linked reports; two fail and four remain unrun. Recordings are 2×; repeated recordings still contain stutter.
Saved-contact routing and profile admission ✅ Selected regression assertions pass.
Checked in XCTest results and logs: contact recaching after admission revocation, private routing, missing/wrong acknowledgement controls, removal coordination, backup metadata resumption, scanner and funding regressions all pass. The recording shows the test host; it is not the assertion source.
delta-components.2x.mp4
Saved contacts wait for a private link and open privately after mutual saving ✅ Both pending routes refuse public fallback; a reciprocal private link reaches Amount.
Contact Pay and Send → Contact showed private-connection guidance without opening Amount, even though the peer published public endpoints. A separate reciprocal-peer fixture then opened native private payment-list version 0 and Amount with a funded wallet; no contact payment was sent. The brief guidance was also checked in a full-resolution screenshot because the recording stutters.
Pending private connection:
private-link-pending.2x.mp4
Guidance captured in the still:
Reciprocal private link, aligned payer and peer:
private-link-ready.2x.mp4
Fixed on-chain requests keep their amount through confirmation and send ✅ The requested amount survives confirmation and payment.
A 10,000-sat BIP21 opened Confirm directly, retained its amount after touching the review amount, and sent successfully with a 143-sat fee. A 500-sat URI showed the 547-sat minimum; an amount-free URI opened Amount. Amount, fee and dust feedback were checked in full-resolution screenshots; successful payment and the resulting 10,143-sat debit were also checked in the UI.
fixed-onchain.2x.mp4
Profile deletion finishes after a failed contact withdrawal ❌ Registry downgrade still stops removal.
Refusing the linked peer's connection produced a genuine failed-withdrawal warning. Contact deletion cleared its local published flag and pending cleanup key, checked in persisted state. Subsequent profile deletion failed syncPaykitApp with the new policy-category log and showed “Finish local cleanup” with only Disconnect. The original recovery UUID and identity remained. The exact native Shared/UncertainClear classification was not exported. This confirms the narrower failed-withdrawal trigger in the existing thread.
failed-withdrawal.2x.mp4
Saving a public contact without a session gives feedback ❌ Save remains enabled and does nothing.
After completing explicit local Disconnect, I opened a public contact link and tapped Save twice. Add Contact stayed open without saving, navigating or showing an error. Persisted checks confirmed no identity or removal marker before or after. This adds installed-app evidence to the signed-out branch, without claiming a delayed-restoration reproduction.
contact-without-session.2x.mp4
A remaining peer acknowledgement permits profile deletion ⏭️ The combined native completion remains unproved.
Three identities and reciprocal links were prepared, and current exact-acknowledgement component controls pass. The failed registry downgrade blocks the required final success. The head moved before a separate combined withholding/acknowledgement fixture completed, so no native action-16 pass is claimed.
Pending cleanup survives a real network reconnect ⏭️ No application network-path transition was produced.
Restarting the simulator configuration service retained the recovery UUID, but produced no new application network-change event. This attempt does not establish reconnect behavior.
network-attempt.2x.mp4
Wrong native acknowledgements and open-ended cancellation retain safety ⏭️ The authenticated native matrix was not driven.
Missing/wrong acknowledgement component controls pass. Native operations and reciprocal peers ran, but available fixture routes provide ordinary operations rather than the required mismatched signed acknowledgement. The full authenticated mismatch and open-ended cancellation matrix remains unrun; further execution stopped when the head moved.
Delayed native callbacks reject replacement and wipe ⏭️ The complete native callback matrix was not driven.
Current admission and coordinator controls pass, but do not prove every native SDK, publication, adoption, receipt, indexing and restore callback against replacement and wipe. Prior bounded results retain their original limits; head movement ended further execution.
Ordinary profile deletion finishes after contact deletion ✅ Carried from 065a4c6.
The earlier installed-app report records two no-fault controls reaching onboarding and clearing local identity/recovery state. Remote absence was not independently queried. Not rerun here; the reached behavior and Paykit RC4 pin are unchanged.
Credential and acknowledgement failures preserve cold recovery ✅ Carried from 630be75.
The storage-fault report and recordings establish retained safety state and original recovery until explicit completion, within the report's owned-profile and Ring limits. Not rerun here; the reached credential, reservation and acknowledgement paths are unchanged.
Fixed-term subscriptions retain remote state during local Disconnect ✅ Carried from 630be75.
The subscription report and recordings cover owned payer, owned payee and Ring-payee refusal, Cancel, explicit local completion and cold launch. Not rerun here; those paths are unchanged.
Receipt attribution survives profile cleanup and cold resync ✅ Carried from 3e49570.
The receipt report and recordings establish that the same real regtest receipt retained its amount and historical contact key through cleanup and cold resync. Not rerun here; receipt attribution is unchanged.
Corrupt reservations stay strict for profiles and clear during wallet reset ✅ Carried from 065a4c6.
The reservation report and recordings cover malformed/version-2 ledgers and separate credential failures across cold starts. Watermarks were injected; power-loss atomicity was not established. Not rerun here; the reached wipe and strict profile-cleanup paths are unchanged.
Pair PR synonymdev/bitkit-android#1693 fix: bind profile removal to its original owner matches the owner/acknowledgement design. At its inspected c228adb head, Android also filters blocked peers; no filter parity difference is established. Android results do not prove iOS execution.
This delta includes merged payment and backup changes, rather than a full re-review of the parent stack. USDT-specific device paths, the complete native matrices above and delayed contact-session restoration were not driven. The accepted uncertain-private-withdrawal retry limitation remains deferred in its existing discussion.
The PR advanced to 0f2b654 during testing. These results apply to 43c72a5; the new head is unreviewed here.
Reviewed by gpt-6.1-sol, reasoning effort high.
jvsena42
left a comment
There was a problem hiding this comment.
Delta review of 0f2b654 ("fix: restore contact actions for the original session"), compared with 43c72a5.
No new findings. The open MEDIUM on PubkyProfileManager.swift (Paykit RC4 policy_error after a failed withdrawal) still stands until pubky/paykit-rs#267 is released and integrated, so this stays as changes requested.
What I checked:
- piotr-iohk's MEDIUM at
AddContactView.swift:258is closed on all three screens: Add Contact Save, Edit Contact Save and Delete, and Contact Detail Edit, tag edits and Delete all go throughContactScreenSession.current(onSignedOut:). A signed-out Save showspubky_auth__no_identity. - Adopting a session cannot select a different identity:
- Adoption requires
owner.publicKey == nil, an unchangedsessionRevisionand a non-nilcurrentSession. - When a read-only display key is set, the restored key must match it.
walletForPresentation()andwipe.isGateUpreject a screen opened before an identity change or a wallet wipe.- An outstanding Delete confirmation fails on
currentSession == session,isGateUporvalidate(generation)after a switch, a wipe request or a removal reservation.
- Adoption requires
- In
deleteContact, noawaitsits between the ownership check and the call insidewithScope. Edits bindexpectedIdentityand anisSessionCurrentclosure that now also requiresowns(session). - The five new tests run against a real
PubkyProfileManagerandProfileOperationAdmission, not a copy of the view logic. - A screen opened while signed out still rejects silently after a later interactive sign-in, because the revision bump means the owner is not adopted. I reproduced that in a unit test, but no navigation path keeps a signed-out contact screen under a sign-in flow: Back pops it, and the drawer and the sign-in completions replace the path. So I'm not raising it.
CI: Run Tests fails in SendSheetLifecycleTests.testExternalLnurlHandoffDoesNotTransferOwnershipToOpenSend. The PR touches no Send, LNURL, scanner or sheet source, and that test is byte-identical at 43c72a5, where it passed. The log points to a timing flake on a loaded runner, so it needs a re-run.
Reviewer model: claude-fable-5-1 (delta review), claude-opus-5-5 (verification, coordination)
Reasoning effort: Not exposed (no effort setting in the agents' run metadata)
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — profile deletion still fails the Paykit capability update after contact deletion.
Reaudit: 43c72a5…0f2b654, seven files, plus earlier open concerns.
Findings: No new changed-line findings. Existing MEDIUM at PubkyProfileManager.swift:1650: the registry-downgrade concern remains open. Paykit is still pinned to RC4; pubky/paykit-rs#267 Retain explicit empty-list withdrawals when blocking peers is not integrated.
QA for 0f2b654:
Driven on iOS 26.5 simulators with disposable staging identities. Videos are 2×; recordings still contain stutters after repeat captures.
Contact actions use only the original restored session ✅ The five new regressions and reached admission/contact suites passed in XCTest.
The five new regressions and reached admission/contact suites passed in XCTest. The recording shows the test host; assertions were checked in XCTest output. The previously hosted-failing Send-sheet test also passed locally, which does not explain or clear the hosted failure.
contact-sessions.2x.mp4
Saving a public contact without a session gives feedback ✅ On the pristine head build, two Save taps each showed “Pubky Identity Required” and kept Add Contact open.
On the pristine head build, two Save taps each showed “Pubky Identity Required” and kept Add Contact open.
signed-out-save.2x.mp4
A contact opened during session restoration can be saved ✅ The same Add Contact screen saved after genuine native restoration.
On 0f2b654 + a timing-only hook, I held a genuine native startup-restoration result before application, opened Add Contact through scanner entry, and released restoration. Save then completed on that same screen. A second contact repeated the result. Edit and Contact Detail restoration branches were checked in source and component tests, not driven as separate installed-app cases.
restored-contact.2x.mp4
Timing-only test hook
Not pushed. Holds only a genuine native restoration result until explicit release, cancellation or its deadline; it substitutes no result.
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index 1da08681..bf8d9416 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -614,6 +614,20 @@ class PubkyProfileManager: ObservableObject {
let initialized = try await removalAdmission.withDetached {
try await initializeSession()
}
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_HOLD_CONTACT_RESTORE"), case .restored = initialized { // test hook
+ let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] // test hook
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+ let release = directory.appendingPathComponent("review-contact-restore-release") // test hook
+ try "held".write(to: directory.appendingPathComponent("review-contact-restore-held"), atomically: true, encoding: .utf8) // test hook
+ Logger.info("review test hook: genuine native restoration held before result application", context: "ContactRestoreHook") // test hook
+ let limit = ContinuousClock.now.advanced(by: .seconds(180)) // test hook
+ while !FileManager.default.fileExists(atPath: release.path), ContinuousClock.now < limit, !Task.isCancelled { // test hook
+ try await Task.sleep(for: .milliseconds(100)) // test hook
+ } // test hook
+ Logger.info("review test hook: native restoration result released", context: "ContactRestoreHook") // test hook
+ } // test hook
+ #endif // test hook
try removalAdmission.checkCurrent()
result = initialized
} catch {The hook log confirmed that genuine restoration was held, then released; the recording shows the same Add Contact screen reaching Contact Saved.
A remaining peer acknowledgement permits profile deletion ❌ The exact acknowledgement passed; final deletion still failed policy.
In the same failed-withdrawal attempt, the native report omitted the blocked contact and reported both clear and acknowledgement for only the remaining peer and message 3. That gate passed, but final profile deletion still failed the public capability downgrade. The full combined completion therefore fails. Both deletion results come from the same attempt.
failed-withdrawal.2x.mp4
Pending cleanup survives a real network reconnect ⏭️ Recovery UUID and guidance survived the simulator service restart, but no app network-path transition occurred.
Recovery UUID and guidance survived the simulator service restart, but no app network-path transition occurred. Restarting the simulator configuration service emitted no disconnected/connected event; the simulator CLI exposes no isolated connectivity toggle.
Wrong native acknowledgements and open-ended cancellation retain safety ⏭️ Native peers ran and ordinary links/operations were attempted; an authenticated wrong-peer or wrong-message acknowledgement was not produced.
Native peers ran and ordinary links/operations were attempted; an authenticated wrong-peer or wrong-message acknowledgement was not produced. Available native fixture controls cannot emit a mismatched signed acknowledgement. The broader open-ended cancellation matrix remains undriven.
Delayed native callbacks reject replacement and wipe ⏭️ The full installed callback matrix remains unproved.
The genuine delayed startup-restoration case passed separately. The earlier bounded native tails retain their original scope. Native fixture attempts did not produce delayed authenticated receipt/indexing and all other callback tails needed for the complete replacement/wipe matrix; component tests do not establish that installed-app result.
Profile deletion finishes after a failed contact withdrawal ❌ Native withdrawal failed, then profile deletion failed policy.
On 0f2b654 + logging-only instrumentation, refusing the linked contact’s connection produced a genuine failed-withdrawal warning. After contact deletion completed, I restored connectivity. Profile deletion later failed syncPaykitApp with policy and retained identity and recovery. The native Shared/UncertainClear classification was not exported.
failed-withdrawal.2x.mp4
Ordinary profile deletion finishes after contact deletion ✅ The pristine head build completed contact deletion and profile deletion.
The pristine head build completed contact deletion and profile deletion. Cold launch returned to profile onboarding; persisted checks confirmed no profile identity or recovery marker. Remote profile absence and a complete keychain audit were not independently checked. Native contact removal took at least 11.8 seconds with a spinner; this service path is unchanged in the delta.
ordinary-cleanup.2x.mp4
Credential and acknowledgement failures preserve cold recovery ✅ Carried from 630be75; not rerun.
Carried from 630be75, not rerun: Owned and Ring credential, acknowledgement, reservation and final-marker fault cycles retained cold recovery; original native watermark and nonempty-override checks apply. Original report. The reached behavior is unchanged by this delta.
Fixed-term subscriptions retain remote state during local Disconnect ✅ Carried from 630be75; not rerun.
Carried from 630be75, not rerun: Genuine app-approved finite owned payer, owned payee and Ring payee cases retained remote state during local Disconnect; broader open-ended cancellation remains unrun. Original report. The reached behavior is unchanged by this delta.
Receipt attribution survives profile cleanup and cold resync ✅ Carried from 3e49570; not rerun.
Carried from 3e49570, not rerun: Genuine regtest receipt attribution and cold resync passed within the original report scope. Original report. The reached behavior is unchanged by this delta.
Corrupt reservations stay strict for profiles and clear during wallet reset ✅ Carried from 065a4c6; not rerun.
Carried from 065a4c6, not rerun: Malformed/version-2 reservation wipe and credential faults passed; consumed watermark was injected and power-loss durability was not proved. Original report. The reached behavior is unchanged by this delta.
Saved contacts wait for a private link and open privately after mutual saving ✅ Carried from 43c72a5; not rerun.
Carried from 43c72a5, not rerun: Both saved-contact routes waited for the private link, then resolved privately after mutual saving. Original report. The reached behavior is unchanged by this delta.
Fixed on-chain requests keep their amount through confirmation and send ✅ Carried from 43c72a5; not rerun.
Carried from 43c72a5, not rerun: Fixed 10000-sat on-chain payment retained its amount through send; 500-sat dust and amount-free controls retained their reported behavior. Original report. The reached behavior is unchanged by this delta.
Profile deletion finishes after re-adding a contact ❌ Profile deletion failed policy after a re-added contact was deleted.
On 0f2b654 + logging-only instrumentation, I re-created a same-key profile and re-added its retained contact. Native state was linking when I deleted the contact. Subsequent profile deletion failed syncPaykitApp with the policy category and retained local recovery. No explicit network refusal was active. This is additional evidence for the existing concern under a different precondition; it is not a failed linked-peer-withdrawal reproduction.
readded-peer-cleanup.2x.mp4
Logging-only test hook
Not pushed. Logs actual native peer states and clear/acknowledgment pairs; it changes no decision or returned result.
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 159c9a54..5c16cec4 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1250,6 +1250,11 @@ actor PaykitSdkService {
func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? {
try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers().filter { PubkyPublicKeyFormat.matches($0.counterparty, publicKey) }
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review contact peer states: \(peers.map { String(describing: $0.state) })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let now = Date()
let activeSubscriptions = try await sdk.paymentRequests().filter {
PubkyPublicKeyFormat.matches($0.counterparty, publicKey) &&
@@ -1405,6 +1410,11 @@ actor PaykitSdkService {
guard !counterparties.isEmpty else { return nil }
return try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers()
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let blockedPeers = peers.filter { $0.state == .blocked }
let updates = counterparties.filter { counterparty in
!blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }
@@ -1434,10 +1444,17 @@ actor PaykitSdkService {
throw PubkyServiceError.sessionNotActive
}
try checkMutationAdmission()
- return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(
+ let report = try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( // test hook
updates: updates,
clearUnlistedLinkedPeers: false
)
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup cleared: \(report.cleared.map { "\($0.counterparty):\(String(describing: $0.outboundMessageId))" })", context: "CleanupTraceHook") // test hook
+ Logger.info("review cleanup acknowledged: \(report.acknowledged.map { "\($0.counterparty):\($0.outboundMessageId)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return report // test hook
}
}
@@ -1813,7 +1830,13 @@ actor PaykitSdkService {
try await withSdk(priority: priority, beforeOperation: { try await self.checkRefreshAdmission(priority) }) { sdk in
try await readRetryingSharedStateBusy {
try await self.checkRefreshAdmission(priority)
- return try await sdk.linkedPeers()
+ let peers = try await sdk.linkedPeers() // test hook
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review observed peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return peers // test hook
}
}
}The deciding native log recorded linking at contact removal, followed by Paykit app capability update failed during profile removal (policy). Recovery was checked in the UI snapshot and persisted marker.
Profile cleanup stops without the remaining peer acknowledgement ✅ Missing acknowledgement retained recovery and stopped cleanup.
On a fresh three-wallet fixture, contact deletion first produced the same genuine failed-withdrawal warning. I then refused the remaining linked peer. The native report cleared message 5 but acknowledged none; privateUnavailable stopped cleanup and retained identity and recovery. No later public-capability update or final profile-delete call was logged in that attempt. Cold launch and opening Profile retained the same identity, marker and pending private state, checked on disk and in the appended cold-launch clip. The recording combines the deletion attempt with a separate cold launch and Profile entry. This proves the missing-acknowledgement refusal, not the broader authenticated wrong-peer/wrong-message matrix.
missing-acknowledgement.2x.mp4
Check: hosted CI ⚠️ Run Tests remains failed.
The hosted Run Tests job fails SendSheetLifecycleTests.testExternalLnurlHandoffDoesNotTransferOwnershipToOpenSend. That test passed in the focused local run. No hosted rerun or full-suite success is claimed.
The delta review and installed-app cases cover signed-out Add Contact and genuine startup restoration. The complete Edit/Detail action matrix and broader native callback matrices remain unverified on a device.
Pair PR: synonymdev/bitkit-android#1693 fix: bind profile removal to its original owner matches the owner/attempt and acknowledgment contract; the new iOS contact-screen restoration behavior has no runtime parity claim.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — profile deletion still fails the Paykit capability update after contact deletion.
The PR moved to e9354eb after this run; these results are for bb26b03.
Findings: No new changed-line finding in the Send startup delta. The existing MEDIUM registry-downgrade concern remains at Bitkit/Managers/PubkyProfileManager.swift:1650: a failed contact withdrawal, or deleting a re-added Linking peer, can leave profile deletion at Disconnect-only recovery. I reproduced both preconditions at bb26b03. The reviewed head pins Paykit to RC4; pubky/paykit-rs#267 Retain explicit empty-list withdrawals when blocking peers is not integrated at this reviewed head.
Scope: 0f2b654..bb26b03, plus the open cleanup concern. The six changed files and reached callers have no new concrete defect from this review. The PR advanced to e9354eb during final checks; that head is outside this review.
QA for bb26b03: 13 passed, 3 failed, 3 unrun. Nine passes retain earlier evidence at unchanged code paths; the two failed-withdrawal assertions share one attempt and one existing defect. Installed-app checks used staging identities and regtest funds. Videos are 2×; joined clips are separate excerpts. Some recordings still stutter after a repeat; the native ordering and persisted-state claims below were checked in logs and storage.
External payments survive Send startup and sheet replacement ✅ Both controlled startup overlaps preserved the replacement payment.
I built the pristine head and passed the 104 selected Send, scanner, navigation and sheet tests, then repeated the five critical startup/queued-sheet/late-scan checks. Their assertions come from XCTest output; the first video shows component execution. On the timing-hook build, I held a genuine native BIP21 decode while releasing old Send setup first, from both options and an existing confirmation. Both releases then reached the replacement 10,000-sat Confirm, checked in full accessibility snapshots and screenshots. No payment was sent. Native LNURL decoding was not separately driven; LNURL ownership/remount behavior is covered by the component checks.
send-startup.2x.mp4
startup-native-options.2x.mp4
startup-native-confirmation.2x.mp4
Temporary test hook, not pushed
Holds genuine setup, decode or saved-contact caller results until explicit release, with a bounded deadline. It does not substitute SDK data or change an ownership decision. Only setup/decode holds were armed in this case.
The log below contains the two successful variants; the component test execution used the pristine source.
diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift
index 518000c4..5eeafc2f 100644
--- a/Bitkit/Services/PrivatePaykitService+Payments.swift
+++ b/Bitkit/Services/PrivatePaykitService+Payments.swift
@@ -134,6 +134,9 @@ extension PrivatePaykitService {
private func savedContact(_ publicKey: String) async throws -> (publicKey: String, identity: IdentityStatus)? {
guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return nil }
let identity = try await contactPaymentOperations.identityStatus().flatMap { $0.publicKey == nil ? nil : $0 }
+ #if DEBUG // test hook
+ await reviewHoldSendStage("saved-contact", drain: true) // test hook
+ #endif // test hook
if isKnownSavedContact(normalizedKey) {
guard let identity else { throw PubkyServiceError.sessionNotActive }
return (normalizedKey, identity)
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 898d23ae..f1654c24 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -934,6 +934,9 @@ extension AppViewModel {
data = prevalidatedPaymentRequest
} else {
data = try await scanPaymentOperations.decodeInvoice(uri)
+ #if DEBUG // test hook
+ await reviewHoldSendStage("scan-result") // test hook
+ #endif // test hook
try ensureScannedDataHandlingOwnership(
handlingId,
claimedContactPaymentContext: claimedContactPaymentContext,
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index 4dde07cd..bc9ad635 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -399,6 +399,9 @@ struct SendSheet: View {
preparation.isSuspendedForAppLock = false
flow.rootRoute = route
}
+ #if DEBUG // test hook
+ await reviewHoldSendStage("setup") // test hook
+ #endif // test hook
setUpSend()
}
.onChange(of: wallet.nodeLifecycleState) { _, state in
@@ -1622,3 +1625,30 @@ private struct SendComingSoonView: View {
.padding(.horizontal, 16)
}
}
+
+#if DEBUG // test hook
+func reviewHoldSendStage(_ stage: String, drain: Bool = false) async { // test hook
+ guard ProcessInfo.processInfo.arguments.contains("-REVIEW_HOLD_SEND") else { return } // test hook
+ let folder = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] // test hook
+ let armed = folder.appendingPathComponent("review-hold-" + stage) // test hook
+ let release = folder.appendingPathComponent("review-release-" + stage) // test hook
+ guard FileManager.default.fileExists(atPath: armed.path) else { return } // test hook
+ try? "held".write(to: folder.appendingPathComponent("review-held-" + stage), atomically: true, encoding: .utf8) // test hook
+ Logger.info("review send timing hold: " + stage, context: "SendTimingHook") // test hook
+ let deadline = ContinuousClock.now.advanced(by: .seconds(180)) // test hook
+ if drain { // test hook
+ await Task.detached { // test hook
+ while !FileManager.default.fileExists(atPath: release.path), ContinuousClock.now < deadline { // test hook
+ try? await Task.sleep(for: .milliseconds(50)) // test hook
+ } // test hook
+ }.value // test hook
+ } else { // test hook
+ while !FileManager.default.fileExists(atPath: release.path), ContinuousClock.now < deadline, !Task.isCancelled { // test hook
+ try? await Task.sleep(for: .milliseconds(50)) // test hook
+ } // test hook
+ } // test hook
+ try? FileManager.default.removeItem(at: armed) // test hook
+ try? FileManager.default.removeItem(at: release) // test hook
+ Logger.info("review send timing release: " + stage, context: "SendTimingHook") // test hook
+} // test hook
+#endif // test hookINFOℹ️: review send timing hold: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1637]
INFOℹ️: review send timing hold: scan-result - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1637]
INFOℹ️: review send timing release: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1652]
INFOℹ️: review send timing release: scan-result - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1652]
INFOℹ️: review send timing hold: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1637]
INFOℹ️: review send timing hold: scan-result - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1637]
INFOℹ️: review send timing release: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1652]
INFOℹ️: review send timing release: scan-result - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1652]
Contact actions use only the original restored session ✅ Retained prior pass; the tested path is unchanged.
The five contact-session regressions cover signed-out feedback, restoration, session replacement, a read-only key and queued wipe. Assertions came from XCTest output; the recording in that report shows component execution. Retained from the original report at 0f2b654; not rerun here. Its original scope, diagnostic hooks and limits still apply.
Saving a public contact without a session gives feedback ✅ Retained prior pass; the tested path is unchanged.
At the reported head, two Save taps each showed “Pubky Identity Required” and kept Add Contact open without saving. Retained from the original report at 0f2b654; not rerun here. Its original scope, diagnostic hooks and limits still apply.
A contact opened during session restoration can be saved ✅ Retained prior pass; the tested path is unchanged.
A timing-only hook held a genuine native restoration result while Add Contact opened through scanner entry. After explicit release, Save completed on the same screen; a second contact repeated it. Retained from the original report at 0f2b654; not rerun here. Its original scope, diagnostic hooks and limits still apply.
Temporary test hook, not pushed
Retained evidence at 0f2b654, not a new execution. This timing-only hook held a genuine native startup-restoration result until explicit release; it did not substitute the result.
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index 1da08681..bf8d9416 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -614,6 +614,20 @@ class PubkyProfileManager: ObservableObject {
let initialized = try await removalAdmission.withDetached {
try await initializeSession()
}
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_HOLD_CONTACT_RESTORE"), case .restored = initialized { // test hook
+ let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] // test hook
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+ let release = directory.appendingPathComponent("review-contact-restore-release") // test hook
+ try "held".write(to: directory.appendingPathComponent("review-contact-restore-held"), atomically: true, encoding: .utf8) // test hook
+ Logger.info("review test hook: genuine native restoration held before result application", context: "ContactRestoreHook") // test hook
+ let limit = ContinuousClock.now.advanced(by: .seconds(180)) // test hook
+ while !FileManager.default.fileExists(atPath: release.path), ContinuousClock.now < limit, !Task.isCancelled { // test hook
+ try await Task.sleep(for: .milliseconds(100)) // test hook
+ } // test hook
+ Logger.info("review test hook: native restoration result released", context: "ContactRestoreHook") // test hook
+ } // test hook
+ #endif // test hook
try removalAdmission.checkCurrent()
result = initialized
} catch {INFOℹ️: review test hook: genuine native restoration held before result application - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 623]
INFOℹ️: review test hook: native restoration result released - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 628]
INFOℹ️: review test hook: genuine native restoration held before result application - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 623]
INFOℹ️: review test hook: native restoration result released - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 628]
INFOℹ️: review test hook: genuine native restoration held before result application - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 623]
INFOℹ️: review test hook: native restoration result released - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 628]
INFOℹ️: review test hook: genuine native restoration held before result application - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 623]
INFOℹ️: review test hook: native restoration result released - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 628]
Profile deletion finishes after a failed contact withdrawal ❌ The failed withdrawal still prevents remote profile deletion.
With the logging-only hook, I refused only the linked contact’s connection until its deletion finished. Native withdrawal failed, the peer became Blocked, and contact deletion cleared its local published-list and pending-cleanup state. I restored access before profile deletion. Cleanup omitted the blocked peer, then the capability update failed with policy. The original identity and recovery 05B9DAD4-1916-4A31-9389-D3D0DDC00E19 remained after cold launch and Profile entry. The video joins contact deletion, recovery and cold-entry excerpts; the native ACK and failing step were checked in the log below. This updates the existing thread.
failed-withdrawal.2x.mp4
Temporary test hook, not pushed
Logs genuine native peer states and actual cleared/acknowledged peer/message identifiers. It changes no result or decision.
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 159c9a54..5c16cec4 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1250,6 +1250,11 @@ actor PaykitSdkService {
func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? {
try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers().filter { PubkyPublicKeyFormat.matches($0.counterparty, publicKey) }
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review contact peer states: \(peers.map { String(describing: $0.state) })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let now = Date()
let activeSubscriptions = try await sdk.paymentRequests().filter {
PubkyPublicKeyFormat.matches($0.counterparty, publicKey) &&
@@ -1405,6 +1410,11 @@ actor PaykitSdkService {
guard !counterparties.isEmpty else { return nil }
return try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers()
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let blockedPeers = peers.filter { $0.state == .blocked }
let updates = counterparties.filter { counterparty in
!blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }
@@ -1434,10 +1444,17 @@ actor PaykitSdkService {
throw PubkyServiceError.sessionNotActive
}
try checkMutationAdmission()
- return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(
+ let report = try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( // test hook
updates: updates,
clearUnlistedLinkedPeers: false
)
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup cleared: \(report.cleared.map { "\($0.counterparty):\(String(describing: $0.outboundMessageId))" })", context: "CleanupTraceHook") // test hook
+ Logger.info("review cleanup acknowledged: \(report.acknowledged.map { "\($0.counterparty):\($0.outboundMessageId)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return report // test hook
}
}
@@ -1813,7 +1830,13 @@ actor PaykitSdkService {
try await withSdk(priority: priority, beforeOperation: { try await self.checkRefreshAdmission(priority) }) { sdk in
try await readRetryingSharedStateBusy {
try await self.checkRefreshAdmission(priority)
- return try await sdk.linkedPeers()
+ let peers = try await sdk.linkedPeers() // test hook
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review observed peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return peers // test hook
}
}
}INFOℹ️: review contact peer states: ["linked"] - CleanupTraceHook [PubkyService.swift: removeContact(publicKey:) line: 1255]
WARN⚠️: Failed to withdraw private endpoints before contact deletion - PaykitSdkService [PubkyService.swift: removeContact(publicKey:) line: 1277]
INFOℹ️: Removed contact pubky5t38pd5... - ContactsManager [ContactsManager.swift: removeContact(publicKey:) line: 998]
INFOℹ️: review cleanup peers: ["pubky5t38pd5pxwxikhp6ijmkty1exyrmrrrm8r3x34fp5mqkrk5j8hqo:blocked", "pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:linked"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1415]
INFOℹ️: review cleanup cleared: ["pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:Optional(4)"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1453]
INFOℹ️: review cleanup acknowledged: ["pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:4"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1454]
DEBUG: Failed Paykit operation syncPaykitApp(privatePaymentsEnabled:priority:isSessionCurrent:) trace=809F5429-EE0A-4E94-9145-1727985123F8 queue=0.000307125 seconds work=3.966153584 seconds - PaykitSdkService [PubkyService.swift: withSdk(priority:deferDuringPayment:operationName:beforeOperation:_:) line: 2169]
WARN⚠️: Paykit app capability update failed during profile removal (policy) - PubkyProfileManager [PubkyProfileManager.swift: completePublicRemoval(_:) line: 1650]
Timing warning: the intentionally refused contact deletion took at least 13.48 seconds of native work, with no dedicated deletion progress indicator on this Edit Contact path. This does not establish normal-service latency.
A remaining peer acknowledgement permits profile deletion ❌ The exact remaining-peer acknowledgement passed, but final deletion failed.
This is the same attempt as the previous case. Native output cleared the remaining linked peer at message 4 and acknowledged that exact peer/message pair. The blocked deleted peer was absent from the withdrawal targets. The subsequent capability update still failed with policy, so the journey did not reach completed profile deletion. The log, rather than the video, establishes the exact acknowledgement.
failed-withdrawal.2x.mp4
Temporary test hook, not pushed
Logs genuine native peer states and actual cleared/acknowledged peer/message identifiers. It changes no result or decision.
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 159c9a54..5c16cec4 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1250,6 +1250,11 @@ actor PaykitSdkService {
func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? {
try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers().filter { PubkyPublicKeyFormat.matches($0.counterparty, publicKey) }
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review contact peer states: \(peers.map { String(describing: $0.state) })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let now = Date()
let activeSubscriptions = try await sdk.paymentRequests().filter {
PubkyPublicKeyFormat.matches($0.counterparty, publicKey) &&
@@ -1405,6 +1410,11 @@ actor PaykitSdkService {
guard !counterparties.isEmpty else { return nil }
return try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers()
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let blockedPeers = peers.filter { $0.state == .blocked }
let updates = counterparties.filter { counterparty in
!blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }
@@ -1434,10 +1444,17 @@ actor PaykitSdkService {
throw PubkyServiceError.sessionNotActive
}
try checkMutationAdmission()
- return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(
+ let report = try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( // test hook
updates: updates,
clearUnlistedLinkedPeers: false
)
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup cleared: \(report.cleared.map { "\($0.counterparty):\(String(describing: $0.outboundMessageId))" })", context: "CleanupTraceHook") // test hook
+ Logger.info("review cleanup acknowledged: \(report.acknowledged.map { "\($0.counterparty):\($0.outboundMessageId)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return report // test hook
}
}
@@ -1813,7 +1830,13 @@ actor PaykitSdkService {
try await withSdk(priority: priority, beforeOperation: { try await self.checkRefreshAdmission(priority) }) { sdk in
try await readRetryingSharedStateBusy {
try await self.checkRefreshAdmission(priority)
- return try await sdk.linkedPeers()
+ let peers = try await sdk.linkedPeers() // test hook
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review observed peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return peers // test hook
}
}
}INFOℹ️: review contact peer states: ["linked"] - CleanupTraceHook [PubkyService.swift: removeContact(publicKey:) line: 1255]
WARN⚠️: Failed to withdraw private endpoints before contact deletion - PaykitSdkService [PubkyService.swift: removeContact(publicKey:) line: 1277]
INFOℹ️: Removed contact pubky5t38pd5... - ContactsManager [ContactsManager.swift: removeContact(publicKey:) line: 998]
INFOℹ️: review cleanup peers: ["pubky5t38pd5pxwxikhp6ijmkty1exyrmrrrm8r3x34fp5mqkrk5j8hqo:blocked", "pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:linked"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1415]
INFOℹ️: review cleanup cleared: ["pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:Optional(4)"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1453]
INFOℹ️: review cleanup acknowledged: ["pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:4"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1454]
DEBUG: Failed Paykit operation syncPaykitApp(privatePaymentsEnabled:priority:isSessionCurrent:) trace=809F5429-EE0A-4E94-9145-1727985123F8 queue=0.000307125 seconds work=3.966153584 seconds - PaykitSdkService [PubkyService.swift: withSdk(priority:deferDuringPayment:operationName:beforeOperation:_:) line: 2169]
WARN⚠️: Paykit app capability update failed during profile removal (policy) - PubkyProfileManager [PubkyProfileManager.swift: completePublicRemoval(_:) line: 1650]
Profile deletion finishes after re-adding a contact ❌ Deleting a re-added Linking contact still leads to policy recovery.
I completed local Disconnect, recreated the same-key profile, re-added its retained peer, and deleted that contact without an active connection refusal. Native state at contact deletion was Linking. Later profile cleanup matched the other peer’s clear/ACK pair at message 7, then the capability update failed with policy. Identity and recovery 4BF21565-DDFA-4479-ABC1-2F68748C18DB remained. The log establishes the native states and pair; the video joins contact deletion, profile deletion and the separate local-completion control.
readded-peer.2x.mp4
Temporary test hook, not pushed
Logs genuine native peer states and actual cleared/acknowledged peer/message identifiers. It changes no result or decision.
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 159c9a54..5c16cec4 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1250,6 +1250,11 @@ actor PaykitSdkService {
func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? {
try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers().filter { PubkyPublicKeyFormat.matches($0.counterparty, publicKey) }
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review contact peer states: \(peers.map { String(describing: $0.state) })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let now = Date()
let activeSubscriptions = try await sdk.paymentRequests().filter {
PubkyPublicKeyFormat.matches($0.counterparty, publicKey) &&
@@ -1405,6 +1410,11 @@ actor PaykitSdkService {
guard !counterparties.isEmpty else { return nil }
return try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers()
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let blockedPeers = peers.filter { $0.state == .blocked }
let updates = counterparties.filter { counterparty in
!blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }
@@ -1434,10 +1444,17 @@ actor PaykitSdkService {
throw PubkyServiceError.sessionNotActive
}
try checkMutationAdmission()
- return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(
+ let report = try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( // test hook
updates: updates,
clearUnlistedLinkedPeers: false
)
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup cleared: \(report.cleared.map { "\($0.counterparty):\(String(describing: $0.outboundMessageId))" })", context: "CleanupTraceHook") // test hook
+ Logger.info("review cleanup acknowledged: \(report.acknowledged.map { "\($0.counterparty):\($0.outboundMessageId)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return report // test hook
}
}
@@ -1813,7 +1830,13 @@ actor PaykitSdkService {
try await withSdk(priority: priority, beforeOperation: { try await self.checkRefreshAdmission(priority) }) { sdk in
try await readRetryingSharedStateBusy {
try await self.checkRefreshAdmission(priority)
- return try await sdk.linkedPeers()
+ let peers = try await sdk.linkedPeers() // test hook
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review observed peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return peers // test hook
}
}
}INFOℹ️: review contact peer states: ["linking"] - CleanupTraceHook [PubkyService.swift: removeContact(publicKey:) line: 1255]
INFOℹ️: Removed contact pubky5t38pd5... - ContactsManager [ContactsManager.swift: removeContact(publicKey:) line: 998]
INFOℹ️: review cleanup peers: ["pubky5t38pd5pxwxikhp6ijmkty1exyrmrrrm8r3x34fp5mqkrk5j8hqo:blocked", "pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:linked"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1415]
INFOℹ️: review cleanup cleared: ["pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:Optional(7)"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1453]
INFOℹ️: review cleanup acknowledged: ["pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:7"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1454]
WARN⚠️: Paykit app capability update failed during profile removal (policy) - PubkyProfileManager [PubkyProfileManager.swift: completePublicRemoval(_:) line: 1650]
Timing warning: this contact deletion took at least 11.36 seconds of native work, with no dedicated deletion progress indicator on this Contact Detail path. The path is unchanged by the Send delta.
Fixed on-chain requests keep their amount through confirmation and send ✅ Fixed requests retained 10,000 sats through confirmation and send.
On the pristine build, tapping the reviewed amount kept the fixed 10,000-sat Confirm; the request sent with a 143-sat fee, and the wallet balance decreased by 10,143 sats. A 500-sat request showed the 547-sat minimum. An amount-free URI opened Amount and reached 10,000-sat Confirm after keypad entry; I closed it without sending. After restoring the pristine app, I repeated those three amount controls without another broadcast and verified the installed library hash against the saved pristine build. The video joins the original send and that separate repeat; amounts and controls were checked in full accessibility output and screenshots.
fixed-payment.2x.mp4
Saved contacts wait for a private link and open privately after mutual saving ✅ Both saved-contact routes reached Amount with private endpoints.
On the diagnostic build, Contact → Pay and Send → Contact each reached Amount after mutual saving, without sending. Full accessibility output showed SendAmount; native/cache checks showed Linked peers and resolved private btc-regtest-p2wpkh endpoints. No timing holds were armed during these routes. The unchanged one-sided-save guidance and absence of public fallback retain the result from the review at 43c72a5; that branch was not rerun as a fresh pass here.
private-routing-contact.2x.mp4
private-routing-send.2x.mp4
Temporary test hook, not pushed
The diagnostic build also contains the timing hook disclosed in the startup case; no holds were armed. This hook only logs native peer states.
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 159c9a54..5c16cec4 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1250,6 +1250,11 @@ actor PaykitSdkService {
func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? {
try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers().filter { PubkyPublicKeyFormat.matches($0.counterparty, publicKey) }
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review contact peer states: \(peers.map { String(describing: $0.state) })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let now = Date()
let activeSubscriptions = try await sdk.paymentRequests().filter {
PubkyPublicKeyFormat.matches($0.counterparty, publicKey) &&
@@ -1405,6 +1410,11 @@ actor PaykitSdkService {
guard !counterparties.isEmpty else { return nil }
return try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers()
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let blockedPeers = peers.filter { $0.state == .blocked }
let updates = counterparties.filter { counterparty in
!blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }
@@ -1434,10 +1444,17 @@ actor PaykitSdkService {
throw PubkyServiceError.sessionNotActive
}
try checkMutationAdmission()
- return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(
+ let report = try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( // test hook
updates: updates,
clearUnlistedLinkedPeers: false
)
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup cleared: \(report.cleared.map { "\($0.counterparty):\(String(describing: $0.outboundMessageId))" })", context: "CleanupTraceHook") // test hook
+ Logger.info("review cleanup acknowledged: \(report.acknowledged.map { "\($0.counterparty):\($0.outboundMessageId)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return report // test hook
}
}
@@ -1813,7 +1830,13 @@ actor PaykitSdkService {
try await withSdk(priority: priority, beforeOperation: { try await self.checkRefreshAdmission(priority) }) { sdk in
try await readRetryingSharedStateBusy {
try await self.checkRefreshAdmission(priority)
- return try await sdk.linkedPeers()
+ let peers = try await sdk.linkedPeers() // test hook
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review observed peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return peers // test hook
}
}
}INFOℹ️: review observed peers: ["pubky5t38pd5pxwxikhp6ijmkty1exyrmrrrm8r3x34fp5mqkrk5j8hqo:linked", "pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:linked"] - CleanupTraceHook [PubkyService.swift: linkedPeers(priority:) line: 1836]
INFOℹ️: review observed peers: ["pubky5t38pd5pxwxikhp6ijmkty1exyrmrrrm8r3x34fp5mqkrk5j8hqo:linked", "pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:linked"] - CleanupTraceHook [PubkyService.swift: linkedPeers(priority:) line: 1836]
INFOℹ️: review observed peers: ["pubky5t38pd5pxwxikhp6ijmkty1exyrmrrrm8r3x34fp5mqkrk5j8hqo:linked", "pubkydcyjens7yp8p7kxuz84nk4azjbahs8fynfboohb43wbsep1khcpy:linked"] - CleanupTraceHook [PubkyService.swift: linkedPeers(priority:) line: 1836]
Wallet reset drains a held contact-payment caller tail ✅ Reset waited for the held genuine caller tail, then completed.
I held a genuine native identity-status result in the saved-contact caller, left Send, and requested wallet reset while that tail remained held. Console order was hold → explicit release → first wipe step → credential/state deletion → Wipe completed. Final persisted checks found no identity, private cache, reservation ledger or recovery marker, and fresh onboarding appeared with no old Send. The video joins the held-caller/reset flow and a separate onboarding excerpt. This holds the result in the app caller after the SDK read; it does not hold inside the SDK or prove the broader callback matrix.
native-contact-tail.2x.mp4
Temporary test hook, not pushed
Only the saved-contact hold was armed. It retains the genuine SDK read result in its caller until explicit release, including after cancellation. It never substitutes a native result.
diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift
index 518000c4..5eeafc2f 100644
--- a/Bitkit/Services/PrivatePaykitService+Payments.swift
+++ b/Bitkit/Services/PrivatePaykitService+Payments.swift
@@ -134,6 +134,9 @@ extension PrivatePaykitService {
private func savedContact(_ publicKey: String) async throws -> (publicKey: String, identity: IdentityStatus)? {
guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return nil }
let identity = try await contactPaymentOperations.identityStatus().flatMap { $0.publicKey == nil ? nil : $0 }
+ #if DEBUG // test hook
+ await reviewHoldSendStage("saved-contact", drain: true) // test hook
+ #endif // test hook
if isKnownSavedContact(normalizedKey) {
guard let identity else { throw PubkyServiceError.sessionNotActive }
return (normalizedKey, identity)
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 898d23ae..f1654c24 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -934,6 +934,9 @@ extension AppViewModel {
data = prevalidatedPaymentRequest
} else {
data = try await scanPaymentOperations.decodeInvoice(uri)
+ #if DEBUG // test hook
+ await reviewHoldSendStage("scan-result") // test hook
+ #endif // test hook
try ensureScannedDataHandlingOwnership(
handlingId,
claimedContactPaymentContext: claimedContactPaymentContext,
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index 4dde07cd..bc9ad635 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -399,6 +399,9 @@ struct SendSheet: View {
preparation.isSuspendedForAppLock = false
flow.rootRoute = route
}
+ #if DEBUG // test hook
+ await reviewHoldSendStage("setup") // test hook
+ #endif // test hook
setUpSend()
}
.onChange(of: wallet.nodeLifecycleState) { _, state in
@@ -1622,3 +1625,30 @@ private struct SendComingSoonView: View {
.padding(.horizontal, 16)
}
}
+
+#if DEBUG // test hook
+func reviewHoldSendStage(_ stage: String, drain: Bool = false) async { // test hook
+ guard ProcessInfo.processInfo.arguments.contains("-REVIEW_HOLD_SEND") else { return } // test hook
+ let folder = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] // test hook
+ let armed = folder.appendingPathComponent("review-hold-" + stage) // test hook
+ let release = folder.appendingPathComponent("review-release-" + stage) // test hook
+ guard FileManager.default.fileExists(atPath: armed.path) else { return } // test hook
+ try? "held".write(to: folder.appendingPathComponent("review-held-" + stage), atomically: true, encoding: .utf8) // test hook
+ Logger.info("review send timing hold: " + stage, context: "SendTimingHook") // test hook
+ let deadline = ContinuousClock.now.advanced(by: .seconds(180)) // test hook
+ if drain { // test hook
+ await Task.detached { // test hook
+ while !FileManager.default.fileExists(atPath: release.path), ContinuousClock.now < deadline { // test hook
+ try? await Task.sleep(for: .milliseconds(50)) // test hook
+ } // test hook
+ }.value // test hook
+ } else { // test hook
+ while !FileManager.default.fileExists(atPath: release.path), ContinuousClock.now < deadline, !Task.isCancelled { // test hook
+ try? await Task.sleep(for: .milliseconds(50)) // test hook
+ } // test hook
+ } // test hook
+ try? FileManager.default.removeItem(at: armed) // test hook
+ try? FileManager.default.removeItem(at: release) // test hook
+ Logger.info("review send timing release: " + stage, context: "SendTimingHook") // test hook
+} // test hook
+#endif // test hookINFOℹ️: review send timing hold: saved-contact - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1637]
INFOℹ️: review send timing release: saved-contact - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1652]
INFOℹ️: Wipe step: quiesce - WipeCoordinator [WipeCoordinator.swift: runSteps(_:) line: 355]
INFOℹ️: Wipe step: wipeKeychain - WipeCoordinator [WipeCoordinator.swift: runSteps(_:) line: 355]
INFOℹ️: Wipe step: clearPubkyState - WipeCoordinator [WipeCoordinator.swift: runSteps(_:) line: 355]
INFOℹ️: Wipe step: commit - WipeCoordinator [WipeCoordinator.swift: runSteps(_:) line: 355]
INFOℹ️: Wipe completed - WipeCoordinator [WipeCoordinator.swift: runAttempt() line: 379]
Ordinary profile deletion finishes after contact deletion ✅ Retained prior pass; the tested path is unchanged.
At the reported head, the pristine app completed contact deletion and profile deletion to onboarding. Cold launch and persisted checks found no identity or recovery marker. Retained from the original report at 0f2b654; not rerun here. Its original scope, diagnostic hooks and limits still apply.
The retained contact-deletion operation took at least 11.78 seconds of native work in its original report; this unchanged path was not retimed here.
Credential and acknowledgement failures preserve cold recovery ✅ Retained prior pass; the tested path is unchanged.
Owned and Ring credential, acknowledgement, reservation and final-marker fault cycles retained cold recovery. The original native-watermark and nonempty-override limits apply. Retained from the original report at 630be75; not rerun here. Its original scope, diagnostic hooks and limits still apply.
Fixed-term subscriptions retain remote state during local Disconnect ✅ Retained prior pass; the tested path is unchanged.
Genuine app-approved fixed-term owned payer, owned payee and Ring payee cases retained remote state during local Disconnect. Broader open-ended cancellation remains unrun. Retained from the original report at 630be75; not rerun here. Its original scope, diagnostic hooks and limits still apply.
Receipt attribution survives profile cleanup and cold resync ✅ Retained prior pass; the tested path is unchanged.
Genuine regtest receipt attribution survived profile cleanup and cold resync within the original report’s scope. Retained from the original report at 3e49570; not rerun here. Its original scope, diagnostic hooks and limits still apply.
Corrupt reservations stay strict for profiles and clear during wallet reset ✅ Retained prior pass; the tested path is unchanged.
Malformed and version-2 reservation wipe and credential faults passed. The consumed watermark was injected; power-loss durability was not proved. Retained from the original report at 065a4c6; not rerun here. Its original scope, diagnostic hooks and limits still apply.
Profile cleanup stops without the remaining peer acknowledgement ✅ Retained prior pass; the tested path is unchanged.
After a failed contact withdrawal and deletion, refusing the remaining linked peer produced a clear at message 5 with no acknowledgement. privateUnavailable stopped the attempt before public capability or final profile deletion. Cold launch and Profile entry retained the same identity, recovery, private cache and pending key. Retained from the original report at 0f2b654; not rerun here. Its original scope, diagnostic hooks and limits still apply.
Pending cleanup survives a real network reconnect ⏭️ A real application network reconnect was not established.
The simulator exposed no isolated network toggle, and networksetup was unavailable. I restarted its actual configd_sim service using both accepted service forms, but observed no disconnected/connected application path pair. The recovery identity and marker remained through those attempts and later cold entry. This proves retained recovery only; a literal network reconnect remains unrun.
reconnect-attempt.2x.mp4
Wrong native acknowledgements and open-ended cancellation retain safety ⏭️ Wrong authenticated acknowledgements and open-ended cancellation remain unrun.
The repository native fixture started and advertised replace, forget and reset-link controls. Wrong-message, wrong-peer and acknowledgement-control requests returned 404. Staging and those controls could not produce the requested authenticated mismatch or open-ended cancellation state. A fabricated SDK return would not establish the native contract. The correct acknowledgement above and the retained missing-ACK result above cover narrower cases.
Delayed native callbacks reject replacement and wipe ⏭️ The complete callback and identity-replacement matrix remains unrun.
The genuine saved-contact caller-tail check above covers one bounded path. Neither the reachable staging flows nor the native-peer controls supplied all publication, adoption, receipt, indexing, restore, same-grant refresh and ABA callback orderings. Component checks and one held result do not establish that complete native matrix.
Check: hosted CI ⚠️ The overall unit job is red.
The hosted unit run passed the five Send checks, but failed two assertions in BackupServiceTests.testWalletRecoveryResumesMetadataAfterFailedJobRetires. That test and its service are unchanged by this delta; I did not establish the failure’s cause. Formatting CI passed. The focused local pass does not establish full-suite success.
Installed-app evidence covers real external BIP21 handoffs, fixed and amount-free requests, private contact entry, cleanup failure, and one native caller tail. Complete native LNURL decoding, Edit/Detail restoration actions and the three unrun matrices above have no new device proof.
Pair PR: synonymdev/bitkit-android#1693 fix: bind profile removal to its original owner matches the owner/attempt and exact-acknowledgement contract in source. The iOS Send startup change has no Android runtime parity claim.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — profile deletion still fails the Paykit capability update after contact deletion.
Findings: No new finding in bb26b03..e9354eb. The existing MEDIUM registry-downgrade defect remains at Bitkit/Managers/PubkyProfileManager.swift:1650. After failed withdrawal or deletion of a re-added Linking peer, users cannot finish remote profile deletion and are left with local Disconnect. This PR’s backup-test change passes; production code and the Paykit RC4 pin are unchanged. paykit-rs#267 Retain explicit empty-list withdrawals when blocking peers is still open and unintegrated.
Scope: The one-file delta bb26b03..e9354eb, the scheduler and backup dependency paths it reaches, and open findings from the prior review. Both review passes found no new changed-line defect.
QA for e9354eb: 14 passed, 3 failed, 5 unrun. Thirteen passes retain prior evidence because this push changes only an isolated backup test. The two failed-withdrawal assertions share one attempt and one existing defect. All funds and identities used here are disposable regtest fixtures. Videos are 2×; the full failure recording remains uneven, with a separately repeated recovery-state excerpt. Native ordering and durable-state claims were checked in logs and storage.
Wallet backup recovery resumes metadata after the failed job retires ✅ The backup suite and repeated regression check passed.
Pristine build: all 46 BackupServiceTests passed, then the corrected two-branch regression test passed 10 repeated executions. Assertions verify resumed metadata only in the active generation and no resume after stopping. Assertions checked in XCTest logs/XCResult, not inferred from the recording.
backup-tests.2x.mp4
Profile deletion finishes after a failed contact withdrawal ❌ Profile deletion stopped at the capability update after the contact withdrawal failed.
On the logging-only build, a linked contact withdrawal failed under a peer-only refusal, then contact deletion cleared its local published/pending state. With access restored, cleanup skipped the blocked peer and matched the remaining peer clear/ACK at message 8, but the capability update failed with policy. Native logs and storage verify the order and retained original identity/recovery. J2 actions 14–16. The later recovery excerpt shows the separate explicit local Disconnect reaching onboarding; it does not claim remote deletion. Contact removal took at least 15.0 seconds from native entry to local completion under the deliberate connection refusal, without a dedicated deletion indicator.
| Flow | Recovery / local completion |
|---|---|
failed-withdrawal.2x.mp4 |
failure-state-repeat.2x.mp4 |
Temporary test hook, not pushed
Logs actual peer states and exact clear/acknowledgement IDs; changes no SDK result or cleanup decision.
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 159c9a54..5c16cec4 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1250,6 +1250,11 @@ actor PaykitSdkService {
func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? {
try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers().filter { PubkyPublicKeyFormat.matches($0.counterparty, publicKey) }
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review contact peer states: \(peers.map { String(describing: $0.state) })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let now = Date()
let activeSubscriptions = try await sdk.paymentRequests().filter {
PubkyPublicKeyFormat.matches($0.counterparty, publicKey) &&
@@ -1405,6 +1410,11 @@ actor PaykitSdkService {
guard !counterparties.isEmpty else { return nil }
return try await withStateRevisionTracking { sdk in
let peers = try await sdk.linkedPeers()
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
let blockedPeers = peers.filter { $0.state == .blocked }
let updates = counterparties.filter { counterparty in
!blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }
@@ -1434,10 +1444,17 @@ actor PaykitSdkService {
throw PubkyServiceError.sessionNotActive
}
try checkMutationAdmission()
- return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(
+ let report = try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( // test hook
updates: updates,
clearUnlistedLinkedPeers: false
)
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review cleanup cleared: \(report.cleared.map { "\($0.counterparty):\(String(describing: $0.outboundMessageId))" })", context: "CleanupTraceHook") // test hook
+ Logger.info("review cleanup acknowledged: \(report.acknowledged.map { "\($0.counterparty):\($0.outboundMessageId)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return report // test hook
}
}
@@ -1813,7 +1830,13 @@ actor PaykitSdkService {
try await withSdk(priority: priority, beforeOperation: { try await self.checkRefreshAdmission(priority) }) { sdk in
try await readRetryingSharedStateBusy {
try await self.checkRefreshAdmission(priority)
- return try await sdk.linkedPeers()
+ let peers = try await sdk.linkedPeers() // test hook
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_TRACE_CLEANUP") { // test hook
+ Logger.info("review observed peers: \(peers.map { "\($0.counterparty):\($0.state)" })", context: "CleanupTraceHook") // test hook
+ } // test hook
+ #endif // test hook
+ return peers // test hook
}
}
}[2026-11-07 00:59:06.403 UTC] INFOℹ️: review contact peer states: ["linked"] - CleanupTraceHook [PubkyService.swift: removeContact(publicKey:) line: 1255]
[2026-11-07 00:59:12.695 UTC] WARN⚠️: Failed to withdraw private endpoints before contact deletion - PaykitSdkService [PubkyService.swift: removeContact(publicKey:) line: 1277]
[2026-11-07 00:59:51.293 UTC] INFOℹ️: review cleanup cleared: ["pubkye7misstsi8f79ygi4bggybi7opussfy3itcoerpfstafxeynbjro:Optional(8)"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1453]
[2026-11-07 00:59:51.294 UTC] INFOℹ️: review cleanup acknowledged: ["pubkye7misstsi8f79ygi4bggybi7opussfy3itcoerpfstafxeynbjro:8"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1454]
[2026-11-07 00:59:59.115 UTC] WARN⚠️: Paykit app capability update failed during profile removal (policy) - PubkyProfileManager [PubkyProfileManager.swift: completePublicRemoval(_:) line: 1664]
[2026-11-07 01:08:19.793 UTC] INFOℹ️: review contact peer states: ["linking"] - CleanupTraceHook [PubkyService.swift: removeContact(publicKey:) line: 1255]
[2026-11-07 01:08:56.247 UTC] INFOℹ️: review cleanup cleared: ["pubkye7misstsi8f79ygi4bggybi7opussfy3itcoerpfstafxeynbjro:Optional(11)"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1453]
[2026-11-07 01:08:56.247 UTC] INFOℹ️: review cleanup acknowledged: ["pubkye7misstsi8f79ygi4bggybi7opussfy3itcoerpfstafxeynbjro:11"] - CleanupTraceHook [PubkyService.swift: clearPrivatePaymentLists(to:isSessionCurrent:) line: 1454]
[2026-11-07 01:09:04.024 UTC] WARN⚠️: Paykit app capability update failed during profile removal (policy) - PubkyProfileManager [PubkyProfileManager.swift: completePublicRemoval(_:) line: 1664]
A remaining peer acknowledgement permits profile deletion ❌ The exact remaining-peer acknowledgement did not permit final deletion.
Same attempt as the failed-withdrawal case above: the remaining linked peer had an exact clear/ACK pair at message 8. The later capability update failed with policy before final profile deletion. The acknowledgement and retained original identity/recovery were checked in native logs and storage. The logging-only tracing hook is shown under the failed-withdrawal case.
| Flow | Recovery / local completion |
|---|---|
failed-withdrawal.2x.mp4 |
failure-state-repeat.2x.mp4 |
Profile deletion finishes after re-adding a contact ❌ Deleting the re-added peer still left profile removal incomplete.
On the logging-only build, recreated the same-key profile, re-added its retained peer and deleted it without a connection fault. The native peer was Linking. Cleanup matched the remaining peer clear and acknowledgement at message 11, then the capability update again failed with policy and retained identity/recovery. Exact ordering and recovery UUID 4E98BD3D-4A09-40A2-BA43-3EE028E30E5C were checked in native logs/storage. A separate recovery excerpt shows explicit local completion, without claiming remote deletion. The logging-only tracing hook is shown under the failed-withdrawal case. Contact deletion took at least 11.2 seconds from native entry to local completion, with no dedicated deletion indicator.
| Flow | Recovery / local completion |
|---|---|
readded-peer.2x.mp4 |
readded-local-completion.2x.mp4 |
Contact actions use only the original restored session ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Retained the five contact-session regressions for signed-out feedback, restoration, replacement, read-only key and queued wipe, from the unchanged 0f2b654 paths. Assertions were checked in XCTest output; video in the original report shows component execution. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
Saving a public contact without a session gives feedback ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Retained earlier pass; not rerun: On the pristine head build, two Save taps each showed Pubky Identity Required and kept Add Contact open. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
A contact opened during session restoration can be saved ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Retained earlier pass; not rerun: With a timing-only hook holding a genuine native restoration result, Add Contact opened through scanner entry and saved on the same screen after explicit release. Repeated with a second contact. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
Ordinary profile deletion finishes after contact deletion ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Retained earlier pass; not rerun: The pristine current-head app completed contact deletion and profile deletion to onboarding. Cold launch retained onboarding and persisted checks showed no identity or recovery marker. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
Credential and acknowledgement failures preserve cold recovery ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Retained earlier pass; not rerun: Carried from 630be75, not rerun: Owned and Ring credential, acknowledgement, reservation and final-marker fault cycles retained cold recovery; original native watermark and nonempty-override checks apply. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
Fixed-term subscriptions retain remote state during local Disconnect ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Retained earlier pass; not rerun: Carried from 630be75, not rerun: Genuine app-approved finite owned payer, owned payee and Ring payee cases retained remote state during local Disconnect; broader open-ended cancellation remains unrun. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
Receipt attribution survives profile cleanup and cold resync ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Retained earlier pass; not rerun: Carried from 3e49570, not rerun: Genuine regtest receipt attribution and cold resync passed within the original report scope. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
Corrupt reservations stay strict for profiles and clear during wallet reset ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Retained earlier pass; not rerun: Carried from 065a4c6, not rerun: Malformed/version-2 reservation wipe and credential faults passed; consumed watermark was injected and power-loss durability was not proved. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
Profile cleanup stops without the remaining peer acknowledgement ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Retained earlier pass; not rerun: After a genuine failed withdrawal and contact deletion, the remaining linked peer was refused. The native report cleared its message 5 but acknowledged none; privateUnavailable stopped the attempt, retaining identity and recovery. No subsequent public-capability or final profile-delete call was logged in that attempt. Cold launch and Profile entry preserved the same marker, identity, private cache and pending key, checked in persisted state and the separate appended clip. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
Fixed on-chain requests keep their amount through confirmation and send ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Pristine app: 10000-sat BIP21 retained its amount after tapping review and sent with a 143-sat fee; 500 sats showed the 547-sat minimum. An amount-free URI opened Amount and reached 10000-sat Confirm after entry; closed without sending. Amount, fee and controls were read in screenshots/AX, and balance fell by 10143 sats. A second pristine installation repeated the three amount controls without another broadcast; the installed debug-library hash matched the saved pristine build. Media joins the original send and the separate control repeat. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
External payments survive Send startup and sheet replacement ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Pristine component suites passed, including a repeated five-case startup/queued-sheet/late-scan subset. On the timing-hook build, a genuine native BIP21 decode stayed held while old options or confirmation setup resumed first; releasing decode then reached the replacement 10000-sat Confirm in both variants. No payment was sent. XCTest logs and explicit hold/release files/AX snapshots provide the assertions. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply.
Wallet reset drains a held contact-payment caller tail ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: On the timing-hook build, a genuine native identity-status result was held in the saved-contact caller. I left Send and requested reset while its caller remained held; wallet reset remained before cleanup until explicit release. Console order is hold, release, first wipe step, key/state deletion, Wipe completed. The completed reset reached fresh onboarding with no old Send, identity, private cache, reservation ledger or recovery marker. Persisted checks and the console establish the result; the final onboarding leg is a separate recorded excerpt. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply. Holds the genuine result in the app caller after the SDK read; it does not hold inside the SDK or prove the broader callback matrix.
Saved contacts wait for a private link and open privately after mutual saving ✅ Retained prior pass; this push leaves the tested path unchanged.
Retained prior pass: Both current-head routes, Contact → Pay and Send → Contact, reached Amount after mutual saving. Full accessibility snapshots showed SendAmount, and genuine native/cache checks showed linked peers with private btc-regtest-p2wpkh endpoints. No payment was sent. The one-sided-save guidance retains its earlier pass because that resolver branch is unchanged; newly saved screen attempts that lost their generation were retried from a loaded contact. Retained from the prior report at its recorded revisions, without rerunning; its diagnostic hooks and evidence limits still apply. Diagnostic build with the disclosed timing/logging hooks; no holds were armed during the successful routes.
Pending cleanup survives a real network reconnect ⏭️ Foreground recovery remained available; a real network reconnect was not produced.
The same recovery UUID and identity persisted across actual background/foreground and the Disconnect guidance remained visible. This proves the narrower foreground check; no real disconnected/connected application network path was produced. The simulator exposes no device-local network toggle, and networksetup is absent from its runtime. Endpoint refusal cannot establish an NWPath reconnect; host networking was preserved.
reconnect-attempt.2x.mp4
Wrong native acknowledgements and open-ended cancellation retain safety ⏭️ The authenticated wrong-acknowledgement and open-ended cancellation matrix remains unrun.
No app failure inferred from missing fixture controls. The owned native peer is healthy, but wrong-message, wrong-peer and acknowledgement fault routes return 404; its public request/cancel endpoints require an authorized linked app and do not control authenticated acknowledgements. No genuine mismatched native report was produced.
Delayed native callbacks reject replacement and wipe ⏭️ The complete native callback and replacement matrix remains unrun.
No app failure inferred from missing fixture controls. The owned native fixture does not expose callback holds (hold-callback returned 404). Timing hooks can hold genuine app caller results but do not establish every SDK/publication/adoption/receipt/indexing/restore/refresh/ABA ordering. The complete native matrix remains unrun.
An external LNURL survives Send startup ⏭️ Native decoding ran, but the amount and confirmation route remains unrun.
On the timing-hook build, held a genuine native LNURL-pay decode from the owned HTTP fixture, released old Send setup first, then released the decoder. The app showed its real no-Lightning-capacity warning. Native decoding and this bounded ordering ran; the amount/confirmation route did not. No payment was sent. No funded outbound Lightning channel was established for the complete native amount/confirmation overlap. Component LNURL routing and prior native BIP21 overlaps remain narrower evidence.
startup-native-lnurl.2x.mp4
Temporary test hook, not pushed
Holds genuine Send setup and decoder results until explicit release; changes no generation or ownership decision.
diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift
index 518000c4..5eeafc2f 100644
--- a/Bitkit/Services/PrivatePaykitService+Payments.swift
+++ b/Bitkit/Services/PrivatePaykitService+Payments.swift
@@ -134,6 +134,9 @@ extension PrivatePaykitService {
private func savedContact(_ publicKey: String) async throws -> (publicKey: String, identity: IdentityStatus)? {
guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return nil }
let identity = try await contactPaymentOperations.identityStatus().flatMap { $0.publicKey == nil ? nil : $0 }
+ #if DEBUG // test hook
+ await reviewHoldSendStage("saved-contact", drain: true) // test hook
+ #endif // test hook
if isKnownSavedContact(normalizedKey) {
guard let identity else { throw PubkyServiceError.sessionNotActive }
return (normalizedKey, identity)
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 898d23ae..f1654c24 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -934,6 +934,9 @@ extension AppViewModel {
data = prevalidatedPaymentRequest
} else {
data = try await scanPaymentOperations.decodeInvoice(uri)
+ #if DEBUG // test hook
+ await reviewHoldSendStage("scan-result") // test hook
+ #endif // test hook
try ensureScannedDataHandlingOwnership(
handlingId,
claimedContactPaymentContext: claimedContactPaymentContext,
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index 4dde07cd..bc9ad635 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -399,6 +399,9 @@ struct SendSheet: View {
preparation.isSuspendedForAppLock = false
flow.rootRoute = route
}
+ #if DEBUG // test hook
+ await reviewHoldSendStage("setup") // test hook
+ #endif // test hook
setUpSend()
}
.onChange(of: wallet.nodeLifecycleState) { _, state in
@@ -1622,3 +1625,30 @@ private struct SendComingSoonView: View {
.padding(.horizontal, 16)
}
}
+
+#if DEBUG // test hook
+func reviewHoldSendStage(_ stage: String, drain: Bool = false) async { // test hook
+ guard ProcessInfo.processInfo.arguments.contains("-REVIEW_HOLD_SEND") else { return } // test hook
+ let folder = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] // test hook
+ let armed = folder.appendingPathComponent("review-hold-" + stage) // test hook
+ let release = folder.appendingPathComponent("review-release-" + stage) // test hook
+ guard FileManager.default.fileExists(atPath: armed.path) else { return } // test hook
+ try? "held".write(to: folder.appendingPathComponent("review-held-" + stage), atomically: true, encoding: .utf8) // test hook
+ Logger.info("review send timing hold: " + stage, context: "SendTimingHook") // test hook
+ let deadline = ContinuousClock.now.advanced(by: .seconds(180)) // test hook
+ if drain { // test hook
+ await Task.detached { // test hook
+ while !FileManager.default.fileExists(atPath: release.path), ContinuousClock.now < deadline { // test hook
+ try? await Task.sleep(for: .milliseconds(50)) // test hook
+ } // test hook
+ }.value // test hook
+ } else { // test hook
+ while !FileManager.default.fileExists(atPath: release.path), ContinuousClock.now < deadline, !Task.isCancelled { // test hook
+ try? await Task.sleep(for: .milliseconds(50)) // test hook
+ } // test hook
+ } // test hook
+ try? FileManager.default.removeItem(at: armed) // test hook
+ try? FileManager.default.removeItem(at: release) // test hook
+ Logger.info("review send timing release: " + stage, context: "SendTimingHook") // test hook
+} // test hook
+#endif // test hook[2026-11-07 01:02:37.116 UTC] INFOℹ️: review send timing hold: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1637]
[2026-11-07 01:03:25.306 UTC] INFOℹ️: review send timing release: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1652]
[2026-11-07 01:03:57.821 UTC] INFOℹ️: review send timing hold: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1637]
[2026-11-07 01:04:44.574 UTC] INFOℹ️: review send timing release: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1652]
[2026-11-07 01:05:11.368 UTC] INFOℹ️: review send timing hold: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1637]
[2026-11-07 01:05:13.432 UTC] INFOℹ️: review send timing hold: scan-result - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1637]
[2026-11-07 01:05:13.554 UTC] INFOℹ️: review send timing release: setup - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1652]
[2026-11-07 01:05:14.159 UTC] INFOℹ️: review send timing release: scan-result - SendTimingHook [SendSheet.swift: reviewHoldSendStage(_:drain:) line: 1652]
Edit and Contact Detail actions use the original restored profile ⏭️ The complete installed Edit and Detail restoration matrix remains unrun.
A timing-only hook held a genuine native restoration result. The Contacts route opened, but its saved rows were unavailable before that result was applied. The hold was released on exit; no original-screen Edit/Detail action matrix was driven. The normal startup UI did not expose the saved Detail/Edit screens while the genuine restoration result was held. This attempt does not replace the prior component checks or prove replacement/wipe confirmations.
restored-contact-actions.2x.mp4
Temporary test hook, not pushed
Holds a genuine native startup restoration result before application; substitutes no SDK result.
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index 1da08681..bf8d9416 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -614,6 +614,20 @@ class PubkyProfileManager: ObservableObject {
let initialized = try await removalAdmission.withDetached {
try await initializeSession()
}
+ #if DEBUG // test hook
+ if ProcessInfo.processInfo.arguments.contains("-REVIEW_HOLD_CONTACT_RESTORE"), case .restored = initialized { // test hook
+ let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] // test hook
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+ let release = directory.appendingPathComponent("review-contact-restore-release") // test hook
+ try "held".write(to: directory.appendingPathComponent("review-contact-restore-held"), atomically: true, encoding: .utf8) // test hook
+ Logger.info("review test hook: genuine native restoration held before result application", context: "ContactRestoreHook") // test hook
+ let limit = ContinuousClock.now.advanced(by: .seconds(180)) // test hook
+ while !FileManager.default.fileExists(atPath: release.path), ContinuousClock.now < limit, !Task.isCancelled { // test hook
+ try await Task.sleep(for: .milliseconds(100)) // test hook
+ } // test hook
+ Logger.info("review test hook: native restoration result released", context: "ContactRestoreHook") // test hook
+ } // test hook
+ #endif // test hook
try removalAdmission.checkCurrent()
result = initialized
} catch {[2026-11-07 00:56:15.482 UTC] INFOℹ️: review test hook: genuine native restoration held before result application - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 623]
[2026-11-07 00:56:50.248 UTC] INFOℹ️: review test hook: native restoration result released - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 628]
[2026-11-07 00:57:33.944 UTC] INFOℹ️: review test hook: genuine native restoration held before result application - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 623]
[2026-11-07 00:58:11.053 UTC] INFOℹ️: review test hook: native restoration result released - ContactRestoreHook [PubkyProfileManager.swift: initializeSessionState(mode:initializeSession:) line: 628]
Check: hosted CI ✅ The configured unit and UI job passed.
The unit/UI run passed, including testWalletRecoveryResumesMetadataAfterFailedJobRetires. Its explicit fixture skips and configured integration exclusions remain exclusions. Formatting CI passed. The fresh local pristine run also passed all 46 BackupServiceTests, then all ten repeated executions of the corrected two-branch test.
Twin: bitkit-android#1693 fix: bind profile removal to its original owner retains the matching original-owner/attempt contract at unchanged head c228adb. This iOS delta changes only a test; no Android runtime parity is claimed for it.
Reviewed by gpt-6.1-sol-high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: follow-up at e9354eb, covering the entire delta since 43c72a5 and affected contact/session and Send lifecycle paths. The full PR inventory was checked; unchanged coverage is inherited from the completed baseline.
No new actionable code findings.
Validation: inspected native CI logs for merge e62c45e974cc51401fff905551068c8cd04d72d8 of the exact pinned head/base. Contact restoration/replacement/wipe tests, Send startup/handoff and delayed-replacement tests, and backup ordering regression passed there. Local diff checks passed. Android and iOS #1125 comparisons were limited to affected contact/session contracts; Appium coverage was inspected at 849806e.
Device testing: not performed in this review. No local iOS build or native suite was run on this Linux host; CI component results do not establish authenticated recovery journeys or installed UI behavior. The authored J1–J3 and manual cases retain their execution requirements.
Suggested additional test cases
- iOS, disposable wallet with usable Lightning and Quickpay disabled: delay a fixed-amount LNURL response, dismiss the scanner and request Send before the response returns, then release it. Expect the manual Send to remain active without adopting the stale target. Repeat by opening an external LNURL while Send is mounting; expect the new confirmation to retain its recipient and amount through dismissal of the old presentation.
Earlier findings reconciled at this revision: 2 still present, 5 verified fixed.
Existing concerns
- [MEDIUM] Disconnect cannot retry an uncertain private withdrawal — still unresolved; already tracked.
- [MEDIUM] Profile deletion stops after a failed contact withdrawal — still unresolved; already tracked.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Delta review 0f2b65428..e9354eb2d: no new findings. The open MEDIUM (profile deletion stops at syncPaykitApp after a failed withdrawal) still blocks until pubky/paykit-rs#267 is released and integrated, so the review state stays as it is.
Checked and clean in bb26b03d5 ("the later request wins"):
SheetViewModel.showSheetnow resets Send state at request time (:93-96). The mount-time reset inSendSheetruns only for embedded presentations (:567-568).- Double pay: a Send tap during a pending external LNURL decode bumps
sendStateGeneration.ensureScannedDataHandlingOwnershipthen throwsCancellationErrorat the next await, andScannerManagerswallows it. A queued.quickpayreturns early once a later Send has clearedpendingSheet(:102-104). - Stale target: an external scan that lands before a requested Send mounts keeps its
lnurlPayData, because the non-owning sheet skips the reset incleanup()(:518). The two new tests cover this. - Contact context: the request-time reset clears
contactPaymentContext, and theownsContactPaymentContextguards inPaymentNavigationHelper(:283-290) stop a second reset from wiping the user's Send. - Cancel/Back of a non-owning Send skips the app, wallet and quickpay resets. A scan started inside it re-adopts ownership through
onChange(of: sendStateGeneration)(:388-393). isReplacingSheetis always cleared by whichever presentation shows. Non-send pending sheets behave as before.e9354eb2dis test-only.
Device gate: not re-run for this delta. @ovi-reviewer drove the Send startup checks at bb26b03 and they passed. perf: not relevant.
e9354ebfixes backup-test ordering. Unit tests and SwiftFormat passed. Unit run · Open RC4 withdrawal finding.Related issue: synonymdev/bitkit-android#1687 (remains open for native conditional contact cleanup and authenticated fault journeys).
Twin: synonymdev/bitkit-android#1693
Dependency: #1125
This PR binds profile deletion, Disconnect and their late callbacks to the sign-in and confirmation that started them. Interrupted cleanup stays visible after restart, including after credentials have already been removed, and requires explicit local completion.
Description
Out of Scope
Design
N/A — no design available. Existing Profile controls and recovery components are reused.
Preview
The native hosted-view test records the three recovery screens: interrupted profile cleanup with Disconnect, interrupted wallet wipe with its explicit confirmation path, and an unreadable local record with Retry. Screenshots are retained in the final XCResult evidence. These are controlled installed view states, not authenticated journey recordings.
QA Notes
Setup
Use the committed pins: Bitkit Core 0.8.0-rc11, Paykit 0.1.1-rc4, VSS 0.5.26-rc1 and LDK 0.7.0-rc.78. RC4 resolves to
d56e051b4b1479a8da30c0a05f3fade868ff6477; released XCFramework SHA-256:317add9f1b537a01345ecfc706d4d3f4d622af806e3283c172e48ab70b8e451d. Select an available concrete simulator and the repository unit selection in.github/workflows/unit-tests.yml. The controlled UI checks can be run withxcodebuild test -project Bitkit.xcodeproj -scheme Bitkit -destination 'platform=iOS Simulator,id=<UDID>' -only-testing:BitkitUITests/ProfileRecoveryUITests -only-testing:BitkitUITests/ContactImportUITests; their isolated DEBUG fixtures need no funded wallet.Authenticated J1/J2 require a disposable wallet, regtest funds, compatible Pubky 0.16.2 native homeserver/Homegate services, app-approved companion grants, a peer, and principal/method/path-scoped fault controls that can reject before storage or drop an acknowledgement after committing. The recorded local environment used
E2E_BUILD=1,E2E_BACKEND=local,E2E_NETWORK=regtest,E2E_LOCAL_HOST=127.0.0.2, Homegatehttp://127.0.0.2:18889, and the launch argument-E2E_PROFILE_REMOVAL_FAULTS. The loopback alias was provisioned explicitly. Standard staging does not provide these faults. The working fixture/operators and frozen receipts are preserved in the validation workspace; a portable committed fixture recipe is not available. A reviewer needs equivalent owned services before reproducing the authenticated fault cases. Preserve existing wallets and pending operations; do not use live funds or reset a personal wallet.The DEBUG simulator fault selector is an app-scoped sticky file,
profile-removal-test-fault, in Application Support. Values arepaykit_session:delete,pubky_secret_key:delete,shared-own:enumerate,shared-own:delete,preferences:flush,reservations:acknowledge,marker:persist, ormarker:complete. It additionally requires E2E/regtest and is disabled for unit tests. Clear it before another profile activation/adoption, unrelated backup or reset. Faults invoke real error paths; they are not bound to a particular profile. Controlled mock UI states remain separate evidence.Journeys
J2's authored payer/payee subscription-refusal steps, unrelated saved contact, and cleanup after cancellation were exercised on October 29 on the owned unfunded simulator. Both roles displayed the active-subscription instruction without a Retry dialog; before/after reads preserved the profile revision, contact records, endpoint and reservation records, and outbound history. The earlier missing-contact refusal remains separately recorded. After both unpaid subscriptions were canceled, a new explicit confirmation completed cleanup and returned to profile onboarding. J2 remains unchecked: these earlier point-in-time observations do not establish zero attempted server mutations. The later bounded payer/unlink trace below supplies a scoped assertion for one new attempt; the earlier fault checkpoints retain their stated limits. The separate genuine Ring-owned unlink journey passed later that day, as recorded below.
profile-removal-recovery.xml— original confirmation, uncertain cleanup/local Disconnect, restart and ordinary receive. Added actions 14–21 require original-grant proof retry, same-grant refresh, replacement/ABA rejection and actual tail draining; historical installed results do not establish these new expectations. Actions 22–24 cover endpoint/app failure continuation; the native zero-target registry case passed, while nonempty-target coverage remains open.profile-cleanup-failure.xml— incomplete cleanup stays a failure; local Disconnect remains separately confirmed. Action 6 now also requires an exact positive private-withdrawal acknowledgement; earlier installed results do not establish this added acknowledgement expectation. Actions 12–13 cover first-attempt withdrawal while sharing is disabled. Added actions 14–16 cover deleting a contact before profile cleanup: contact deletion clears blocked-peer state; later profile removal sends it no withdrawal, while remaining peers still need exact acknowledgements. Earlier runs do not establish these added actions.profile-fixed-term-disconnect.xml— refuse profile removal during an active finite subscription, cancel the separate local Disconnect confirmation, then explicitly finish local cleanup while preserving server state. Component coverage and source review are recorded below; this candidate's authenticated simulator journey remains unrun.Manual Tests
1. Hold an admitted SDK/publication/local completion at each teardown boundary, then replace the identity or begin a controlled wipe → the old action drains without publishing or completing UI for the replacement. Exact scheduler barriers require the native test seams.
2. Fail keychain enumeration/deletion and preference/reservation acknowledgement, restart at each credential/local-cleanup boundary → consumed-list watermarks and the exact recovery obligation survive until required cleanup is acknowledged. These deterministic durable-store failures need controlled resource injection.
3. On the owned regtest simulator, fail session deletion before local mappings clear, receive one payment at the genuine peer-reserved address, then explicitly finish local cleanup and cold-restart/resync → the same activity retains its amount and historical contact key while profile mappings and the recovery marker are removed. The sticky teardown fault and stored-row inspection require capabilities outside the journey runner.
4. On a fresh disposable wallet, inject malformed or unsupported reservation data. Profile-only cleanup must preserve it and refuse completion; full wipe must discard it after credential acknowledgement. Fail session and shared credential erasure separately: reservation bytes and consumed-list state must remain. Requires controlled storage injection; never alter a retained wallet.
Current authored QA status (bounded to the recorded evidence):
profile-removal-recovery.xmlprofile-cleanup-failure.xmlunlink-ring-pubky.xml, actions 1–10The finite follow-up mapped historical J2 fault actions to current actions 4–11 and checked the relevant cleanup methods and package pins. Those results remain attributed to their original runtime candidate; the new global-subscription gates and LDK change are recorded explicitly. Manual 2 distinguishes credential acknowledgement from final local completion: consumed-list watermarks survive required credential-erasure failures, while the exact recovery marker remains until all required local acknowledgements succeed. Independent credential cold cycles do not all contain the original genuine watermark, and that historical installed selector has no reservation-acknowledgement fault. Manual 2 remains unchecked on those specific limits.
On the owned simulator, one additional active payer unlink refusal now has a continuous scoped mutation journal. The same fixture and Ring identity were preserved; after the fixture owner resumed the retained VSS/auth services, the wallet started and Bitkit published its private endpoint. A genuine future 1,000-sat subscription from another app was ActiveRecurring on both native peers, with zero proofs, alongside an unrelated saved contact. Tapping Unlink pubky displayed “End active subscriptions before removing this profile,” with no Retry dialog or navigation away. From 06:17:28.137858 through 06:17:38.913338 UTC on October 29 (10.775481 seconds), the current-owner journal recorded zero authorized PUT/DELETE attempts. Its prefix/hash was unchanged, setup mutations confirmed the observer was active, the scope/control remained unchanged, and no receipt-loss warning was found. The local marker was absent at both checkpoints.
This assertion covers that payer/unlink attempt at the authorized mutation boundary, excluding GET/LOCK/auth and rejected authorization. It does not retroactively instrument earlier payer/Delete or payee attempts. Broad pre/post reads retained the profile, both exact contacts and public endpoint, while reservation/outbound hashes differed outside the exact interval; full-state equality is not claimed. The one subscription was then Canceled on both peers with zero proofs, and the app and its scoped automation services were stopped. Initial wallet-not-ready and incomplete-link setup attempts are preserved. No product, dependency, compiled fixture or commit changed. The finite report and hashed receipts are retained under
finite-j2-manual2-20261029.RC4 includes the native original-grant proof execution and disabled-capability empty-withdrawal fixes from #248 and #249. This app captures that live handle before cleanup and retains its full native/local tail. It never substitutes ordinary sign-out or reconstructs retry authority after restart. Disabled sharing still requires an acknowledged empty withdrawal without registering or re-enabling sharing.
Automated Checks
ProfileRemovalCoordinatorTests.swiftandPubkyProfileManagerTests.swiftwith five tests for signed-out Save feedback, startup restoration, identity replacement, and contact actions and confirmations during wallet wipe.PaykitContactLifecycleTests.swift,PubkyProfileManagerTests.swift— blocked-peer local cleanup, strict remaining-peer ACKs and wallet-only ledger discard after credential acknowledgement. Existing SDK/keychain/contact/sharing/backup coverage retains late callbacks, import generations, cold reads and disabled-sharing withdrawal; auth cancellation uses isolated gates.ProfileRecoveryUITestscover failed local cleanup, retained UUID/watermark, canceled confirmation and explicit completion; fourContactImportUITestscover outstanding saves and navigation after leaving.PrivatePaykitServiceTests.swift— covers a saved-contact lookup that completes after profile removal reserves admission. The lookup must fail before it updates the contact cache.Evidence
Current head
0f2b654. Add Contact, Edit Contact, and Contact Detail can use the session restored for their original wallet and profile. A signed-out Save shows the sign-in warning. Identity changes and wallet wipe invalidate pending actions. Ovi reproduced the silent Save on43c72a5. All five new session tests passed locally and in CI. Repeating the public-link Save taps on this head is still open. Contact fix and review.The iPhone 16 simulator passed 3,376 distinct tests (3,405 executions), with five skips and zero failures or retries. Seven runtime advisories also appeared on the parent or previous head. SwiftFormat reported zero violations across 867 files locally and in CI. Translation validation had zero errors and the same 17 warnings as the parent. GitHub verified all 34 PR commits.
First hosted native run failed. Of 3,410 executions, 3,404 passed, five skipped and one failed. In
testExternalLnurlHandoffDoesNotTransferOwnershipToOpenSend, the open Send sheet had an unexpected generation after an external scan reset. The replacement then lost its LNURL confirmation route and amount. Those three assertions failed. The same test passed locally on this tree. Its cause remains open. All nine migration jobs skipped. The failed-withdrawal findings and manual J1/J2 gaps below remain open on RC4. Native CI · SwiftFormat · Migration.Previous head
43c72a5composes #1125 at814a837and keeps Paykit 0.1.1-rc4. A saved-contact lookup rechecks profile admission before caching its result. Profile removal logs the failed stage and error category without logging wallet or peer data. The SDK queue distinguishes completion, deferral and failure.The full local Debug suite on iPhone 16 / iOS 26.2 (Xcode 27.2) passed 3,371 distinct tests, with five fixture skips and zero failures or retries. Counting all 30 declared launch configurations, 3,400 executions passed. The first build exposed an RC2 test fixture missing RC4’s required acknowledgement field. The corrected fixture, late contact lookup, Jade test and all launch configurations passed on the published tree. SwiftFormat passed 0/866. Translation validation found zero errors and the same 17 warnings as the parent. The local audit retained five QoS findings, one SwiftUI state finding and two AppIntents advisories.
The failed-withdrawal reproduction remains open on released RC4. J2 action 15 hit the native policy error on two identities. J2 action 16 and J1 actions 10 and 12 still lack end-to-end evidence. The SDK change is awaiting release and app integration. Native reproduction · Paykit #267.
GitHub verified all 33 commits against the parent. On the first hosted run, 3,400 executions passed, five skipped and zero failed. XCTest retried the Restore tap twice inside one passing test. It reran zero test cases. The 286 duplicate-symbol warnings matched the parent, and a system-gesture timeout was recorded during a passing USDT receive test. Hosted SwiftFormat passed 0/866 with 120 exclusions. All nine migration jobs skipped. Native CI · Formatting · Migration.
Runtime
f9890dc, docs065a4c6(Nov 5): f989: 261/261 focused; local/CI 3,363 pass, 5 skip; 0 failures. Local: 0 retries, 8 advisories; CI: 2 tap retries. Format 0/866 (118 excluded). All 31 commits signed/verified. Docs XML/diff pass; runtime unchanged. CI on065a4c6: 3,359 pass, 4 fail, 5 skip (inherited Jade test race; 3 launch timeouts); audit complete; launch cause unresolved. Lint HTTP504, then pass after one owner retry without the required grant. External QA confirms native policy_error after failed withdrawal (2 identities); no-fault controls reached onboarding. João retracts “any contact.” Shared/UncertainClear unknown; J2 16 unrun.At
fc4b5b7(Nov 4), #11256306930added wipe/startup recovery: expired cleanup checks ownership; native tails retain admission. Focused 5/5. Local Xcode27.2/iOS26.2: 3,361 pass/5 skip, 1 tap retry, 7 advisories. CI: same counts, 2 tap retries; no test reruns. Format 0/866 (118 excluded); 17 verified parent translation warnings, 286 prior symbols/extractor limit, migration skipped. Reviews clean; 29 signatures locally/GitHub valid. Compile errors fixed; Xcode26.1 bootstrap timeout unexplained. No new native acceptance; prior gaps remain.At
21d5ee2(parent5f5fcf5), local and hosted gates each passed 3,190 tests with five skips; hosted had two Restore tap retries, no test reruns. All 27 commits verified. The earlier40be059full-local clipboard failure (missing NSItemProvider staging file) remains unexplained despite focused/CI passes; the parent QR-copy failure is separate. Native author evidence stays at630be75. Prior reports:ios1143-parent5f5f-20261103,hosted-ios1143-21d5-20261103, and earlier revision-specific evidence.2c6cbdbf(base53b67b07) adopted Paykit0.1.1-rc3with exact private-withdrawal acknowledgements. Released ZIP/all seven cached XCFramework files match; other pins unchanged. Fifteen receipt scenarios/all three removal actions reject missing, conflicting or partial acknowledgements; an empty queue is not success.At
6e5dccb6, the local Xcode 26.1 / iPhone 16 / iOS 26.2 unit selection passed 3,012 tests with zero failures/skips and 1,685 unchanged inputs. All 22,286 log lines and nested results were inspected; three native DHT/Pubky QoS observations, two AppIntents notices and simulator appearance/haptic output are retained without assigning an introduction/timing cause. Earlier compile failures and the 173-pass/one-failure attempt remain preserved; the finite-expiry assertion then passed focused and full reruns.Hosted run 41004930635 at
6e5dccb6failed: 3,011 unit executions passed, one test failed two assertions, and one skipped; UI had 40 passes/four skips. All 6,018 log lines, 3,057 nested results and 19 annotations were inspected; the artifact digest and 7,383 original entries/CRC match. The 571 raw linker diagnostics/286 symbols match audited08f29903. Five skips require owned USDT/Trezor fixtures. Modern counts collapse 30 launch executions to one; the formatted log omits 31 individual pass lines confirmed by XCResult.The failed auth-cancellation test shared one admission gate across independent cases; a cancelled publication can retain a revoked scope after its caller returns and reject the next case. Independent review supports isolating each case's gate, with no product, timeout or assertion change. Original and repaired tests each passed 50 local repetitions; the exact CI scheduling interleaving is inferred. The repaired full local selection passed 3,012/0 failures/0 skips with 1,685 unchanged inputs after one preserved simulator Busy launch failure. SwiftFormat: 0/834 (102 excluded). Unchanged translations retain zero errors/15 warnings verified against
53b67b07; XML/diff checks pass. All 17 PR commits verify locally and on GitHub. Unit/UI run 41010521651 passed 3,012 unit and 40 UI executions, with five fixture skips. Complete logs/results and artifact digest/CRC audited; hosted lint passed. Evidence:work/validation/ios1143-rc3-integration-20261030/andios1143-cancellation-isolation-20261030/.Local gates exclude the workflow's four integration classes and the separately owned USDT fixture; no standalone UI or authenticated journey ran on the wallet-bearing unit simulator. J1/J2/J3, Manual 1/2 and the SDK execution limitation remain bounded below. Migration was skipped.
At earlier head
08f29903, artifact12879768977matched GitHub's SHA-256 and all 6,133 entries passed CRC; mergedbfd6ceahad the head's tree. Complete root/build/per-test/diagnostic inspection reconciled 2,993 executions: 2,988 passed/five skipped. Modern results collapse 30 successful launch executions to one. The 286 distinct build warnings matched saved logs; runtime/per-test issue containers were empty, while raw logs retained six UI USDT refresh failures and a missing graph-cache warning. Original files stayed unchanged; the reader added onlydatabase.sqlite3. This was not complete USDT acceptance; Manual 2 remained incomplete. Evidence:work/validation/ios1143-subscription-composition-20261029/hosted-08f29903-xcresult/.Hosted CI for
08f29903has now completed: unit/UI run 40832068350 passed 2,948 unit tests and 40 UI tests, with zero failures and five explicit fixture-dependent skips. Lint run 40832068393 reported 0/831 files requiring formatting (95 excluded). The full test log's 571 duplicate-symbol warning lines (SQLite, Rust/secp256k1 and BLAKE3) match the prior successfulacc028f4run after removing only CI prefixes/timestamps; all ten exposed warning annotations and all five skips also match. No new diagnostic was found in that comparison. Migration was skipped. This hosted result does not complete the reservation fault/cold-restart acceptance or the remaining Manual 2 watermark cases. Full logs, annotations and comparisons are preserved inwork/validation/ios1143-subscription-composition-20261029/hosted-08f29903/.Earlier head
08f29903adds the opt-inreservations:acknowledgefault at the real reservation-state validation boundary. It retains the existing DEBUG, simulator, E2E/regtest, explicit launch-flag, sticky-file, and non-unit guards. A refusal occurs after required credential erasure and private-cache cleanup, before final marker completion; this does not assert that watermarks survive that later boundary. A fresh independent review found no actionable issue.The frozen candidate that became
08f29903passed local validation on 2026-10-29: SwiftFormat 0.64.0 0/831 (95 excluded), E2E simulator build, and 2,948 unit tests with zero failures and one owned-USDT-fixture skip. Structured diagnostics had no compiler errors, warnings or test failures; raw Apple AppIntents notices remain. Tracked inputs matched before/after. All 11 PR commits againstef1286d8verified locally and on GitHub (verification.verified=true,reason=valid).The new reservation refusal/cold-restart/retry acceptance was not executed. Creating a disposable iOS26.2 simulator stalled; an unused-looking shutdown simulator also did not boot and its ownership was not established, so no app was installed there or further retry made. The already-booted, task-owned unit destination contains a production wallet seed and was preserved. Only this task's stalled create/boot subprocesses were terminated; no shared-service restart, wallet reset, ledger/cache injection, mining or payment was performed. Manual 2 remains unchecked. Source review confirms a genuine consumed-list watermark needs the incoming-request payment path; the earlier ordinary contact payment retained a nil watermark. Existing chain transactions and the retained Ring wallet were untouched. Evidence:
work/validation/ios1143-subscription-composition-20261029/runtime-acceptance/manual2-feasibility-20261029/.Earlier candidate
acc028f4added a test-only correction to the composed subscription safeguard. The Create Profile admission test now waits for the screen's actual original-owner capture before releasing its controlled transition or replacing the owner; a keyboard notification is no longer used as a readiness signal. The original two-second resumption deadline and replacement-owner assertions remain. The hosted test view disables user interaction; no claim is made that this alone proves programmatic keyboard suppression. An independent affected-delta review found no actionable issue. Production code and dependency pins are unchanged from95195079.On tree
3959dd8d, all 2,948 selected unit tests passed with zero skips or retries. The 12 focused profile-screen tests passed. SwiftFormat reported 0/831 and translation validation matched the parent’s 15 warnings.All ten commits against
ef1286d8verified locally and on GitHub. Hosted merge67b19194matched the tested tree. CI passed 2,948 unit tests and 40 UI executions with five fixture skips. Native run.This run retained 286 linker warnings matching parent
ef1286d8. XCTest retried the fixture Restore tap twice and the test executed once. The five skips required a recovery fixture, local Arbitrum or disabled Trezor cases.SwiftFormat passed 0/831 with one runner-image notice. All nine migration jobs skipped. João’s SDK-backed Disconnect retry remains open. Formatting · Migration · Discussion.
The initial bounded subscription attempt used the audited local E2E build from
95195079on an unfunded iPhone 16 / iOS 26.2 simulator. The normal server settings savedtcp://127.0.0.1:18682; the local app was reinstalled preserving the wallet. Profile creation, app-approved companion authorization, peer linking, and proposal delivery succeeded, but acceptance initially failed and that proposal expired. The October 29 continuation found a fixture helper mismatch: it advertisedoutgoing_payments=false, while the native SDK requires that capability to claim and accept a request. The old generic error did not retain its exact native cause. The fixture-only correction retained an unsupported payment adapter and passed formatting, Clippy with warnings denied, and build checks. A capability preflight succeeded before one new proposal was sent on the same peer; the expired intent and evidence were preserved.The corrected native companion and peer both confirmed ActiveRecurring for a future 1,000-sat subscription (November 5–6, 2026), with zero payment proofs and no payment execution. At 04:20:19 UTC, Delete Profile displayed “End active subscriptions before removing this profile.” After native
remove_contactremoved only the ContactRecord, the same refusal appeared at 04:21:01 UTC. Native post-reads confirmed the global active request survived with the contact absent. The source-defined localprofile-removal-v1.jsonwas absent at the before/after checkpoints; this is point-in-time evidence, not a continuous filesystem trace. The remote fixture journal still targets the old principal, so no zero-server-mutation assertion is claimed for this identity. All 105 installed app files matched the audited bundle;acc028f4changes only one unit-test file from the built revision. No app source, network configuration, wallet funding, reset, or payment execution changed. Five startup/RGS diagnostics are retained, so runtime is not described as warning-free. The active orphan and expired-proposal evidence were frozen before the intentional transitions below.A further bounded continuation exercised Delete Profile with an unrelated linked contact in each subscription role. The payer refusal at 04:48:02 UTC and payee refusal at 04:48:39 UTC left the selected native before/after snapshots identical, including the unrelated contact and public profile revision. The reverse proposal was genuinely accepted as ActiveRecurring by the same owned peer, with a future November 5–6 schedule and zero payment proofs. Fixture helpers retained unsupported payment adapters; no payment was executed. The original payer subscription and new payee subscription were canceled through the native SDK, and the peer confirmed both terminal states. At 04:49:49 UTC, a fresh “Yes, Delete” confirmation completed and displayed profile onboarding. Independent peer GETs found both Paykit and legacy profiles absent. The GET-only encrypted-state observer found zero contacts, the managed Bitkit endpoint Removed, private payments disabled, all 11 outbound messages Sent, and no recovery markers. The local removal journal was absent and the wallet directory remained. This establishes the recorded cleanup outcome, without a server mutation-attempt count or complete keychain/reservation audit. At that checkpoint Ring was not installed; the later genuine Ring unlink result is recorded separately below. All 105 installed files still match the audited app; helper formatting, Clippy with warnings denied, and builds passed after one retained helper compile error was fixed. Five startup/RGS errors remain in the captured runtime log. The app and its scoped Argent services are stopped; the completed fixture and historical evidence are preserved. No app source, commit, funding, wallet reset or hosted-CI audit changed.
The separate
unlink-ring-pubky.xmljourney passed all 10 authored actions on October 29 using genuine Pubky Ring frompubky/pubky-ring@218922f7, built in Release for the owned simulator with only local-fixture startup/default-homeserver configuration. Ring generated and signed up a fresh disposable identity through its real UI/deep-link flow on the owned native backend. Bitkit discovered it through the shared keychain, adopted it through the Ring row and saved a profile. The UI displayed UNLINK, the link icon, “Unlink pubky” and the exact authored confirmation. “Yes, Unlink” at 05:21:03 UTC returned to profile onboarding with the same Ring identity still offered. Reopened Ring listed it, and its detail screen showed the identical full public key. Independent native peer GETs retained the exact Ring-owned Paykit profile revision; legacy profile was absent both before and after. This is the intended unlink behavior (testUnlinkDoesNotDeleteRingOwnedProfile): Bitkit disconnected its credentials while Ring's identity and public profile remained. The app logged session deletion and sign-out, the local recovery journal was absent afterward, and the wallet directory remained. This does not claim deletion of Ring-owned public data, a complete keychain/reservation audit, or a server mutation-attempt count. All 105 installed Bitkit files and 165 installed Ring files matched their recorded builds. Ring's build succeeded with zero errors and 474 structured warnings retained; its fixture edits passed type checking, lint and formatting, while eight lint warnings and five formatting files were verified byte-identical to its source baseline. The captured Bitkit runtime retains five startup/RGS errors plus a contact-load “changed” error and shared-contact-refresh warning during adoption; no warning-free runtime claim is made. Both test apps and only the owned simulator's Argent services were stopped, preserving the fixture and identity. No Bitkit source, commit, funds, wallet reset or hosted-CI result changed.Prior signed head
95195079composes the subscription safeguard from parent #1125 atef1286d8into the original owner/attempt coordinator. Raw payer/payee subscriptions from every app, including records without saved contacts, are checked during preparation, confirmation and the admitted cleanup. The strict contact batch and revision-bound final profile deletion recheck the same policy. A late refusal preserves the original recovery UUID and retained safety state; it adds no remote retry. Two independent affected-delta reviews found no introduced actionable issue.Local validation on the frozen composed tree
824c2cc5, using Xcode 26.1 (17B55) and the owned iPhone 16 / iOS 26.2 simulator, passed 2,948 unit tests and 40 UI invocations, with zero failures and three explicitly disabled Trezor skips. The focused 76-test gate also passed. All 1,660 source inputs remained unchanged. Full SwiftFormat 0.64.0 reports 0/831 files requiring formatting, 95 configured skips. Translation validation reports zero errors and 15 warnings verified individually against the exact parent. The current changes add three inherited SDK test functions and extend existing coordinator coverage to unlink, without a new child test framework.The sole complete diagnostic audit reconciled all 2,991 invocation records, all referenced results and 64 attachments. Five QoS warnings and 24 CoreGraphics NaN notices remain with unresolved cause; no introduced actionable diagnostic blocker was verified. All six dependency pins match; their status/diff checks are clean using read-only object mirrors, with prior incomplete object-store errors retained. This is not a claim that every dependency archive is available.
At
95195079, all nine commits againstef1286d8verified locally/GitHub (verified=true,reason=valid); published tree824c2cc5equals the tested tree. Native CI 40808362510, attempt 1, failed the Create Profile admission resumption timeout; the later key-load assertion passed and replacement-owner execution continued. A keyboard-queue timeout and invalid session message overlap the 9.734-second test without proving causation. Lint 40808362568 passed 0/831, 95 skipped, with a runner-image notice; complete logs/annotations were inspected. All nine migration jobs skipped. No runtime pass for added J2 expectations; readiness uses Ben's exception.Prior composed head
86fb0c5cincorporates published parent #1125 at3f887088, including LDK 0.7.0-rc.78. The backup cache API integration preserves profile admission and generation validation; the recovery fixture retains the actual local cleanup path alongside the parent's diagnostic trace. Two independent affected-delta source reviews found no actionable blocker, with unchanged prior source coverage retained by exact file/region identity.Local tree
ce248fd2passed 2,945 unit tests and 40 UI executions with three Trezor skips. The diagnostic audit retained five native QoS warnings of unresolved cause and two AppIntents notices. SwiftFormat passed 0/831. Translation validation matched all 15 parent warnings.At
86fb0c5c, all eight commits against3f887088verified locally and on GitHub. CI passed 2,945 unit tests and 40 UI executions with five fixture skips. Its 286 duplicate symbols matched1f1bead0. Lint passed 0/831 and migration skipped. Native run · Formatting · Migration.The installed receipt acceptance below remains tied to
f1fa5eeaand its earlier dependency pin; it is not new composed-binary acceptance. The four configured live/destructive suite exclusions and the native-USDT/local-Arbitrum fixture exclusions are not passes. Both literal J1 runtime gaps and João's open SDK-backed Disconnect-retry discussion remain unchanged. The PR remains ready under Ben's explicit testing exception.At prior head
1f1bead0, the repair closes the receipt and restore wipe-admission gaps reported onf1fa5eea. An isolated Core test reproduced the rejected receipt writing after cleanup. A rejected wallet lease now stops the entire operation. Public payment enumeration, queued processing and transaction-detail writes register before starting; wipe waits for their actual awaited tails. Pending VSS, legacy and USDT restore entries use the same wallet lifetime, and foreground/reconnect retry paths stop while startup is blocked. A restore invalidated by wipe cannot report successful completion. Profile-only cleanup retains the earlier receipt-attribution behavior.Four regression tests were added and one admission test updated. They cover reserved, persisted and unreadable wipe state; pending-restore keychain read failure; suspended enumeration; cancelled and uncancelled restore tails; and fresh replacement operations after cleanup. Both independent source-review perspectives are clean on the exact frozen candidate. This protects the public processing entries and their awaited work; it does not establish a global epoch guard for native event tasks before those calls.
At
1f1bead0, the local gate passed 3,105 unit tests and 40 UI executions, with three Trezor skips. All 31 focused tests passed. The diagnostic audit retained five native QoS warnings of unresolved cause and two AppIntents notices. SwiftFormat passed 0/862.All seven commits against
6c0a1607verified locally and on GitHub. The hosted merge and local validation used treec4c8025f.On attempt one, CI passed 3,105 unit tests and 40 UI executions, including all four added tests, with five fixture skips. Its 286 duplicate symbols matched
f1fa5eea. Lint passed 0/862 and migration skipped. Native run · Formatting · Migration.The prior simulator receipt acceptance remains evidence for
f1fa5eea, not a new-head device acceptance run. The incomplete J1 reconnect/replacement cases, broader fault matrices and João's marker-order discussion remain recorded below. The earlier review and test passes did not expose the wipe-admission gaps now corrected.João's revised same-UUID Disconnect retry remains under discussion. The bounded source assessment distinguishes existing SDK retry safeguards from the missing read-only original-grant and outgoing-withdrawal observations needed before fresh confirmation. A focused SDK-backed follow-up was proposed, not agreed. The thread and changes request remain open. This assessment changed no source, fixture or acceptance result.
Prior head
f1fa5eeafixes contact attribution for receipts first indexed during pending profile removal. Controlled before-tests reproduced the lost invoice/address associations; a review-found mixed-snapshot case also failed before its correction. The final candidate keeps live profile admission blocked, permits bounded historical receipt lookup, validates again inside each Core write and drains receipt work before wallet destruction. Both independent final source review perspectives are clean on the exact frozen candidate.Final local validation on Xcode 26.1 (17B55), iPhone 16 / iOS 26.2 (23C54) passed 3,101 selected unit tests and 40 UI invocations, zero failures, with three explicit Trezor skips. All 3,144 invocation summaries were exported for diagnostic inspection; source hashes were unchanged through the run. The 35 focused receipt/admission tests also passed. SwiftFormat 0.64.0 reports 0/862 files requiring formatting, 94 skipped; its cache-write warning is retained. Diff checks pass. The same live-service/native-USDT/local-Arbitrum exclusions listed below remain excluded, not passed. The complete 30,890-line raw log and every nested invocation were audited: no failure summaries or compiler errors. Seven QoS warnings remain (three unit traces through unchanged native SDK constructors and four UI traces without symbols). Their prior occurrence or timing cause is not established; they are not claimed pre-existing. Expected failure-path runtime diagnostics also remain.
Manual 3 passed on the
f1fa5eeaDebug/E2E candidate built at 08:23–08:26 UTC on 2026-10-28 and installed without clearing the preserved wallet. A real native peer received the app’sbitkitlist version 7 for its native-SegWit reservation at index 8. Normal Delete Profile hit the existingpaykit_session:deletefault, retaining the durable profile UUID and mappings. One fresh 5,000-sat regtest incoming transaction was indexed with the exact peer contact key while pending. Explicit local Disconnect, cold restart and normal resync preserved the same activity ID, amount and stored contact key; after restart the marker, current assignments and assignment history were absent, with reserved indexes preserved. The immediate external plist sample still contained old mappings after marker removal; the app acknowledges cleanup through UserDefaults synchronization/readback, and this observation does not prove immediate physical persistence or power-loss atomicity. The UI showed the received 5,000 sats and no active profile. The historical key was verified in Core SQLite; the deleted live contact name is not displayed. The transaction is unconfirmed; no blocks were mined and no prior wallet operation was retried. Helper export was fixture-only, exact-identity/list/network restricted, mode 0600, and passed its selection/network controls, format and strict lint.All six PR commits are cryptographically verified locally and GitHub reports
verified=true,reason=validfor the identical commit set against base6c0a1607. Synthetic merge8ebc0ac2has exactly the published head’s tree.At
f1fa5eea, native CI passed on attempt 1: 3,101 unit and 40 UI invocations passed, five explicit fixture-dependent skips, zero failures. All 3,146 invocation summaries, the complete structured issue report, the job log and all eight exported diagnostic logs were audited. The 575-object issue report is byte-identical to the prior91379a54report: 572 duplicate-symbol message representations plus three containers, covering the same 286 symbols. No introduced actionable diagnostic issue was verified. The setup probe’s broken-pipe exception and simulator/network diagnostics remain recorded; this is not a claim that all logs are clean. No hosted QoS warnings were reported, without changing the local warning limitations above. The five skips are the native-USDT fixture case, three Trezor UI cases and the local-Arbitrum UI case; configured live-service exclusions are also not passes.At
f1fa5eea, formatting CI passed with 0/862 files requiring formatting, 94 skipped. Its complete log and runner-image migration notice were inspected. The migration workflow was skipped by its release-branch condition. Prior hosted results below remain attributed to91379a54.João’s proposed delayed marker is intentionally not applied: the durable obligation precedes the first possible remote mutation, and uncertain attempts remain local-only recovery. See the contract disposition. The review thread and changes-requested state remain visible.
Prior head
91379a54adds only two test synchronization fixes to60b1485b: the sharing test waits for actual ownership after admission, and the profile test lets keyboard setup finish before its unchanged two-second admission assertion. Missing keyboard presentation is a bounded, non-failing setup outcome; the admission and replacement-owner assertions still execute. No production behavior or test cases were added or removed. A delayed-entry control failed the old sharing barrier and passed the repair; an unavailable-notification control timed out twice and still passed the original profile assertions. One independent combined review and its bounded correction recheck are clean.The final local gate on this exact patch passed 3,093 selected unit tests, zero failures and zero skips; all 1,699 source inputs were unchanged. Both keyboard setup waits completed. The full 23,225-line log and all 3,093 nested results were inspected: two AppIntents notices, three QoS warnings and runtime/service diagnostics remain, with no verified introduced actionable finding. SwiftFormat 0.64.0 reports 0/861 requiring changes, 94 skipped; diff checks pass. The workflow's six live-service exclusions and the separately owned native-USDT fixture case were excluded, not passed. A preceding run passed the other 3,093 tests but failed that opt-in case after discovering an active fixture. Its failure is preserved: it changed fixture controls/tip; the owner's read-only comparison found stored ciphertext and the original signed operation unchanged, with no new backup acknowledgement or broadcast event. This case remains unvalidated here.
All five PR commits against
6c0a1607are locally signature-verified and GitHub reportsverified=true,reason=validfor the identical commit set. Synthetic merge66261d34has exactly the locally tested tree2a1cd5f2. New-head native CI passed on attempt 1: 3,093 unit successes and 40 UI invocation successes, with one unit and four UI skips, zero failures (3,138 total invocations). The complete job log, artifact, every nested test result, structured issues and all annotations were inspected. Its 572 duplicate-symbol issue representations exactly match the preceding run; they are not new diagnostics. Formatting CI passed 0/861 files, with 94 skipped; the complete log and annotation were inspected. The migration workflow is skipped by its release-branch condition.The original ready-triggered native run, attempt 1 on
60b1485b, failed two unit tests: sharing ownership ordering and profile admission timing. It passed 3,091 other unit tests and 40 UI invocations, with five explicit fixture/Trezor skips. Complete job logs, annotations, artifact digest and every nested test result were audited. A keyboard-queue timeout overlapped the profile waiter; that observation does not establish historical causation. The two-test repair above preserves all original behavior deadlines and ownership assertions. The old run was not retried unchanged.Prior published head
60b1485bcomposes parent6c0a1607; its treebf36b7d2is exactly the frozen candidate tested below. One combined final run on Xcode 26.1 (17B55), iPhone 16/iOS 26.2 (23C54), Debug, two jobs and nonparallel testing passed 3,093 unit tests plus 40 UI invocations (11 logical UI cases), with zero failures and three explicit Trezor-emulator skips. The 30 launch invocations account for the repeated UI count. All 1,699 captured source inputs matched afterward. Both Profile Recovery and all four Contact Import cases passed; all 46 mapped Manual 1 identifiers passed in the same run. The workflow’s six live-service unit exclusions, opt-in native-USDT recovery class and local-Arbitrum payment UI case were excluded, not counted as passes.At
60b1485b, the audit covered all 27,329 raw lines, 3,136 nested invocations, action diagnostics and eight exported logs. No actionable introduced finding was verified. Two AppIntents notices, five nested QoS warnings (six raw), and offline/rate-limit/simulator diagnostics remain; external availability and timing attribution are unproven. SwiftFormat 0.64.0: 0/861, 94 skipped, retained cache-write warning. Translations: zero errors/15 warnings equal to parent6c0a1607; diff and both profile XML journeys pass. Independent source and affected-parent reviews are clean. All four parent-relative commits verify locally/GitHub (verified=true,reason=valid), with exact commit-set equality.Before readiness, hosted SwiftFormat passed 0/861 (94 skipped). Its complete log and annotation were inspected; the sole annotation is GitHub’s Ubuntu runner-image migration notice. The checked synthetic merge
ab5479d7has exactly treebf36b7d2, matching this head and local validation. The pre-readiness hosted unit/E2E jobs were skipped while the PR was draft, and are not passes. The ready-triggered result is recorded above.Authenticated simulator evidence was collected on the Debug/E2E build made 2026-10-27 17:35:48–17:40:25 UTC: then-HEAD
2ca19ed3, pending parent8592e58b, and the reviewed local recovery fixes now preserved by signed commit0eef45d1. All 1,692 captured build inputs matched before/after. The final parent composition changes 33 paths, plus the later J2 description clarification; this installed evidence remains attributed to that candidate. Core removal/coordinator, credential, contact and package inputs are unchanged. The source recheck preserves that bounded component evidence. Previous foreground/reconnect and later WALLET-backup resumption observations remain historical for this composition because AppScene and BackupService changed; final Home/USDT presentation also needs its own validation.J1 observed a real consumed-list version 1 established by an incoming-request payment, profile DELETE committed with a lost acknowledgement, no automatic deletion replay, separately confirmed local completion, cancellation, genuine remote profile/contact edits after uncertainty, and recovery across cold launches after credential removal without an active identity. Ordinary Receive remained available. Separate recreated profile cycles exercised session/root deletion, shared enumeration/deletion, preferences and final-marker failures on the preserved wallet/principal. These separate cycles do not all contain the original consumed-list watermark or a nonempty override.
J2 observed private withdrawal pending, uncertain native contact removal with a retained nonempty override, partial removal retaining the exact actively leased peer, pre-storage profile refusal, and DELETE returning HTTP 404 while fresh GET still returned the unchanged profile. The exact reviewed fault configuration and rejection journal prove the 404 refusal before storage. Credentials and the pending UUID survived Cancel/cold; explicit local completion cleared local tracked credentials/marker/identity while retaining the seed and remote profile. The native SDK sanitizes the error to HTTP 404; no direct client response/error-text capture established a literal
404 Not Foundmessage across FFI. The J2 description now states the actual HTTP status boundary; its action IDs are unchanged.Ordinary authenticated Disconnect was tested separately with a valid uniquely identified grant and genuinely absent remote profile: its own grant/session was revoked, tracked local credentials cleared, unrelated grants and the linked contact retained. Local Disconnect after uncertainty instead completed the durable local obligation; it does not claim fresh remote deletion or authenticated revocation.
Manual 1 has 46 passing tests mapped across 14 native scheduler boundary groups on the prior 3,071-test candidate. Production orchestration holds admission/SDK/publication/rollback/local-install/UI-completion races with injected continuations or noPointer SDK subclasses. Three other tests exercise real Rust-to-Swift provider callbacks for typed failure/retry. Those layers do not demonstrate an installed native callback held during wipe/replacement, or every concrete coordinator-stage-by-successor permutation. All 46 exact mapped test identifiers also executed and passed in the final composed-candidate run; this remains scheduler/component evidence, not the unexecuted installed J1 action 12.
Manual 2 has actual fault/cold-recovery observations. A fresh shared-own deletion follow-up independently retained the same pending UUID through cold launch, then cleared credentials/marker only after explicit local completion; identity was absent after 30.0662 seconds and a second cold launch, with seed/reserved indexes retained and own PUT/DELETE counters quiet. Its immediate preference sample still contained the old identity. The audit rejected an accidentally selected old log excerpt and corroborated the new run's timestamped fault directly. Earlier samples retain their historical limitations.
Independent source and credential/persistence risk reviews covered the recovery change; affected-parent rechecks reused unchanged coverage. Reports retain input/configuration hashes, fixture identities, warnings, failures and limits.
Earlier published-candidate evidence (retained at its original revision)
At
2ca19ed3(changelog rename only; product8b6cd5de, base3a7210b0), both PR commits were locally and GitHub signature-verified. Xcode 26.1 (17B54), iOS 26.5: 3,004 selected native tests passed, zero failures/skips; 1,681 inputs unchanged. Complete 20,974-line log and nested XCResults were inspected. Two AppIntents notices, one physical-device enumeration assertion and three native SDK/DHT QoS observations remained without established timing cause. The separate USDT fixture and six integration exclusions were not passes. SwiftFormat: 0/856; translations: 0 errors, 15 warnings verified against that base.Both Profile Recovery and four Contact Import fixture tests passed; logs, nested results and three rendered recovery views were inspected. Controls retained recovery UUID, credentials and consumed watermark, rejected restoration while pending, preserved cancellation, required explicit local acknowledgement, and counted no remote cleanup. They did not prove authenticated network behavior. Independent reviews 7 and 8 were clean; prior failed/interrupted attempts remained preserved.
Formatting run 40457650414 passed 0/856 after full 193-line inspection. Native/E2E jobs were skipped while draft. Setup then used Paykit 0.1.0 and VSS 0.5.23. J1/J2 and authenticated faults remained unrun after two Docker API failures; no Docker reset occurred. Mock/hosted-view checks did not prove deployed homeserver behavior, a physical-phone journey or wallet reset. Native contact CAS and safe remote observation remained limitations. These historical results do not validate later revisions.
Gaps
Receipt attribution intentionally fails closed for request-only mappings with an empty reservation ledger during profile removal, and never reconstructs erased mappings. The actual receipt acceptance covers on-chain regtest only; Lightning receipt behavior is covered by Core-backed component tests. It does not establish wallet-wipe or replacement-identity callback acceptance.
J1 action 10 asks for test-device network reconnect. Owned endpoint outage/restart plus foregrounding establishes service recovery, but does not prove the distinct application network-change trigger. The available command interfaces expose no documented isolated connectivity control; a bounded inspection on the owned iOS26.2 simulator found no Wi-Fi/Airplane controls at the top of Settings and no search results for either term. No toggle was attempted or claimed to fail, and no network settings were changed. A verified isolated connectivity operation with an observed app reconnect event is still needed. J1 action 12 has a reviewed fixture-only gate in a separate source archive and disposable simulator built from head
60b1485b. The first attempt reached the old completion after real unlink, but iOS rejected the unregisteredpubkyauthURL scheme (-10814), and a subsequent platform safety flag stopped that run. On 2026-10-28, the installed executable, debug library and Info.plist still matched the reviewed build. The old hold deadline had passed. A device-scoped transport restart restored touch input, and a fresh normal-UI adoption/unlink cycle reached a new held completion with admission available, no identity or pending removal, and the task still active. The supported Scan/Paste route opened, but two simulator clipboard copies reported success while clipboard readback was empty; the app displayed Clipboard Empty. No signup approval or replacement B was reached. The new hold was explicitly released to end the incomplete cycle; same-token before/after-guard receipts show no authenticated identity, so this is not evidence of replacement protection. Production source and installed instrumentation were unchanged. Scheduler-seam coverage for Manual 1 remains separate.Only the original sequential J1 fault sequence contains consumed version 1; fresh local checkpoints do not all include that watermark or a nonempty contact override. Preference-flush refusal occurs after preference writes and does not prove asynchronous disk writes were prevented. Samples are non-atomic, indexed credential counts are not a complete keychain audit, and navigation is not proved to await physical preference persistence. Receive checks do not include a separate decoded-QR/nonreuse proof. Own counters cover authorized PUT/DELETE, not all GET/LOCK/auth traffic.
A portable shared authenticated fixture recipe, real wallet wipe, physical-phone behavior and native contact CAS are not established. Parent/old-candidate runs are not counted as final-head runtime passes. The two literal J1 app-acceptance cases remain incomplete; component checks do not substitute for them. Ben explicitly authorized marking this PR ready for review on 2026-10-28 even if these checks cannot be completed. The remaining verification is retained for reviewers: action 10 needs an authorized disposable device or verified simulator-local control that causes an actual reconnect event while preserving the app and unrelated fixtures; action 12 needs working input delivery, genuine activation of B after admission permits it, and delivery of a newly held old callback with B's screen unchanged. The isolated fixture and evidence are preserved, with no callback left intentionally held from the follow-up. Native contact CAS and full-WALLET redesign remain out of scope.
The bounded native original-grant cases above passed on RC4. Same-grant bearer refresh, the full credential replacement/ABA and linked-peer sharing-disabled matrices, and the remaining J1 cases are still incomplete. External reviewer observations remain separately attributed; this continuation does not claim those executions. Cold recovery stays local-only.
Models used
gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh,medium),gpt-6.1-sol(reasoning:high),Unknown(reasoning:Unknown). Piotr self-reportsclaude-opus-5-5/high; independent runtime metadata was unavailable.