Repository navigation
fix: decouple wallet restore from profile refresh - #1121
ben-kaufman wants to merge 20 commits into
Conversation
|
Triage: 3.1.0, with its Android twin synonymdev/bitkit-android#1686. |
talosmachina
left a comment
There was a problem hiding this comment.
1 finding (1 P1). Moves profile initialization off the wallet-restore critical path and blocks an identity-free WALLET backup while saved credentials exist. Reviewed ec20b26, full tier. Reasoned from the code and CI only: this review did not build the branch or run the iOS suite.
What I checked, and 4 candidates I ruled out
Read in full: the diff; PubkyProfileManager initialization, recovery and resolveSessionInitialization; BackupService wallet payload, runBackup, runScheduledBackup, isTemporaryBackupFailure; PaykitSdkService.currentPublicKey, importSessionLocked, signInLocked
Call sites traced: scheduleInitializationAfterRestore (AppScene restore path), backupSnapshot (WALLET payload), hasStoredIdentityForBackup
CI: SwiftFormat green, Run Tests still pending at review time
Ruled out
- A fresh backup missed after activation:
importSessionLockedandsignInLockedboth callmarkWalletBackupDataChanged(), and the BackupService listener re-marks WALLET as required, so a delayed activation gets its fresh upload. - A stale restore task reviving an old identity: both the scheduled task and
beginInitializationre-checksessionRevisionbefore and after awaiting, andinitializeSessionStatedrops a result from an older revision. - A second restore scheduled for the same revision: the
restoreInitialization?.revisionguard makes it a no-op, andbeginInitializationhands back the in-flight task. - Malformed UTF-8 credentials let through:
hasStoredIdentityForBackupreads rawKeychain.loadbytes, unlikehasStoredIdentity'sloadString, so a non-UTF-8 entry still counts.
Merge confidence: 3/5, one P1.
Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed (the runner does not report a reasoning setting)
| ) async throws -> (state: String?, sdkVersion: UInt64) { | ||
| let initialSdkVersion = await sdkService.backupStateVersion | ||
| guard try await sdkService.currentPublicKey() != nil else { | ||
| guard try !hasStoredIdentity() else { throw PubkyServiceError.sessionNotActive } |
There was a problem hiding this comment.
P1: a session that cannot be restored blocks every WALLET backup
The guard is right for the short window before activation, but it fires the same way once resolveSessionInitialization returns .restorationFailed. That happens with a saved session and no reachable secret key (PubkyProfileManager.swift:2056-2061), or when re-sign-in fails with an error that is not temporary (:2074). initializeSessionState handles that case with clearAuthenticatedState(clearCachedProfile: false) and leaves the keychain alone (:394-399). The credentials stay, the SDK has no identity, and from then on every WALLET payload throws sessionNotActive here.
sessionNotActive is not in BackupService.isTemporaryBackupFailure. runScheduledBackup therefore ends with .failed and does not reschedule. Each later data change marks the backup required again and fails again. Until the user signs in again or removes the profile, the remote WALLET record stays at its last upload, including the parts unrelated to Paykit: transfers, watch-only accounts, reservation indices and payment-proof state. Before this change, the same state produced a backup with paykitSdkBackupState: nil.
The tests cover an identity that later activates (testUnresolvedStoredIdentityCannotReplaceCompleteWalletBackup), not one that never will. One fix: fail closed only while restoration is pending or deferred. Once it has definitely failed, carry the previous paykitSdkBackupState forward. Either way, add a test with .restorationFailed and retained credentials that checks a later wallet change still uploads.
There was a problem hiding this comment.
Confirmed: after terminal restoration failure this guard leaves complete WALLET backups pending, including later ordinary wallet changes. The Android twin has the same availability limitation. I am keeping this thread open while addressing it.
I cannot safely carry an arbitrary cached paykitSdkBackupState forward yet. The current WALLET payload includes consumed/private receive state, reservation ceilings, payment requests/proofs and active payment attempts; a stale SDK snapshot has no established consistency contract with those newer records. Returning nil is also unsafe: the restore path treats absent SDK state as clearing Paykit state. The guard prevents a complete backup from being replaced by such a payload.
The ongoing cross-platform follow-up is an independently recoverable ordinary-wallet record, with explicit restore selection, identity/version binding, migration and concurrent-writer rules while preserving complete WALLET. Older apps currently ignore additional records and do not enforce the envelope version, so the old-reader compatibility policy must be settled before publishing this split. The existing independent USDT recovery record is unaffected. I will not mark this finding resolved or claim full-wallet backup independence until the replacement path has controlled outage/restart/restore tests.
There was a problem hiding this comment.
At ec20b26 the terminal-restoration limitation still holds: saved credentials remain, and an absent SDK identity still makes the WALLET snapshot throw sessionNotActive. The controlled app test confirmed that a temporary unavailable identity preserves the remote WALLET bytes and pending work, then permits a complete backup after credentials recover. That recovery result does not resolve this terminal-failure availability issue.
There was a problem hiding this comment.
At 754a899 I drove a terminal/no-secret case through the real resolver, then reserved a new ordinary watch-only account index. WALLET upload failed, leaving the prior remote bytes/version unchanged and newer work pending; restoring the original credentials allowed upload. On this composition that probe failed with the SDK Identity error before the sessionNotActive guard. J1 separately confirmed sessionNotActive protection and recovery from a temporary unavailable identity. The terminal availability limitation remains open.
There was a problem hiding this comment.
Retested at 2d4fc71: the real no-secret resolver branch leaves a later ordinary account reservation pending while remote WALLET bytes/version remain unchanged. Capture raises the SDK Identity error before the sessionNotActive guard. Restoring the original credentials uploads and clears pending work; the terminal availability finding remains open.
There was a problem hiding this comment.
Retested at d83a217: the real no-secret resolver branch left a later ordinary account reservation pending while complete WALLET bytes/version stayed unchanged. Capture raised the SDK Identity error before sessionNotActive. Restoring the original credentials allowed upload and cleared pending work. The terminal availability finding remains open.
There was a problem hiding this comment.
Retested at a6f233d: the real no-secret resolver branch left a later ordinary account reservation pending while remote WALLET bytes/version stayed unchanged. Capture raised the SDK Identity error before sessionNotActive. Restoring the original credentials allowed upload and cleared pending work. The terminal availability finding remains open.
There was a problem hiding this comment.
Retested at 485a9dc: invalid saved-session credentials with no local secret reached the real no-secret resolver branch. A later ordinary account reservation left remote WALLET bytes/version unchanged and work pending; capture raised the SDK Identity error. Restoring the captured credentials allowed upload and cleared pending. The availability finding remains open. The UI failure flag sampled false, so that flag is not the resolver-outcome assertion.
There was a problem hiding this comment.
Retested at 111ddd8 with a non-nil WALLET record that decoded with SDK state. Invalid saved-session input without a local secret reached the real no-secret resolver branch. A later ordinary account reservation left that remote record byte-identical and WALLET pending; capture raised the SDK Identity error. Restoring the captured credentials allowed upload, and a fresh remote read contained both SDK state and the new reservation with pending work cleared. This confirms the existing availability failure with stronger remote-record assertions; it remains open.
There was a problem hiding this comment.
Retested at 1c3cde6 with an existing remote WALLET decoded with SDK state. Invalid saved-session input without a local secret reached the real terminal resolver; a later ordinary reservation left that complete record byte-identical and pending. Credential repair uploaded SDK state plus the reservation. I also used normal Profile → Disconnect in the terminal state: private endpoint cleanup raised the SDK Identity error before local sign-out, leaving the unusable credentials and pending backups. The proposed disconnect recovery route therefore failed for this input. The existing finding remains open.
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: no findings.
Checked and clean:
- No cross-identity leak on wipe and restore: the static
clearLocalStatebumps the revision, and the state tree is recreated. - Identity isn't lost for good: the retry paths cover a failed background activation. The only lasting block is the WALLET backup already raised in the open P1 thread.
- No payment path starts before the identity exists: Paykit starts only when
authState == .authenticated, so the header stays signed-out or loading until the background activation lands. - Twin synonymdev/bitkit-android#1686 shows the restored identity at once and fills profile and contacts in afterwards.
Performance-relevant (the purpose is a faster restore). wallet/restore-during-identity-outage.xml needs fault controls that aren't provisioned, so the gate uses a proxy. It restores a regtest wallet whose backup carries a pubky session, then:
- times Restore →
restoreWalletBackupAndStartreturn, head vs master, with the homeserver reachable and with it blocked; - checks that profile and contacts arrive afterwards;
- counts
Paykit session restored forper restore (expected 1); - runs Settings → Backup once the identity is active.
Device gate (partial): ec20b26 — not run yet; pending that proxy run. Device results will follow in a separate review.
There was a problem hiding this comment.
Verdict: 💬 Comment — the acknowledged backup availability issue remains open.
Findings: No new findings. I confirmed that the existing HIGH finding still applies: complete WALLET backups remain blocked after terminal identity restoration failure while credentials remain stored. The successful temporary-fault recovery below does not resolve that case.
QA for ec20b26: iPhone 17 Pro, iOS 26.5 Simulator, staging regtest. I built the unmodified head, ran its focused native suites, then drove J1 with local Debug fault controls over the real SDK and VSS paths.
Test 1: J1 — restore, backup protection, restart and stale profile ✅ All six actions passed with the fault controls described below.
- Required recovery reached a running wallet while final identity initialization remained held and authentication stayed idle. I left restore and copied a regtest receive address, confirmed from the simulator clipboard. Releasing initialization restored the identity and allowed fresh complete backups.
- With SDK session access removed and malformed local session bytes, the backup failed with
sessionNotActive. The remote WALLET bytes and version stayed unchanged, and backup work remained pending. Restoring the original credentials allowed a complete backup with SDK state and cleared pending status. These assertions were checked in the app logs. - Killing the app during held initialization preserved saved credentials. After restart and release, the saved contact remained a single entry; the complete logs contained one contact-add event.
- Disconnecting while a real profile response was held invalidated that response. On release, its normal guard reported
can apply: false, and the identity-choice screen remained visible. - A malformed retained WALLET payload kept required recovery pending, the node stopped and wallet actions unavailable. Repairing that original payload and retrying reached the wallet while optional initialization was still held. Node and authentication states were checked in the app logs.
| Required recovery and optional initialization | Backup protection |
|---|---|
J1-required-focus-2x.2x.mp4 |
J1-backup-2x.2x.mp4 |
| Required failure, retry and wallet access while identity work is held; focused excerpt, 2×. | The real backup rejects unavailable identity data, then succeeds after credential recovery; assertions checked in logs; 2×. |
| Restart with a saved contact | Late profile response |
|---|---|
J1-contact-restart-focus-2x.2x.mp4 |
J1-stale-profile-2x.2x.mp4 |
| One contact before and after process restart and identity recovery; focused excerpt, 2×. | Disconnect while the profile read is held, then release it and retain the signed-out UI; 2×. |
The test wallet had no funds. No funded-payment replay, USDT-specific restore failure, live homeserver blackout or head-versus-base timing comparison was driven. The native suites cover coalescing and queued identity replacement separately.
Check 1: focused native regressions ✅ The selected suites passed with no failures or skips.
I ran xcodebuild test with -only-testing:BitkitTests/PubkyProfileManagerTests and -only-testing:BitkitTests/PrivatePaykitServiceTests on the unmodified head and inspected the finalized XCResult. These cover held initialization, coalescing, stale completion, replacement credentials, malformed stored bytes and backup retry. The simulator build and git diff --check also passed.
Local test hooks and deciding log lines
Not pushed. Holds wait for explicit file removal. The backup control temporarily replaces only this disposable wallet's local credential input and restores its original bytes afterward; it uses the actual SDK, backup writer and VSS record. The required-recovery control corrupts a local retained payload, then restores its original captured bytes before retry. SDK results and recovery decisions are not mocked.
required recovery complete; scheduling final identity
final identity initialization held
wallet start returned: running, authenticated: idle
backup fault staged; SDK identity absent: true
Backup failed for: 'WALLET': sessionNotActive
blocked backup: bytes unchanged: true, version unchanged: true, pending: true
recovered backup: SDK state present: true, pending: false
required restore failed; pending: true, node: stopped
original retained WALLET repaired before retry
profile response released; can apply: false
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index c39b57cf..8956b76f 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -1169,8 +1169,14 @@ struct AppScene: View {
widgets.loadSavedWidgets()
widgets.objectWillChange.send()
+ #if DEBUG // test hook
+ Logger.info("QA1121 required recovery complete; scheduling final identity", context: "TestHook") // test hook
+ #endif // test hook
pubkyProfile.scheduleInitializationAfterRestore()
await startWallet(completingBackupRestore: true)
+ #if DEBUG // test hook
+ Logger.info("QA1121 wallet start returned: \(wallet.nodeLifecycleState), authenticated: \(pubkyProfile.authState)", context: "TestHook") // test hook
+ #endif // test hook
}
private func retryWalletStart() async {
@@ -1244,9 +1250,21 @@ struct AppScene: View {
private func restoreVssBackup() async -> Bool {
do {
+ #if DEBUG // test hook
+ let repair = Env.appStorageUrl.appendingPathComponent("qa1121-repair-required") // test hook
+ if FileManager.default.fileExists(atPath: repair.path) { // test hook
+ let data = try Data(contentsOf: Env.appStorageUrl.appendingPathComponent("qa1121-valid-wallet")) // test hook
+ try Keychain.upsert(key: .paykitPendingBackupRestore, data: data) // test hook
+ try FileManager.default.removeItem(at: repair) // test hook
+ Logger.info("QA1121 original retained WALLET repaired before retry", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
try await BackupService.shared.performFullRestoreFromLatestBackup()
return true
} catch {
+ #if DEBUG // test hook
+ Logger.info("QA1121 required restore failed; pending: \(BackupService.shared.hasPendingWalletRestore()), node: \(wallet.nodeLifecycleState)", context: "TestHook") // test hook
+ #endif // test hook
app.toast(error)
return false
}
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index d29dd6b1..08a427bd 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -1208,6 +1208,16 @@ class PubkyProfileManager: ObservableObject {
let loadedProfile = try await Task.detached {
try await resolve(pk)
}.value
+ #if DEBUG // test hook
+ let gate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-profile") // test hook
+ if FileManager.default.fileExists(atPath: gate.path) { // test hook
+ Logger.info("QA1121 profile response held", context: "TestHook") // test hook
+ while FileManager.default.fileExists(atPath: gate.path) { // test hook
+ try await Task.sleep(for: .milliseconds(200)) // test hook
+ } // test hook
+ Logger.info("QA1121 profile response released; can apply: \(publicKey == pk && profileWriteGeneration == read.generation)", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
if publicKey == pk, profileWriteGeneration == read.generation {
profile = loadedProfile
readOnlyProfile = nil
@@ -1926,7 +1936,17 @@ class PubkyProfileManager: ObservableObject {
}
private nonisolated static func initializePersistedSession() async throws -> SessionInitializationResult {
- try await PaykitSdkService.shared.restorePersistedSession()
+ #if DEBUG // test hook
+ let gate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-identity") // test hook
+ if FileManager.default.fileExists(atPath: gate.path) { // test hook
+ Logger.info("QA1121 final identity initialization held", context: "TestHook") // test hook
+ while FileManager.default.fileExists(atPath: gate.path) { // test hook
+ try await Task.sleep(for: .milliseconds(200)) // test hook
+ } // test hook
+ Logger.info("QA1121 final identity initialization released", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
+ return try await PaykitSdkService.shared.restorePersistedSession()
}
private nonisolated static func notifyAppStateBackupChanged() {
diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
index 31b84c1a..34d6a147 100644
--- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
+++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
@@ -50,6 +50,14 @@ struct DataBackupsScreen: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
NavigationBar(title: t("settings__data_backups_nav_title"))
+ #if DEBUG // test hook
+ if FileManager.default.fileExists(atPath: Env.appStorageUrl.appendingPathComponent("qa1121-controls").path) { // test hook
+ Button("QA1121: unavailable identity backup") { Task { await qaUnavailableIdentityBackup() } } // test hook
+ .accessibilityIdentifier("QA1121BackupFault") // test hook
+ Button("QA1121: stage required restore error") { Task { await qaRequiredRestoreError() } } // test hook
+ .accessibilityIdentifier("QA1121RequiredFault") // test hook
+ } // test hook
+ #endif // test hook
GeometryReader { geometry in
ScrollView(showsIndicators: false) {
@@ -98,4 +106,48 @@ struct DataBackupsScreen: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
}
+
+ #if DEBUG // test hook
+ @MainActor private func qaUnavailableIdentityBackup() async { // test hook
+ do { // test hook
+ let before = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] // test hook
+ let values = try keys.map { try Keychain.load(key: $0) } // test hook
+ do { // test hook
+ defer { // test hook
+ for (key, value) in zip(keys, values) { // test hook
+ if let value { try? Keychain.upsert(key: key, data: value) } else { try? Keychain.delete(key: key) } // test hook
+ } // test hook
+ } // test hook
+ try await PaykitSdkService.shared.forgetSessionAccess() // test hook
+ try Keychain.upsert(key: .paykitSession, data: Data([0xFF])) // test hook
+ let active = try await PaykitSdkService.shared.currentPublicKey() // test hook
+ Logger.info("QA1121 backup fault staged; SDK identity absent: \(active == nil)", context: "TestHook") // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let after = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 blocked backup: bytes unchanged: \(before?.value == after?.value), version unchanged: \(before?.version == after?.version), pending: \(pending)", context: "TestHook") // test hook
+ } // test hook
+ _ = try await PaykitSdkService.shared.restorePersistedSession() // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let fresh = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let data = try JSONSerialization.jsonObject(with: fresh!.value) as! [String: Any] // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 recovered backup: SDK state present: \(data["paykitSdkBackupState"] is String), pending: \(pending)", context: "TestHook") // test hook
+ } catch { // test hook
+ Logger.error("QA1121 backup control error: \(error)", context: "TestHook") // test hook
+ } // test hook
+ } // test hook
+
+ @MainActor private func qaRequiredRestoreError() async { // test hook
+ do { // test hook
+ guard let item = try await VssBackupClient.shared.getObject(key: "WALLET") else { return } // test hook
+ try item.value.write(to: Env.appStorageUrl.appendingPathComponent("qa1121-valid-wallet")) // test hook
+ try Keychain.upsert(key: .paykitPendingBackupRestore, data: Data("invalid-json".utf8)) // test hook
+ Logger.info("QA1121 invalid retained WALLET staged; original recovery payload saved", context: "TestHook") // test hook
+ } catch { // test hook
+ Logger.error("QA1121 required-restore control error: \(error)", context: "TestHook") // test hook
+ } // test hook
+ } // test hook
+ #endif // test hook
}Coverage: full 34047db..ec20b26, all seven changed files and the reached restore, SDK activation, backup, identity replacement and reset paths. All six J1 actions were driven with the stated controls; genuine payment authorization was not exercised.
Twin: bitkit-android#1686 fix: decouple wallet restore from profile refresh matches the scheduling and backup protections. Android retains an already-active identity during optional enrichment; iOS waits for final initialization before authenticating its profile manager.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full review of the PR diff against merge base 34047dbd, at ec20b264.
No new actionable code findings.
Wallet restore returns after required wallet and USDT recovery, and identity initialization continues in the background. A second request for the same session revision shares the in-flight initialization, and a result from an older revision is dropped. If the SDK has no active identity while saved, adopted, or unreadable credentials remain, the WALLET snapshot throws before upload, so the previous complete record stays in place. Required restore failure still returns before startWallet. Paykit work that uses automaticPaykitSession stays idle until authentication is applied for the current revision.
Terminal restoration still blocks later WALLET uploads remains open on this head. After .restorationFailed, the saved credentials stay in the keychain, backupSnapshot throws sessionNotActive, and that error is outside BackupService.isTemporaryBackupFailure, so a later ordinary wallet change is marked required and fails again. The author has left that thread open until a separate recoverable wallet record exists. This pass did not identify a further defect.
GitHub reports SwiftFormat and Run Tests successful for this head. I inspected the new scheduler and backup tests and did not run them. Device testing: not performed in this review. A separate simulator report attributes a J1 pass on this same head with local fault controls that are not in the tree. J1 cannot be driven from the committed app alone.
Source comparison with bitkit-android#1686 at 0fd5e424: both refuse a complete wallet backup while a saved identity cannot be exported, and journeys/wallet/restore-during-identity-outage.xml matches. Android keeps an already-active identity and loads profile data afterwards; iOS shows the profile manager authenticated only after final initialization. bitkit-ios#1120 at fd3ce015 was checked only for the receive-invoice commit above this base. That commit does not change the restore or backup contract reviewed here. That check is not a full review of #1120.
Suggested additional test cases
- iOS simulator. Disposable test-network wallet with a complete VSS backup and a saved session that initialization classifies as restoration failed (saved session, no usable secret key). After ordinary wallet access returns, make a non-Paykit wallet change and run backup. Expected result: the remote WALLET bytes remain the previous complete record and the WALLET backup stays pending.
Reviewer model: grok-4.7
Reasoning effort: high
|
Device gate (partial): iPhone 17 simulator, fresh regtest wallet, profile on staging with one saved contact, WALLET backup done; restore through a local homeserver proxy in pass-through (homeserver reachable). Times are UTC:
Still running: the same restore with the homeserver blocked, and both runs on the base branch ( |
jvsena42
left a comment
There was a problem hiding this comment.
Approving. The code scan found nothing (#1121 (comment) has the first device partial), and the device gate passed.
Device gate: 39fcaec (app code ec20b26; the head moved only by merges, the PR's own diff against its base is unchanged and the one conflict resolution is in PrivatePaykitServiceTests.swift) — iPhone 17 simulator, base 34047db (tip of fix/ordinary-wallet-paykit-waits, since this PR is stacked on #1120). perf, Full restore success → Node started / UI ready:
| Homeserver | Head | Base |
|---|---|---|
| Held (accepted, never answered) | 1.53 / 2.50 s | 31.44 / 31.48 s |
| Reachable | 1.56 / 1.58 / 2.24 s | 6.88 / 8.04 s |
| Refused | 1.33 s | 1.44 s |
Base waits on the 30 s Pubky re-sign-in timeout
[2026-10-27 09:30:35.594 UTC] Full restore success
[2026-10-27 09:31:05.704 UTC] WARN: Re-sign-in failed, keeping saved session for retry: Identity(... "complete Pubky auth flow")
[2026-10-27 09:31:07.038 UTC] Node started
- One
Paykit session restored forper restore. With the homeserver reachable, profile and contacts arrive about 3.4 s after the UI is ready; after an outage, about 97 s after release, the re-sign-in retry interval. - Settings › Security › Data backups once the identity is active: every category recent, no failures.
- journeys:
wallet/restore-during-identity-outage.xmlsteps 1, 2, 3 and 5 passed through a local homeserver fault proxy. Step 6 passed as an approximation: blocking all of VSS failed the restore to Try Again with the node stopped, and Try Again after unblocking restored the wallet and balance. During the outage the WALLET backup reportedtemporarily unavailablefour times and thensessionNotActive, with no upload, which is the case @talosmachina's open P1 thread covers. ⚠️ Not run: step 4. The proxy can't delay profile reads alone (TLS hides which request is which), and removing the identity needs the homeserver.- Both builds also spend 30 s inside the required restore under the outage (
Failed to restore pubky session backup state); that wait is inperformFullRestoreFromLatestBackup, outside this PR. - The merges since
ec20b26bring Paykit0.1.1-rc2and VSS client0.5.26-rc1from the base branch; they weren't on the build under test and belong to the PRs that introduce them.
l10n: no UI text changed. Run Tests is still pending at this head.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the entire PR at 39fcaec8 against merge base df5b0742, because the comparison base changed. No prior coverage was inherited.
No new actionable code findings.
The existing HIGH concern about terminal identity failure preventing later WALLET backups remains unresolved and applies at this head.
Validation: The current-composition Run Tests check failed. Its uploaded XCTest result confirms all six added regressions passed, but reports a SIGSEGV in BackupServiceTests.testTemporaryFailureRemainsPendingAcrossServiceRestart. Diagnose that crash and obtain a successful run; its cause is not established. Native tests were not run locally because this workspace lacks Xcode.
Targeted source comparisons covered restore scheduling and backup protection on Android 0fd5e424, plus startup/receive separation in dependency #1120 at df5b074. These were compatibility checks, not full reviews of those PRs.
Device testing: not performed in this review. J1 requires complete-app fault controls absent from the committed fixtures. Earlier simulator evidence used the older dependency build and does not validate this composition.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
|
@jvsena42 one correction on the device gate, re-checked at A blocked homeserver gives a temporary error, so restoration ends |
|
The crash requested in review 5666197929 is addressed in signed commit 754a899. Complete XCResult inspection symbolicated the prior SIGSEGV to cancellable Set insertion/resize inside the unjoined listener task. All insertions and removal now execute synchronously on MainActor; child #1123 retains its separate lifecycle/drain implementation. The final build and all 2,957 selected units passed, including the exact previously crashing test, with 1,675 unchanged input hashes, full formatting, complete nested diagnostics and two clean independent final reviews. All four live-parent commits are locally/GitHub signature-verified. The previous failure remains recorded; current-head hosted CI is running, so I am waiting for its result before requesting affected re-review. The terminal-identity/WALLET thread remains open and no full-app acceptance is implied by this unit result. |
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed the new commit 754a899 (serialize backup observer cancellable mutations). No findings.
- All
cancellablesmutations now run on the main actor, which removes the off-main insert racingstopObservingBackups. - No new deadlock path: the only synchronous hops are short
stateQueue.syncclosures and the serial backup queue, and nothing callsmain.sync. - No lost or doubled backup: the status observers prepend the current statuses, and
backupJobs/runningBackupTasksstill guard against a second upload.
This commit doesn't touch the open WALLET backup sessionNotActive thread.
Device gate: not needed for this delta. The earlier gate at 39fcaec still covers the restore path.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up at 754a8991, covering the delta since 39fcaec8 and inheriting the completed baseline review. Merge base remains df5b0742. The new commit only moves backup observer subscription storage and cancellation onto the main actor.
No new actionable code findings.
Settings, widgets, and app-state listeners are now registered in that same main-actor step, so a stop clears them with the rest of the set. Wallet snapshot validation, upload, and pending-retry behavior are unchanged.
The unresolved terminal identity-restoration failure that blocks later WALLET backups remains in this thread. PrivatePaykitService+Backup.swift is unchanged since the baseline, and sessionNotActive is still outside BackupService.isTemporaryBackupFailure. No matching GitHub issue was found.
Validation: unit tests succeeded on this head, including all 12 BackupServiceTests. testTemporaryFailureRemainsPendingAcrossServiceRestart passed; the SIGSEGV reported from run 40482860269 did not recur. SwiftFormat reported 0/849 files requiring formatting. Tests were not rerun locally because this workspace has no Xcode.
Device testing: not performed in this review. Android backup observation at 0fd5e424 remains synchronous and is outside this iOS isolation change. Dependency #1120 compatibility at df5b074 stays inherited.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the acknowledged terminal-identity WALLET backup availability failure remains reproducible.
Findings: No new findings in the delta. The existing HIGH availability finding remains open at Bitkit/Services/PrivatePaykitService+Backup.swift:11–12: later ordinary wallet state cannot be backed up while identity recovery remains unusable. T1 below adds device evidence to that thread.
QA for 754a899: iPhone 17 Pro, iOS 26.5 Simulator, Xcode 27.0, disposable unfunded staging-regtest wallet. Built the unmodified head with its committed Core 0.8.0-rc11, Paykit 0.1.1-rc2 and VSS 0.5.26-rc1 dependencies, then drove the app with the local Debug controls below.
Test 1: N1 — native identity, backup and observer regressions ✅ The selected suites passed without failures or skips, including the previously crashing backup restart test.
Ran xcodebuild test with these selections on the unmodified head:
-only-testing:BitkitTests/PubkyProfileManagerTests
-only-testing:BitkitTests/PrivatePaykitServiceTests
-only-testing:BitkitTests/BackupServiceTests
I inspected the finalized XCResult and complete log. The suites exercise held/coalesced initialization, stale identity completion, replacement credentials, malformed stored bytes, backup protection, pending retries and service restart. testTemporaryFailureRemainsPendingAcrossServiceRestart passed. The result bundle retains a QoS priority-inversion warning in the profile-manager tests; its cause is unproven.
N1-execution-2x.2x.mp4
Simulator execution excerpt, 2×; assertions were checked in XCResult.
Test 2: J1 — wallet restore during optional identity outage ✅ All six actions passed with the timing and input controls described below.
- A full VSS restore completed required recovery and reached a running node while final identity initialization remained held and authentication stayed idle. I left restore and copied a regtest receive address, checked from the clipboard. Releasing the hold restored the same identity and permitted fresh complete backups.
- Removing SDK session access and supplying malformed local credential input made WALLET backup fail with
sessionNotActive. App-log comparisons confirmed unchanged remote bytes and version, with required work retained. Restoring the original credentials permitted a fresh SDK export and complete upload, clearing pending status. - Killing the app while initialization was held preserved the saved credentials. Release after relaunch restored the identity and the same single contact. Before/after contact snapshots contained one entry, and the complete logs contained one contact-add event. No payment was submitted.
- A real fetched profile response was held before applying it. After normal UI disconnect, release logged
can apply: false; the accessibility snapshot confirmed the identity-choice screen remained visible. - Malformed retained WALLET data left required recovery pending, the node stopped and the UI on Try Again without wallet actions. Repairing the original captured payload and retrying reached wallet access while final identity initialization was still held. Node/authentication state and recovery markers were checked in app logs.
| Required failure, retry and held initialization | Complete-backup protection |
|---|---|
J1-required-2x.2x.mp4 |
J1-backup-2x.2x.mp4 |
| Retry excerpt showing required recovery and ordinary wallet access before identity release; 2×. | Unavailable identity, retained backup work and recovery; record contents checked in logs; 2×. |
| Process restart | Delayed profile after disconnect |
|---|---|
J1-restart-2x.2x.mp4 |
J1-stale-rerun-2x.2x.mp4 |
| Saved credentials and one contact recover after release; counts checked in snapshots/logs; 2×. | Disconnect, release and retain the identity-choice screen; rejection checked in logs; 2×. |
This used an unfunded wallet. Funded-payment replay, a USDT-specific required-restore failure and a live homeserver blackout were not exercised.
Test 3: T1 — ordinary WALLET backup after terminal identity failure ❌ A later ordinary allocation change stayed unbacked-up until manual credential repair.
After clearing live SDK session access, the control supplied an invalid saved session and no local secret. The real resolver logged No secret key to recover session, the branch that returns .restorationFailed. I then reserved a new ordinary watch-only account index through the normal store. WALLET backup failed; its previous remote bytes and version remained unchanged and work stayed pending. Restoring the original credentials allowed a complete backup and cleared pending status.
On these dependencies the backup attempt failed with the SDK Identity error while restoring a grant from the platform provider, before the sessionNotActive guard. This establishes the terminal/no-secret availability failure; J1 separately verifies the guard's protective behavior and temporary recovery. The restoration-failure alert flag is consumed by the UI, so its later printed value is not the resolver's result.
T1-terminal-rerun-2x.2x.mp4
Control execution and credential recovery, 2×; resolver branch, allocation, remote record and pending-state assertions were checked in app logs.
The existing thread remains unresolved. Preserving the previous complete record protects identity recovery but leaves the newer ordinary allocation state absent from it.
Reports considered:
- Earlier J1 report at ec20b26: historical control recipe; J1 was re-driven here on the new dependency composition.
- Current-head source and hosted-test review: confirms the observer repair and successful current-head CI.
- Merged-parent device report: receive, payment and reconciliation coverage belongs to that report; its complete service journey remains partial.
Coverage: contiguous delta ec20b264..754a899, all changed files and reached callers, plus the open terminal-backup issue. Merged receive/payment/USDT changes received source compatibility review; their complete device journeys were not repeated here. The current-head native run and J1 cover the restore/backup composition and observer restart regression.
Twin: bitkit-android#1686 fix: decouple wallet restore from profile refresh at 0fd5e424 matches optional scheduling and complete-backup protection. Android retains an identity already activated during required metadata recovery; iOS applies authentication after final initialization.
Local Debug controls and deciding evidence
Not pushed. Holds wait for explicit file removal. Backup controls operate on this disposable wallet's actual SDK, keychain inputs, backup writer and VSS records, restoring captured credentials afterwards. Required recovery uses a corrupted retained payload and repairs it from the captured original. Before the final stale-profile attempt, a one-use setup control reset this disposable saved grant; normal stored-secret sign-in fetched the held profile. SDK responses and recovery decisions were not mocked.
Full restore success
QA1121 required recovery complete; scheduling final identity
QA1121 final identity initialization held
QA1121 wallet start returned: running, authenticated: idle
QA1121 blocked backup: bytes unchanged: true, version unchanged: true, pending: true
QA1121 recovered backup: SDK state present: true, pending: false
QA1121 required restore failed; pending: true, node: stopped
QA1121 original retained WALLET repaired before retry
No secret key to recover session
Backup failed for: 'WALLET': Identity(code: "identity_error", context: "restore Pubky grant session from platform provider")
ordinary reservation index: 1, bytes unchanged: true, version unchanged: true, pending: true
QA1121 terminal manual credential recovery: pending: false
QA1121 profile response released; can apply: false
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 0dc59e23..2d3387a9 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -1171,8 +1171,14 @@ struct AppScene: View {
widgets.loadSavedWidgets()
widgets.objectWillChange.send()
+ #if DEBUG // test hook
+ Logger.info("QA1121 required recovery complete; scheduling final identity", context: "TestHook") // test hook
+ #endif // test hook
pubkyProfile.scheduleInitializationAfterRestore()
await startWallet(completingBackupRestore: true)
+ #if DEBUG // test hook
+ Logger.info("QA1121 wallet start returned: \(wallet.nodeLifecycleState), authenticated: \(pubkyProfile.authState)", context: "TestHook") // test hook
+ #endif // test hook
}
private func retryWalletStart() async {
@@ -1246,9 +1252,21 @@ struct AppScene: View {
private func restoreVssBackup() async -> Bool {
do {
+ #if DEBUG // test hook
+ let repair = Env.appStorageUrl.appendingPathComponent("qa1121-repair-required") // test hook
+ if FileManager.default.fileExists(atPath: repair.path) { // test hook
+ let data = try Data(contentsOf: Env.appStorageUrl.appendingPathComponent("qa1121-valid-wallet")) // test hook
+ try Keychain.upsert(key: .paykitPendingBackupRestore, data: data) // test hook
+ try FileManager.default.removeItem(at: repair) // test hook
+ Logger.info("QA1121 original retained WALLET repaired before retry", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
try await BackupService.shared.performFullRestoreFromLatestBackup()
return true
} catch {
+ #if DEBUG // test hook
+ Logger.info("QA1121 required restore failed; pending: \(BackupService.shared.hasPendingWalletRestore()), node: \(wallet.nodeLifecycleState)", context: "TestHook") // test hook
+ #endif // test hook
app.toast(error)
return false
}
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index d29dd6b1..8fcc51b2 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -1208,6 +1208,16 @@ class PubkyProfileManager: ObservableObject {
let loadedProfile = try await Task.detached {
try await resolve(pk)
}.value
+ #if DEBUG // test hook
+ let gate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-profile") // test hook
+ if FileManager.default.fileExists(atPath: gate.path) { // test hook
+ Logger.info("QA1121 profile response held", context: "TestHook") // test hook
+ while FileManager.default.fileExists(atPath: gate.path) { // test hook
+ try await Task.sleep(for: .milliseconds(200)) // test hook
+ } // test hook
+ Logger.info("QA1121 profile response released; can apply: \(publicKey == pk && profileWriteGeneration == read.generation)", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
if publicKey == pk, profileWriteGeneration == read.generation {
profile = loadedProfile
readOnlyProfile = nil
@@ -1926,7 +1936,23 @@ class PubkyProfileManager: ObservableObject {
}
private nonisolated static func initializePersistedSession() async throws -> SessionInitializationResult {
- try await PaykitSdkService.shared.restorePersistedSession()
+ #if DEBUG // test hook
+ let gate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-identity") // test hook
+ if FileManager.default.fileExists(atPath: gate.path) { // test hook
+ Logger.info("QA1121 final identity initialization held", context: "TestHook") // test hook
+ while FileManager.default.fileExists(atPath: gate.path) { // test hook
+ try await Task.sleep(for: .milliseconds(200)) // test hook
+ } // test hook
+ Logger.info("QA1121 final identity initialization released", context: "TestHook") // test hook
+ } // test hook
+ let reset = Env.appStorageUrl.appendingPathComponent("qa1121-reset-test-grant") // test hook
+ if FileManager.default.fileExists(atPath: reset.path) { // test hook
+ try Keychain.delete(key: .paykitSession) // test hook
+ try FileManager.default.removeItem(at: reset) // test hook
+ Logger.info("QA1121 disposable saved grant reset; keeping original local secret", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
+ return try await PaykitSdkService.shared.restorePersistedSession()
}
private nonisolated static func notifyAppStateBackupChanged() {
diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
index 31b84c1a..9c0d7555 100644
--- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
+++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
@@ -39,6 +39,7 @@ private struct StatusItemView: View {
struct DataBackupsScreen: View {
@StateObject private var viewModel = BackupViewModel()
+ @EnvironmentObject private var pubkyProfile: PubkyProfileManager // test hook
private var allSynced: Bool {
BackupCategory.allCases.allSatisfy { category in
@@ -50,6 +51,15 @@ struct DataBackupsScreen: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
NavigationBar(title: t("settings__data_backups_nav_title"))
+ #if DEBUG // test hook
+ if FileManager.default.fileExists(atPath: Env.appStorageUrl.appendingPathComponent("qa1121-controls").path) { // test hook
+ Button("QA1121: terminal identity failure") { Task { await qaTerminalIdentity() } }.accessibilityIdentifier("QA1121TerminalFault") // test hook
+ Button("QA1121: unavailable identity backup") { Task { await qaUnavailableIdentityBackup() } } // test hook
+ .accessibilityIdentifier("QA1121BackupFault") // test hook
+ Button("QA1121: stage required restore error") { Task { await qaRequiredRestoreError() } } // test hook
+ .accessibilityIdentifier("QA1121RequiredFault") // test hook
+ } // test hook
+ #endif // test hook
GeometryReader { geometry in
ScrollView(showsIndicators: false) {
@@ -98,4 +108,80 @@ struct DataBackupsScreen: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
}
+
+ #if DEBUG // test hook
+ @MainActor private func qaTerminalIdentity() async { // test hook
+ do { // test hook
+ let before = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] // test hook
+ let values = try keys.map { try Keychain.load(key: $0) } // test hook
+ do { // test hook
+ defer { // test hook
+ for (key, value) in zip(keys, values) { // test hook
+ if let value { try? Keychain.upsert(key: key, data: value) } else { try? Keychain.delete(key: key) } // test hook
+ } // test hook
+ } // test hook
+ try await PaykitSdkService.shared.forgetSessionAccess() // test hook
+ try Keychain.delete(key: .pubkySecretKey) // test hook
+ try Keychain.upsert(key: .paykitSession, data: Data("invalid-session".utf8)) // test hook
+ await pubkyProfile.initialize() // test hook
+ let terminal = pubkyProfile.sessionRestorationFailed // test hook
+ let active = try? await PaykitSdkService.shared.currentPublicKey() // test hook
+ let index = try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: "qa1121-terminal-" + UUID().uuidString) // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let after = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 terminal: restorationFailed: \(terminal), sdk unavailable: \(active == nil), ordinary reservation index: \(index), bytes unchanged: \(before?.value == after?.value), version unchanged: \(before?.version == after?.version), pending: \(pending)", context: "TestHook") // test hook
+ } // test hook
+ await pubkyProfile.initialize() // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 terminal manual credential recovery: pending: \(pending)", context: "TestHook") // test hook
+ } catch { // test hook
+ Logger.error("QA1121 terminal control error: \(error)", context: "TestHook") // test hook
+ } // test hook
+ } // test hook
+
+ @MainActor private func qaUnavailableIdentityBackup() async { // test hook
+ do { // test hook
+ let before = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] // test hook
+ let values = try keys.map { try Keychain.load(key: $0) } // test hook
+ do { // test hook
+ defer { // test hook
+ for (key, value) in zip(keys, values) { // test hook
+ if let value { try? Keychain.upsert(key: key, data: value) } else { try? Keychain.delete(key: key) } // test hook
+ } // test hook
+ } // test hook
+ try await PaykitSdkService.shared.forgetSessionAccess() // test hook
+ try Keychain.upsert(key: .paykitSession, data: Data([0xFF])) // test hook
+ let active = try await PaykitSdkService.shared.currentPublicKey() // test hook
+ Logger.info("QA1121 backup fault staged; SDK identity absent: \(active == nil)", context: "TestHook") // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let after = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 blocked backup: bytes unchanged: \(before?.value == after?.value), version unchanged: \(before?.version == after?.version), pending: \(pending)", context: "TestHook") // test hook
+ } // test hook
+ _ = try await PaykitSdkService.shared.restorePersistedSession() // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let fresh = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let data = try JSONSerialization.jsonObject(with: fresh!.value) as! [String: Any] // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 recovered backup: SDK state present: \(data["paykitSdkBackupState"] is String), pending: \(pending)", context: "TestHook") // test hook
+ } catch { // test hook
+ Logger.error("QA1121 backup control error: \(error)", context: "TestHook") // test hook
+ } // test hook
+ } // test hook
+
+ @MainActor private func qaRequiredRestoreError() async { // test hook
+ do { // test hook
+ guard let item = try await VssBackupClient.shared.getObject(key: "WALLET") else { return } // test hook
+ try item.value.write(to: Env.appStorageUrl.appendingPathComponent("qa1121-valid-wallet")) // test hook
+ try Keychain.upsert(key: .paykitPendingBackupRestore, data: Data("invalid-json".utf8)) // test hook
+ Logger.info("QA1121 invalid retained WALLET staged; original recovery payload saved", context: "TestHook") // test hook
+ } catch { // test hook
+ Logger.error("QA1121 required-restore control error: \(error)", context: "TestHook") // test hook
+ } // test hook
+ } // test hook
+ #endif // test hook
}Reviewer model: gpt-6.1-sol
Reasoning effort: high
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the acknowledged terminal-identity backup failure remains reproducible.
Findings: No new findings in the delta. The existing HIGH finding at Bitkit/Services/PrivatePaykitService+Backup.swift:11 remains open: after terminal identity recovery failure, newer ordinary wallet state cannot be included in a complete WALLET backup until credentials are repaired.
QA for 73b7d58: Tested with a disposable, unfunded regtest wallet and controlled Debug timing and credential faults over the real SDK and VSS operations.
Test 1: Identity, backup and USDT regression suites ✅ The focused native suites passed on the unmodified head.
The finalized result bundle reports 288 passes with no failures or skips, including identity, backup observer, foreground recovery, cache and balance cases. A recorded repeat also passed. Xcode timed out collecting optional simulator diagnostics after the first suite finished; its test result still finalized successfully.
native-2x.2x.mp4
Simulator execution at 2×; assertions were checked in the result bundles and logs.
Test 2: J1 — restore during optional identity outage ✅ Required recovery completed and the wallet remained usable while final identity initialization was held.
The restored wallet reached Home and copied a regtest receive address while the node was running and authentication remained idle; these states and the copied address were checked in logs and the clipboard. An unavailable identity left the previous WALLET bytes and version unchanged with work pending; restoring credentials produced a complete backup. A corrupted required restore kept the node stopped and work pending; repairing the retained record and retrying started the wallet. A restart while initialization was held recovered the saved identity after release. A fetched profile held through disconnect was rejected on release, and the identity-choice screen remained visible; the guard and final screen were checked in logs and the accessibility snapshot.
restore-complete-2x.2x.mp4
backup-2x.2x.mp4
required-2x.2x.mp4
restart-2x.2x.mp4
Recordings at 2×. The late-profile recording stuttered and is omitted; the prior device report also covers that unchanged profile guard and checks that a contact was not duplicated across restart. No funded payment replay, USDT-specific required-restore failure or live homeserver blackout was driven.
Test 3: Ordinary backup after terminal identity failure ❌ A new ordinary allocation remained unbacked-up until credential repair.
With an invalid saved session and no local secret, the real resolver reached restoration failure. A subsequent watch-only allocation remained pending; the previous remote WALLET bytes and version stayed unchanged. Restoring the credentials allowed backup and cleared pending work. The resolver, allocation, remote record and pending status were checked in app logs.
terminal-2x.2x.mp4
Control execution and recovery at 2×.
Check 1: Merged USDT display and receive changes ✅ The earlier device evidence covers the display and receive behavior, with recovery integration checked in the native suites.
The cached balance review and receive transition report cover the merged display and receive paths. The receive view is unchanged since that report; the manager has additional recovery integration, covered here by focused native tests. The complete provider and funded-payment journeys were not repeated.
Coverage: contiguous delta 754a899..73b7d58, its reached callers and the open terminal-backup finding. The merged foreground notification scheduling and USDT recovery integration received source and native-test coverage; the complete notification timing and USDT service journeys were not driven on a device.
Twin: bitkit-android#1686 fix: decouple wallet restore from profile refresh matches the shared optional scheduling and complete-backup protection; Android retains an identity already activated during required metadata recovery.
Reviewer model: Unknown
Reasoning effort: Not exposed
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 reassessment of the full PR diff against merge base 2512c46a, at 73b7d58a. No coverage baseline was supplied. The comparison base moved from df5b074 to 2512c46, including parent edits to Bitkit/AppScene.swift and Bitkit/Services/BackupService.swift.
No new actionable code findings.
No new actionable code findings. The existing terminal-restoration limitation remains open in discussion r4368930202: .restorationFailed keeps saved credentials, a missing SDK identity still rejects the WALLET snapshot, and that error is not treated as a temporary backup failure, so later ordinary wallet changes stay pending. The author has confirmed this. A temporary hold still preserves the previous WALLET object and can upload after activation.
Android twin bitkit-android#1686 at 0fd5e424 rejects the same identity-free WALLET replacement. It also skips a second session import when its in-memory public key is already set. This restore signs in through the SDK before scheduling profile initialization and does not assign PubkyProfileManager.publicKey on that path.
Dependency bitkit-ios#1120 was checked only for the required-restore gate, at 7f4d0296. That pin is one test-only commit ahead of this PR's base. Wallet start still waits until required recovery finishes. This was not a full review of #1120.
Validation: this review did not run unit tests or a simulator. The new backup and scheduling tests were inspected. Hosted Run Tests and SwiftFormat succeeded on this head. Device testing: not performed in this review.
The open terminal-backup thread remains unresolved, so this review does not treat the branch as ready for a new device pass.
Reviewer model: grok-4.7
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at b9f3bbda. The Blocktank backup now reads cached info without throwing it away: an unreadable table fails the backup and keeps required set, instead of uploading info: nil and marking it synced. Retry and the failure notification follow the existing orders/cjit path, and BlocktankBackupTests covers it.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The terminal-identity backup finding is still open. The new VSS drain itself holds.
Findings: No new findings in b9f3bbd..25f25ea. The existing HIGH finding at Bitkit/Services/PrivatePaykitService+Backup.swift:12 is still open, as the PR description also says. While saved identity credentials cannot be restored, later ordinary wallet changes are left out of complete WALLET backups. This push does not touch that path.
QA for 25f25ea: I built the app and tests from the head with the committed dependencies and ran them on an iPhone 17 Pro simulator (iOS 26.5).
VSS client coordinator tests, with a before-fix control ✅ All 8 VssClientCoordinatorTests pass. With the drain removed, 4 of them fail, so the tests do check it.
At the head, VssClientCoordinatorTests ran 8 tests with 0 failures, as the xcodebuild test log shows.
For the control, I deleted the if activeCalls > 0 { await … } wait in closeAdmission and reran the tests. 4 of the 8 failed:
testResetDrainsBothDomainsAndRejectsTheirResults: shutdown ran while calls were still admitted.testResetDrainsOldSetupAndRejectsLateCompletion: shutdown ran, and new admission was allowed during the drain.testCancelledResetStillWaitsForNativeCalltestRetiredNativeFailureIsCancellationRatherThanNewOwnerAuthFailure
The recording below shows only the app host. The assertions are in the test logs.
coord-tests-2x.2x.mp4
Reset the wallet, then back up a new wallet ✅ A reset made right after a settings change finishes with the "Wallet Data Deleted" toast. The next new wallet backs up every category.
On a new wallet, I toggled "Swipe balance to hide" and then reset straight away, so that a settings upload would probably still be running. The reset returned to the terms screen with the "Wallet Data Deleted" toast about 11 s after the tap. There was no "Wipe Failed" error and no hang.
I then created a new wallet. On Data Backups, all eight categories showed as synced within a minute.
reset-new-wallet-2x.2x.mp4
Reset the wallet, then restore the first wallet from its recovery phrase ✅ After two resets, restoring wallet A brings back its EUR currency and its last settings change, and every category syncs again.
Steps:
- On wallet A, I set the local currency to EUR and waited until Settings showed as synced.
- I reset wallet A, created wallet B, which showed the default USD, and reset B.
- I restored A from its recovery phrase.
Results:
- The restore finished.
- The local currency came back as EUR.
- "Swipe balance to hide" came back off. That toggle was made just before A's reset, so it reached A's backup.
- Data Backups showed all categories synced again.
This shows that VSS calls after each wipe reach the restored wallet's own store.
wallet-a-setup-2x.2x.mp4
reset-restore-2x.2x.mp4
Restore during an identity outage (J1 journey) ⏭️ Not run: it needs complete-app fault controls that the repository does not commit.
The PR description says these controls are unprovisioned. Earlier reports cover this journey at older heads: the review at a6f233d, the fresh recovery and setup-pending profile report, the required recovery and restart report and external review 5658584985. This push sends the restore's VSS reads and reset() through the new coordinator. The restore case above re-drives that path at this head.
Coverage: the b9f3bbd..25f25ea delta, plus the open terminal-backup finding. I read every VssBackupClient caller: BackupService backup and restore, the USDT recovery store, the routing-cache reset, the VSS debug screen, the restore reset() calls and AppReset.wipe. I also read the steps the wipe runs while admission is closed.
- None of those steps calls
VssBackupClient, so the wipe cannot deadlock on itself. - No throwing path sits between closing and reopening admission.
- Restore runs in unstructured tasks that are never cancelled, so the new
CancellationErrorcannot mark a category restored without fetching it. - The store id is derived the same way as
VssStoreIdProvider, without its cache.
No device case drove a slow VSS call during a wipe, a wipe overlapping a restore's reset(), or the LDK debug-screen domain. The unit tests cover those.
Twin: bitkit-android#1686 at 43e0f3f has no matching change. Its VssBackupClient.reset() still cancels setup without draining calls in flight. Restore scheduling, the profile retry and complete-backup refusal still match. The terminal complete-WALLET limitation remains on both platforms.
Reviewed by claude-opus-5-5-medium
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at 25f25eaed ("drain VSS calls before replacing wallet clients"). No HIGH or MEDIUM.
Checked and clean:
withClientadmission andcloseAdmissionhave no suspension point between the check and the counter.- An admitted setup is awaited before
shutdown(). The native client is cloned at call start, so a retired put can't reach the next wallet's store. getLnurlAuthParamsnow derives the store id from the keychain.reopenAdmissionruns on both success and throw.- A cancelled call surfaces
CancellationError, so the backup staysrequiredand is never marked synced. - The keychain wipe runs inside the closed window.
One LOW inline. Also a note, not a finding: the drain now waits for in-flight native calls, and Swift cancellation doesn't reach Rust. So a wipe started during a stalled upload waits as long as the VSS HTTP client's request timeout. Worth confirming that vss-client-ng sets one.
Device gate not run; CI "Run Tests" still pending.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The new reset/wipe composition holds; the terminal-identity backup finding remains reproducible.
Findings: No new findings in the 25f25ea..bf4061f delta. The existing HIGH at Bitkit/Services/PrivatePaykitService+Backup.swift:12 remains open: after terminal identity restoration failure, later ordinary wallet changes stay absent from complete WALLET backups until credentials are repaired. The terminal case below reproduces it at this head.
QA for bf4061f: iPhone 17 Pro simulator, iOS 26.5, Xcode 27.0, committed dependencies and a disposable unfunded staging-regtest wallet. I built the unmodified head for native checks, then used the unpushed Debug controls below for app cases. Recordings are focused excerpts at 2×; state and remote-record assertions were checked in app logs and finalized XCTest results.
Wipe waits for a concurrent VSS reset and retains the gate through commit ✅ The native regressions passed; restoring the former guard reproduced the reported failure.
The selected wipe, node, backup-observation and identity suites passed: 330 tests, no failures or skips. All nine coordinator tests also passed in a repeat. With only withSuspendedClients changed back to the former throwing guard, the new overlap regression failed at resetLocalServices with CancellationError, before credential deletion. Its 14 failed assertions include the missing credential scope and incomplete wipe.
The recording shows the simulator during the positive repeat; assertions were checked in the finalized XCTest result tree. The native result retains a QoS runtime warning in the profile-manager tests.
vss-fixed-2x.2x.mp4
Finish a wallet wipe after a concurrent VSS reset drains ✅ The app stayed gated until the held VSS response was released, then completed the wipe without a failed attempt.
Held a completed real VSS read inside client admission, started a concurrent reset(), then reset the wallet through Settings. The gate reached step 4 while the earlier reset was still draining. Releasing the response let the wipe finish through credential removal and commit, returning to onboarding with “Wallet Data Deleted.” The captured app console confirms admission and step ordering and contains no failed wipe attempt.
vss-wipe-overlap-2x.2x.mp4
Held gate and completion, 2×; the native-call hold and admission assertions were checked in logs.
Reset a wallet and restore its settings from its recovery phrase ✅ EUR returned after reset, and every backup category resumed syncing.
Set EUR and acknowledged its settings backup before reset. Restoring the same recovery phrase brought EUR back. After identity recovery, all eight Data Backups categories showed acknowledged timestamps, with successful WALLET uploads in the app log. The video shows the restored currency; the still shows the later acknowledged backup state.
| Restored currency | Backups after recovery |
|---|---|
reset-restore-confirmation-2x.2x.mp4 |
![]() |
Restore wallet access while final identity initialization is held ✅ Required recovery finished and ordinary Receive worked while profile authentication remained idle.
Fresh recovery through the recovery-phrase UI completed the real VSS and SDK imports before final optional initialization was held. The node ran, Home stayed usable and Receive copied a bitcoin:bcrt1… URI while authentication was idle. Restore ordering, node/authentication states and the copied URI were checked in app logs and the clipboard. The original identity recovered after release in the restart case below.
j1-restore-2x.2x.mp4
Ordinary Receive during the hold, 2×; required-recovery and authentication assertions were checked in logs.
Preserve a complete backup while identity data is unavailable ✅ The previous WALLET record and pending work survived; credential recovery permitted a complete upload.
The control removed live SDK access and supplied malformed saved-session bytes. The real backup failed with sessionNotActive; log comparisons confirmed unchanged remote WALLET bytes/version and pending work. Restoring the captured credentials produced a complete backup with SDK state and cleared pending work. The recording shows the control invocation; the record comparisons and recovery outcome were checked in logs.
j1-backup-2x.2x.mp4
Recover the saved identity and contact after a restart during the outage ✅ Release recovered the same identity and exactly one saved contact without another contact-add event.
Terminated the app during held initialization, relaunched and reached Home while the hold remained active. Release restored the same public key and saved profile name. The Contacts snapshot contains one saved contact, and the complete post-restart console contains no contact-add event. No payment was submitted.
| Restart and identity recovery | Contact after recovery |
|---|---|
j1-restart-2x.2x.mp4 |
![]() |
Keep wallet access gated when required recovery fails ✅ A corrupt required payload kept the node stopped; repairing it allowed retry while optional initialization remained held.
A malformed retained WALLET failed required recovery after restart. Logs confirmed pending recovery and a stopped node; the screen offered only “Try Again,” without wallet actions. Repairing the captured original payload and retrying completed required imports and reached Home with the node running and authentication idle under the optional hold. Releasing that hold recovered the identity and allowed WALLET upload again.
j1-required-2x.2x.mp4
Required error and retry, 2×; pending, node and authentication states were checked in logs.
Back up ordinary wallet changes after terminal identity failure ❌ A later ordinary allocation remained unbacked-up until manual credential repair.
An invalid saved session with no local secret reached the real resolver’s terminal no-secret branch. A subsequent ordinary watch-only account reservation left remote WALLET bytes/version unchanged and work pending. Capture raised the SDK Identity error before sessionNotActive. Restoring the original credentials permitted upload and cleared pending work. These assertions were checked in app logs; the recording shows the terminal control and disconnected-profile state.
terminal-backup-2x.2x.mp4
This is additional evidence for the existing availability finding, not a new duplicate finding.
Check: retained profile recovery and stale-result evidence ✅ Earlier passing reports cover the unchanged profile retry and apply guards.
- The setup-pending and missing-profile recovery report verifies recovery on Home without re-importing the active session.
- The delayed-profile report verifies rejection after disconnect.
Those profile read, coalescing, setup-pending and write-generation guards are unchanged in this delta; the profile-manager addition concerns endpoint removal during wipe. The current native selection also passes their regressions. These device subcases were not repeated.
Check: formatting, translations and merged-parent evidence ✅ Static checks passed; the parent’s separate device coverage remains attributed to its report.
SwiftFormat 0.64.0 passed on the 93 changed Swift files. Translation validation reported no errors and 17 warnings; delta whitespace checks passed. The current-parent review covers its PIN-lockout wipe/recreated-wallet Receive case and broader native assertions. Its genuine pending-swap case remains unrun there and is not claimed here.
Coverage: the contiguous 25f25ea..bf4061f delta, including VSS ownership, durable wipe integration, restore/backup callers and the open terminal-backup issue. The merged allowance, send, amount, PIN and hardware paths received source compatibility review. Their separate funded-payment and hardware journeys were not repeated. These cases exercised wallet-required recovery failure and the contact-mutation restart alternative; they do not establish populated USDT recovery, funded payment replay, a live homeserver blackout or running LDK quiescence.
Twin: bitkit-android#1686 fix: decouple wallet restore from profile refresh at a2c58f2 now also drains standalone VSS client work during wallet replacement. Optional scheduling, missing-profile retry and complete-backup refusal match. Android retains an identity activated during required recovery; iOS applies profile-manager authentication after final initialization. The terminal complete-WALLET limitation remains on both platforms.
Executed Debug controls and deciding evidence
Not pushed. The controls hold real completion boundaries, stage disposable credential/recovery inputs and log state. They use the actual SDK, backup writer and VSS records. Captured credentials and recovery bytes are restored afterward; SDK results and restore decisions are not mocked.
real VSS read completed; response held inside admission
VSS admission closed; active calls: 1
Wipe step: stopNode
Node stopped
Wipe step: removeRemoteEndpoints
VSS response released
VSS admission closed; active calls: 0
concurrent reset completed
Wipe step: wipeKeychain
Wipe step: commit
Wipe completed
Full restore success
required recovery complete; scheduling final identity
final identity initialization held
wallet start returned: running, authenticated: idle
backup fault staged; SDK identity absent: true
Backup failed for: 'WALLET': sessionNotActive
blocked backup: bytes unchanged: true, version unchanged: true, pending: true
recovered backup: SDK state present: true, pending: false
No secret key to recover session
terminal: restorationFailed: true, sdk unavailable: true, ordinary reservation index: 1,
bytes unchanged: true, version unchanged: true, pending: true
terminal manual credential recovery: pending: false
required restore failed; pending: true, node: stopped
original retained WALLET repaired before retry
Full restore success
wallet start returned: running, authenticated: idle
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 121f15c9..102b74bf 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -585,6 +585,31 @@ struct AppScene: View {
) {
config in AppUpdateSheet(config: config)
}
+ .task { // test hook
+ #if DEBUG // test hook
+ while !Task.isCancelled { // test hook
+ let command = Env.appStorageUrl.appendingPathComponent("qa1121-command") // test hook
+ if let text = try? String(contentsOf: command, encoding: .utf8) { // test hook
+ try? FileManager.default.removeItem(at: command) // test hook
+ switch text.trimmingCharacters(in: .whitespacesAndNewlines) { // test hook
+ case "seed": // test hook
+ if let seed = try? Keychain.loadString(key: .bip39Mnemonic(index: 0)) { // test hook
+ try? seed.write(to: Env.appStorageUrl.appendingPathComponent("qa1121-seed"), atomically: true, encoding: .utf8) // test hook
+ Logger.info("QA1121 disposable recovery phrase captured without logging contents", context: "TestHook") // test hook
+ } // test hook
+ case "status": // test hook
+ Logger.info("QA1121 state: node: \(wallet.nodeLifecycleState), auth: \(pubkyProfile.authState), identity: \(pubkyProfile.publicKey ?? "nil"), required recovery: \(BackupService.shared.hasPendingWalletRestore()), wipe gate: \(wipeCoordinator.gate)", context: "TestHook") // test hook
+ case "hold-vss": // test hook
+ Task { _ = try? await VssBackupClient.shared.getObject(key: "QA1121-HOLD") } // test hook
+ case "reset-vss": // test hook
+ Task { await VssBackupClient.shared.reset(); Logger.info("QA1121 concurrent reset completed", context: "TestHook") } // test hook
+ default: break // test hook
+ } // test hook
+ } // test hook
+ try? await Task.sleep(for: .milliseconds(250)) // test hook
+ } // test hook
+ #endif // test hook
+ } // test hook
.task(priority: .userInitiated, setupTask)
.task(id: [
scenePhase == .active,
@@ -1263,8 +1288,14 @@ struct AppScene: View {
widgets.loadSavedWidgets()
widgets.objectWillChange.send()
+ #if DEBUG // test hook
+ Logger.info("QA1121 required recovery complete; scheduling final identity", context: "TestHook") // test hook
+ #endif // test hook
pubkyProfile.scheduleInitializationAfterRestore()
await startWallet(completingBackupRestore: true)
+ #if DEBUG // test hook
+ Logger.info("QA1121 wallet start returned: \(wallet.nodeLifecycleState), authenticated: \(pubkyProfile.authState)", context: "TestHook") // test hook
+ #endif // test hook
}
private func retryWalletStart() async {
@@ -1338,9 +1369,21 @@ struct AppScene: View {
private func restoreVssBackup() async -> Bool {
do {
+ #if DEBUG // test hook
+ let repair = Env.appStorageUrl.appendingPathComponent("qa1121-repair-required") // test hook
+ if FileManager.default.fileExists(atPath: repair.path) { // test hook
+ let data = try Data(contentsOf: Env.appStorageUrl.appendingPathComponent("qa1121-valid-wallet")) // test hook
+ try Keychain.upsert(key: .paykitPendingBackupRestore, data: data) // test hook
+ try FileManager.default.removeItem(at: repair) // test hook
+ Logger.info("QA1121 original retained WALLET repaired before retry", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
try await BackupService.shared.performFullRestoreFromLatestBackup()
return true
} catch {
+ #if DEBUG // test hook
+ Logger.info("QA1121 required restore failed; pending: \(BackupService.shared.hasPendingWalletRestore()), node: \(wallet.nodeLifecycleState)", context: "TestHook") // test hook
+ #endif // test hook
app.toast(error)
return false
}
@@ -1424,6 +1467,9 @@ struct AppScene: View {
await LightningService.shared.reconnectPeers()
try? await wallet.sync()
await sessionRecovery
+ #if DEBUG // test hook
+ Logger.info("QA1121 foreground wallet maintenance returned; profile held: \(pubkyProfile.isLoadingProfile), auth: \(pubkyProfile.authState)", context: "TestHook") // test hook
+ #endif // test hook
await retryPendingPaykitEndpointRemoval()
}
foregroundWalletSync = foregroundSync
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index c18cde11..4f0b319f 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -1211,6 +1211,14 @@ class PubkyProfileManager: ObservableObject {
}
#endif
+ #if DEBUG // test hook
+ func qaLoseProfile() { // test hook
+ invalidateProfileLoads() // test hook
+ profile = nil // test hook
+ clearCachedProfileMetadata() // test hook
+ } // test hook
+ #endif // test hook
+
// MARK: - Profile
func loadProfile() async {
@@ -1227,13 +1235,32 @@ class PubkyProfileManager: ObservableObject {
let resolve = remoteProfileResolver
do {
+ #if DEBUG // test hook
+ Logger.info("QA1121 admitted profile read", context: "TestHook") // test hook
+ if FileManager.default.fileExists(atPath: Env.appStorageUrl.appendingPathComponent("qa1121-fail-profile").path) { // test hook
+ throw URLError(.notConnectedToInternet) // test hook
+ } // test hook
+ #endif // test hook
let loadedProfile = try await Task.detached {
try await resolve(pk)
}.value
+ #if DEBUG // test hook
+ let gate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-profile") // test hook
+ if FileManager.default.fileExists(atPath: gate.path) { // test hook
+ Logger.info("QA1121 real profile response held", context: "TestHook") // test hook
+ while FileManager.default.fileExists(atPath: gate.path) { // test hook
+ try await Task.sleep(for: .milliseconds(200)) // test hook
+ } // test hook
+ Logger.info("QA1121 profile response released; can apply: \(publicKey == pk && profileWriteGeneration == read.generation)", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
if publicKey == pk, profileWriteGeneration == read.generation {
profile = loadedProfile
readOnlyProfile = nil
cacheProfileMetadata(loadedProfile)
+ #if DEBUG // test hook
+ Logger.info("QA1121 profile applied: \(loadedProfile.name)", context: "TestHook") // test hook
+ #endif // test hook
}
} catch {
Logger.error("Failed to load profile: \(error)", context: "PubkyProfileManager")
@@ -1964,7 +1991,24 @@ class PubkyProfileManager: ObservableObject {
}
private nonisolated static func initializePersistedSession() async throws -> SessionInitializationResult {
- try await PaykitSdkService.shared.restorePersistedSession()
+ #if DEBUG // test hook
+ Logger.info("QA1121 initialize persisted session invoked", context: "TestHook") // test hook
+ let gate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-identity") // test hook
+ if FileManager.default.fileExists(atPath: gate.path) { // test hook
+ Logger.info("QA1121 final identity initialization held", context: "TestHook") // test hook
+ while FileManager.default.fileExists(atPath: gate.path) { // test hook
+ try await Task.sleep(for: .milliseconds(200)) // test hook
+ } // test hook
+ Logger.info("QA1121 final identity initialization released", context: "TestHook") // test hook
+ } // test hook
+ let reset = Env.appStorageUrl.appendingPathComponent("qa1121-reset-test-grant") // test hook
+ if FileManager.default.fileExists(atPath: reset.path) { // test hook
+ try Keychain.delete(key: .paykitSession) // test hook
+ try FileManager.default.removeItem(at: reset) // test hook
+ Logger.info("QA1121 disposable saved grant reset; keeping original local secret", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
+ return try await PaykitSdkService.shared.restorePersistedSession() // test hook
}
private nonisolated static func notifyAppStateBackupChanged() {
diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift
index c1ea78c7..4f8283e1 100644
--- a/Bitkit/Services/VssBackupClient.swift
+++ b/Bitkit/Services/VssBackupClient.swift
@@ -67,6 +67,9 @@ actor VssClientCoordinator {
private func closeAdmission() async {
resetting = true
+ #if DEBUG // test hook
+ Logger.info("QA1121 VSS admission closed; active calls: \(activeCalls)", context: "TestHook") // test hook
+ #endif // test hook
generation = UUID()
setups.values.forEach { $0.task.cancel() }
if activeCalls > 0 {
@@ -237,6 +240,16 @@ class VssBackupClient {
return item
} else {
Logger.debug("VSS 'getObject' success null for '\(key)'", context: "VssBackupClient")
+ #if DEBUG // test hook
+ if key == "QA1121-HOLD" { // test hook
+ let gate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-vss") // test hook
+ Logger.info("QA1121 real VSS read completed; response held inside admission", context: "TestHook") // test hook
+ while FileManager.default.fileExists(atPath: gate.path) { // test hook
+ try? await Task.sleep(for: .milliseconds(200)) // test hook
+ } // test hook
+ Logger.info("QA1121 VSS response released", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
return nil
}
} catch {
diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
index 31b84c1a..9c0d7555 100644
--- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
+++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
@@ -39,6 +39,7 @@ private struct StatusItemView: View {
struct DataBackupsScreen: View {
@StateObject private var viewModel = BackupViewModel()
+ @EnvironmentObject private var pubkyProfile: PubkyProfileManager // test hook
private var allSynced: Bool {
BackupCategory.allCases.allSatisfy { category in
@@ -50,6 +51,15 @@ struct DataBackupsScreen: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
NavigationBar(title: t("settings__data_backups_nav_title"))
+ #if DEBUG // test hook
+ if FileManager.default.fileExists(atPath: Env.appStorageUrl.appendingPathComponent("qa1121-controls").path) { // test hook
+ Button("QA1121: terminal identity failure") { Task { await qaTerminalIdentity() } }.accessibilityIdentifier("QA1121TerminalFault") // test hook
+ Button("QA1121: unavailable identity backup") { Task { await qaUnavailableIdentityBackup() } } // test hook
+ .accessibilityIdentifier("QA1121BackupFault") // test hook
+ Button("QA1121: stage required restore error") { Task { await qaRequiredRestoreError() } } // test hook
+ .accessibilityIdentifier("QA1121RequiredFault") // test hook
+ } // test hook
+ #endif // test hook
GeometryReader { geometry in
ScrollView(showsIndicators: false) {
@@ -98,4 +108,80 @@ struct DataBackupsScreen: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
}
+
+ #if DEBUG // test hook
+ @MainActor private func qaTerminalIdentity() async { // test hook
+ do { // test hook
+ let before = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] // test hook
+ let values = try keys.map { try Keychain.load(key: $0) } // test hook
+ do { // test hook
+ defer { // test hook
+ for (key, value) in zip(keys, values) { // test hook
+ if let value { try? Keychain.upsert(key: key, data: value) } else { try? Keychain.delete(key: key) } // test hook
+ } // test hook
+ } // test hook
+ try await PaykitSdkService.shared.forgetSessionAccess() // test hook
+ try Keychain.delete(key: .pubkySecretKey) // test hook
+ try Keychain.upsert(key: .paykitSession, data: Data("invalid-session".utf8)) // test hook
+ await pubkyProfile.initialize() // test hook
+ let terminal = pubkyProfile.sessionRestorationFailed // test hook
+ let active = try? await PaykitSdkService.shared.currentPublicKey() // test hook
+ let index = try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: "qa1121-terminal-" + UUID().uuidString) // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let after = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 terminal: restorationFailed: \(terminal), sdk unavailable: \(active == nil), ordinary reservation index: \(index), bytes unchanged: \(before?.value == after?.value), version unchanged: \(before?.version == after?.version), pending: \(pending)", context: "TestHook") // test hook
+ } // test hook
+ await pubkyProfile.initialize() // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 terminal manual credential recovery: pending: \(pending)", context: "TestHook") // test hook
+ } catch { // test hook
+ Logger.error("QA1121 terminal control error: \(error)", context: "TestHook") // test hook
+ } // test hook
+ } // test hook
+
+ @MainActor private func qaUnavailableIdentityBackup() async { // test hook
+ do { // test hook
+ let before = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] // test hook
+ let values = try keys.map { try Keychain.load(key: $0) } // test hook
+ do { // test hook
+ defer { // test hook
+ for (key, value) in zip(keys, values) { // test hook
+ if let value { try? Keychain.upsert(key: key, data: value) } else { try? Keychain.delete(key: key) } // test hook
+ } // test hook
+ } // test hook
+ try await PaykitSdkService.shared.forgetSessionAccess() // test hook
+ try Keychain.upsert(key: .paykitSession, data: Data([0xFF])) // test hook
+ let active = try await PaykitSdkService.shared.currentPublicKey() // test hook
+ Logger.info("QA1121 backup fault staged; SDK identity absent: \(active == nil)", context: "TestHook") // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let after = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 blocked backup: bytes unchanged: \(before?.value == after?.value), version unchanged: \(before?.version == after?.version), pending: \(pending)", context: "TestHook") // test hook
+ } // test hook
+ _ = try await PaykitSdkService.shared.restorePersistedSession() // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let fresh = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let data = try JSONSerialization.jsonObject(with: fresh!.value) as! [String: Any] // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 recovered backup: SDK state present: \(data["paykitSdkBackupState"] is String), pending: \(pending)", context: "TestHook") // test hook
+ } catch { // test hook
+ Logger.error("QA1121 backup control error: \(error)", context: "TestHook") // test hook
+ } // test hook
+ } // test hook
+
+ @MainActor private func qaRequiredRestoreError() async { // test hook
+ do { // test hook
+ guard let item = try await VssBackupClient.shared.getObject(key: "WALLET") else { return } // test hook
+ try item.value.write(to: Env.appStorageUrl.appendingPathComponent("qa1121-valid-wallet")) // test hook
+ try Keychain.upsert(key: .paykitPendingBackupRestore, data: Data("invalid-json".utf8)) // test hook
+ Logger.info("QA1121 invalid retained WALLET staged; original recovery payload saved", context: "TestHook") // test hook
+ } catch { // test hook
+ Logger.error("QA1121 required-restore control error: \(error)", context: "TestHook") // test hook
+ } // test hook
+ } // test hook
+ #endif // test hook
}Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal-identity WALLET backup failure remains reproducible. The expired-cleanup repair holds.
Findings: No new findings in the bf4061f..485a9dc delta. The existing HIGH at Bitkit/Services/PrivatePaykitService+Backup.swift:12 remains open: unusable saved identity credentials prevent newer ordinary wallet state from reaching a complete WALLET backup until credentials are repaired.
QA for 485a9dc: iPhone 17 Pro simulator, iOS 26.5, Xcode 27.0, committed package revisions, disposable unfunded staging-regtest wallets. Built the unmodified head for native checks; app cases used the unpushed Debug controls below.
Expired cleanup cannot mutate a replacement wallet ✅ The committed assertions pass; restoring the old service reproduces all four newly covered failures.
All 333 selected clean-head tests passed with no failures or skips, including SDK identity/operation-lock, wipe, backup and profile recovery coverage. The five cleanup cases suspend registry, backup-revision, capability and peer reads, or the first link recovery; each finishes the wipe and creates the replacement SDK before releasing the old work. They assert no subsequent mutation and no recreated old key-generation entry. An already-started recovery may finish.
With only PubkyService.swift restored from bf4061f, the registry case passes and the four new cases fail on stale endpoint publication, app publication or link recovery. A recorded five-case repeat also passes on the Debug-control build with its hooks inactive. Assertions were checked in finalized XCTest results; the recording shows the test host. The clean-head result retains three QoS runtime warnings, without an established cause.
cleanup-repeat-2x.2x.mp4
Five-case repeat at 2×; these tests use isolated SDK fakes.
Expire remote cleanup, then use a replacement wallet ✅ Wipe finishes after the cleanup deadline; the released response stops at cancelled mutation admission, and replacement Receive remains usable.
Held a completed real SDK backup-revision response inside public endpoint cleanup, then let the ordinary Settings reset exceed its cleanup deadline. Wipe completed and returned to onboarding. Created a new regtest wallet while the old response remained held, opened Receive, then released the response. Logs show cancelled: true at the new mutation admission and CancellationError from cleanup. Receive still copied a bitcoin:bcrt1… URI, checked from the simulator clipboard.
The recording shows reset, replacement-wallet creation and Receive. The hold, deadline and cancelled-admission assertions were checked in the captured app console. This does not cancel or prove rollback of a native mutation already in flight.
reset-replacement-2x.2x.mp4
Reset and replacement Receive at 2×; invisible state checked in logs.
Back up ordinary wallet changes after terminal identity failure ❌ A later ordinary account reservation remains unbacked-up until credential repair.
With an invalid saved session and no local secret, the actual resolver reached its no-secret return. A subsequent ordinary watch-only account reservation left remote WALLET bytes/version unchanged and work pending. Capture raised the SDK Identity error before sessionNotActive. Restoring the original captured credentials permitted upload and cleared pending work. These assertions were checked through real VSS reads and the app console.
The sampled sessionRestorationFailed UI flag was false; it is not used to assert the resolver outcome. The supplied no-secret input and the resolver's No secret key to recover session line establish the branch exercised. This adds evidence to the existing finding.
terminal-backup-2x.2x.mp4
Focused excerpt at 2× showing the control and disconnected-profile state; record comparisons checked in logs.
Check: retained restore and profile evidence ✅ Earlier passing reports cover the unchanged restore ordering and profile recovery paths.
- The bf4061f report covers required recovery completing while optional initialization is held, complete-backup protection/recovery, restart with one saved contact, required WALLET failure/retry and concurrent VSS reset draining.
- The profile recovery report covers missing-profile recovery on Home and setup-pending suppression.
- The delayed-profile report covers rejection after disconnect.
Those restore scheduling, profile read/apply and VSS admission paths are unchanged since their passing reports. This delta adds SDK mutation admission; its newly affected cleanup boundary was driven above. These earlier device cases were not repeated. No required case is unrun in this delta. No new populated-USDT recovery, funded-payment replay, live homeserver blackout or running-LDK quiescence is claimed.
Coverage: contiguous bf4061f..485a9dc, all three changed files and reached cleanup, deadline, locking, revision tracking, identity and backup paths. Independent source review found no further defect; postcommit payment/proof callers retain durable recovery records when admission is cancelled. Changed-file SwiftFormat 0.64.0 and delta whitespace checks pass. The source-only compatibility checks do not establish additional device journeys.
Twin: synonymdev/bitkit-android#1686 fix: decouple wallet restore from profile refresh at a2c58f2 matches optional restore scheduling, missing-profile retry and the acknowledged complete-backup limitation. Android preserves an identity activated during required recovery; iOS applies profile-manager authentication after final initialization. This delta hardens iOS cleanup admission; Android device testing was not repeated.
Executed Debug controls and deciding evidence
Not pushed. One control temporarily supplies invalid local credentials to the actual resolver and backup writer, compares real VSS records, then restores the captured credentials. The other holds a real completed SDK revision read during public wipe cleanup until explicit release. Neither mocks SDK results nor changes restore, wipe or mutation-admission decisions.
No secret key to recover session
Backup failed for: 'WALLET': Identity(code: "identity_error", context: "restore Pubky grant session from platform provider")
terminal: restorationFailed: false, sdk unavailable: true, ordinary reservation index: 1,
bytes unchanged: true, version unchanged: true, pending: true
terminal manual credential recovery: pending: false
real backup revision read complete; cleanup response held
Remote cleanup timed out, continuing the wipe
Wipe completed
cleanup response released; cancelled: true
cleanup mutation admission; cancelled: true
Failed to remove public Paykit state before clearing session: CancellationError()
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index a44b9dc1..1108d96d 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -423,6 +423,10 @@ enum PubkyService {
// MARK: - Paykit SDK Runtime
actor PaykitSdkService {
+ #if DEBUG // test hook
+ @TaskLocal // test hook
+ static var qaCleanup: Bool = false /* test hook */
+ #endif // test hook
typealias BootstrapFactory = (String, PubkyClientConfig) throws -> PubkySessionBootstrap
private enum OperationDeferral: Error {
@@ -1735,6 +1739,9 @@ actor PaykitSdkService {
}
private func checkMutationAdmission() throws {
+ #if DEBUG // test hook
+ if Self.qaCleanup { Logger.info("QA1121 cleanup mutation admission; cancelled: \(Task.isCancelled)", context: "TestHook") } // test hook
+ #endif // test hook
try Task.checkCancellation()
try operationLock.checkWipeOwnership()
}
@@ -1781,7 +1788,19 @@ actor PaykitSdkService {
Logger.debug("Read Paykit backup revision duration=\(started.duration(to: clock.now))", context: "PaykitSdkService")
}
#endif
- return try await self.withSdkErrorHandling { try await sdk.backupStateRevision() }
+ let revision = try await self.withSdkErrorHandling { try await sdk.backupStateRevision() } // test hook
+ #if DEBUG // test hook
+ let gate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-cleanup") // test hook
+ if Self.qaCleanup, FileManager.default.fileExists(atPath: gate.path) { // test hook
+ Logger.info("QA1121 real backup revision read complete; cleanup response held", context: "TestHook") // test hook
+ while FileManager.default.fileExists(atPath: gate.path) { // test hook
+ try? await Task.sleep(for: .milliseconds(200)) // test hook
+ if Task.isCancelled { try? await Task.detached { try await Task.sleep(for: .milliseconds(200)) }.value } // test hook
+ } // test hook
+ Logger.info("QA1121 cleanup response released; cancelled: \(Task.isCancelled)", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
+ return revision // test hook
},
readStateRevision: { try sdk.stateRevision() },
readObservedSnapshot: {
diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift
index 49724dd4..563a770e 100644
--- a/Bitkit/Utilities/AppReset.swift
+++ b/Bitkit/Utilities/AppReset.swift
@@ -75,7 +75,13 @@ final class AppReset: WipeStepExecutor {
try await wallet.stopForWipe()
case .removeRemoteEndpoints:
await Self.runRemoteCleanup {
- await PubkyProfileManager.removePublicPaykitEndpointsForWipe(context: "AppReset.wipe")
+ #if DEBUG // test hook
+ await PaykitSdkService.$qaCleanup.withValue(true) { // test hook
+ await PubkyProfileManager.removePublicPaykitEndpointsForWipe(context: "AppReset.wipe") // test hook
+ } // test hook
+ #else // test hook
+ await PubkyProfileManager.removePublicPaykitEndpointsForWipe(context: "AppReset.wipe") // test hook
+ #endif // test hook
}
await Self.runRemoteCleanup {
await PubkyProfileManager.removePrivatePaykitEndpointsForWipe(context: "AppReset.wipe")
diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
index 31b84c1a..e5882952 100644
--- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
+++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
@@ -39,6 +39,7 @@ private struct StatusItemView: View {
struct DataBackupsScreen: View {
@StateObject private var viewModel = BackupViewModel()
+ @EnvironmentObject private var pubkyProfile: PubkyProfileManager // test hook
private var allSynced: Bool {
BackupCategory.allCases.allSatisfy { category in
@@ -50,6 +51,9 @@ struct DataBackupsScreen: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
NavigationBar(title: t("settings__data_backups_nav_title"))
+ #if DEBUG // test hook
+ Button("QA1121: terminal identity failure") { Task { await qaTerminalIdentity() } }.accessibilityIdentifier("QA1121TerminalFault") // test hook
+ #endif // test hook
GeometryReader { geometry in
ScrollView(showsIndicators: false) {
@@ -98,4 +102,39 @@ struct DataBackupsScreen: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
}
+ #if DEBUG // test hook
+ @MainActor private func qaTerminalIdentity() async { // test hook
+ do { // test hook
+ let before = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] // test hook
+ let values = try keys.map { try Keychain.load(key: $0) } // test hook
+ do { // test hook
+ defer { // test hook
+ for (key, value) in zip(keys, values) { // test hook
+ if let value { try? Keychain.upsert(key: key, data: value) } else { try? Keychain.delete(key: key) } // test hook
+ } // test hook
+ } // test hook
+ try await PaykitSdkService.shared.forgetSessionAccess() // test hook
+ try Keychain.delete(key: .pubkySecretKey) // test hook
+ try Keychain.upsert(key: .paykitSession, data: Data("invalid-session".utf8)) // test hook
+ await pubkyProfile.initialize() // test hook
+ let terminal = pubkyProfile.sessionRestorationFailed // test hook
+ let active = try? await PaykitSdkService.shared.currentPublicKey() // test hook
+ let index = try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: "qa1121-terminal-" + UUID().uuidString) // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let after = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 terminal: restorationFailed: \(terminal), sdk unavailable: \(active == nil), ordinary reservation index: \(index), bytes unchanged: \(before?.value == after?.value), version unchanged: \(before?.version == after?.version), pending: \(pending)", context: "TestHook") // test hook
+ } // test hook
+ await pubkyProfile.initialize() // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info("QA1121 terminal manual credential recovery: pending: \(pending)", context: "TestHook") // test hook
+ } catch { // test hook
+ Logger.error("QA1121 terminal control error: \(error)", context: "TestHook") // test hook
+ } // test hook
+ } // test hook
+
+ #endif // test hook
+
}Reviewed by gpt-6.1-sol-high
|
Device gate (partial):
|
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: full reassessment of 485a9dc1 against merge base 1b88e0bd, because the comparison base changed and no usable baseline was supplied. Covered the entire PR delta and affected restore, identity, backup and wipe paths. Relevant parity was checked at Android a2c58f24; this was not a full Android review.
1 actionable finding — resolve or provide an evidence-backed rebuttal.
The existing HIGH terminal-identity backup concern remains open and source-verified: unusable saved identity credentials can keep newer ordinary wallet state out of complete WALLET backups. Temporary recovery does not resolve that case.
Validation: local diff and journey XML checks passed. Inspected matching native CI and SwiftFormat logs, including the changed regression cases; both succeeded on the merge of these exact pins. Native fixtures include skips. Swift/Xcode are unavailable locally; the Rust probe toolchain could not start. The new timeout finding is source analysis.
Device testing: not performed in this review. Authenticated J1 fault cases remain unexecuted here; committed complete-app fault controls are unavailable.
Suggested additional test cases
- iOS, disposable wallet with Dev Settings enabled: hold only the debug LDK client’s LNURL-auth response open while VSS Debug loads keys, return to Security and reset. After the timeout fix, expect a finite completion or retryable failure, with no native call surviving credential deletion.
Findings
- [MEDIUM] Bound LNURL authentication before draining VSS for wipe — inline at
Bitkit/Services/VssBackupClient.swift:73.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
| generation = UUID() | ||
| setups.values.forEach { $0.task.cancel() } | ||
| if activeCalls > 0 { | ||
| await withCheckedContinuation { idleWaiters.append($0) } |
There was a problem hiding this comment.
[MEDIUM] Bound LNURL authentication before draining VSS for wipe
When a key read on VSS Debug’s LDK tab needs a fresh JWT and the LNURL-auth server accepts the connection but holds its response, leaving that screen and choosing Security → Reset would hang before credential deletion. The new closeAdmission() waits for every admitted call, but the pinned VSS dependency's LNURL provider builds a separate reqwest client with no timeout. Its header lookup runs before the VSS client's 10-second request deadline; retry limits only apply after an error returns. The pinned Swift async bridge does not forward task cancellation, so leaving Debug or cancelling setup cannot release this wait. AppReset.withLocalStateWipeScope has no deadline, leaving the durable wipe gated without reaching failure/retry until the auth response finishes or the process restarts. Give native authentication a finite deadline and ensure it settles before retirement; retain the drain safeguard. Add a stalled-auth regression that verifies wipe reaches completion or a retryable failure within a bounded time. Evidence: source analysis of the pinned app and dependency sources; no runtime reproduction.
There was a problem hiding this comment.
Confirmed in the pinned native provider: its authentication await precedes the VSS request timeout. I published the source fix in synonymdev/vss-rust-client-ffi#36 at 1a5b788ceabac22739a9c9deabef67fea071b644. It directly bounds header acquisition to ten seconds in both app and dedicated-LDK factories, retaining the original provider, signer, cache and store. The four-row real-provider loopback regression holds challenge/callback headers or bodies, verifies rejection before any VSS write and no late write, then verifies same-client retry/cache reuse. All 28 Rust tests and private platform binding/consumer checks passed; new hosted checks are running.
This is not yet delivered to this app: the existing SDK pin remains unchanged, and release/adoption plus the installed-app stalled-auth/drain case remain outstanding. The ten-second limit is per header acquisition, not a universal wipe deadline; ordinary retries remain. I am retaining this thread as unresolved and preserving admission closure and drain-before-retirement.
There was a problem hiding this comment.
Verified against the exact dependency pinned at 111ddd8: the cached vss-client-ng 0.4.0 archive SHA-256 matches Cargo.lock (52c6bf7f2c3e22e62c638ad7d8c48dd5dc7e79033c5e088bdd797bbc815b29bb), and the provider/client source bytes match that archive. Authentication still awaits a separate reqwest client without a timeout before the VSS request deadline starts. VSS remains pinned to eba41ef6 at this reviewed head, so the upstream source fix is not delivered there. The installed-app stalled-auth/reset case was not driven because the head moved during the current outage case; this thread remains unresolved.
There was a problem hiding this comment.
Reproduced in the installed app at 1c3cde6. I held the dedicated LDK client's real LNURL-auth challenge response, left VSS Debug, and reset through Settings. Admission closed with one call active; Resetting Bitkit stayed at step 4/12 for over 113 seconds without Retry. The challenge was held for 206 seconds in total. Releasing it allowed the wipe to return to Terms. The endpoint override and admission logs were unpushed Debug controls; native authentication and drain decisions were unchanged. This head still pins eba41ef6, so the upstream timeout fix is not delivered. The thread remains unresolved.
jvsena42
left a comment
There was a problem hiding this comment.
Device gate result: nothing above LOW, approving. The code review at 25f25eaed stands. The PR-owned delta since is the merge resolution in bf4061f50 (the VSS withSuspendedClients wait and the wipe split into withLocalStateWipeScope), which I read and found clean, plus the #1120 commit 1b88e0bd2, approved there.
Device gate: 485a9dc12 — journeys: 1 run (restore-during-identity-outage.xml), 5 of 7 actions passed, 1 partial, 1 not run; perf: restore tap → success screen 78.7 s with the homeserver held vs 87.8 s with no fault, cold start → Home 10.0 s held vs 9.2 s; Figma: n/a; l10n: no UI text changed; iOS only.
Setup: Debug build (regtest, staging VSS/homegate/homeserver) on the iPhone 17 Pro simulator; a fresh wallet with USDT and a staging profile, 200,000 sats and every backup synced. Homeserver and VSS traffic went through a local CONNECT proxy (HTTPS_PROXY for the Rust clients) that holds, refuses or cuts connections.
- A1 PASS. With the homeserver held from "Full restore success", Home came up with the placeholder header and Paykit was not reported restored. Receive gave an address, and a 20,000 sat send succeeded.
- A2 PASS. The send's WALLET backup failed after a 30 s SDK timeout (
Identity … restore Pubky grant session) and stayed Required. The VSS export still held the complete 10:33 WALLET record, includingpaykitSdkBackupState. - A3 PARTIAL. After release, the session came back by re-sign-in 12 s later, the retained WALLET backup completed exactly once, and the header switched to the profile on Home. METADATA, which had failed with
walletBackupFailed, stayed Required until a manual retry 5 min 48 s later. That path is unchanged from master; details in a separate comment. - A4 PASS. The profile read was refused, then held while the onchain sync completed. After release the saved profile replaced the placeholder 6.8 s later, staying on Home, with no session re-import.
⚠️ A5 not run. Held enrichment requests did not come back late: they failed after about 25-30 s withSharedStateBusy/ lock timeouts. Profile removal waits for the same Pubky write lock, and the "Yes, Delete" alert ignored taps after Device Hub relaunched. The SDK lock may make this case unreachable.- A6 PASS. A cold start with the homeserver held reached Home in 10.0 s with the cached header and kept the saved session for retry. After release it re-signed in 13.9 s later with no user action, and nothing was duplicated (same 2 activity items, balance unchanged, 0 contacts).
- A7 PASS. VSS refused from "Full restore starting" gave the spending-balance error with only Try Again, and no Home, Send, Receive or address. The pending Paykit/USDT restore markers were kept across a restart. Lifting the fault and tapping Try Again re-ran the same restore to Home.
Skipped — covered by CI at 485a9dc12: unit tests, integration tests, local e2e (all green).
|
Outside this PR's scope, no change needed here. Suggesting a follow-up issue in bitkit-ios. METADATA stays Required after a WALLET backup failure until a manual retry
Observed on this PR's gate (iOS simulator, Log linesFull log: Steps a user can walk: hold or cut homeserver traffic, make a change that backs up both WALLET and Tags (for example a send with a tag), let the WALLET backup fail, restore connectivity, and watch Settings → Data backups: Tags stays "Required" after WALLET shows synced. |
{"kind":"bitkit-retarget-execution-v1","repository":"synonymdev/bitkit-ios","pr":1121,"generation":"42035266437-1","sourceSha":"5dec742a68f1fa0514b55b03f4102bdf075b331a","sourceRef":"master","owner":"github-actions[bot]"}
{"kind":"bitkit-retarget-execution-v1","repository":"synonymdev/bitkit-ios","pr":1121,"generation":"42035790585-1","sourceSha":"5dec742a68f1fa0514b55b03f4102bdf075b331a","sourceRef":"5dec742a68f1fa0514b55b03f4102bdf075b331a","owner":"github-actions[bot]"}
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at 485a9dc12: the retarget to master dismissed the earlier approval. Head is unchanged.
|
@ben-kaufman this conflicts with |
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the complete PR diff against its merge base at 111ddd88, because the comparison base changed (1b88e0b → 8167628a) and target-branch changes touch BitkitTests/PrivatePaykitServiceTests.swift. No coverage is inherited.
2 actionable findings — resolve or provide an evidence-backed rebuttal.
The remaining concern is backup availability when a stored identity cannot activate. The restore scheduling, reconnect profile retry, VSS admission and drain, wipe ordering and cached Blocktank reads traced cleanly at this revision. The VSS drain has no Swift deadline, but native calls are time-limited: vss-rust-client-ffi eba41ef6 caps retries, and vss-client v0.4.0 sets a 10-second request timeout. I read that crate from its GitHub tag because crates.io was blocked in this environment, so I did not compare it with the Cargo.lock checksum.
Parity: the Android twin at a2c58f24 has matching restore scheduling and missing-profile retry, and the same backup block. It also throws a non-temporary failure when the identity is unavailable, so the finding below applies to both platforms. This was a scoped comparison, not a full Android review.
Validation: source analysis only. I ran no tests or builds. At review time SwiftFormat and translation validation had passed. Unit tests, integration tests and e2e were still in progress, so the merged head has no completed test result here. The PR description mixes requirements with earlier review conclusions and acknowledges an open terminal-identity availability concern, so this discovery is not fully blind to that topic. The finding was traced independently from source.
Device testing: not performed in this review. J1 remains unexecuted, and the PR states its complete-app fault controls are not provisioned.
Recommended before device testing: wait for the unit, integration and e2e runs above to finish on this head.
Suggested additional test cases
- iOS, terminal identity: Use a disposable wallet with a stored session that the homeserver rejects non-transiently and no reachable secret key. Change WALLET data such as a transfer, then foreground the app and tap manual backup. Expected: no identity-free upload, and a clear path for the user to resume WALLET and METADATA backups. After Profile sign-out, both categories should upload fresh data.
- iOS, transient outage: Restore a disposable wallet with a saved identity while session activation fails with a transport error, so that WALLET and METADATA attempts fail. Release the fault and keep the app in the foreground with no further metadata changes. Expected: WALLET and METADATA both upload without backgrounding the app, and no 30-minute backup failure alert appears.
- iOS, restore sign-in delay: Restore a local-seed identity while holding the sign-in inside metadata credential replacement (
restoreSessionBackupState), which J1 deliberately keeps available. Expected: restore still reaches Home within a bounded time. This sign-in is unchanged by this PR and remains awaited on the restore path.
Earlier findings reconciled at this revision: 2 still present, 3 verified fixed, 1 duplicate.
Findings
- [MEDIUM] Bound LNURL authentication before draining VSS for wipe — inline at
Bitkit/Services/VssBackupClient.swift:73. Still present from the earlier review. - [LOW] METADATA stays Required after WALLET recovers until a later trigger — METADATA stays Required after WALLET recovers until a later trigger
Raised earlier in review discussion; still present at 111ddd8: Trigger: WALLET fails with a non-temporary error while METADATA is waiting on it, then WALLET later succeeds. Mechanism: prepareMetadataBackup throws walletBackupFailed, that error is not temporary, and the scheduler stops. Identity activation marks only WALLET as changed, and the per-category status observer drops unchanged METADATA values, so WALLET's recovery does not re-trigger METADATA. The periodic task only checks for failure alerts. Consequence: METADATA stays Required and remote tags stay stale until the app foregrounds, relaunches, tags change or the user retries manually. This answers the reporter's open question: a foreground cycle does retry METADATA through retryPendingBackups, by source analysis, so the state is delayed rather than permanent. The reporter called this outside the PR, and the observed run took the unchanged Identity route. The PR does add a new entry through the sessionNotActive guard, so it is classified as worsened with LOW severity. No tracking issue was found, so tracked_separately does not apply. Evidence basis: source analysis plus the reporter's attributed device log at 485a9dc; the sessionNotActive route into this METADATA state was not exercised. Expected behaviour: METADATA is rescheduled when the WALLET backup it was waiting on completes.
Source. Summary placement: The stranding logic is in unchanged BackupService.swift lines outside the PR diff; the PR's new entry into it is the guard already anchored by the discovery finding at PrivatePaykitService+Backup.swift:12. Still present from an earlier unpublished review.
Existing concerns
- [HIGH] Stored identity that cannot activate blocks WALLET and METADATA backups indefinitely — still unresolved; already tracked.
Reviewer model: claude-opus-5-5
Reasoning effort: high
| generation = UUID() | ||
| setups.values.forEach { $0.task.cancel() } | ||
| if activeCalls > 0 { | ||
| await withCheckedContinuation { idleWaiters.append($0) } |
There was a problem hiding this comment.
[MEDIUM] Bound LNURL authentication before draining VSS for wipe
When a key read on VSS Debug’s LDK tab needs a fresh JWT and the LNURL-auth server accepts the connection but holds its response, leaving that screen and choosing Security → Reset would hang before credential deletion. The new closeAdmission() waits for every admitted call, but the pinned VSS dependency's LNURL provider builds a separate reqwest client with no timeout. Its header lookup runs before the VSS client's 10-second request deadline; retry limits only apply after an error returns. The pinned Swift async bridge does not forward task cancellation, so leaving Debug or cancelling setup cannot release this wait. AppReset.withLocalStateWipeScope has no deadline, leaving the durable wipe gated without reaching failure/retry until the auth response finishes or the process restarts. Give native authentication a finite deadline and ensure it settles before retirement; retain the drain safeguard. Add a stalled-auth regression that verifies wipe reaches completion or a retryable failure within a bounded time. Evidence: source analysis of the pinned app and dependency sources; no runtime reproduction.
Still present at 111ddd8 (first reported at 485a9dc): The mechanism is still present at the pinned head. closeAdmission() waits for every admitted call with no deadline, and neither runAttempt nor withLocalStateWipeScope adds one. The pinned LNURL provider builds a reqwest client with no timeout and awaits it in get_headers, before the VSS client's 10-second request timeout applies. Retry limits only apply after an error returns, and the Swift async bridge does not forward task cancellation. A stalled auth response therefore holds the local wipe until the response finishes or the process restarts. The author confirmed this on the thread and opened vss-rust-client-ffi#36 at 1a5b788c, but that PR is open and unmerged, and this head still pins eba41ef6, so the fix is not delivered. The thread is unresolved. The frozen discovery summary says native calls are time-limited; that holds for the VSS request but not for this header acquisition step, and this disposition is the accurate statement. Evidence basis: source analysis, with no runtime reproduction. vss-client-ng and reqwest were read from GitHub tags because crates.io and docs.rs were blocked, so they were not compared with the Cargo.lock checksums; the author's confirmation against the pinned provider corroborates the reading.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal-identity WALLET backup failure reproduces at 111ddd8.
The PR moved to 1c3cde6 after this run; these results are for 111ddd8.
Findings: No new findings in the delta.
- HIGH — existing:
Bitkit/Services/PrivatePaykitService+Backup.swift:12. Unusable saved identity credentials prevent newer ordinary wallet state from reaching a complete WALLET backup. The open terminal-identity thread remains unresolved; the case below adds a verified complete baseline and a remote read after credential repair. - MEDIUM — existing:
Bitkit/Services/VssBackupClient.swift:73. The stalled-authentication drain concern remains at this reviewed head. It pins VSSeba41ef6; the native LNURL provider awaits authentication before the VSS request timeout and has no timeout of its own. I verified the exactvss-client-ng 0.4.0archive against the pinned Cargo checksum and compared its provider/client source bytes. The upstream source fix is not adopted at111ddd8. This is source evidence; the installed-app stalled-auth reset case was not driven.
QA for 111ddd8: iPhone 17 Pro simulator, iOS 26.5, Xcode 27.0, committed package pins, disposable unfunded staging-regtest wallet. Native checks used the unmodified head. App cases used the unpushed controls below.
Restore, identity, payment and wipe regressions ✅ The focused clean-head native checks passed; assertions were checked in the finalized XCResult.
Built the app and ran xcodebuild test for profile initialization, private payments/backups, backup scheduling/foreground recovery, VSS coordination, wipe coordination, scanned-payment navigation, send preparation, USDT links, cached Blocktank capture and SDK operation locking. There were no failures or skips. The result retains three QoS runtime warnings. Optional simulator diagnostic collection was stopped after the tests finished; Xcode finalized successfully.
Test-host excerpt at 2×; the assertions are in XCTest results and the test log.
Back up ordinary wallet changes after terminal identity failure ❌ A later ordinary account reservation stayed pending until credential repair, as checked through real VSS reads.
The baseline WALLET record existed and decoded with non-nil SDK backup state. An invalid saved session with no local secret reached the real resolver's No secret key to recover session branch. A subsequent watch-only account reservation could not be backed up: the same non-nil remote record remained byte-identical and WALLET stayed Required. Capture raised the SDK Identity error before the sessionNotActive guard.
Restoring the captured credentials allowed a complete upload. A fresh remote read decoded with SDK state and the new reservation included; pending work cleared. These assertions were checked in timestamped app logs. The recording shows the control and backup screen.
Terminal failure and credential repair at 2×; remote-state assertions checked in logs.
Temporary WALLET and Tags failures retry ✅ Both categories uploaded automatically about 62 seconds after connectivity returned, checked in timestamped app logs.
A CONNECT proxy held real staging homeserver traffic while the control retained the saved credentials and restarted persisted-session initialization. An ordinary account reservation and Required WALLET/METADATA work were staged. Both backup attempts became temporarily unavailable. After releasing the proxy, WALLET uploaded at +62.2 seconds and METADATA at +62.3 seconds, with no intervening foreground event, user data change or manual initialization. A subsequent remote read and UI inspection confirmed both pending flags cleared. The Debug status line is a sampled inspection; the category rows update live.
This checks retry scheduling in the running app, using the existing Tags payload; it is not a new mnemonic restore. The later manual initialization happened after both successful uploads. It did not drive the non-temporary walletBackupFailed branch behind the earlier Tags report, which remains a separate existing observation. The 30-minute alert window was not observed.
| Temporary failure and recovery | Final status |
|---|---|
| Focused excerpt at 2×; automatic upload timing checked in logs. | Fresh inspection confirms WALLET and Tags are no longer pending. |
Required identity sign-in remains gated during restore ✅ Holding the real METADATA sign-in response kept required recovery gated; release resumed restore, checked in app logs and a subsequent Home snapshot.
Staged a valid retained WALLET payload and restarted. The actual local-seed sign-in succeeded, then its response was held inside restoreSessionBackupState for 82.1 seconds. Wallet actions remained unavailable. After release, full restore completed in 0.2 seconds and wallet initialization in 2.2 seconds; the subsequent UI snapshot showed Home.
This checks the required credential-replacement boundary, which J1 explicitly keeps available. It does not establish a backend sign-in deadline or ordinary restore latency. This awaited step is unchanged in the delta.
Controlled response hold and resumed setup at 2×; completion checked in logs and the subsequent UI snapshot.
Reset while VSS authentication is stalled ⏭️ Not driven because the head moved while the outage case was in progress.
The exact pinned native provider and Swift drain were source-checked, including the Cargo archive checksum. There is no installed-app timeout/reset result from this run.
Check: retained restore and profile evidence ✅ Earlier passing cases remain applicable to unchanged paths; they were not re-driven.
- The prior staging-proxy gate covers ordinary Receive and a funded send during optional identity outage, complete-backup protection, missing-profile recovery on Home, restart and required-recovery failure/retry. Its METADATA recovery action was partial, and its late-enrichment attempt was not run successfully.
- The earlier controlled restore report covers restore/restart, complete-backup protection/recovery, a saved contact across restart and concurrent VSS reset draining. Its underlying restore, profile, backup and VSS files are unchanged.
- The delayed-profile report verifies rejection after disconnect; its read/apply and disconnect guards remain unchanged.
- The required-USDT failure report covers malformed retained USDT recovery and retry. It is same-wallet retained recovery, not populated clean-install USDT recovery.
Check: incoming payment and contact fixes ✅ The relevant paths match earlier tested versions; source comparison supplies this check.
- The saved-contact device report at
e6956960covers both contact entry points, private-link pending behavior, mutual-save recovery and private resolution opening Amount. The relevant implementation and journey are unchanged. - The fixed-amount report covers immutable Bitcoin amounts, minimum rejection and fixed USDT review/error routing.
- The Bitcoin send report covers a 10,000-sat send with its displayed fee.
- The hardware fallback report covers switching when Savings cannot cover the fixed amount, through fee preview.
These reports retain their limits. This run adds no funded USDT send, hardware signing, injected private-link recovery, public-only/no-Noise peer, narrow funding-switch race, running-LDK quiescence or payment/proof replay acceptance.
Coverage: contiguous 485a9dc..111ddd8, all 44 changed files, reached callers and unresolved prior findings. Independent review found no further defect. The delta imports the payment/contact fixes above; the owned restore/profile/VSS implementation is unchanged. Incoming device journeys were covered by the attributed reports on unchanged paths. The live head advanced to 1c3cde6 during QA; this review and its results cover 111ddd8 only.
Twin: synonymdev/bitkit-android#1686 fix: decouple wallet restore from profile refresh at a2c58f2 matches optional initialization, missing-profile retry and the acknowledged complete-backup limitation. Android preserves an identity activated during required recovery; iOS applies profile-manager authentication after final initialization. Android device testing was not repeated.
Executed Debug controls and deciding evidence
Not pushed. Controls supply disposable credential/recovery inputs, hold a real completed METADATA sign-in response and inspect actual SDK/VSS records. The outage control restores captured credentials after clearing live access; it does not mock SDK results or change backup/restore decisions. The LDK endpoint override in the saved overlay was not exercised. Network release was the removal of the proxy's response gate.
[2026-11-05 22:25:10.944 UTC] WARN⚠️: No secret key to recover session - PubkyProfileManager [PubkyProfileManager.swift: resolveSessionInitialization(savedSessionSecret:storedSecretKeyHex:importSession:signInWithSecretKey:publicKeyFromSecretKey:) line: 2109]
[2026-11-05 22:25:11.065 UTC] INFOℹ️: QA1121 terminal baseline-present=true complete=true before=1 after=1 bytes-equal=true index=1 pending=true - TestHook [DataBackupsScreen.swift: qaTerminal() line: 148]
[2026-11-05 22:25:17.882 UTC] INFOℹ️: QA1121 terminal repair version=1 complete=true reservation-included=true pending=false - TestHook [DataBackupsScreen.swift: qaTerminal() line: 161]
[2026-11-05 22:26:04.555 UTC] INFOℹ️: QA1121 required metadata sign-in completed; response held - TestHook [PubkyProfileManager.swift: restoreSessionBackupState(_:loadKeychainString:persistKeychainString:deleteKeychainValue:removeOwnSharedRecords:forgetSessionAccess:signInWithSecretKey:) line: 1940]
[2026-11-05 22:27:26.666 UTC] INFOℹ️: QA1121 required metadata sign-in response released - TestHook [PubkyProfileManager.swift: restoreSessionBackupState(_:loadKeychainString:persistKeychainString:deleteKeychainValue:removeOwnSharedRecords:forgetSessionAccess:signInWithSecretKey:) line: 1944]
[2026-11-05 22:36:30.924 UTC] INFOℹ️: QA1121 inspect wallet-version=1 metadata-version=1 wallet-pending=false metadata-pending=false remote-outage-tag=true - TestHook [DataBackupsScreen.swift: qaInspect() line: 210]
[2026-11-05 22:38:28.514 UTC] WARN⚠️: Backup temporarily unavailable for: 'WALLET' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:38:28.515 UTC] WARN⚠️: Backup temporarily unavailable for: 'METADATA' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:38:28.519 UTC] INFOℹ️: QA1121 outage failed-attempt phase finished - TestHook [DataBackupsScreen.swift: qaOutage() line: 199]
[2026-11-05 22:38:59.866 UTC] WARN⚠️: Backup temporarily unavailable for: 'WALLET' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:38:59.867 UTC] WARN⚠️: Backup temporarily unavailable for: 'METADATA' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:39:33.181 UTC] WARN⚠️: Backup temporarily unavailable for: 'WALLET' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:39:33.184 UTC] WARN⚠️: Backup temporarily unavailable for: 'METADATA' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:39:35.013 UTC] INFOℹ️: QA1121 inspect wallet-version=1 metadata-version=1 wallet-pending=true metadata-pending=true remote-outage-tag=true - TestHook [DataBackupsScreen.swift: qaInspect() line: 210]
[2026-11-05 22:39:43.417 UTC] WARN⚠️: Backup temporarily unavailable for: 'WALLET' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:39:53.302 UTC] WARN⚠️: Backup temporarily unavailable for: 'WALLET' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:39:53.304 UTC] WARN⚠️: Backup temporarily unavailable for: 'METADATA' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:40:02.172 UTC] INFOℹ️: QA1121 inspect wallet-version=1 metadata-version=1 wallet-pending=true metadata-pending=true remote-outage-tag=true - TestHook [DataBackupsScreen.swift: qaInspect() line: 210]
[2026-11-05 22:40:04.003 UTC] WARN⚠️: Backup temporarily unavailable for: 'WALLET' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 456]
[2026-11-05 22:40:34.249 UTC] INFOℹ️: Backup succeeded for: 'WALLET' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 435]
[2026-11-05 22:40:34.301 UTC] INFOℹ️: Backup succeeded for: 'METADATA' - BackupService [BackupService.swift: runBackup(category:isScheduled:) line: 435]
[2026-11-05 22:42:29.930 UTC] INFOℹ️: QA1121 inspect wallet-version=1 metadata-version=1 wallet-pending=false metadata-pending=false remote-outage-tag=true - TestHook [DataBackupsScreen.swift: qaInspect() line: 210]
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index c18cde11..c5216a96 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -1933,7 +1933,18 @@ class PubkyProfileManager: ObservableObject {
try await PubkyService.forgetSessionAccess()
},
signInWithSecretKey: @escaping (String) async throws -> String = {
- try await PubkyService.signIn(secretKeyHex: $0)
+ let result = try await PubkyService.signIn(secretKeyHex: $0) // test hook
+ #if DEBUG // test hook
+ let gate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-metadata-signin") // test hook
+ if FileManager.default.fileExists(atPath: gate.path) { // test hook
+ Logger.info("QA1121 required metadata sign-in completed; response held", context: "TestHook") // test hook
+ while FileManager.default.fileExists(atPath: gate.path) {
+ try await Task.sleep(for: .milliseconds(200))
+ } // test hook
+ Logger.info("QA1121 required metadata sign-in response released", context: "TestHook") // test hook
+ } // test hook
+ #endif // test hook
+ return result // test hook
}
) async throws {
try await beginSessionMutation()
diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift
index c1ea78c7..a4019add 100644
--- a/Bitkit/Services/VssBackupClient.swift
+++ b/Bitkit/Services/VssBackupClient.swift
@@ -66,6 +66,7 @@ actor VssClientCoordinator {
}
private func closeAdmission() async {
+ Logger.info("QA1121 VSS close admission active=\(activeCalls)", context: "TestHook") // test hook
resetting = true
generation = UUID()
setups.values.forEach { $0.task.cancel() }
@@ -116,6 +117,9 @@ actor VssClientCoordinator {
private func finishCall() {
activeCalls -= 1
+ if resetting {
+ Logger.info("QA1121 VSS finish during reset remaining=\(activeCalls)", context: "TestHook")
+ } // test hook
if activeCalls == 0 {
let waiters = idleWaiters
idleWaiters.removeAll()
@@ -194,6 +198,11 @@ class VssBackupClient {
guard let params = try await getLnurlAuthParams(walletIndex: walletIndex) else {
throw CustomServiceError.vssAuthRequired
}
+ #if DEBUG // test hook
+ let qaAuthUrl = ProcessInfo.processInfo.environment["QA1121_LDK_AUTH_URL"] ?? params.lnurlAuthServerUrl // test hook
+ #else // test hook
+ let qaAuthUrl = params.lnurlAuthServerUrl // test hook
+ #endif // test hook
do {
try await withTimeout(seconds: 30) {
Logger.debug("VSS LDK client setting up…", context: "VssBackupClient")
@@ -202,7 +211,7 @@ class VssBackupClient {
storeId: params.storeId,
mnemonic: params.mnemonic,
passphrase: params.passphrase,
- lnurlAuthServerUrl: params.lnurlAuthServerUrl
+ lnurlAuthServerUrl: qaAuthUrl // test hook
)
Logger.info("VSS LDK client setup with server: '\(params.vssUrl)'", context: "VssBackupClient")
}
diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
index 31b84c1a..b4a1fdd3 100644
--- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
+++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
@@ -39,6 +39,9 @@ private struct StatusItemView: View {
struct DataBackupsScreen: View {
@StateObject private var viewModel = BackupViewModel()
+ @EnvironmentObject private var pubkyProfile: PubkyProfileManager // test hook
+ @EnvironmentObject private var tagManager: TagManager // test hook
+ @State private var qaStatus = "QA ready" // test hook
private var allSynced: Bool {
BackupCategory.allCases.allSatisfy { category in
@@ -51,6 +54,26 @@ struct DataBackupsScreen: View {
VStack(alignment: .leading, spacing: 0) {
NavigationBar(title: t("settings__data_backups_nav_title"))
+ #if DEBUG // test hook
+ Text(qaStatus).font(.caption).accessibilityIdentifier("QA1121Status") // test hook
+ HStack { // test hook
+ Button("Terminal") { Task { await qaTerminal() } }.accessibilityIdentifier("QA1121Terminal") // test hook
+ Button("Outage") { Task { await qaOutage() } }.accessibilityIdentifier("QA1121Outage") // test hook
+ Button("Recover") { Task { await pubkyProfile.initialize(); qaStatus = "Identity recovery returned" } }
+ .accessibilityIdentifier("QA1121Recover") // test hook
+ } // test hook
+ HStack { // test hook
+ Button("Repair fixture") {
+ Task {
+ try? await PubkyProfileManager.restoreSessionBackupState(PubkySessionBackupV1(kind: .localSeed)); await pubkyProfile
+ .initialize(); await BackupService.shared.triggerBackup(category: .wallet); qaStatus = "Fixture repaired"
+ }
+ }
+ .accessibilityIdentifier("QA1121RepairFixture") // test hook
+ Button("Inspect") { Task { await qaInspect() } }.accessibilityIdentifier("QA1121Inspect") // test hook
+ Button("Stage restore") { Task { await qaStageRestore() } }.accessibilityIdentifier("QA1121StageRestore") // test hook
+ } // test hook
+ #endif // test hook
GeometryReader { geometry in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
@@ -98,4 +121,108 @@ struct DataBackupsScreen: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
}
+
+ #if DEBUG // test hook
+ @MainActor private func qaTerminal() async { // test hook
+ do { // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ guard let before = try await VssBackupClient.shared.getObject(key: "WALLET"), // test hook
+ try JSONDecoder().decode(WalletBackupV1.self, from: before.value).paykitSdkBackupState != nil // test hook
+ else { qaStatus = "SETUP: no complete baseline"; return } // test hook
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] // test hook
+ let values = try keys.map { try Keychain.load(key: $0) } // test hook
+ let fingerprint = "qa1121-terminal-" + UUID().uuidString // test hook
+ do { // test hook
+ defer { // test hook
+ for (key, value) in zip(keys, values) { // test hook
+ if let value {
+ try? Keychain.upsert(key: key, data: value)
+ } else {
+ try? Keychain.delete(key: key)
+ } // test hook
+ } // test hook
+ } // test hook
+ try await PaykitSdkService.shared.forgetSessionAccess() // test hook
+ try Keychain.delete(key: .pubkySecretKey) // test hook
+ try Keychain.upsert(key: .paykitSession, data: Data("invalid-session".utf8)) // test hook
+ await pubkyProfile.initialize() // test hook
+ let index = try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: fingerprint) // test hook
+ BackupService.shared.markBackupRequired(category: .wallet, schedule: false) // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ guard let after = try await VssBackupClient.shared.getObject(key: "WALLET")
+ else { throw AppError(message: "Baseline disappeared", debugMessage: "QA baseline disappeared") } // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info(
+ "QA1121 terminal baseline-present=true complete=true before=\(before.version) after=\(after.version) bytes-equal=\(before.value == after.value) index=\(index) pending=\(pending)",
+ context: "TestHook"
+ ) // test hook
+ qaStatus = "Terminal: unchanged=\(before.value == after.value), pending=\(pending)" // test hook
+ } // test hook
+ await pubkyProfile.initialize() // test hook
+ await BackupService.shared.triggerBackup(category: .wallet) // test hook
+ guard let repaired = try await VssBackupClient.shared.getObject(key: "WALLET")
+ else { throw AppError(message: "No repaired record", debugMessage: "QA repaired record absent") } // test hook
+ let payload = try JSONDecoder().decode(WalletBackupV1.self, from: repaired.value) // test hook
+ let included = payload.watchOnlyAccountAllocationState?.pendingAccountIndexByRequest["0:" + fingerprint] != nil // test hook
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ Logger.info(
+ "QA1121 terminal repair version=\(repaired.version) complete=\(payload.paykitSdkBackupState != nil) reservation-included=\(included) pending=\(pending)",
+ context: "TestHook"
+ ) // test hook
+ qaStatus = "Repair: reservation=\(included), pending=\(pending)" // test hook
+ } catch { qaStatus = "Control error: \(error)"; Logger.error(
+ "QA1121 terminal control error: \(error)",
+ context: "TestHook"
+ ) } // test hook
+ } // test hook
+
+ @MainActor private func qaOutage() async { // test hook
+ do { // test hook
+ qaStatus = "Restoring identity through blocked homeserver" // test hook
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] // test hook
+ let values = try keys.map { try Keychain.load(key: $0) } // test hook
+ guard values.contains(where: { $0 != nil }) else { qaStatus = "SETUP: saved credentials required"; return } // test hook
+ try await PaykitSdkService.shared.forgetSessionAccess() // test hook
+ for (key, value) in zip(keys, values) {
+ if let value {
+ try Keychain.upsert(key: key, data: value)
+ }
+ } // test hook
+ await pubkyProfile.initialize() // test hook
+ _ = try WatchOnlyAccountStore
+ .reserveAccountIndex(walletIndex: 0, requestFingerprint: "qa1121-outage-" + UUID().uuidString) // test hook
+ tagManager.addToLastUsedTags("qa1121-outage") // test hook
+ BackupService.shared.markBackupRequired(category: .wallet, schedule: false) // test hook
+ BackupService.shared.markBackupRequired(category: .metadata, schedule: false) // test hook
+ await BackupService.shared.triggerBackup(category: .metadata) // test hook
+ qaStatus = "Outage attempts returned; release proxy then Recover" // test hook
+ Logger.info("QA1121 outage failed-attempt phase finished", context: "TestHook") // test hook
+ } catch { qaStatus = "Control error: \(error)" } // test hook
+ } // test hook
+
+ @MainActor private func qaInspect() async { // test hook
+ do { // test hook
+ let wallet = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+ let metadata = try await VssBackupClient.shared.getObject(key: "METADATA") // test hook
+ let tags = try metadata.map { try JSONDecoder().decode(MetadataBackupV1.self, from: $0.value).cache.lastUsedTags } ?? [] // test hook
+ let wp = BackupService.shared.getBackupStatus(category: .wallet).isRequired // test hook
+ let mp = BackupService.shared.getBackupStatus(category: .metadata).isRequired // test hook
+ Logger.info(
+ "QA1121 inspect wallet-version=\(wallet?.version ?? -1) metadata-version=\(metadata?.version ?? -1) wallet-pending=\(wp) metadata-pending=\(mp) remote-outage-tag=\(tags.contains("qa1121-outage"))",
+ context: "TestHook"
+ ) // test hook
+ qaStatus = "Wallet pending=\(wp), Tags pending=\(mp), remote tag=\(tags.contains("qa1121-outage"))" // test hook
+ } catch { qaStatus = "Inspect error: \(error)" } // test hook
+ } // test hook
+
+ @MainActor private func qaStageRestore() async { // test hook
+ do { // test hook
+ guard let item = try await VssBackupClient.shared.getObject(key: "WALLET") else { return } // test hook
+ try Keychain.upsert(key: .paykitPendingBackupRestore, data: item.value) // test hook
+ try Data().write(to: Env.appStorageUrl.appendingPathComponent("qa1121-hold-metadata-signin")) // test hook
+ Logger.info("QA1121 valid retained WALLET staged", context: "TestHook") // test hook
+ qaStatus = "Valid retained restore staged; restart app" // test hook
+ } catch { qaStatus = "Stage error: \(error)" } // test hook
+ } // test hook
+ #endif // test hook
}
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal-identity backup failure and stalled-authentication reset failure remain. The retry change passes.
Findings: No new findings in the 111ddd8..1c3cde6 delta.
- HIGH — existing:
Bitkit/Services/PrivatePaykitService+Backup.swift:12. The terminal-identity finding still prevents newer ordinary wallet state from reaching complete WALLET backups. Normal Profile → Disconnect also failed in this terminal state, before local credential removal. - MEDIUM — existing:
Bitkit/Services/VssBackupClient.swift:73. The stalled-authentication finding is now reproduced in the installed app. The app still pins VSSeba41ef6; the upstream timeout fix is not adopted.
The pending Tags report is fixed for the exercised non-temporary WALLET-dependency failure. Recovery also survives the scheduler-retirement interleaving in the new native regression; stopping observation prevents the old generation from restarting work.
QA for 1c3cde6: iPhone 17 Pro simulators, iOS 26.5, Xcode 27.0, committed dependencies and disposable unfunded staging-regtest wallets. Native checks used the unmodified head; app cases used the unpushed Debug controls below.
Backup scheduling and identity regression checks ✅ The selected clean-head suites passed, checked in finalized XCTest results.
Built and ran backup scheduling, foreground recovery, profile initialization, private Paykit, VSS coordination and wipe-coordinator checks. The new failed-job retirement/generation regression passed. No selected case failed or skipped. Three QoS runtime warnings remain in the first result; none were reported in the second. SwiftFormat 0.64.0 passed both changed Swift files, and the committed delta passed git diff --check.
Test-host excerpt at 2×; assertions were checked in XCTest results and logs.
Resume Tags after a non-temporary WALLET failure ✅ WALLET recovery automatically uploaded Tags about five seconds later; a fresh remote read contained the new tag and both pending flags cleared.
Started with an existing remote METADATA record. Invalid saved-session input without a local secret reached the real terminal resolver. WALLET capture raised the SDK Identity error; METADATA returned walletDependencyFailed. Its previous remote bytes stayed unchanged and both categories remained Required. The scheduled retry also failed and retired before recovery.
Restored the captured credentials and initialized the identity, with no manual backup. METADATA uploaded 5.206 seconds after WALLET. No intervening foreground transition, Tags retry or data edit occurred. Inspection decoded the new remote METADATA with the unique tag staged before failure and confirmed both categories no longer pending. These assertions were checked in app logs; this is the non-temporary branch the previous temporary-error pass did not cover.
Failure, credential repair and automatic recovery at 2×; remote-content assertions checked in logs.
Back up ordinary wallet changes after terminal identity failure ❌ A later ordinary account reservation stayed pending until credential repair.
The baseline WALLET record existed and decoded with SDK state. Invalid saved-session input with no local secret reached No secret key to recover session. A new watch-only account reservation could not be backed up: the same non-nil remote record stayed byte-identical and WALLET remained Required. Capture raised the SDK Identity error before sessionNotActive.
Restoring the captured credentials allowed upload. A fresh remote read decoded with SDK state and the new reservation, and pending work cleared. Assertions were checked in timestamped app logs. This confirms the existing HIGH; the previous complete record remains protected.
Terminal failure and credential repair at 2×; remote-record assertions checked in logs.
Sign out after terminal identity failure and back up both categories ❌ Normal Profile → Disconnect failed before removing the unusable credentials.
Repeated the invalid-session/no-secret input, then used Profile's actual Disconnect button and confirmation. Private endpoint cleanup raised the SDK Identity error while restoring its grant, before PubkyService.signOut and local cleanup. The app remained on the empty Profile with Try Again and Disconnect, and both categories stayed pending. This recovery route failed; no successful terminal-state sign-out or subsequent fresh backup is claimed. The cleanup ordering predates this delta, so this adds evidence to the existing HIGH rather than a new finding.
Terminal input and the real Profile disconnect attempt at 2×; cleanup failure checked in app logs.
Reject late profile enrichment after removing identity ✅ Releasing a real fetched profile after successful sign-out left both the identity and profile absent.
Held a completed real profile response after its SDK read, then called the normal sign-out implementation while the identity was healthy. Sign-out completed before release. The normal apply guard rejected the late response; the control's post-return inspection found both publicKey and profile nil. This controls the response boundary without retaining the SDK lock. It does not claim a separately held contact response.
Held response, healthy sign-out and rejected late completion at 2×; state checked in logs and the Debug status.
Reset while VSS authentication is stalled ❌ Reset stayed on step 4 of 12 for over 113 seconds, until the held authentication response was released.
Held only the dedicated LDK client's real LNURL-auth challenge response. Its native setup completed and VSS Debug began listing LDK keys. Left Debug, chose Security → Reset wallet and confirmed. Admission closed with one native call active. The UI stayed on Resetting Bitkit, step 4 of 12, with no retry action; logs reached VSS drain before local deletion. The challenge was held for 206.1 seconds overall, including 113.6 seconds after admission closed. Release forwarded the real response and the wipe returned to Terms. A canceled LDK key-read toast also appeared on Terms.
This reproduces the existing native-authentication timeout concern. The Debug control changes only the dedicated client's endpoint and logs admission state; the hold is in an app-specific local endpoint, with no host networking change. App logs were saved before the wipe's normal log deletion. The initial incorrectly proxied local request was a setup attempt, corrected before this credited run.
LDK key load, actual reset, held drain and completion after release at 2×; active-call state and elapsed intervals checked in app/proxy logs.
No overdue backup alert after recovery ✅ Both categories stayed synced across a real 31-minute observation with no overdue-backup alert.
After the failed terminal-state Disconnect, the local control repaired this disposable identity using the existing seed-derived recovery import. Real WALLET and METADATA uploads completed, and fresh remote reads confirmed SDK/session state present with both pending flags cleared. Kept the app open on Data Backups for at least 31 minutes afterward, without a clock override or a manual backup. Periodic real failure checks continued to report no overdue backup; the app log contained no “Backup failed for more than 30 minutes” entry and no alert appeared in the recording.
This observes the alert window after non-temporary failure and credential recovery. The previous-head report separately covers real temporary-error retry scheduling; this is not a new mnemonic restore or an assertion that terminal-state Disconnect succeeded.
Continuous observation at 2×; elapsed time and alert decisions were checked against the periodic app logs. The Debug tag-probe field is not an assertion in this case.
Check: retained restore and profile evidence ✅ Earlier reports cover the unchanged required recovery, ordinary access, restart and missing-profile paths.
- The staging-proxy gate covers ordinary Receive and a funded send during optional identity outage, missing-profile recovery on Home, restart, and required-recovery failure/retry. Its partial Tags recovery is superseded by the exercised fix above.
- The previous-head report covers temporary WALLET/METADATA retries and a required METADATA sign-in response held for 82.1 seconds. Required recovery stayed gated until release; that proves the credential-replacement boundary, not bounded backend latency.
- The controlled restore report covers restore/restart, a saved contact across restart and concurrent VSS reset draining.
- The required-USDT report covers malformed retained USDT recovery and repair/retry on the same wallet.
These keep their original scope; no new populated clean-install USDT recovery, funded payment/proof replay or running-LDK quiescence is claimed.
Coverage: contiguous 111ddd8..1c3cde6, all three changed files, reached scheduling/cancellation/restore/wipe callers and unresolved prior findings. The independent source review found no additional defect. Device cases drove non-temporary recovery; the retiring-job race and stop-generation branch received native coverage. Restore, profile, VSS, SDK pins and wipe implementations are unchanged in this delta.
Twin: synonymdev/bitkit-android#1686 fix: decouple wallet restore from profile refresh at a2c58f2 matches optional initialization, missing-profile retry and the acknowledged complete-WALLET limitation. Its metadata dependency/scheduler differs; this iOS wake-up change has no identical Android implementation. Android device testing was not repeated.
Executed Debug controls and deciding evidence
Not pushed. The first build stages disposable credential input, makes an ordinary reservation, compares actual decoded VSS records, and holds a real completed profile response. The second adds seed-derived fixture repair, diagnostic remote reads and periodic alert-state logs. Both retain the reviewed backup, profile and drain decisions. The dedicated LDK endpoint override sends native authentication to a local response gate; removing its gate forwards the real staging response. Host networking remains unchanged.
First app build (terminal backup, non-temporary Tags recovery, delayed profile and actual terminal-state UI disconnect):
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index c18cde11..62d6205e 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -1230,6 +1230,16 @@ class PubkyProfileManager: ObservableObject {
let loadedProfile = try await Task.detached {
try await resolve(pk)
}.value
+ #if DEBUG
+ let qaGate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-profile")
+ if FileManager.default.fileExists(atPath: qaGate.path) {
+ Logger.info("QA1121 real profile response held after SDK read", context: "TestHook")
+ while FileManager.default.fileExists(atPath: qaGate.path) {
+ try? await Task.sleep(for: .milliseconds(200))
+ }
+ Logger.info("QA1121 real profile response released", context: "TestHook")
+ }
+ #endif
if publicKey == pk, profileWriteGeneration == read.generation {
profile = loadedProfile
readOnlyProfile = nil
diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift
index c1ea78c7..ffc47f24 100644
--- a/Bitkit/Services/VssBackupClient.swift
+++ b/Bitkit/Services/VssBackupClient.swift
@@ -66,6 +66,7 @@ actor VssClientCoordinator {
}
private func closeAdmission() async {
+ Logger.info("QA1121 VSS close admission active=\(activeCalls)", context: "TestHook")
resetting = true
generation = UUID()
setups.values.forEach { $0.task.cancel() }
@@ -116,6 +117,7 @@ actor VssClientCoordinator {
private func finishCall() {
activeCalls -= 1
+ if resetting { Logger.info("QA1121 VSS finish during reset remaining=\(activeCalls)", context: "TestHook") }
if activeCalls == 0 {
let waiters = idleWaiters
idleWaiters.removeAll()
@@ -202,7 +204,7 @@ class VssBackupClient {
storeId: params.storeId,
mnemonic: params.mnemonic,
passphrase: params.passphrase,
- lnurlAuthServerUrl: params.lnurlAuthServerUrl
+ lnurlAuthServerUrl: ProcessInfo.processInfo.environment["QA1121_LDK_AUTH_URL"] ?? params.lnurlAuthServerUrl
)
Logger.info("VSS LDK client setup with server: '\(params.vssUrl)'", context: "VssBackupClient")
}
diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
index 31b84c1a..3c148966 100644
--- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
+++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
@@ -39,6 +39,12 @@ private struct StatusItemView: View {
struct DataBackupsScreen: View {
@StateObject private var viewModel = BackupViewModel()
+ @EnvironmentObject private var pubkyProfile: PubkyProfileManager
+ @EnvironmentObject private var tagManager: TagManager
+ @State private var qaStatus = "QA ready"
+ @State private var qaSaved: [(KeychainEntryType, Data?)] = []
+ @State private var qaMarker = ""
+ @State private var qaMetadataBefore: Data?
private var allSynced: Bool {
BackupCategory.allCases.allSatisfy { category in
@@ -51,6 +57,19 @@ struct DataBackupsScreen: View {
VStack(alignment: .leading, spacing: 0) {
NavigationBar(title: t("settings__data_backups_nav_title"))
+ #if DEBUG
+ Text(qaStatus).font(.caption).accessibilityIdentifier("QA1121Status")
+ HStack {
+ Button("Terminal") { Task { await qaTerminal() } }.accessibilityIdentifier("QA1121Terminal")
+ Button("Tags fail") { Task { await qaTagsFailure() } }.accessibilityIdentifier("QA1121TagsFail")
+ Button("Recover") { Task { await qaRecover() } }.accessibilityIdentifier("QA1121Recover")
+ }
+ HStack {
+ Button("Inspect") { Task { await qaInspect() } }.accessibilityIdentifier("QA1121Inspect")
+ Button("Late read") { Task { await qaLateRead() } }.accessibilityIdentifier("QA1121LateRead")
+ Button("Disconnect") { Task { do { try await pubkyProfile.signOut(); qaStatus = "Disconnected; release held read" } catch { qaStatus = "Disconnect error: \(error)" } } }.accessibilityIdentifier("QA1121Disconnect")
+ }
+ #endif
GeometryReader { geometry in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
@@ -98,4 +117,107 @@ struct DataBackupsScreen: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
}
+
+ #if DEBUG
+ @MainActor private func qaTerminal() async {
+ do {
+ await BackupService.shared.triggerBackup(category: .wallet)
+ guard let before = try await VssBackupClient.shared.getObject(key: "WALLET"),
+ try JSONDecoder().decode(WalletBackupV1.self, from: before.value).paykitSdkBackupState != nil
+ else { qaStatus = "SETUP: no complete baseline"; return }
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey]
+ let values = try keys.map { try Keychain.load(key: $0) }
+ let fingerprint = "qa1121-terminal-" + UUID().uuidString
+ do {
+ defer {
+ for (key, value) in zip(keys, values) {
+ if let value { try? Keychain.upsert(key: key, data: value) } else { try? Keychain.delete(key: key) }
+ }
+ }
+ try await PaykitSdkService.shared.forgetSessionAccess()
+ try Keychain.delete(key: .pubkySecretKey)
+ try Keychain.upsert(key: .paykitSession, data: Data("invalid-session".utf8))
+ await pubkyProfile.initialize()
+ let index = try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: fingerprint)
+ BackupService.shared.markBackupRequired(category: .wallet, schedule: false)
+ await BackupService.shared.triggerBackup(category: .wallet)
+ guard let after = try await VssBackupClient.shared.getObject(key: "WALLET") else { throw AppError(message: "Baseline disappeared", debugMessage: "QA baseline disappeared") }
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired
+ Logger.info("QA1121 terminal complete=true bytes-equal=\(before.value == after.value) before=\(before.version) after=\(after.version) index=\(index) pending=\(pending)", context: "TestHook")
+ qaStatus = "Terminal: unchanged=\(before.value == after.value), pending=\(pending)"
+ }
+ await pubkyProfile.initialize()
+ await BackupService.shared.triggerBackup(category: .wallet)
+ guard let repaired = try await VssBackupClient.shared.getObject(key: "WALLET") else { throw AppError(message: "No repaired record", debugMessage: "QA repaired absent") }
+ let payload = try JSONDecoder().decode(WalletBackupV1.self, from: repaired.value)
+ let included = payload.watchOnlyAccountAllocationState?.pendingAccountIndexByRequest["0:" + fingerprint] != nil
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired
+ Logger.info("QA1121 terminal repair complete=\(payload.paykitSdkBackupState != nil) reservation-included=\(included) pending=\(pending)", context: "TestHook")
+ qaStatus = "Repair: reservation=\(included), pending=\(pending)"
+ } catch { qaStatus = "Control error: \(error)"; Logger.error("QA1121 terminal error: \(error)", context: "TestHook") }
+ }
+
+ @MainActor private func qaTagsFailure() async {
+ do {
+ await BackupService.shared.triggerBackup(category: .metadata)
+ guard let baseline = try await VssBackupClient.shared.getObject(key: "METADATA") else { qaStatus = "SETUP: no baseline METADATA"; return }
+ qaMetadataBefore = baseline.value
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey]
+ qaSaved = try keys.map { ($0, try Keychain.load(key: $0)) }
+ guard qaSaved.contains(where: { $0.1 != nil }) else { qaStatus = "SETUP: no saved identity"; return }
+ try await PaykitSdkService.shared.forgetSessionAccess()
+ try Keychain.delete(key: .pubkySecretKey)
+ try Keychain.upsert(key: .paykitSession, data: Data("invalid-session".utf8))
+ await pubkyProfile.initialize()
+ qaMarker = "qa1121-tags-" + UUID().uuidString
+ tagManager.addToLastUsedTags(qaMarker)
+ _ = try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: qaMarker)
+ BackupService.shared.markBackupRequired(category: .wallet, schedule: false)
+ BackupService.shared.markBackupRequired(category: .metadata, schedule: false)
+ let result = await BackupService.shared.triggerBackup(category: .metadata)
+ let after = try await VssBackupClient.shared.getObject(key: "METADATA")
+ Logger.info("QA1121 tags staged dependency-failure=\(result == .walletDependencyFailed) baseline-present=true unchanged=\(after?.value == baseline.value) wallet-pending=\(BackupService.shared.getBackupStatus(category: .wallet).isRequired) metadata-pending=\(BackupService.shared.getBackupStatus(category: .metadata).isRequired)", context: "TestHook")
+ qaStatus = "Tags dependency failed; previous remote retained"
+ } catch { qaStatus = "Tags control error: \(error)"; Logger.error("QA1121 tags error: \(error)", context: "TestHook") }
+ }
+
+ @MainActor private func qaRecover() async {
+ do {
+ for (key, value) in qaSaved {
+ if let value { try Keychain.upsert(key: key, data: value) } else { try Keychain.delete(key: key) }
+ }
+ qaSaved = []
+ qaStatus = "Recovering identity; no manual backup"
+ await pubkyProfile.initialize()
+ Logger.info("QA1121 identity repaired; awaiting automatic backups", context: "TestHook")
+ qaStatus = "Identity repaired; waiting for automatic backups"
+ } catch { qaStatus = "Recovery control error: \(error)" }
+ }
+
+ @MainActor private func qaInspect() async {
+ do {
+ let wallet = try await VssBackupClient.shared.getObject(key: "WALLET")
+ let metadata = try await VssBackupClient.shared.getObject(key: "METADATA")
+ let tags = try metadata.map { try JSONDecoder().decode(MetadataBackupV1.self, from: $0.value).cache.lastUsedTags } ?? []
+ let wp = BackupService.shared.getBackupStatus(category: .wallet).isRequired
+ let mp = BackupService.shared.getBackupStatus(category: .metadata).isRequired
+ let included = !qaMarker.isEmpty && tags.contains(qaMarker)
+ Logger.info("QA1121 inspect wallet-present=\(wallet != nil) metadata-present=\(metadata != nil) wallet-pending=\(wp) metadata-pending=\(mp) fresh-tag=\(included) remote-changed=\(metadata?.value != qaMetadataBefore)", context: "TestHook")
+ qaStatus = "Wallet pending=\(wp), Tags pending=\(mp), fresh tag=\(included)"
+ } catch { qaStatus = "Inspect error: \(error)" }
+ }
+
+ @MainActor private func qaLateRead() async {
+ do {
+ qaSaved = try [KeychainEntryType.paykitSession, .pubkySecretKey].map { ($0, try Keychain.load(key: $0)) }
+ try Data().write(to: Env.appStorageUrl.appendingPathComponent("qa1121-hold-profile"))
+ qaStatus = "Holding completed profile read; disconnect next"
+ await pubkyProfile.loadProfile()
+ let empty = pubkyProfile.profile == nil && pubkyProfile.publicKey == nil
+ Logger.info("QA1121 late profile returned identity-and-profile-absent=\(empty)", context: "TestHook")
+ qaStatus = "Late read returned: old identity absent=\(empty)"
+ } catch { qaStatus = "Late read control error: \(error)" }
+ }
+ #endif
+
}Final inspection build (stalled native authentication/reset and timed alert observation):
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index c18cde11..62d6205e 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -1230,6 +1230,16 @@ class PubkyProfileManager: ObservableObject {
let loadedProfile = try await Task.detached {
try await resolve(pk)
}.value
+ #if DEBUG
+ let qaGate = Env.appStorageUrl.appendingPathComponent("qa1121-hold-profile")
+ if FileManager.default.fileExists(atPath: qaGate.path) {
+ Logger.info("QA1121 real profile response held after SDK read", context: "TestHook")
+ while FileManager.default.fileExists(atPath: qaGate.path) {
+ try? await Task.sleep(for: .milliseconds(200))
+ }
+ Logger.info("QA1121 real profile response released", context: "TestHook")
+ }
+ #endif
if publicKey == pk, profileWriteGeneration == read.generation {
profile = loadedProfile
readOnlyProfile = nil
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index 83a8c20e..9646696d 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -1091,6 +1091,9 @@ class BackupService {
private func checkForFailedBackups() {
let currentTime = UInt64(Date().timeIntervalSince1970)
+ #if DEBUG
+ Logger.info("QA1121 failure alert check failed=\(hasFailedBackups(at: currentTime)) wallet-pending=\(getBackupStatus(category: .wallet).isRequired) metadata-pending=\(getBackupStatus(category: .metadata).isRequired)", context: "TestHook")
+ #endif
if hasFailedBackups(at: currentTime) {
showBackupFailureNotification()
}
diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift
index c1ea78c7..ffc47f24 100644
--- a/Bitkit/Services/VssBackupClient.swift
+++ b/Bitkit/Services/VssBackupClient.swift
@@ -66,6 +66,7 @@ actor VssClientCoordinator {
}
private func closeAdmission() async {
+ Logger.info("QA1121 VSS close admission active=\(activeCalls)", context: "TestHook")
resetting = true
generation = UUID()
setups.values.forEach { $0.task.cancel() }
@@ -116,6 +117,7 @@ actor VssClientCoordinator {
private func finishCall() {
activeCalls -= 1
+ if resetting { Logger.info("QA1121 VSS finish during reset remaining=\(activeCalls)", context: "TestHook") }
if activeCalls == 0 {
let waiters = idleWaiters
idleWaiters.removeAll()
@@ -202,7 +204,7 @@ class VssBackupClient {
storeId: params.storeId,
mnemonic: params.mnemonic,
passphrase: params.passphrase,
- lnurlAuthServerUrl: params.lnurlAuthServerUrl
+ lnurlAuthServerUrl: ProcessInfo.processInfo.environment["QA1121_LDK_AUTH_URL"] ?? params.lnurlAuthServerUrl
)
Logger.info("VSS LDK client setup with server: '\(params.vssUrl)'", context: "VssBackupClient")
}
diff --git a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
index 31b84c1a..0b524f21 100644
--- a/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
+++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift
@@ -39,6 +39,12 @@ private struct StatusItemView: View {
struct DataBackupsScreen: View {
@StateObject private var viewModel = BackupViewModel()
+ @EnvironmentObject private var pubkyProfile: PubkyProfileManager
+ @EnvironmentObject private var tagManager: TagManager
+ @State private var qaStatus = "QA ready"
+ @State private var qaSaved: [(KeychainEntryType, Data?)] = []
+ @State private var qaMarker = ""
+ @State private var qaMetadataBefore: Data?
private var allSynced: Bool {
BackupCategory.allCases.allSatisfy { category in
@@ -51,6 +57,20 @@ struct DataBackupsScreen: View {
VStack(alignment: .leading, spacing: 0) {
NavigationBar(title: t("settings__data_backups_nav_title"))
+ #if DEBUG
+ Text(qaStatus).font(.caption).accessibilityIdentifier("QA1121Status")
+ HStack {
+ Button("Terminal") { Task { await qaTerminal() } }.accessibilityIdentifier("QA1121Terminal")
+ Button("Tags fail") { Task { await qaTagsFailure() } }.accessibilityIdentifier("QA1121TagsFail")
+ Button("Recover") { Task { await qaRecover() } }.accessibilityIdentifier("QA1121Recover")
+ }
+ HStack {
+ Button("Inspect") { Task { await qaInspect() } }.accessibilityIdentifier("QA1121Inspect")
+ Button("Seed recovery") { Task { do { try await PubkyProfileManager.restoreSessionBackupState(PubkySessionBackupV1(kind: .localSeed)); await pubkyProfile.initialize(); qaStatus = "Seed identity fixture repaired" } catch { qaStatus = "Seed recovery error: \(error)" } } }.accessibilityIdentifier("QA1121SeedRecovery")
+ Button("Late read") { Task { await qaLateRead() } }.accessibilityIdentifier("QA1121LateRead")
+ Button("Disconnect") { Task { do { try await pubkyProfile.signOut(); qaStatus = "Disconnected; release held read" } catch { qaStatus = "Disconnect error: \(error)" } } }.accessibilityIdentifier("QA1121Disconnect")
+ }
+ #endif
GeometryReader { geometry in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
@@ -98,4 +118,110 @@ struct DataBackupsScreen: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
}
+
+ #if DEBUG
+ @MainActor private func qaTerminal() async {
+ do {
+ await BackupService.shared.triggerBackup(category: .wallet)
+ guard let before = try await VssBackupClient.shared.getObject(key: "WALLET"),
+ try JSONDecoder().decode(WalletBackupV1.self, from: before.value).paykitSdkBackupState != nil
+ else { qaStatus = "SETUP: no complete baseline"; return }
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey]
+ let values = try keys.map { try Keychain.load(key: $0) }
+ let fingerprint = "qa1121-terminal-" + UUID().uuidString
+ do {
+ defer {
+ for (key, value) in zip(keys, values) {
+ if let value { try? Keychain.upsert(key: key, data: value) } else { try? Keychain.delete(key: key) }
+ }
+ }
+ try await PaykitSdkService.shared.forgetSessionAccess()
+ try Keychain.delete(key: .pubkySecretKey)
+ try Keychain.upsert(key: .paykitSession, data: Data("invalid-session".utf8))
+ await pubkyProfile.initialize()
+ let index = try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: fingerprint)
+ BackupService.shared.markBackupRequired(category: .wallet, schedule: false)
+ await BackupService.shared.triggerBackup(category: .wallet)
+ guard let after = try await VssBackupClient.shared.getObject(key: "WALLET") else { throw AppError(message: "Baseline disappeared", debugMessage: "QA baseline disappeared") }
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired
+ Logger.info("QA1121 terminal complete=true bytes-equal=\(before.value == after.value) before=\(before.version) after=\(after.version) index=\(index) pending=\(pending)", context: "TestHook")
+ qaStatus = "Terminal: unchanged=\(before.value == after.value), pending=\(pending)"
+ }
+ await pubkyProfile.initialize()
+ await BackupService.shared.triggerBackup(category: .wallet)
+ guard let repaired = try await VssBackupClient.shared.getObject(key: "WALLET") else { throw AppError(message: "No repaired record", debugMessage: "QA repaired absent") }
+ let payload = try JSONDecoder().decode(WalletBackupV1.self, from: repaired.value)
+ let included = payload.watchOnlyAccountAllocationState?.pendingAccountIndexByRequest["0:" + fingerprint] != nil
+ let pending = BackupService.shared.getBackupStatus(category: .wallet).isRequired
+ Logger.info("QA1121 terminal repair complete=\(payload.paykitSdkBackupState != nil) reservation-included=\(included) pending=\(pending)", context: "TestHook")
+ qaStatus = "Repair: reservation=\(included), pending=\(pending)"
+ } catch { qaStatus = "Control error: \(error)"; Logger.error("QA1121 terminal error: \(error)", context: "TestHook") }
+ }
+
+ @MainActor private func qaTagsFailure() async {
+ do {
+ await BackupService.shared.triggerBackup(category: .metadata)
+ guard let baseline = try await VssBackupClient.shared.getObject(key: "METADATA") else { qaStatus = "SETUP: no baseline METADATA"; return }
+ qaMetadataBefore = baseline.value
+ let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey]
+ qaSaved = try keys.map { ($0, try Keychain.load(key: $0)) }
+ guard qaSaved.contains(where: { $0.1 != nil }) else { qaStatus = "SETUP: no saved identity"; return }
+ try await PaykitSdkService.shared.forgetSessionAccess()
+ try Keychain.delete(key: .pubkySecretKey)
+ try Keychain.upsert(key: .paykitSession, data: Data("invalid-session".utf8))
+ await pubkyProfile.initialize()
+ qaMarker = "qa1121-tags-" + UUID().uuidString
+ tagManager.addToLastUsedTags(qaMarker)
+ _ = try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: qaMarker)
+ BackupService.shared.markBackupRequired(category: .wallet, schedule: false)
+ BackupService.shared.markBackupRequired(category: .metadata, schedule: false)
+ let result = await BackupService.shared.triggerBackup(category: .metadata)
+ let after = try await VssBackupClient.shared.getObject(key: "METADATA")
+ Logger.info("QA1121 tags staged dependency-failure=\(result == .walletDependencyFailed) baseline-present=true unchanged=\(after?.value == baseline.value) wallet-pending=\(BackupService.shared.getBackupStatus(category: .wallet).isRequired) metadata-pending=\(BackupService.shared.getBackupStatus(category: .metadata).isRequired)", context: "TestHook")
+ qaStatus = "Tags dependency failed; previous remote retained"
+ } catch { qaStatus = "Tags control error: \(error)"; Logger.error("QA1121 tags error: \(error)", context: "TestHook") }
+ }
+
+ @MainActor private func qaRecover() async {
+ do {
+ for (key, value) in qaSaved {
+ if let value { try Keychain.upsert(key: key, data: value) } else { try Keychain.delete(key: key) }
+ }
+ qaSaved = []
+ qaStatus = "Recovering identity; no manual backup"
+ await pubkyProfile.initialize()
+ Logger.info("QA1121 identity repaired; awaiting automatic backups", context: "TestHook")
+ qaStatus = "Identity repaired; waiting for automatic backups"
+ } catch { qaStatus = "Recovery control error: \(error)" }
+ }
+
+ @MainActor private func qaInspect() async {
+ do {
+ let wallet = try await VssBackupClient.shared.getObject(key: "WALLET")
+ let metadata = try await VssBackupClient.shared.getObject(key: "METADATA")
+ let tags = try metadata.map { try JSONDecoder().decode(MetadataBackupV1.self, from: $0.value).cache.lastUsedTags } ?? []
+ let wp = BackupService.shared.getBackupStatus(category: .wallet).isRequired
+ let mp = BackupService.shared.getBackupStatus(category: .metadata).isRequired
+ let included = !qaMarker.isEmpty && tags.contains(qaMarker)
+ let sdk = try wallet.map { try JSONDecoder().decode(WalletBackupV1.self, from: $0.value).paykitSdkBackupState != nil } ?? false
+ let session = try metadata.map { try JSONDecoder().decode(MetadataBackupV1.self, from: $0.value).pubkySession != nil } ?? false
+ Logger.info("QA1121 remote inspection sdk-present=\(sdk) session-present=\(session) tags-count=\(tags.count)", context: "TestHook")
+ Logger.info("QA1121 inspect wallet-present=\(wallet != nil) metadata-present=\(metadata != nil) wallet-pending=\(wp) metadata-pending=\(mp) fresh-tag=\(included) remote-changed=\(metadata?.value != qaMetadataBefore)", context: "TestHook")
+ qaStatus = "Wallet pending=\(wp), Tags pending=\(mp), fresh tag=\(included)"
+ } catch { qaStatus = "Inspect error: \(error)" }
+ }
+
+ @MainActor private func qaLateRead() async {
+ do {
+ qaSaved = try [KeychainEntryType.paykitSession, .pubkySecretKey].map { ($0, try Keychain.load(key: $0)) }
+ try Data().write(to: Env.appStorageUrl.appendingPathComponent("qa1121-hold-profile"))
+ qaStatus = "Holding completed profile read; disconnect next"
+ await pubkyProfile.loadProfile()
+ let empty = pubkyProfile.profile == nil && pubkyProfile.publicKey == nil
+ Logger.info("QA1121 late profile returned identity-and-profile-absent=\(empty)", context: "TestHook")
+ qaStatus = "Late read returned: old identity absent=\(empty)"
+ } catch { qaStatus = "Late read control error: \(error)" }
+ }
+ #endif
+
}Deciding app/proxy lines:
[2026-11-06 01:42:43.733 UTC] INFOℹ️: QA1121 terminal complete=true bytes-equal=true before=1 after=1 index=1 pending=true - TestHook [DataBackupsScreen.swift: qaTerminal() line: 146]
[2026-11-06 01:42:50.424 UTC] INFOℹ️: QA1121 terminal repair complete=true reservation-included=true pending=false - TestHook [DataBackupsScreen.swift: qaTerminal() line: 155]
[2026-11-06 01:43:08.113 UTC] INFOℹ️: QA1121 tags staged dependency-failure=true baseline-present=true unchanged=true wallet-pending=true metadata-pending=true - TestHook [DataBackupsScreen.swift: qaTagsFailure() line: 179]
[2026-11-06 01:43:33.583 UTC] INFOℹ️: QA1121 identity repaired; awaiting automatic backups - TestHook [DataBackupsScreen.swift: qaRecover() line: 192]
[2026-11-06 01:44:04.713 UTC] INFOℹ️: QA1121 inspect wallet-present=true metadata-present=true wallet-pending=false metadata-pending=false fresh-tag=true remote-changed=true - TestHook [DataBackupsScreen.swift: qaInspect() line: 205]
[2026-11-06 01:44:32.881 UTC] INFOℹ️: QA1121 real profile response held after SDK read - TestHook [PubkyProfileManager.swift: loadProfile() line: 1236]
[2026-11-06 01:45:25.429 UTC] INFOℹ️: QA1121 real profile response released - TestHook [PubkyProfileManager.swift: loadProfile() line: 1240]
[2026-11-06 01:45:25.430 UTC] INFOℹ️: QA1121 late profile returned identity-and-profile-absent=true - TestHook [DataBackupsScreen.swift: qaLateRead() line: 217]
[2026-11-06 01:45:43.341 UTC] INFOℹ️: QA1121 identity repaired; awaiting automatic backups - TestHook [DataBackupsScreen.swift: qaRecover() line: 192]
[2026-11-06 01:46:36.156 UTC] INFOℹ️: QA1121 tags staged dependency-failure=true baseline-present=true unchanged=true wallet-pending=true metadata-pending=true - TestHook [DataBackupsScreen.swift: qaTagsFailure() line: 179]
[2026-11-06 01:47:06.836 UTC] WARN⚠️: Failed to remove private Paykit endpoints before clearing session: Identity(code: "identity_error", context: "restore Pubky grant session from platform provider") - PubkyProfileManager.signOut [PubkyProfileManager.swift: removePrivatePaykitEndpoints(context:isSessionCurrent:operation:) line: 1543]
[2026-11-06 01:52:08.642 UTC] INFOℹ️: QA1121 inspect wallet-present=true metadata-present=true wallet-pending=false metadata-pending=false fresh-tag=false remote-changed=true - TestHook [DataBackupsScreen.swift: qaInspect() line: 209]
[2026-11-06 01:56:28.179 UTC] INFOℹ️: Wipe step: quiesce - WipeCoordinator [WipeCoordinator.swift: runSteps(_:) line: 350]
[2026-11-06 01:56:28.184 UTC] INFOℹ️: Wipe step: stopNode - WipeCoordinator [WipeCoordinator.swift: runSteps(_:) line: 350]
[2026-11-06 01:56:28.386 UTC] INFOℹ️: Node stopped [LightningService.swift: stop(clearEventCallback:) line: 333]
[2026-11-06 01:56:28.389 UTC] INFOℹ️: Wipe step: removeRemoteEndpoints - WipeCoordinator [WipeCoordinator.swift: runSteps(_:) line: 350]
[2026-11-06 01:56:28.453 UTC] INFOℹ️: QA1121 VSS close admission active=1 - TestHook [VssBackupClient.swift: closeAdmission() line: 69]
1793930095.875 LDK authentication challenge requested
1793930095.875 HOLD LDK challenge response
1793930302.015 RELEASE LDK challenge response
1793930302.285 LDK authentication challenge response forwarded
[2026-11-06 01:44:04.713 UTC] INFOℹ️: QA1121 inspect wallet-present=true metadata-present=true wallet-pending=false metadata-pending=false fresh-tag=true remote-changed=true - TestHook [DataBackupsScreen.swift: qaInspect() line: 205]
[2026-11-06 01:50:36.468 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=true metadata-pending=true - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:50:36.475 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=true metadata-pending=true - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:51:31.778 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=true metadata-pending=true - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:52:08.642 UTC] INFOℹ️: QA1121 remote inspection sdk-present=true session-present=true tags-count=2 - TestHook [DataBackupsScreen.swift: qaInspect() line: 208]
[2026-11-06 01:52:08.642 UTC] INFOℹ️: QA1121 inspect wallet-present=true metadata-present=true wallet-pending=false metadata-pending=false fresh-tag=false remote-changed=true - TestHook [DataBackupsScreen.swift: qaInspect() line: 209]
[2026-11-06 01:52:32.044 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:53:32.794 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:54:32.878 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:55:33.446 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:56:33.477 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:57:34.291 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:58:34.336 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 01:59:35.096 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:00:35.380 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:01:35.395 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:02:36.145 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:03:36.438 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:04:36.800 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:05:37.040 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:06:37.178 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:07:37.344 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:08:38.040 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:09:38.442 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:10:38.787 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:11:39.197 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:12:39.357 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:13:39.856 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:14:40.219 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:15:40.562 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:16:40.887 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:17:41.215 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:18:41.484 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:19:41.660 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:20:42.016 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:21:42.087 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:22:42.223 UTC] INFOℹ️: QA1121 failure alert check failed=false wallet-pending=false metadata-pending=false - TestHook [BackupService.swift: checkForFailedBackups() line: 1095]
[2026-11-06 02:23:28.152 UTC] INFOℹ️: QA1121 remote inspection sdk-present=true session-present=true tags-count=2 - TestHook [DataBackupsScreen.swift: qaInspect() line: 208]
[2026-11-06 02:23:28.152 UTC] INFOℹ️: QA1121 inspect wallet-present=true metadata-present=true wallet-pending=false metadata-pending=false fresh-tag=false remote-changed=true - TestHook [DataBackupsScreen.swift: qaInspect() line: 209]
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 1c3cde65. Re-reviewed all changes since 111ddd88, including affected code paths, and inventoried the current 17-file PR diff against merge base 8167628a. Coverage of unchanged surfaces is inherited from the baseline review. The delta is one commit touching BackupService.swift, BackupServiceTests.swift and the changelog.
2 actionable findings — resolve or provide an evidence-backed rebuttal.
Metadata resume: source tracing supports the new behavior. A WALLET success now schedules pending METADATA, and a metadata job that failed on the wallet dependency re-checks when it retires. If wallet recovery finds the metadata slot still occupied, the wallet status is already cleared, so the retiring job's later check sees it. The resume cannot loop, because a resumed metadata run has no wallet dependency left to fail on. Wallet-before-metadata ordering is still enforced by prepareMetadataBackup and requireMetadataBackupReady. Stop, wipe and restore are blocked by the cancellation, isObserving, shouldSkipBackup and generation guards. .walletDependencyFailed retries the same way .failed did, and no production code compares against the old outcome. runBackup is still the only persistWalletBackup caller, so no payment path waits on this.
Android parity: the behavior matches at a2c58f24, where a WALLET success clears the metadata failure marker and bumps its requirement so the observer reschedules it.
Validation: nothing was executed locally, because this host has no Xcode. In hosted Run Tests, testWalletRecoveryResumesMetadataAfterFailedJobRetires and testFailedWalletBackupPreservesLegacyRemoteMetadataUntilRetry passed. The job failed overall on one UI test, UsdtWalletUITests.testReceiveAndInvalidPaymentStaySeparateFromBitcoin, at an XCTAssertTrue. That test file is not in this PR's diff; I did not establish the cause, and the PR description attributes it to a camera permission dialog. Integration, SwiftFormat and e2e-status checks at this head are green.
Limitations:
- The hosted job tested a merge of this head into master
2d5744b8, which is 17 commits ahead of the recorded base. Those commits were not reviewed. - The PR description mixes requirements with fix notes and references to earlier findings, so independence is possibly contaminated. I did not use those notes as evidence, and earlier concerns are left to the separate reconciliation stage.
- Device testing: not performed in this review.
Suggested additional test cases (proposed, not executed):
- iOS unit,
BackupServiceTests: hold a metadata job that failed on the wallet dependency, recover the wallet, then stop and restart observation withoutsetWiping(true), as the node lifecycle does atAppScene.swift:1369, before releasing retirement. Expected: the stale job schedules nothing, the new observation uploads METADATA once, and WALLET precedes it. The new test sets wiping first, soshouldSkipBackupalone blocks its resume and the generation guard is not independently exercised. - iOS simulator: with a WALLET failure staged and METADATA pending, background the app, clear the failure cause, then return to the foreground. Expected: WALLET uploads before METADATA, both pending flags clear, and no backup failure alert appears.
Earlier findings reconciled at this revision: 2 still present, 4 verified fixed.
Findings
- [MEDIUM] Bound LNURL authentication before draining VSS for wipe — inline at
Bitkit/Services/VssBackupClient.swift:73. Still present from the earlier review. - [HIGH] Stored identity that cannot activate blocks WALLET and METADATA backups indefinitely — inline at
Bitkit/Services/PrivatePaykitService+Backup.swift:12. Still present from the earlier review.
Reviewer model: claude-opus-5-5
Reasoning effort: high
| generation = UUID() | ||
| setups.values.forEach { $0.task.cancel() } | ||
| if activeCalls > 0 { | ||
| await withCheckedContinuation { idleWaiters.append($0) } |
There was a problem hiding this comment.
[MEDIUM] Bound LNURL authentication before draining VSS for wipe
When a key read on VSS Debug’s LDK tab needs a fresh JWT and the LNURL-auth server accepts the connection but holds its response, leaving that screen and choosing Security → Reset would hang before credential deletion. The new closeAdmission() waits for every admitted call, but the pinned VSS dependency's LNURL provider builds a separate reqwest client with no timeout. Its header lookup runs before the VSS client's 10-second request deadline; retry limits only apply after an error returns. The pinned Swift async bridge does not forward task cancellation, so leaving Debug or cancelling setup cannot release this wait. AppReset.withLocalStateWipeScope has no deadline, leaving the durable wipe gated without reaching failure/retry until the auth response finishes or the process restarts. Give native authentication a finite deadline and ensure it settles before retirement; retain the drain safeguard. Add a stalled-auth regression that verifies wipe reaches completion or a retryable failure within a bounded time. Evidence: source analysis of the pinned app and dependency sources; no runtime reproduction.
Still present at 1c3cde6 (first reported at 485a9dc): The mechanism is still present at the pinned head. closeAdmission() waits for every admitted call with no deadline, and neither WipeCoordinator.runAttempt nor AppReset.withLocalStateWipeScope adds one. The pinned LNURL provider builds its reqwest client with no timeout and awaits it during header acquisition, before the VSS client's 10-second request timeout applies. The Swift async bridge does not forward task cancellation, so a stalled auth response holds the local wipe until the response finishes or the process restarts. The 111ddd8..1c3cde6 delta does not touch this path. Both review threads on this line (r4479760379 and r4486023699) are unresolved and not outdated. The author confirmed the mechanism and opened vss-rust-client-ffi#36, but that PR is still open and unmerged, and this head still pins eba41ef6, so the fix is not delivered. The author also states the proposed bound is per header acquisition, not a universal wipe deadline. Severity stays MEDIUM: the wipe is delayed without limit and offers no Retry, but it finishes once the response arrives or after a process restart, and it needs an auth server that accepts the connection and then stalls. Evidence basis: source analysis here, plus the attributed runtime reproduction listed in updateEvidence. I did not execute it. No GitHub issue tracks this mechanism.
| ) async throws -> (state: String?, sdkVersion: UInt64) { | ||
| let initialSdkVersion = await sdkService.backupStateVersion | ||
| guard try await sdkService.currentPublicKey() != nil else { | ||
| guard try !hasStoredIdentity() else { throw PubkyServiceError.sessionNotActive } |
There was a problem hiding this comment.
[HIGH] Stored identity that cannot activate blocks WALLET and METADATA backups indefinitely
Trigger: A wallet has stored Pubky credentials or an adopted reference, but the session cannot activate. This happens when session import is rejected and no secret key is reachable, or when re-sign-in fails with a non-temporary error. Both return .restorationFailed and deliberately keep the credentials "for a later retry" (PubkyProfileManager.swift:2091-2112).
Mechanism: With no live session, currentPublicKey() has no identity to return. Bitkit uses Pubky shared storage, whose local identity read is Ok(None) (paykit-rs storage.rs:112-118). The new guard at this line then throws sessionNotActive on every WALLET capture.
That error is not in isTemporaryBackupFailure (BackupService.swift:492-501), so the outcome is .failed and the scheduler stops retrying (BackupService.swift:1050-1062). Foreground retries, manual backup and later data changes reach the same guard. METADATA is blocked as well, because prepareMetadataBackup requires the pending WALLET backup to complete first (BackupService.swift:469-481).
Consequence: This path would leave the remote WALLET and METADATA records frozen for as long as the credentials stay stored. WALLET carries transfers, private address reservations, watch-only accounts and Paykit payment state, so a later restore would return stale data for those items. The seed and LDK channel state do not go through this backup. The user only gets the generic backup failure alert after 30 minutes. Signing out from Profile clears both credentials and unblocks backups, but nothing links that alert to sign-out, and it discards the identity.
Expected behavior: Refusing to replace a complete backup with an identity-free payload is correct. Ordinary WALLET and METADATA state still needs a way to stay current, or the app needs a specific remedy, when the identity cannot be activated.
Evidence basis: Source analysis at the pinned head; this path was not exercised. testUnresolvedStoredIdentityCannotReplaceCompleteWalletBackup covers the no-upload protection and recovery once an identity appears, but not a state where the identity never recovers. Android has the same guard at a2c58f24.
Still present at 1c3cde6 (first reported at 111ddd8): Still present, and the thread remains unresolved with the author's confirmation. While stored credentials cannot activate, WALLET capture keeps failing non-temporarily, so remote WALLET stays at its last upload, and METADATA also stays behind while it waits on WALLET. The 1c3cde6 delta improves recovery after credentials are repaired but gives no path while they stay unusable. This review corrects the earlier record in three ways. (1) The published mechanism was too broad: there are two routes, and the new guard is reached on only one. With a saved session, identity_status calls load_session_access, which throws Identity from restore_session, so backupSnapshot fails at currentPublicKey() and never reaches line 12. That route has the same source at base, so it is pre-existing and also explains why ovi-reviewer repeatedly saw the SDK Identity error before sessionNotActive. With no saved session (stored secret key or adopted reference, on a runtime with no cached identity), currentPublicKey() returns nil and the guard throws sessionNotActive. That route is new in this PR: base returned (nil, version) and uploaded an identity-free payload. (2) Introduction is therefore corrected from introduced to worsened: the PR adds a new non-temporary entry into a blockage that already existed for the saved-session route. The guard's refusal to replace a complete backup with an identity-free payload remains correct; the gap is the lack of a way to keep ordinary state current or a specific remedy. (3) The earlier statement that signing out from Profile clears both credentials and unblocks backups is wrong for these states, as listed in updateEvidence. Severity stays HIGH because the consequence is unchanged: a later restore would return stale transfers, reservations, watch-only accounts and Paykit payment state, and the assumed workaround does not work. Seed and LDK channel state do not go through this backup. Android has the same guard at a2c58f24. tracked_separately does not apply because the PR worsens the concern and no issue exists. Evidence basis: source analysis at the pinned head and pinned paykit-rs, plus attributed runtime reports; the base-side route conclusion was not executed.
There was a problem hiding this comment.
I checked both paths at 1c3cde65 against the base and the pinned Paykit 01de5dd2. An invalid saved session throws Identity during currentPublicKey() on both revisions. With only a retained key or adopted reference and no cached identity, the lookup can return nil. This PR then refuses the identity-free WALLET write. That preserves the last complete backup, but newer WALLET and METADATA changes stay pending while the identity is unavailable.
The installed-app report covered the invalid saved session. Disconnect stopped during private cleanup before it could erase credentials. The nil-identity path and its base behavior were checked in source. I’m keeping this finding open for recovery while credentials remain unusable. Installed-app evidence
There was a problem hiding this comment.
Approving at 1c3cde650. No HIGH or MEDIUM; one LOW is open inline. You can fix it here or move it to a follow-up issue. Please reply on the thread saying which.
Delta since my last approval: the master merge 111ddd88c and 1c3cde650 ("resume metadata backup after wallet recovery"). This is a backup path, so a Fable review traced it, and a verifier ran the tests.
- Merge: the only conflict was in
PrivatePaykitServiceTests.swift. Both sides are kept, and no production code was in conflict. .walletDependencyFailed: nothing compared against the old.failedoutcome for this case. Every retry decision goes throughcanRetryPendingBackup(lines 111, 280, 1066, 1079), which excludes it the same way.- Ordering: the resume only schedules. The resumed run still has to pass
prepareMetadataBackup(a pending wallet backup must complete first) andrequireMetadataBackupReadybefore uploading, so METADATA can't land before WALLET. - Stop, wipe and restore:
- Every resume checks the observation generation,
isObservingandshouldSkipBackup(.metadata). - A run started before a stop can't schedule after it.
- A stale wallet run that finishes after a wipe can't schedule metadata for the new wallet.
- A restore defers through
hasPendingWalletRestore.
- Every resume checks the observation generation,
- Pending METADATA: every refused resume leaves it required with
running: false. The next observation start or foregroundretryPendingBackupspicks it up, so nothing is dropped and the resume can't loop. - Twin parity: synonymdev/bitkit-android#1686 at
a2c58f24resumes the same way in a different shape: it bumpsMETADATA.requiredon WALLET success insideBackupRepo'sNonCancellableblock.
perf: not relevant (at most one extra 5 s-delayed scheduled metadata job after a wallet success).
Skipped — already driven this round: metadata resumes after the scheduled retry retired (iOS, by ovi-reviewer at 1c3cde6, passed).
Skipped — covered by CI at 1c3cde650: unit tests, SwiftFormat, integration, e2e (green).
CI: Run Tests failed on UsdtWalletUITests.testReceiveAndInvalidPaymentStaySeparateFromBitcoin, on the first run and again on my re-run. The xcresult shows the cause: the test taps Allow on the system camera prompt ("“Bitkit” would like to access the Camera."), and the springboard alert is still there 5 s later. The test passes on master 4c7633a41 (99 s) and on #1208 (107 s). This PR changes no camera, scan or USDT code, so I read it as a simulator permission-alert flake rather than a regression. It still needs a green run before merge.
The open HIGH and MEDIUM threads from ovi-reviewer and piotr-iohk (terminal-identity backup block, stalled LNURL-auth drain) are untouched by this delta. They're still theirs to settle.
Reviewer model: claude-opus-5-5 (coordinator); delta review claude-fable-5-1; verifier claude-opus-5-5
Reasoning effort: Not exposed (no setting in session metadata)


Tags recovery and open findings at
1c3cde65After a WALLET backup succeeds, Bitkit schedules any METADATA backup that was waiting for it. In the external iPhone simulator test, restoring the original credentials triggered the WALLET upload. Tags followed 5.2 seconds later while the app stayed in the foreground. A fresh remote read contained the new tag, and both pending flags cleared. The reviewer used Debug controls on this head to stage the failure and inspect the remote records. Review and execution traces
Two findings remain open. With an invalid saved session and no local secret, an ordinary reservation stayed pending and the previous complete remote WALLET stayed byte-identical. Profile → Disconnect encountered an identity error during private endpoint cleanup, before local sign-out. Separately, holding the dedicated LDK client’s authentication response kept reset at step 4 of 12 for 113.6 seconds after admission closed. Releasing the response let reset finish. This app still pins VSS
eba41ef6. Terminal identity finding · Reset finding · VSS timeout fixLocal validation passed 3,169 distinct tests with five skips. Hosted native CI recorded 3,221 passing executions, five skips and one failure. The camera permission dialog remained open after Allow. Integration recorded 30 passes with one sync-helper retry. End-to-end CI recorded 13 eventual passes, including the RBF case on its configured retry, and one unrun case. Hosted native job
João reran the failed native job on the same checkout. Attempt 2 again recorded 3,221 passing executions, five skips and one failure. The USDT test tapped Allow, but the camera prompt stayed open and the manual-entry checks were never reached. The cause is still open. Second native attempt
Expired cleanup admission composition — November 5
Signed
485a9dc12f71c4c2763c72384782b2d06c1c017fintegrates #1120 at1b88e0bd25dfc8c13e225e93d1b161915d9a98ee. Cleanup rechecks cancellation and the original wipe owner after suspended revision/capability reads and before each private recovery or withdrawal. This child retains its VSS admission closure, reset drain and original signer/store binding through local erase. The complete child delta is preserved; an affected integration review is clean, reusing the two independent reviews of identical parent source.The actual combined tree
0b0e402434b7351d30623207a6eeb60fbf8e2e59passed 3,133 unique tests, zero failures and five explicit skips on Xcode 27.2 with preserved iOS 26.2 (23C54). All four incoming regression cases pass, all 1,721 frozen inputs match, and full SwiftFormat reports 0/851. Translation validation has zero errors and 17 findings verified against the actual parent. Complete native logs, nested results and attachments are audited inwork/validation/ios1121-revision-admission-20261105/final-audit/. The audit retains 24 runtime warnings (23 QoS, one SwiftUI State), one internal N3 tap retry and explicit fixture/export limitations; no cause or harmlessness is inferred. No whole-test retry occurred. Skips and four workflow-excluded integration suites are not passes.All 18 live-base commits verify locally and on GitHub. Current-head native CI and SwiftFormat passed on attempt 1;
draft=false. Native results are 3,122 unit and 40 physical UI passes (3,133 unique passes), five skips, zero failures and no method or tap retries. Hosted formatting is 0/851 with 113 configured exclusions; nine migration jobs skipped. Synthetic checkoute66ffeb0312943623a7299aaf7ad6bfdc767177fexactly matches the published tree and live parent. Complete logs, 17 annotations, nested results and all attachments are inspected; all 9,849 sealed evidence members verify. The 286 distinct linker warnings match the actual parent. Runtime advisories and disabled detectors remain qualified; no new compiler/format violation was found. Hosted report:work/validation/hosted-ios1121-485a-20261105/report.json. Existing named reviewer representation is preserved. This fixes the separate stale-cleanup entry gap; an already-started native operation may still finish. It does not establish running-LDK quiescence, authenticated full J1, literal wallet wipe or terminal complete-WALLET availability. The terminal-identity finding remains open, and earlier evidence below keeps its original revision and scope.The external review 5779261679 was independently checked against its exact three-file DEBUG overlay and two supplied clips. It visually corroborates reset reaching a replacement wallet’s Receive screen, with selected trace evidence of a held revision read resuming into canceled admission. Its terminal-identity clip and resolver trace support the existing finding, but optional before/after equality also permits two missing WALLET records, and no post-repair remote read is supplied. Original executable/full process and remote-version bindings are unavailable. These are bounded external observations, not a new author runtime pass or complete-WALLET acceptance. The finding remains open; no repeated reviewer request was generated.
Hosted validation of the concurrent-reset fix — November 4
At signed
bf4061f50acffd64fde206a4da1afe380895934b, native CI and SwiftFormat passed on attempt 1. The synthetic tree exactly matches the locally validated tree and live #1120 parent36999f7d21a72791cb72d634db7e9cf65d09e161. Canonical results: 3,118 unit plus 40 physical UI passes, five fixture skips, zero failures or method retries. All nine VSS coordinator and 23 wipe coordinator cases passed. SwiftFormat 0/851; nine migration jobs skipped. Complete logs, all nested result references, 6,489 artifact entries and 9,810 sealed evidence members are inspected and verified.The audit retains 572 linker warnings, simulator/runtime diagnostics and disabled runtime detectors. Direct annotation export was unavailable; complete job logs and canonical diagnostics were inspected. The passed real-node test covers failed startup, not running-LDK quiescence. The terminal-identity complete-WALLET finding remains open. This publication fixes the separate concurrent-reset finding and composes the validated parent; it does not claim that broader recovery work is finished.
Durable wipe composition and concurrent reset — November 4
Commit
bf4061f50integrates parent #1120 at36999f7d2with the durable wipe coordinator. VSS admission closes after node-stop/remote-endpoint cleanup and remains closed through local credential removal and commit. Admitted setup/operations drain before slot retirement. A concurrent reset now finishes before this scope enters, instead of rejecting the wipe; the step remains attributable and retryable. This addresses João’s comment 4466350265.The final composed tree
fdb02abcdpassed 3,118 units plus 40 physical UI executions on Xcode 27.2 with preserved iOS 26.2 (23C54): 3,129 unique passes, five explicit skips, no test failures or whole-test retries. All 1,721 frozen inputs matched. SwiftFormat 0.64.0: 0/851; translations: zero errors and 17 warnings verified against the actual base. Both negative controls fail at the intended boundary, and the corrected focused gate passes 62 cases. Two independent affected source reviews are clean.Complete logs, nested XCResults and attachments are audited and 27,103 evidence files sealed. Thirty runtime warnings (29 QoS, one SwiftUI State), one internal tap retry and simulator/diagnostic limitations remain recorded without a harmlessness or causation claim. Five explicit skips and four excluded integration suites are not passes. Local evidence:
work/validation/ios1121-wipe-parent-final-audit-20261104/report.md. All 17 current-base commits verify locally and on GitHub.draft=false; newly triggered hosted CI is pending inspection. Earlier evidence below retains its original revision and scope; prior approvals are stale for this new head.This does not establish literal wallet wipe, authenticated full J1, terminal complete-WALLET availability or actual running LDK quiescence. Those gaps remain distinct and open.
VSS client retirement — November 4
Commit
25f25eaedcloses app/debug VSS admission during reset, drains admitted setup and native operations before clearing both slots, and rejects results or failures from the retired generation. The wipe caller keeps admission closed throughout its existing credential-removal procedure. Setup derives the store ID from the same captured mnemonic/passphrase as the signer, avoiding the independently cached wallet-index lookup. Ordinary calls remain concurrent; compound list/delete operations retain one admission.The original setup race was reproduced: reset returned while setup was pending, then its late completion suppressed replacement setup. The final candidate passed 2,964 selected unit tests and one ProfileRecovery UI test, including all eight ownership controls, with one explicit opt-in USDT fixture skip, zero failures/retries. SwiftFormat 0.64.0 reports 0/830 files requiring changes. Complete 21,942-line logs and all nested XCResult diagnostics were inspected: two inherited AppIntents notices and five DHT QoS observations remain, without a timing-cause claim or introduced Swift compiler finding. Earlier test-harness failures and the corrected checks remain preserved. Evidence:
work/validation/ios-vss-owner-diagnostics-20261104/report.md.Two independent source reviews are clean. All 16 live-base commits verify locally and on GitHub; base
689ec4e39,draft=false. Hosted native CI passed 2,964 unit tests plus 39 UI executions, with five explicit skips, zero failures and no whole-test or workflow reruns. One XCTest internal tap retry remains recorded. SwiftFormat passed 0/830. Nine skipped integration jobs are not passes.Complete logs, all nested results, diagnostics and 67 attachments were inspected; the artifact SHA-256/ZIP CRC checks and all 18,613 sealed evidence files were verified. Hosted synthetic merge
4b16f323contains the live parent's diagnostic attachments around four existing test waits inPaykitPaymentRequestServiceTests.swift; its production sources, dependency pins, project and workflows match the published head. The 286 structured native linker warnings match unchanged dependency inputs. Simulator/system observations and fault-test error messages remain recorded without a blanket harmlessness claim. Missing USDT fixtures and disabled hardware cases remain skips. Evidence:work/validation/hosted-ios1121-25f2-20261104/REPORT.md.João approved this exact head in review 5766857669; Ovi remains pending. The automated review 5766769506 reports separate positive/negative controls and simulator recovery attempts. Those claims remain attributed external evidence: the author has not inspected their raw execution artifacts, and they do not close the terminal complete-WALLET finding.
This finite fix does not prove literal wallet wipe, authenticated full J1, complete-WALLET migration or running LDK quiescence. It changes no capability header, SDK version or server activation. The terminal complete-WALLET finding below remains open; earlier runtime evidence remains bound to its recorded revision.
Earlier-head hosted validation — November 3
At
b9f3bbda22cf8010e79c2c6bd24b69002f543be7, native run 41667625486 passed 2,956 unit tests and 39 UI executions, with five explicit fixture/hardware skips and no failures or retries. The 30 launch executions are 15 locale × two appearance configurations. Formatting is 0/829. Full logs, archive integrity, every nested result and diagnostic signature were inspected; 9,313 evidence payloads were verified. The exact synthetic tree additionally contains the live parent's unrelated failure-stage test wrapper; it is not asserted identical to the local tested tree.The 286 distinct linker messages match sealed prior provenance and actual-base dependency/project inputs. Simulator, keyboard/gesture, SwiftUI and other runtime observations remain documented; no warning-free or runtime-detector-clean claim is made. Skipped migration/fixture cases and the terminal complete-WALLET finding remain uncovered. This validates the finite cache-read refusal/recovery fix without closing that separate finding. Evidence:
work/validation/hosted-ios1121-b9f3-20261103/REPORT.md.Cached Blocktank backup protection — November 3
Signed
b9f3bbda22cf8010e79c2c6bd24b69002f543be7prevents an unreadable cached Blocktank info record from being encoded as legitimate absence. All cached reads must succeed before upload; genuinely absent optional info remains valid. Orders and CJIT explicitly request cached reads. The released Core already read their nil-ID calls locally, so this is not a claim that a network-refresh failure was repaired. Existing payload format, cancellation, restore admission and complete-WALLET gates are preserved.Actual app capture wiring with the released native SQLite bridge is covered by
BlocktankBackupTests: a cached order plus absent info backs up successfully; separate unavailable orders, CJIT and info tables refuse replacement, retain the acknowledged revision and pending work, and permit explicit retry after restoring the same table. The corrected before-control passed the absent-info case and failed only the swallowed-info assertions; the fixed candidate passes both tests. The first fixture failure is retained separately. Upload completion is simulated in these native tests; they are not authenticated VSS, UI, restart or full-wallet acceptance.Required final native validation passed 2,956 unit tests and one Profile Recovery UI test, with zero failures and one fixture-dependent unit test skipped. All 1,688 frozen inputs match. SwiftFormat 0.64.0 passed 0/829, with 105 configured exclusions;
git diff --checkpassed. Complete diagnostics retain six Thread Performance Checker observations in unrelated test paths, plus platform audio/haptic notices; these are not described as warning-free or as a reproduced parent runtime baseline. The exact skipped case and diagnostic evidence remain in the sealed report.Two independent affected source reviews and complete native/structured diagnostics are recorded in
work/validation/ios1121-cached-blocktank-*20261103. All commits against the live parent are locally/GitHub verified. The live parent additionally contains an unrelated failure-diagnostic wrapper inPaykitPaymentRequestServiceTests; local and hosted synthetic-tree evidence retain their exact input scope. New-head hosted checks are pending inspection. The terminal complete-WALLET finding and J1 artifact gaps below remain open.Earlier-head native CI inspected — November 1, 23:22 UTC
At
a6f233df2dcefbd75b0f7b3c838db23d1a984493, native CI passed 2,954 unit tests and 39 physical UI executions, with five explicit skips, zero failures and no retries. The synthetic checkout tree exactly matches this published head; its actual CI parent is recorded in the audit. Earlier exact-head formatter evidence is reused, and skipped migration jobs remain skipped.Complete logs, all nested physical test summaries and 67 attachments were inspected and checksum/CRC verified. The complete linker-warning multiset matches the actual parent: 571 occurrences /286 symbols, with no introduced source compiler finding. Runtime evidence retains 48 CoreGraphics invalid-number messages, haptic/service errors and fixture limitations; passing assertions do not establish their cause or harmlessness. This is not an authenticated recovery or complete-WALLET acceptance pass; the terminal availability finding remains open. Evidence:
work/validation/hosted-ios1121-a6f-20261101-followup-a/NATIVE-REPORT.mdandwork/validation/hosted-ios1121-1123-native-root-integrity-20261101.json.Current parent composition — November 1, 21:42 UTC
Signed additive merge
a6f233df2dcefbd75b0f7b3c838db23d1a984493incorporates live #1120 at66a0320c98b66e7de99fc296c50dc536e42240fe; final treee0fe4f3004d38b91f98f097df886a3b4e154294f. The owned identity/restore patch is unchanged. The incoming last-channel-close publication repair and current-month test fixtures have one clean affected composition review, retaining the prior independent safety reviews.Required native validation passed 2,954 unit tests and one ProfileRecovery UI test, with one explicit USDT fixture skip and zero failures/retries. SwiftFormat 0.64.0 reports 0/828 files requiring changes (105 configured skips). All 1,687 frozen inputs match. Complete logs and nested XCResult issues were inspected: six unsymbolized QoS observations, three AppIntents notices and two UI launch-metric notices remain recorded; their timing cause is not established. All 14 live-base range commits are locally signed and GitHub
true/valid.Current-head native CI is now fully inspected above. This composition adds no authenticated restore journey pass and does not resolve the terminal complete-WALLET availability finding, which stays open. Previous runtime results and failures remain revision-specific. Evidence:
work/validation/ios1121-parent66a-review-20261101/andwork/validation/ios1121-parent66a-final-a/.Description
Ordinary wallet restore finishes after required wallet and USDT recovery, while optional Pubky activation and profile enrichment continue afterward. Same-revision initialization coalesces, and obsolete identity results cannot restore an earlier sign-in.
The reconnect follow-up in
0b77f099retries a missing own profile through existing foreground/reconnection maintenance. It retains the original authenticated session, returns without waiting for the public profile read, and uses the existing overlapping-read and profile-write guards. It does not re-import credentials, add a polling loop, or delay Home.Complete WALLET and METADATA capture still rejects unavailable or unreadable identity data. Terminal identity restoration can therefore leave newer ordinary WALLET state pending; the existing availability finding remains open. No stale SDK snapshot or partial backup is used.
Twin: synonymdev/bitkit-android#1686. This PR includes the earlier #1120 parent and now targets
master.Out of Scope
Current validation — November 1
Signed additive head
d83a21721b67045fdc4e6cb2816fb1988d5e924bincorporates published #1120/6140650c72cb7a3b4026d041a81960994b45a961; exact tree981bc42f0e4db778bb3799fd618b1aba168af354. The seven-file child delta is preserved. Incoming queued allowance offers now recheck their original identity/activation inside the SDK operation before native acceptance, including disable/re-enable while queued. The child's restore, backup and identity guards remain intact.Final build and selected native suite passed 2,953 unit tests plus one affected ProfileRecovery UI execution, zero failures/retries, with one explicit encrypted-USDT fixture skip. All 1,687 frozen inputs match. SwiftFormat 0.64.0 reports 0/828 needing changes, 105 configured exclusions; diff check is clean. Unchanged localization inputs retain their prior validation, not a new execution. Complete 23,253-line build/test output, all 2,955 canonical nested summaries, balanced sessions and 30 attachment payloads were inspected; the owner verified 11,979 sealed evidence files. The wrapper omits two passing cancellation tests and five canonical QoS warnings; those are reconciled in the report. Five unsymbolized QoS observations, two AppIntents notices and explicit negative-path/runtime log groups remain disclosed without a causal pre-existing or warning-free claim.
An affected independent composition review is clean, retaining the two shared concurrency reviews. All 13 complete live-base commits verify locally and on GitHub (
verified=true,reason=valid). Current-head hosted native and SwiftFormat are completely audited: 2,953 unit + 39 UI passing executions, five explicit skips, zero failures/native retries; 0/828 files need formatting. Syntheticd5a3690uses actual base6140650and exactly matches the published head tree. Complete logs, nested XCResults, attachments and artifact digests were inspected; all 18,580 sealed files verify. Native results retain 571 duplicate-symbol warning events (286 symbols), two AppIntents notices and a keyboard timeout/No-stack observation in a passing route test, without a timing-repair or warning-free claim. Actual native configuration is Xcode 16.4/iPhone 16/iOS 26.2. Migration's nine jobs were skipped; no new integration/E2E run is credited from the parent. Evidence:work/validation/hosted-ios1121-d83a-20261101/REPORT.md. The terminal complete-WALLET HIGH and unrun authenticated cases remain open. Other UI results below belong to their earlier revision; four workflow-excluded integration classes and the fixture skip are not passes. No new authenticated J1, process-kill restore or payment replay acceptance is claimed. Evidence:work/validation/ios1121-parent6140-final-a/REPORT.mdandios1121-parent6140-20261101/publication/. Ready status remainsdraft=false; existing reviewer requests are preserved.Retained findings and prior-head evidence
The terminal complete-WALLET availability finding remains open; no stale/nil SDK fallback or partial-backup substitution was added. The latest external terminal-identity report corroborates the existing issue with its stated Debug-control limits, not new author-verified full-app acceptance. The setup-pending correction in
e5143b13remains: intentionally missing profiles are not republished while setup is pending, both before scheduling and in the queued task; healthy recovery and stale-result rejection remain covered.At predecessor
624d7a38, hosted native and formatter were fully audited on the exact head tree: 2,962 logical / 2,991 physical passes, five explicit skips, zero failures/retries, format0/828. Complete diagnostics retain286 unique linker warnings/571 emissions, two AppIntents notices,72 NaN messages, six deliberate/conditional waiter timeouts and one internal hit-point activity failure in passing tests. Runtime detection was disabled. Nine migration jobs were skipped. Historical PATH-only formatter failure, package Git-alternate limits and older runtime observations remain in their sealed receipts; they are not silently waived or claimed reproduced by this composition.Design / Preview
No layout change. Home remains usable while optional identity recovery continues. A temporarily missing own profile can recover during ordinary maintenance without navigating to Profile or re-authenticating.
QA Notes
Setup
Use the committed Debug dependencies: Core 0.8.0-rc11, Paykit 0.1.1-rc2 and VSS 0.5.26-rc1. J1 needs a disposable test-network wallet, a complete VSS backup and saved identity. Delay only final optional activation/enrichment after metadata credential replacement and required recovery imports succeed. Preserve existing wallets and use no live funds.
Journeys
restore-during-identity-outage.xml: optional identity outage after required restore, complete-backup protection, screen exit, missing-profile reconnect, stale enrichment after identity replacement, restart, and required recovery failures retaining spending/address gates.The reconnect action holds a real profile read while maintenance continues, then expects Home to recover after release without manual Profile navigation. Native tests use controlled service resources and are not a complete authenticated app journey. The author's isolated complete-app J1 fault controls remain unprovisioned.
External simulator review 5658584985 reports six J1 actions at historical head
ec20b264using real SDK/VSS paths and uncommitted Debug fault controls. Its raw artifacts were not independently verified by the author. It does not establish this follow-up's new case, terminal unrecoverable identity, USDT-specific restore failure or a live homeserver blackout. Piotr's subsequent review retained the terminal-backup finding.Remaining findings and historical evidence
The terminal identity/full-WALLET finding remains unresolved on both platforms. Updated-reader compatibility was approved, but the released conditional VSS API alone cannot exclude legacy unconditional writers or version-reset ABA. This PR does not activate a split backup writer, infer historical incident causation, or claim that full-wallet redesign/contact CAS is acceptance evidence for J1. Pubky #741 merged externally; deployed server revision/configuration remains unverified.
The earlier backup-observer Set race repair and its failed run 40482860269 remain revision-scoped evidence; the exact competing schedule was not reproduced. Prior combined head
c0b1e86fpassed native CI 40719305858: 2,818 unit and 39 UI executions, one unit/four UI skips, zero failures. Its complete logs/nested results were inspected, and its 286 distinct linker warnings matched actual-parent evidence. Earlier failures, skips and runtime observations are retained, not erased or carried forward as new-head passes.Models used
Planning/implementation: gpt-6-astra (xhigh). Independent source/integration/diagnostic reviews: gpt-6-astra (xhigh) and gpt-6.1-sol (high), with actual execution metadata retained.