Skip to content

fix: serialize backup observation lifecycle - #1123

Open
ben-kaufman wants to merge 20 commits into
fix/restore-identity-backgroundfrom
fix/backup-observer-lifecycle
Open

ben-kaufman wants to merge 20 commits into
fix/restore-identity-backgroundfrom
fix/backup-observer-lifecycle

Conversation

@ben-kaufman

@ben-kaufman ben-kaufman commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Paired authentication transport classification — November 3

Signed commit 11fb65d780055a10081bd9d7e12fee61949d140d recognizes the released VSS AuthError request-send form as temporary for scheduled backups. The raw HTTP(S) form and one exact native external-request wrapper are accepted. Authorization denial, response/decode errors, repeated wrappers, unrelated errors and malformed URLs remain permanent. Valid configured callback paths, IPv6 and punycode remain supported. Existing UnknownError transport handling stays restricted to /putObjects. No credential, restore, owner, cancellation or scheduler admission rule changed.

Controlled before tests reproduced the classifier and scheduled SETTINGS failures, while denial stayed permanent. The final candidate passes 2,973 selected unit tests and one ProfileRecovery UI test, with one explicit local-protocol fixture skip, zero failures and zero retries. Four workflow integration classes remain excluded. All 1,689 frozen hashes match. SwiftFormat0.64.0 reports 0/829 requiring formatting, with106 configured exclusions; whitespace checks and two independent affected correctness/security reviews pass. Original repeated-wrapper and punycode failures, formatter findings and their corrections are retained. Complete raw logs and all2,975 canonical results were inspected. Two AppIntents notices and five QoS observations remain disclosed; all five share prior stack prefixes, four have identical normalized frame sequences, and no new timing cause is claimed.

Evidence: work/validation/ios1123-auth-transport-final-b-audit-20261103/report.json, SHA256 351b376b7ba253018b14d40a6e819738ad19b36d806be87dae6f16df7cea5b78; source delta SHA256 c88ed7de25535e0980496c095d391de3efdacc1da29aee14939707e33114e0aa. The owner verified6,192 sealed evidence files. The full20-commit range against live parent b9f3bbda22cf8010e79c2c6bd24b69002f543be7 is locally and GitHub verified. Current-head native CI and formatting passed on their first attempts. The synthetic checkout 2983c260 has the exact published tree. Canonical results record 2,973 unit passes and 39 UI passes, five explicit fixture/hardware skips and no failures or retries; thirty distinct launch configurations are separate physical executions. Hosted formatting is 0/829, with 106 configured exclusions. Migration was skipped. Complete logs, all nested XCResult references and checksum/CRC-verified artifacts were inspected, with 9,324 sealed evidence members verified. The 573 normalized build diagnostic lines match the previous head; runtime diagnostics remain retained without a causal or warning-free claim. Evidence: work/validation/hosted-ios1123-auth-transport-11fb-20261103/report.json.

Scope of runtime evidence: these new iOS controls exercise scheduled SETTINGS backup with observation stopped and refusal remaining pending. They are native tests, not a new authenticated full-app auth journey. The Backups-screen direct Retry returns its outcome without enqueueing a follow-up; this change preserves that behavior. Android twin #1689 at e7fb0ac37 has separate actual-app transport recovery and authorization-denial evidence, which is not counted as iOS execution. Existing Manual1–4 app evidence below retains its tested revisions and stated boundaries. Terminal complete-WALLET availability remains a separate parent finding.

Attributed current-head app evidence

External review 5760648183 reports scheduled SETTINGS recovery after auth-relay refusal, invalid-challenge failure remaining pending for 25 seconds, and recovery from a USDT VSS connection failure at 11fb65d7, using disclosed local DEBUG controls and staging authentication. The author inspected the full report, inline logs and added control diff, and verified its revision. Original build/install receipts, raw relay/VSS artifacts and media were not independently inspected, so these remain attributed external results. Invalid-challenge evidence is distinct from a genuine server authorization rejection, which that reviewer did not drive. Neither result establishes direct-UI automatic Retry behavior or terminal complete-WALLET availability. Ovi's separate current-head human approval is recorded; existing João and Piotr requests remain pending, without duplicate notifications.

Scheduled-backup completion repair — November 3

Signed 4e6c06ed79d0b916529b293fc83cd98d8acb0d33 removes scheduled-job admission before publishing idle, within the same serial backup-queue operation. A foreground retry can no longer observe idle while the completed job still blocks it. Latest required/synced revisions and cancellation drains are preserved.

The controlled app-queue regression fails before the repair and passes afterward; all 13 affected tests pass. The original test-fixture mistake (holding the test module’s separate queue) is retained and superseded by the explicitly app-qualified control. Final combined validation passed 2,970 units and one Profile Recovery UI test, zero failures/retries, with one opt-in native-fixture skip. All 1,689 frozen inputs match. SwiftFormat 0.64.0 reports 0/829 (106 exclusions). Two independent affected reviews are clean; complete diagnostics retain six native Paykit QoS observations and two AppIntents notices, without a timing-cause claim. All 19 commits against live #1121 b9f3bbda verify locally and on GitHub.

This repair follows #1124’s authorized CI retry: its camera case passed, but a late-retry scheduling test failed. The independently reproduced admission gap is fixed here; the exact historical job-map state was not captured, so the CI failure’s cause is not conclusively established. The new-head hosted inspection below is complete; dependent PR composition continues. Existing authenticated acceptance and terminal complete-WALLET gaps remain scoped below. Evidence: work/validation/ios1123-idle-publication-final-a-20261103/inspection/REPORT.md and work/validation/ios1123-idle-publication-20261103.

Hosted validation at 4e6c06ed — November 3

Native run 41704088643 passed 2,970 unit tests and 39 UI executions, with five explicit skips, zero failures and no test-method retries. Both scheduled-backup regression cases passed. One N5 tap recovered on its second action attempt. SwiftFormat reports 0/829, with 106 configured exclusions; migration jobs were skipped. The synthetic checkout and published head have the exact same tree against live #1121 b9f3bbda.

The complete logs, nested results, 6,181 archive entry CRCs and 9,332 sealed evidence payloads were inspected and verified. The 572 canonical linker warnings are 286 repeated messages matching the verified base/pin provenance. Simulator and UI advisories remain documented; this is not a warning-free or full authenticated fixture claim. Evidence: work/validation/hosted-ios1123-4e6c-20261103/REPORT.md.

Historical hosted validation at c6336881 — November 3

Native run 41670750702 on c6336881d41b8aefe9a967e282b11c2a9301f3eb passed 2,969 unit tests and 39 UI executions, with five explicit skips and no failures or retries. Thirty launch executions cover 15 locale × two appearance configurations. Formatting is 0/829. The actual synthetic checkout, published head and local composed tree match exactly against live #1121/b9f3bbda.

Full logs, nested results and diagnostic references were inspected; 9,352 evidence payloads and all archive CRCs/digests were verified. The 286 distinct linker messages match sealed prior provenance and actual-base dependency/project bytes. Simulator, keyboard/gesture, optional-wait and SwiftUI diagnostics remain documented, with no blanket harmlessness or detector-clean claim. Migration and fixture skips are not passes; this adds no terminal complete-WALLET, remote restoration or funded-USDT acceptance. Evidence: work/validation/hosted-ios1123-c633-20261103/REPORT.md.

Cached-backup parent composition — November 3

Signed c6336881d41b8aefe9a967e282b11c2a9301f3eb additively incorporates #1121 at b9f3bbda22cf8010e79c2c6bd24b69002f543be7. Unreadable cached Blocktank records now refuse upload, preserving the acknowledged backup and pending revision; valid absent optional info remains supported. The backup observer/stop/drain/retry behavior is preserved. The merge had no conflicts.

The actual combined tree passed 2,969 unit tests and one Profile Recovery UI test, with zero failures/retries and one opt-in USDT native-fixture skip. All 1,689 source inputs match. SwiftFormat 0.64.0 passed 0/829 with 106 configured exclusions, and diff checking passed. The initial PATH formatter stopped for an insufficient version; it produced no clean result and is preserved separately. Complete nested diagnostics retain five QoS observations and two AppIntents extraction notices in unchanged paths, without claiming a reproduced parent runtime baseline. Two independent parent source reviews and the fresh affected composition review are clean. The full 18-commit live-parent range verifies locally and on GitHub.

New-head hosted CI is pending inspection. These combined native checks add no authenticated journey or terminal complete-WALLET acceptance; the existing case-specific evidence and gaps below remain distinct. Detailed local receipts: work/validation/ios1123-cached-blocktank-composition-20261103 and work/validation/ios1123-cached-blocktank-diagnostics-20261103.

Authored lifecycle acceptance — November 2

All four finite manual cases below passed on disposable, unfunded iOS26.2 simulators using ff3ba674 plus independently reviewed, local DEBUG timing/observation controls. The installed app manifests match all105 built files. Cases1–3 bind final-c; case4 binds final-d, which adds only cosmetic initial progress and completion logging. Published source, real capture, native LNURL authentication, encrypted VSS transport, node-ready admission and completion guards remain unchanged. No wallet reset, funds, release or server deployment was used.

  • Manual1: held scheduled cleanup and its final status write block manual METADATA admission and ordinary restart uploads. Edits received during the drain upload afterward. A separate actual SETTINGS acknowledgement held before caller return retains a newer required marker through cancellation; later native readback matches the changed setting and synthetic watch-only record.
  • Manual2: real USDT snapshot persistence and readback succeed during cleanup and while observation is stopped. A native connection refusal preserves pending state; the existing10-second scheduled retry receives a real server acknowledgement after the endpoint is restored, without another manual request or Lightning restart. Recovered retry has acknowledgement/completion evidence; explicit readback belongs to the successful direct calls.
  • Manual3: three start/stop cycles, then a seven-second stopped wallet edit, leave observed status markers and the108-write counter unchanged. A received settings notification remains pending. Restart and one fresh wallet edit produce one WALLET upload and matching selected-field readback.
  • Manual4: the actual New Wallet screen appears with node not Running and controlled progress99.95%; the real node transition occurs at100%, followed by exactly one admitted completion/callback and Home. A prior existing-wallet cold launch never presented this screen and is retained as prerequisite-unreached, not a pass.

Complete build/runtime diagnostics and native/proxy acknowledgement correlations are inspected in work/validation/ios1123-lifecycle-app-20261102/. The stock local server authorizes real native requests. Retained limits: selected-field readback is not complete-WALLET equality or restoration; tracked cleanup/post-ACK cancellation is not proof of cancelling in-flight native HTTP; loopback refusal is not literal external transport loss. Ancillary node/balance/RGS/system diagnostics and failed fixture preparations remain disclosed. The separate terminal complete-WALLET availability finding remains open. Current published CI and signatures are unchanged; no CI rerun or source push was needed for this evidence update.

Retained lifecycle acceptance evidence — November 2

The recovered controls and traces supplement the historical 262a447 Manual 1/3 and e12df99 Manual 2 executions. The author read the complete report, verified all 19 disclosed control-hunk contexts against those exact source revisions, and inspected the four event/readback excerpts. They record drain-before-manual-admission, cancellation, acknowledged-upload edits, stopped-listener cycles, and native request-send failure followed by bounded retry. The stopped-listener trace ends with verify-wallet matches=false, consistent with its deliberately unobserved wallet edits; it is not a successful complete-WALLET readback.

The reviewer explicitly cannot recover the contemporaneous historical installed-executable/build binding. The original unredacted patch and complete-trace hashes remain reviewer assertions; only the disclosed redacted patches/excerpts were inspected. Those earlier ff3ba674 executable hashes bound the component tests alone; the new authored installation receipts above separately bind the finite runtime cases. These are attributed historical executions, not independently bound current-head runtime passes.

Seven previously inspected lifecycle/native helper/view/queue/lockfile inputs match from 262a447 to ff3ba674. The older e12df99 composition has subsequent BackupService and LDK changes, so no whole-composition equivalence is claimed. Manual 4 remains component evidence. Those historical provenance gaps remain; the new authored finite cases above supply separate current-composition evidence. No repeat historical artifact request was made. Evidence: work/validation/ios1123-recovered-controls-20261102/ and the earlier retained-evidence report.

Current-head native CI inspected — November 1, 23:22 UTC

At ff3ba674464c796057415188d460e6009371496a, native CI passed 2,967 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 72 CoreGraphics invalid-number messages and 2 system-gesture timeouts, 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-ios1123-ff3b-20261101-current-a/NATIVE-REPORT.md and work/validation/hosted-ios1121-1123-native-root-integrity-20261101.json.

Validated parent composition — November 1, 22:00 UTC

Signed additive merge ff3ba674464c796057415188d460e6009371496a incorporates #1121/a6f233df2dcefbd75b0f7b3c838db23d1a984493. The nine-file owned patch is unchanged. On the composed tree, 2,967 unit tests and one ProfileRecovery UI test passed; one opt-in USDT fixture test was skipped. SwiftFormat 0.64.0 reported 0/828, and all 1,688 frozen inputs matched. Complete logs and nested diagnostics were inspected; six unit QoS observations and three AppIntents notices remain documented. No introduced compiler/format finding was identified. All 17 commits in the live-base range verify locally and on GitHub.

These are native/component results, not a new authenticated complete-WALLET or profile-removal journey. The terminal complete-WALLET finding remains open. Current-head native CI is now fully inspected above; the newly reported parent #1120 authorization-test timeout is unresolved and has not been waived or retried. Existing acceptance gaps and QA IDs below remain applicable.

Description

Stopping backup observation could crash while startup concurrently modified its subscriptions. Serialize subscription and timer ownership on MainActor, and finish canceled backup cleanup before shutdown returns.

Invalidate queued work from the stopped observation generation. Received changes now remain pending while restart drains old cleanup. Pending subscriptions record dirty state immediately, and observed uploads wait for admitted observation. Cancellation preserves required state for ordinary categories as well as wallet/USDT. Fresh explicit USDT retries remain usable without restarting Lightning observation, and manual migration uploads wait for cleanup instead of being silently skipped.

A released VSS request-send failure is now retried at the backup boundary, including explicit USDT work after observation stops. The compatibility rule accepts only the typed native UnknownError envelope with an HTTP(S) host and putObjects path; unrelated unknown/authentication/decode errors and cancellation retain their behavior. Same-status notifications still advance capture revisions, but no longer rewrite identical persisted status.

Initialization completion also admits its callback only once after the node reaches Running. Progress normalization can notify the view twice; both handlers now share the existing completion latch before scheduling any delayed callback. Existing startup waits and assertions are unchanged.

Out of Scope

Full-wallet format separation and the unresolved identity-restoration availability gap in #1121 remain separate. Android's related setup/job-ownership repair is in its focused twin synonymdev/bitkit-android#1689. Native Paykit/Pubky artifact adoption and historical incident attribution are unchanged.

Design

N/A — no UI changes.

Preview

N/A — background backup lifecycle repair.

QA Notes

Setup

Use the committed released Core 0.8.0-rc11, Paykit 0.1.1-rc2 and VSS 0.5.26-rc1 dependencies. Component tests use private defaults and controlled delay/capture/upload gates. Manual lifecycle checks require a disposable app installation and the dedicated fault fixture described in journeys/backup-restore/wallet-snapshot-consistency.md; do not use a personal wallet or reset wallets.

Journeys

N/A — not drivable; see Manual Tests.

Manual Tests

  • 1. Scheduled cleanup/final-status drain, manual admission, edits during restart and canceled SETTINGS newer-marker retention — authored final-c app cases passed; exact boundaries and limits above.

  • 2. Direct USDT recovery during cleanup and stopped observation, plus native refusal and existing10s retry — authored final-c app cases passed without another manual request/Lightning restart.

  • 3. Repeated observation, stopped wallet edit/status stability, retained pre-stop settings marker and one live listener upload — authored final-c app case passed.

  • 4. Actual New Wallet initialization with cosmetic99.95% fixture control, real Running transition, one admitted completion/callback and Home — authored final-d app case passed. Existing current-head SwiftUI regressions retain separate normalization/slow-node coverage.

Automated Checks

  • updated UsdtWalletUITests.swift — bounded navigation synchronization when a transient toast obstructs the back button.
  • updated BackupServiceTests.swift — stopped observers, canceled-work drain, final status-write ownership, manual admission, restart and independent USDT retry.
  • ran real native rejected-backup and lost-acknowledgement prepare/resume/restore scenarios against the owned encrypted protocol fixture using the released Core binding.

Current final validation — 1 November 2026

Signed additive merge 2c0c1002e03aa9c91828b47bf4db73067270463f incorporates actual #1121 parent d83a21721b67045fdc4e6cb2816fb1988d5e924b. Tree 41d374cbefe6c40c977330187dcf4810738a7a37 preserves this child's normalized delta and composes the parent's queued-allowance admission correction. No deadlines, restore gates or shutdown drains changed.

Final native build and CI-selected units plus the affected ProfileRecovery UI passed 2,966 unit executions and one UI execution, with one explicit native-fixture skip, no failures or retries. All 1,688 frozen inputs match. Complete logs, every one of 2,968 canonical executions, all nested issues, 3,175 successful action sections and 30 attachments were inspected; the owner verified 12,066 sealed evidence files. SwiftFormat 0.64.0 reports 0/828, with 106 configured exclusions; whitespace checks pass. Unchanged translation inputs retain the prior zero-error/15 actual-parent-warning evidence.

Five unsymbolized QoS observations, three AppIntents notices and the recorded application/simulator diagnostics remain disclosed without a proven introduced cause. Canonical results supply three successes and five warnings omitted by the wrapper. Other UI was unselected, and integration exclusions/skips are not passes. Interrupted final-a produced no complete result; final-b was the first bounded recovery after verifying absent processes and identical inputs. No assertion or timeout was weakened.

The independent affected composition review is clean; prior shared concurrency reviews retain their scope. The new Android same-identity contact-sync finding was separately traced: iOS contact sync does not reactivate allowances, and its startup cache rejects repeated same-session activation. No speculative iOS change was added. All 16 live-base commits verify locally and on GitHub (verified=true, reason=valid), without history rewriting.

At this earlier composition checkpoint, hosted inspection and manual cases1–4 were pending. Current-head CI and the subsequently authored finite cases are recorded above. Broader authenticated-fault limits and #1121's terminal complete-WALLET availability remain separate; no such repair is claimed.

Earlier final validation — 31 October 2026

Signed additive merge fb25166fd830f8d59368c09079545a1abcdb5cb9 incorporates live #1121 parent 624d7a38995280e9ad59684705c72e405b1e02bd. Tree 2f1fa9e1dafbb9ce2a65c4959f26474a7e8c089c preserves the child delta against its actual previously composed ancestor and includes the validated parent/master admission changes.

The combined native build and selected suite passed 2,965 unit executions and 39 UI executions, with five explicit skips, zero failures or failure-driven retries (2,975 unique logical passes). All 1,688 frozen source inputs match. Every one of 3,009 canonical execution summaries, nested diagnostics and 67 attachments was inspected; the owner verified 12,414 evidence files and 27 external inputs. SwiftFormat0.64 reports 0/828 with106 exclusions. Reused translations have zero errors and15 verified actual-parent warnings on identical21 inputs.

One fresh combined composition review is clean; earlier unaffected reviews retain their original scope. All 15 live-base commits verify locally and on GitHub (verified=true, reason=valid). No history rewrite occurred.

Current-head hosted native run41241011565, attempt1, and its formatter passed on synthetic merge7d071143 whose tree exactly equals this head: 2,965 unit +39 physical UI passes, five explicit skips, zero failures or failure-driven retries (2,975 logical passes). The complete logs, all3,009 canonical execution summaries, nested diagnostics,67 attachments and digest/CRC-verified artifact13057914768 were inspected; the owner verified all18,651 sealed evidence files and17 external inputs. Hosted SwiftFormat0.64 reports0/828,106 exclusions. Nine migration jobs and four workflow-excluded integration classes are not passes.

Hosted diagnostics retain286 distinct dependency linker warnings/571 emissions, two AppIntents notices and48 NaN messages. A keyboard-queue timeout, copied into three exported logs and followed by No stack!, occurred during a passing SendConfirmationLifecycle test; it has no established source cause. Runtime issue/leak detection and test timeouts are disabled in that hosted configuration, so zero canonical runtime warnings is not general runtime-safety evidence. The profile and camera cases passed once; neither is a causal repair for prior failures. Authenticated manual fault cases and terminal complete-WALLET availability remain open.

The complete diagnostics retain14 QoS warnings,48 UI NaN messages and three AppIntents notices, without a proven introduced cause. The profile-recovery case passed once in62.472s and the camera case in58.858s; neither explains earlier/sibling failures. Canonical records supply three successful cases omitted by the wrapper and expose warnings that wrapper diagnostics omitted. Dependency pins/resolution match; missing alternate-object stores still prevent complete dependency working-tree verification, including Core's unreadable CLAUDE.md diff. The five runtime skips and four excluded integration classes are not passes. Manual cases1–4 retain their documented coverage limits; no newly authenticated full-app journey or terminal complete-WALLET repair is claimed.

Earlier final validation — 29 October 2026

Head 1dd6d2fb8974cffc674802180d5f8ef620a60959 is stacked on #1121 at d0246d1c. The latest commit changes only the activation test: credential setup has a separate bounded deadline, while the existing two-second activation-return assertion and production publication cap remain unchanged. A controlled implementation that awaited publication fails that original assertion; restoring the exact production bytes passes.

Hosted native run 40969995082 passed on its first attempt: 2,880 unit passes and 39 physical UI passes (10 logical cases), five fixture skips, zero failures or retries. Its synthetic merge c94e38cf has the exact head tree 17d7efe4 and parents d0246d1c/1dd6d2fb. Hosted Xcode 16.4, SDK 18.5 and iPhone 16/iOS 26.2 remain distinct from local execution. SwiftFormat 0.64.0 reports 0/824, with 98 exclusions.

Complete 5,824-line native output, all 2,924 nested test records, 17 annotations, eight exported text diagnostics and 54 unique attachment payloads were inspected. Artifact 12941895164 matches GitHub SHA256 92e7b34838d6ef113ef70bec25535e63cdb8d75f6cd6f4845a75941de3477bd1; all 5,997 archive members pass integrity checks. The owner verified all 12,075 sealed evidence files. The 286 distinct duplicate-symbol warnings match the verified parent producer set; two AppIntents notices remain. Runtime output retains 48 CoreGraphics NaN messages across the two passing USDT UI cases plus the disclosed node/cache/network and unit fault-case diagnostics. Their numeric cause is unproven; this is not a runtime-clean claim.

Skipped: the encrypted protocol fixture, three Trezor cases and the local fork payment. Four workflow-excluded test classes remain unrun. The activation test's 2.076786-second whole duration includes its two-second inverted expectation; it is not activation latency. Earlier failed hosted attempts remain retained, and this test-only correction is not claimed as a production causal repair.

The final local gate passed 2,880 selected units and two USDT UI cases, with two prerequisite skips and zero failures/retries; all 1,667 frozen inputs match. Required formatting passed. The independent affected-delta review is clean under the current risk-based policy, with earlier unaffected reviews scoped to their original inputs. All 13 commits in the complete live-base PR range verify locally and on GitHub (verified=true, reason=valid). No history was rewritten.

Gaps

The native protocol tests use real signing and VSS encryption but do not execute the complete app UI/Paykit/deployed-contract journey. The controlled manual lifecycle journey has not been driven through the full app. Android twin synonymdev/bitkit-android#1689 repairs late setup, retained cleanup, dirty-marker loss and manual/USDT reset ownership; its latest explicit-retry follow-up at49e9a99d passed4,696 unit tests and full compile/format/lint, with component-test limits documented there. No claim that this crash or these races caused the historical contact/acquisition failures.

Earlier revision evidence and retained failed attempts

These dated notes retain their original scope. Earlier pending states are superseded by the current validation above.

Earlier-head external evidence — October 30, 21:50 UTC

Automated review 5718993346 at 262a4474 reports authenticated staging-VSS shutdown/drain, pending-edit retention and stopped-observer cases, plus component/navigation checks; it reports no new findings. The owner verified the revision and read the complete report, but has not independently audited its raw logs, XCResults or media. These are attributed external results; the author's hosted nested-result audit remains incomplete. João and Ovi have separately approved this exact head. The inherited terminal complete-WALLET limitation remains open on #1121.

Earlier combined-tree validation — 30 October 2026

Signed additive merge 262a44747ffd8ef522178bfd3850fe78da15cce5 incorporates live #1121 parent d896dba7e78d39d859ed9a844863a194efb4b546. The combined tree 52be49877b6d7ba84b9500b1feb2fe198fc6e8d0 preserves generation-owned observation and shutdown drains, adopts the parent's metadata-before-restore/public-address guards, and makes metadata join an existing wallet writer without canceling another owner's operation. The corrected lifecycle regression uses ACTIVITY because the parent now intentionally completes WALLET before METADATA; its drain, restart and required-state assertions remain unchanged. Its initial failed attempt is retained.

The final native build and selected suite passed 2,956 units and two USDT UI cases, with two prerequisite skips, zero failures/retries. All 1,681 frozen inputs match. Complete 22,530-line output, every nested result, eight exported diagnostic files and 37 attachments were inspected; the owner verified all 6,145 evidence hashes. SwiftFormat 0.64.0 reports 0/827 with 104 exclusions; translation validation reports zero errors and 15 actual-parent warnings. Two independent affected composition reviews are clean. All 14 live-base commits verify locally and on GitHub (verified=true, reason=valid).

Seven QoS observations, 72 CoreGraphics NaN emissions with unproven numeric cause, two AppIntents notices and symbolication diagnostics remain disclosed. Local Xcode 26.1/iOS 26.2 evidence is distinct from hosted execution. The encrypted protocol/local-fork skips and four excluded workflow-integration classes are not passes. Current-head hosted CI is pending inspection; the historical results below do not certify this composition. Manual cases 1–4 retain their recorded component/runtime limits, and #1121's terminal complete-WALLET availability remains separate.

Earlier parent integration — 29 October 2026, 20:05 UTC

Signed additive merge 8947f493a681f57cb76486d96bbc36da3f9999a6 incorporates live #1121 parent d0246d1c782c8f2167300383f85e12f1696c4234. Tree 04884bd9d6c2bfadf5e0e1b022a025027d208c69 retains this PR's stronger ordered observation shutdown and complete writer/task drain byte-for-byte, while importing the parent's repeated-restart regression and related private-publication/admission changes.

Final combined-tree validation passed 2,880 units and two UsdtWallet UI cases, zero failures/retries, with two prerequisite skips (encrypted native recovery and local Arbitrum fork). Other UI was unselected. All 1,667 frozen inputs and four symlink targets match. Full SwiftFormat0.64 reports 0/824, 98 exclusions; the initial system formatter rejected the minimum version before checking and is retained as a failed setup attempt. Unchanged workflow/localization checks reuse exact parent input evidence.

Complete 24,156-line raw output, all 2,884 nested summaries, eight diagnostic logs and 35 attachments were inspected; the owner verified all 13,732 sealed rows. Nine QoS traces/eight nested warnings remain, including a newly observed fee-estimate RwLock sub-stack in parent-identical code; two AppIntents notices and simulator haptic messages are retained without a causal claim. One clean affected composition review preserves earlier full feature reviews. All 12 live-base commits verify locally and on GitHub (true/valid). The current-head hosted native run, attempt 1, failed four UI launch executions: 2,880 unit and 35 UI passes, five prerequisite skips, four failures, no retries. Complete logs, all 2,924 nested summaries, 55 attachment payloads and 4,894,996 simulator system-log events were inspected; all 10,525 sealed evidence rows were verified. XCTest timed out its testing-assertion requests after ten seconds and then deliberately terminated the targets. RunningBoard only received those testing assertions 20–60 seconds after the original requests, after all four targets had exited. No preceding failed-target crash/fired watchdog was established; splash frames do not prove completed startup or general app health. The underlying cause remains unknown. Exactly one bounded native-job retry was requested on that observed testing-service delay; its completed failure and the subsequent test-only correction are recorded above. The original failures remain. Hosted formatting passed 0/824; nine migration jobs skipped. The 286 dependency warnings match verified producer evidence; two AppIntents notices, 24 NaN emissions and a keyboard timeout in passing tests remain disclosed.

Upstream #1120's native attempt1 failed two plain launch tests. System logs show delayed RunningBoard assertion acquisition after XCTest's timeout and subsequent test-driven termination with no demonstrated preceding app crash; the next 30 launch configurations passed. That separate bounded retry has now passed, with its original failed attempt retained. The original failures and unknown underlying service-delay cause remain disclosed; this child publication does not claim they are fixed or that upstream CI is green. Authenticated manual cases below and terminal complete-WALLET availability remain open.

Earlier parent integration — 29 October 2026

Signed additive merge d33547cba681f8cf826a715de5f51a2ba89c28ef incorporates live parent #1121 at 2d4fc7199b476f41ce56ae05c786f3ee1981bed5. The final tree 5a80e5e87368aab5487436d2aa47fb5e271a87b3 retains this PR’s observation ownership, shutdown drains and restart behavior, and adds the parent’s allowance-change publisher to the existing backup change stream.

The final combined-tree native build and tests passed 2,865 unit cases + 39 UI executions, zero failures, with five explicit prerequisite skips. That is 2,875 logical passing tests; the launch case runs 30 declared UI configurations. All 1,660 frozen inputs match. Complete logs, all 2,909 nested summaries, and exported diagnostics were inspected; the owner verified the 13,837-row evidence seal. Three AppIntents tool notices and 12 QoS observations remain disclosed without a causal-fix or uniformly pre-existing claim. Full SwiftFormat reports 0/823, and translation validation reports zero errors / 15 warnings verified against the actual parent. The formatter cache-write notice remains. Original static process completion was reconciled without rerunning checks.

One independent review of the changed composition is clean; prior unchanged review coverage is retained. Every commit in the 11-commit live-base range verifies locally and on GitHub (verified=true, reason=valid). The immediate post-push API briefly returned the older head; one read-only reconciliation verified the exact new range, with no repeated push. Current-head native tests and SwiftFormat passed on synthetic merge b089302c, whose tree exactly equals the published head: 2,865 unit + 39 physical UI passes, zero failures/retries, five explicit skips; formatter 0/823. Complete logs, all 2,909 nested summaries, diagnostic exports and the SHA/CRC-verified result archive were inspected; the owner verified 12,003 sealed evidence rows. All 286 distinct linker warnings match the sealed actual-parent set. Two AppIntents notices, a setup metadata-pipeline broken pipe and 24 CoreGraphics NaN events in a passing USDT UI case remain disclosed without causal claims. Nine migration jobs were skipped; no current-head integration/external E2E result is claimed. This does not complete the authenticated manual fault cases below or close #1121’s terminal-identity/full-WALLET finding. Skipped native/fork/hardware cases and workflow integration exclusions are not passes. Earlier evidence remains scoped to its recorded revision.

Parent integration — 28 October 2026

Signed additive merge 566b2ff8ba7abdd0968544dda5cdba32d8f518f0 incorporates current parent #1121 at c0b1e86f, including the merged independent-USDT/master composition. The only adaptation inside this PR’s existing production delta removes two obsolete try tokens from now-nonthrowing metadata-cache calls; actor hops, backup generations, stop drains and complete-WALLET checks remain unchanged.

The exact tree 9ce5290c passed 2,830 unit executions + 39 UI executions, zero failures, with three explicitly disabled Trezor skips. These are 2,840 distinct passing identifiers: the launch test executes 30 declared UI configurations. Complete 29,980-line logs and all 2,872 nested execution summaries were inspected. All 1,643 frozen inputs match; formatter passed 0/821 with 91 exclusions. Two AppIntents notices, ten nonfailing QoS observations and the formatter cache-write notice remain disclosed; timing causation and exact-parent runtime recurrence are unproven. Actual local toolchain is Xcode 26.1/17B55 on iOS 26.2/23C54. Excluded workflow integration/protected native/fork cases are not passes.

One independent affected-composition review is clean, with unchanged earlier full-PR review coverage retained. All ten commits against the live parent verify locally and on GitHub (verified=true, reason=valid); exact ranges match. The PR remains ready and mergeable. Current-head native tests and formatter now pass on the exact 9ce5290c tree: 2,830 unit + 39 UI passes, five explicit fixture/hardware skips, zero failures/retries; formatter 0/821. Complete logs, all 2,874 nested summaries and 5,889 CRC/SHA-verified artifact members were inspected; the owner verified 8,858 sealed evidence files. The build retains 571 duplicate-symbol occurrences/286 symbols, with identical actual-parent build/pin configuration but no fresh parent runtime baseline. One keyboard queue timeout occurred during the passing 3.629-second pending-wallet test; causation remains unproven. Excluded integration/native fixture cases are not passes. This integration does not resolve #1121’s terminal-identity/full-WALLET availability finding or establish a new authenticated recovery journey.

Earlier validated composition — 27 October 2026

Signed additive merge e12df99e2e91944308314d82bc8d71e9aaa24f66 incorporates live parent #1121 at 73b7d58ab6854114a76f7577452677d91172a04a. Foreground recovery now keeps the original observation generation, joins the existing startup/teardown drains, and hands recoverable failures to the existing bounded scheduler. Queued work from a stopped generation cannot publish or upload; received edits remain required through acknowledged cleanup. No deadline or passing-path UI scheduling was weakened.

The final tree ffac48ab56f4210e256f518c180c17a5e933a937 passed 2,990 selected unit tests with zero failures/skips and 39 UI executions with zero failures and three explicitly disabled Trezor skips. Workflow integration/native-fixture suites and the expired local-fork payment case were excluded, not passed. SwiftFormat 0.64.0: 0/852, with 90 excluded files; translation validation has zero errors and 15 verified actual-parent warnings. All 1,683 frozen inputs match. Two consecutive independent final reviews are clean.

Complete 23,007-line unit and 4,732-line UI logs and every nested result were inspected. Five unit and twelve UI QoS observations, AppIntents and simulator/accessibility notices remain disclosed without a timing-cause claim. Earlier full-suite failures and controlled before/after evidence are retained: the tests now toggle actual persisted Settings values, restore exact prior values/absence, and isolate unrelated coalesced Settings changes; all original lifecycle, age, cooldown and upload assertions remain. These are fixture corrections, not historical-incident causation.

All nine commits against the live parent verify locally and on GitHub (verified=true, reason=valid). New-head hosted checks are pending. The parent #1120's separately recorded hosted ProfileRecovery UI timeout is not claimed fixed by this local pass. Authenticated lifecycle acceptance and the terminal-identity/full-WALLET limitation remain scoped as described below. All earlier head-specific evidence is historical.

Earlier repair — retain edits through shutdown drain

Signed commit bfc4a5e87b2694cf4906d1c6aa784e2fa1c3e78b fixes the lost-edit window reported in review thread 4390767903. Shutdown stops status-driven scheduling immediately, but retains data-store listeners until scheduled/running uploads and both serialized writers have drained. Changes during that interval remain required; the stopped observation cannot upload them. Restart uploads the latest values, including an independently changed category that was already synced. Listeners are removed before stop returns.

Controlled before/after evidence uses actual settings/watch-only notifications and a cancellation-insensitive acknowledged upload: the original implementation fails the one regression with six diagnostics; the repair passes all 23 BackupService tests. Final combined-tree validation passed 2,969 selected units, zero failures/skips, plus 39 UI executions, zero failures, with three disabled Trezor fixture skips. The expired local-fork payment case and workflow integration/native-fixture suites were excluded, not passed. SwiftFormat 0.64.0 reports 0/849. All 1,676 frozen inputs match and two consecutive independent final reviews are clean.

Complete 22,096-line unit and 4,733-line UI logs and every nested test result were inspected. Each run retains two AppIntents notices. Four unit QoS sequences recur in the verified actual parent; nine UI QoS occurrences have unsymbolized zero-address stacks and remain unattributed. The simulator also logged a duplicate accessibility class. These observations are disclosed without a timing-cause claim. This controlled regression and ordinary UI suite do not claim a new authenticated held-upload device journey; the external before-fix staging reproduction remains attributed to its original head.

All eight commits against live parent 754a899182bb39869b37d1f3e9601afecad8c593 verify locally and on GitHub (verified=true, reason=valid). New-head hosted checks are pending. Earlier evidence below is revision-scoped.

Parent composition — 27 October 2026

Signed additive merge 14152f41adda22319e531f3ac979c1fa0a4120a5 incorporates current parent #1121 (754a8991). The resulting source tree is exactly e34dbbd507e6ba6f975f82e9abc0e11c163f9384, identical to the previously validated head: this child already confines listener ownership to the main actor and serializes observation teardown. Two independent composition reviews are clean; all 1,676 native-validation inputs match. Recorded validation remains 2,968 unit passes and 39 UI executions with three Trezor skips; no new test execution is claimed for this ancestry-only update. All seven commits against the live parent verify locally and on GitHub. Newly triggered hosted checks remain pending.

Twin: synonymdev/bitkit-android#1689
Companion: #1122
Dependency: #1121

At signed ff999a50e24326ad45fb033c61155b95c041d1be, a real hosted SwiftUI regression first fails with two completion callbacks versus one, then passes with the shared completion guard. The original slow/error retry case also passes without changing its assertions or deadlines. Final native build/selected unit run passes 2,913 tests, zero failures/skips; SwiftFormat0.64.0 reports 0/847. All1,665 frozen inputs match and two consecutive independent final reviews are clean. The full five-commit live-base range verifies locally and GitHub reports true/valid for every commit. Current-head hosted run40402278397 now passes: 2,913 unit and 39 UI executions, zero failures, one native-fixture unit and four UI skips (2,923 unique passes / 5 unique skips after parameterized-case reconciliation). Both initialization tests pass. Complete5,850-line log and artifact12690973387 were SHA256/CRC checked and every nested XCResult issue inspected. The CI merge tree equals this head. All286 distinct duplicate-symbol warnings match the actual parent; there are no runtime-warning entries. This does not establish the exact prior failure's observer ordering or complete the skipped/manual journeys.

The complete20,360-line final log and nested XCResult issues were inspected. Two AppIntents notices and five QoS observations remain; their native construction stacks recur in the actual prior base, but counts and test associations differ and timing causes are unproven. The first final run failed on disk exhaustion, including early database/file errors and result-finalization failure. Its partial-suite successes are not a pass. After reclaiming only inactive task-owned compiler cache, one exact-input retry produced the complete result above.

Prior-head 494c9492 hosted run40390697155 failed testSlowRetryWaitsForRunningNodeBeforeCompleting through repeated XCTest fulfillment. Its full log and integrity-checked XCResult are retained. The view/test blobs at that revision equal the actual PR base. The new controlled threshold regression proves a reachable duplicate-completion mechanism; the exact observer ordering in the hosted failure is not claimed proven. Manual case4 has hosted SwiftUI coverage, not a newly executed device journey.

At signed 494c9492596100e198b0bdb91c712ebe5888d929, three controlled iOS regressions fail before this repair: native-shaped connection failure classification, stopped-observer retry/backoff, and repeated identical status writes. Final 2,912 selected unit tests pass with zero failures/skips, including all22 backup cases. Full SwiftFormat0.64.0 reports 0/847. Complete23,101-line log and nested XCResult diagnostics were inspected: two AppIntents notices and four QoS warnings remain; three test/message identities match prior evidence, while the SendSheetLifecycle association is new. Relevant source files are unchanged, but the timing cause is unproven. Initial test-helper compilation failure is retained separately from the controlled product failures.

The retry test preserves its payload across nine attempts with delays10/20/40/80/160/300/300/300seconds. The status test proves200 identical pending notifications produce no additional status writes; existing first-marker, restart and in-flight-change tests still pass. This is not a universal UI latency claim. Six fresh released-native rejection/lost-acknowledgement prepare/resume/restore phases also pass through the production backup handler, preserving original signed bytes, each process's own acknowledgement before submission, one unique operation and the complete WALLET sentinel. They use loopback protocol/mock authorization, not the complete authenticated manual journey. Two consecutive final independent reviews are clean; all four then-current live-base commits verify locally and on GitHub. Its later hosted failure is recorded above.

Signed follow-up 1ad665f8 repairs the hosted UI failure in run 40158322810: an Internet Connectivity Issues toast covered NavigationBack during an immediate hit-test query. The verified XCResult recording and accessibility log locate the failure at line 38; the relevant UI/test code was unchanged from the actual base. Back navigation now waits up to ten seconds for a hittable button and retains all existing assertions. Both affected navigation UI cases passed locally, full SwiftFormat checked 847 files with zero violations, and two consecutive independent reviews were clean. The complete three-commit actual-base range verifies locally and on GitHub (verified=true, reason=valid). Hosted formatter 40162108133 and test workflow 40162108190 now pass: 2,909 unit tests and 39 UI executions, zero failures, with one native-fixture unit and four UI fixture skips. Complete logs were inspected; formatter reports 0/847, and test-log notices are the installed xcbeautify and multiple-matching-destinations messages. These existing UI suites do not execute the authenticated fault journeys.

The broader local UI class also selected the existing fork-payment test, which failed before confirmation. Read-only RPC calls reproduced its cause outside the app: the long-lived fork at block 517943110 could no longer fetch required historical account/EntryPoint state from its upstream RPC. No new operation was broadcast. That full class is not claimed green; the previous successful fork journeys retain their original revision/fixture scope. A healthy isolated archive-backed or fresh fork is needed for another payment journey.

October 25 follow-up (signed head 538f45c7): controlled restart-event and canceled-settings regressions failed before the fixes and pass afterward. All 19 backup component cases and the full selected native unit suite passed (2,909 tests, zero failures/skips). Full SwiftFormat 0.64.0 checked 847 files with zero violations. Complete compiler and finalized XCResult diagnostics were inspected; five QoS priority-inversion warnings remain in contacts/SDK/send tests, with no claim that their cause is fixed. The older Xcode legacy reader crashed; the current reader read the same finalized result successfully without rerunning tests. Two consecutive independent final reviews were clean. The full two-commit actual-base range verifies locally and GitHub reports verified=true, reason=valid. Six fresh released-native rejected-backup/lost-acknowledgement prepare, resume and restore phases also passed: the same signed operation is retained, no attempt precedes its required backup acknowledgement, one unique operation is recorded and the complete WALLET sentinel remains unchanged. These exercise the app backup handler with real native signing/VSS encryption, not a full app/Paykit or deployed-contract outage journey. Revised manual cases 1 and 3 have component coverage; full fault-injection journeys remain unrun.

Hosted #1122 run 40142115983 crashed in subscription-set teardown; the backup source was unchanged from #1121. Controlled unchanged-source tests fail for late observer callbacks and premature shutdown/manual upload. A separate held-final-write test fails before the cleanup-order repair.

At the earlier f204e05e revision, the selected native unit/build suite passed 2,906 tests, zero failures/skips. Full SwiftFormat 0.64.0: 0/847 files. Complete structured diagnostics inspected. Three runtime priority warnings match the actual parent's test/message pairs; two additional runtime observations are in unchanged profile-destination/send-confirmation tests and SDK paths. Their timing causes remain unproven. The two compiler notices are the unchanged AppIntents metadata notices.

All six released-native phases passed: rejected backup and lost acknowledgement, each with prepare, process restart/resume and restore. The signed operation stayed identical, no operation was attempted before an acknowledged backup, each scenario contained one unique operation, and the complete WALLET sentinel stayed unchanged. Complete result bundles and fixture event logs were inspected.

Two consecutive final independent reviews found no actionable findings after the ownership fixes. The backup format, required restore gates and acknowledgement-before-broadcast contract are unchanged.

Hosted workflow 40151127771 for f204e05e also passed: 2,906 unit tests and 39 UI executions, zero failures, with one native-fixture unit and four UI fixture skips. The complete log was inspected; diagnostics were limited to the installed-xcbeautify notice and multiple-matching-destinations warning. These standard CI tests do not execute the new authenticated fault journeys.

Earlier parent composition at 2416046

Signed additive merge 24160466a4b6665dcff62d01ea1546b338d0b4de incorporates current #1121 at 39fcaec8 and the released Core RC11, Paykit RC2 and VSS RC1 adoption stack. The seven-file child delta retains immutable backup acknowledgement, observer lifecycle ownership and the existing navigation helper.

Final combined-tree validation passed 2,968 selected units with zero failures/skips and 39 UI executions with zero failures (three Trezor fixture skips; the expired local-fork payment case was explicitly excluded). Full SwiftFormat 0.64.0: 0/849. Translation validation: zero errors and 15 actual-parent warnings. All 1,676 frozen input hashes match. Complete 29,901-line unit and 4,795-line UI logs and nested XCResult data were inspected. Two AppIntents notices occur in each run; unit diagnostics retain three native DHT/SDK QoS observations. UI diagnostics retain seven QoS summaries across 20 occurrences; all 534 exported frames lack symbols, so their precise cause is unproven.

Two consecutive independent final reviews are clean. Every commit in the complete six-commit current-parent range verifies locally and on GitHub (verified=true, reason=valid). New-head hosted checks are pending. Opt-in native/integration exclusions and skipped fixtures are not passes. This composition does not establish authenticated downstream J1 or full-device backup fault acceptance; the earlier evidence below retains its exact revisions.

Hosted validation at bfc4a5e8 — October 27

Tests and SwiftFormat passed on the exact head tree. Complete logs, all nested XCResult test summaries, annotations and the digest/CRC-verified artifact were inspected. Unit execution counts are 2,969 passes and one native-fixture skip; UI counts are 39 passes and four fixture skips. Suite exclusions and migration-skipped jobs are not passes.

The build retains 286 distinct duplicate-symbol linker warnings. The actual-base local run does not emit them, although the seven-file PR diff leaves dependency/build/workflow configuration unchanged. Their cause and matching-hosted-base status remain unproven; they are not labelled pre-existing. No structured test failure or runtime issue was found. The pending current-master dependency composition will receive its own final validation; these results remain tied to bfc4a5e8.

Models used

  • Planning/scoping: gpt-6-astra (reasoning: xhigh)
  • Implementation: gpt-6-astra (reasoning: xhigh)
  • Review: gpt-6-astra (reasoning: xhigh), gpt-6.1-sol (reasoning: high)

@ovitrif ovitrif added this to the 3.1.0 milestone Oct 25, 2026
@ovitrif

ovitrif commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

Triage: 3.1.0. It fixes shipped backup handling, and 3.1.0 is due 28 Oct.

@talosmachina talosmachina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Serializes backup observation start/stop on MainActor, bumps a scheduling generation so queued work from a stopped observation is dropped, and makes stop drain cancelled scheduled and running jobs (and both writers) before it returns. While cleanup runs, manual uploads and fresh USDT requests wait for it instead of being skipped.

No findings. Reviewed head f204e05 against its base fix/restore-identity-background (stacked on #1121), full tier.

Merge confidence: 4/5. No open findings. It is held below 5 because Run Tests was still pending when I reviewed, and the lifecycle manual cases 1 to 3 have not been run on a device.

What I checked
  • iOS does not build on this reviewer's Linux host, so this review is reasoned from the code and CI. SwiftFormat is green and Run Tests was pending.
  • Stop ordering: observationTask chaining means a start queued behind a stop runs only after the drain, isStoppingBackups and the generation bump happen in one .backup queue block, and the drain awaits job values outside that queue, so cleanup that hops back onto .backup (runScheduledBackup removing its key, runBackup removing its task) cannot deadlock against it.
  • No wait cycle through waitForObservationTransition. A cancelled scheduled job skips its tail scheduleBackup. A job that removed itself before the stop snapshot calls scheduleBackup with the old generation and returns at the generation check. Scheduled triggerBackup during a stop returns .busy, and its cancelled loop then exits. So no task the drain awaits is itself waiting on observationTask.
  • Generation capture: markBackupRequired after the bump (the USDT path while observers are stopped) carries the new generation, waits out the stop and then schedules. Work captured before the bump is dropped, but its status stays required, and the restarted observer's prepend(getAllBackupStatuses()) picks it up again.
  • running is now set only inside the admitted scheduleBackup block rather than by markBackupRequired, so a dropped schedule no longer leaves a stale running flag.
  • Callers: AppScene.handleNodeLifecycleChange (start on .running, stop otherwise) and AppReset.wipeLocal both call from MainActor and fit the new @MainActor signatures. BackupViewModel and rewriteMigratedBackups reach the manual triggerBackup path, which now waits for cleanup instead of returning early.
  • Ruled out: the stop now also awaits in-flight running uploads, so a wipe can wait on a VSS put. The VSS setup is bounded by a 30 s timeout and both writers were already awaited before this change, so the behaviour is intended.

Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed (the run metadata available to this reviewer does not report a reasoning setting)

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Code scan: no findings so far.

Checked and clean:

  • A backup is marked synced only after the put returns success. stopObservingBackups drains every task before removePersistentDomain, so after a wipe and restore the old wallet's pending data can't upload under the new wallet.
  • BackupItemStatus is persisted in the same format as v3.0.0, so nothing to migrate.
  • Twin synonymdev/bitkit-android#1689 fences by generation instead.

Performance-relevant. The PR removes the 500 ms .debounce from the settings, widgets, activities, metadata, hw-names, appState and blocktank listeners (BackupService.swift:682-774). Each change event now does a UserDefaults JSON decode, encode and write and spawns a MainActor task, so a sync of N activities costs N round-trips instead of one per burst. The gate counts backupStatuses writes during a sync of a couple of hundred payments, head vs master, and times stopObservingBackups during a wallet reset with a put in flight. If the write count grows with N, skipping saveBackupStatuses when the status is unchanged would bring it back down.

Device gate (partial): 1ad665f — not run yet; pending that measurement. Device results will follow in a separate review.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ⛔️ Request changes — manual test 2 fails for a real VSS connection outage; the same failure reproduces on the PR base.

Findings: I found no correctness defect introduced by this diff. The QA failure below is inherited: the released VSS binding returns VssError.UnknownError for the failed connection, but BackupService.isTemporaryBackupFailure excludes that error, so the pending recovery backup receives no automatic retry.

Reviewed commit: 1ad665f.

QA for 1ad665f: iPhone 17 Pro simulator, iOS 26.5, Xcode 27. The component and navigation tests used the unmodified head. Manual lifecycle checks used local holds and logging over the same head, a disposable wallet and staging VSS. No payment was submitted.

Test 1: cleanup, manual admission and restart ✅ Stop and the manual backup waited for cleanup; the latest wallet and settings state reached VSS.

I held final scheduled cleanup, requested stop and a manual metadata backup, queued restart, then edited wallet/settings state. No capture began before release. I also held a settings upload, canceled it and changed settings during restart. The later upload preserved the new requirement. Hook logs and decrypted VSS reads confirmed the final wallet records and setting matched local state.

lifecycle-12x.2x.mp4

Lifecycle controls on the disposable app; the assertions were checked through logs and VSS reads.

held cleanup
released cleanup
stop-done elapsed=1.692
manual-done result=completed
held upload-SETTINGS
upload-settings swipe=0
upload-settings swipe=1
verify-settings matches=true value=Optional(true)
verify-wallet matches=true
Test 2: USDT recovery with observation stopped ❌ A connection failure left recovery pending without the expected automatic retry.

A fresh USDT recovery request waited for cleanup and completed without restarting observation. I then pointed only the custom VSS endpoint at an unreachable loopback port. The real native request failed after about 10.5 seconds with VssError.UnknownError. The job ended with .failed; no retry occurred during the following observation window of over two minutes.

I reproduced the same classifier and stopped-job behavior on base ec20b264. Restoring the endpoint and making a fresh explicit request completed successfully there. The required marker survived the failure. This is an inherited limitation, not a new inline finding, but it fails the transient-retry expectation in manual case 2.

Head Base comparison
lifecycle-22x.2x.mp4
transport-base2x.2x.mp4

The app remains on Home; native and hook logs establish the request outcomes.

head: usdt-recovery-put
head: UnknownError(errorDetails: "error sending request for url (http://127.0.0.1:1/putObjects)")
head: backup-done USDT_RECOVERY result=failed
base: backup-done USDT_RECOVERY result=failed
base: status USDT_RECOVERY synced=1793040118 required=1793040578 running=false
base: route-ready vss-normal
base: usdt-recovery-ack
base: backup-done USDT_RECOVERY result=completed
Test 3: repeated observation lifecycle ✅ Stopped listeners ignored later wallet edits, and a received settings change survived restart.

I repeated start/stop and a subsequent real wallet-record edit 25 times. The hook log showed unchanged wallet backup status and no wallet capture after each stop. A settings event received before stop remained required and uploaded after restart.

lifecycle-32x.2x.mp4

Repeated lifecycle controls; status comparisons and upload acknowledgement were checked in the hook log.

Test 4: backup component regressions ✅ The focused backup suite passed without skips.

xcodebuild test-without-building -only-testing:BitkitTests/BackupServiceTests passed. The finalized XCResult confirms the cancellation, final-write drain, restart, stopped-listener and bounded-backoff assertions.

backup2x.2x.mp4

App-hosted XCTest execution; the result was checked in XCResult.

Test 5: USDT back navigation ✅ Both affected ordinary navigation cases passed with their existing assertions.

I ran testUnknownBalanceAndManualEntryUseConsistentAmountsInCommaRegion and testReceiveAndInvalidPaymentStaySeparateFromBitcoin, then repeated them for the recordings. Amount entry, recipient retention, receive draft cancellation and back navigation passed. The UI runs reported QoS priority-inversion warnings. The recordings retain all changed frames; capture stutters remain.

Amount and recipient Receive and invalid payment
usdt-entry-smooth2x.2x.mp4
usdt-receive-smooth2x.2x.mp4

Unmodified-head UI tests, with finalized XCResults checked.

Test 6: activity-notification burst ✅ The head/base measurement completed; status-write amplification is confirmed.

For 200 actual activity-publisher notifications, the head wrote backup status 200 times during the burst; the base coalesced them into one write. Notification dispatch took about 0.206 seconds at the head versus 0.002 seconds at the base. The head's subsequent stop returned in about 0.007 seconds with no held upload.

This measures the notification boundary, not a complete sync of 200 payments or the latency of a stalled network upload. It supplies the write-count evidence requested in the performance review; it does not establish a user-visible sync slowdown.

Head Base
performance-head2x.2x.mp4
performance-base2x.2x.mp4

App startup and the measurement run; timings and write counts came from the hook log.

All three listed manual cases were driven; case 2 failed. I did not run a funded payment or cross-platform restore. The author's released-native signing/restore evidence in this PR's QA notes remains scoped to its recorded revisions.

Full review: ec20b264..1ad665f, including all changed files and lifecycle callers. The native suites, live cleanup holds and navigation runs exercised the runtime changes; the changelog and journey text were inspected. SwiftFormat lint passed for the changed Swift files.

Twin: synonymdev/bitkit-android#1689 documents the same drain and pending-change preservation intent, with separate Android wipe-admission guards.

Head lifecycle and performance controls — local test hook, not pushed

Holds, logging, synthetic record edits and an endpoint-only outage control; the backup decisions and native transport results are unchanged.

diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 961935aa..bfc5d58b 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -83,6 +83,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
             }
         }
 
+        #if DEBUG
+        ReviewBackupControl.start() // test hook
+        #endif
         return true
     }
 
diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift
index 76885696..44d6d41a 100644
--- a/Bitkit/Constants/Env.swift
+++ b/Bitkit/Constants/Env.swift
@@ -372,7 +372,12 @@ enum Env {
     }
 
     static var vssServerUrl: String {
-        switch network {
+        #if DEBUG // test hook
+        if ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1", FileManager.default.fileExists(atPath: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup/vss-fault").path) { // test hook
+            return "http://127.0.0.1:1" // test hook
+        } // test hook
+        #endif // test hook
+        return switch network {
         case .bitcoin: "https://bitkit.to/vss_rs_auth"
         default: "https://bitkit.stag0.blocktank.to/vss_rs_auth"
         }
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index 7903e1c8..d414ce84 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -155,7 +155,17 @@ class BackupService {
         backupData: ((BackupCategory) async throws -> Data)? = nil,
         usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(),
         uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in
+            #if DEBUG
+            await ReviewBackupControl.hold("upload-\(key)") // test hook
+            if key == "SETTINGS", let payload = try? SettingsBackupV1.decode(from: data) { // test hook
+                ReviewBackupControl.log("upload-settings swipe=\(payload.settings["enableSwipeToHideBalance"] ?? "missing")") // test hook
+            } // test hook
+            ReviewBackupControl.log("upload-start \(key)") // test hook
+            #endif
             _ = try await VssBackupClient.shared.putObject(key: key, data: data)
+            #if DEBUG
+            ReviewBackupControl.log("upload-ack \(key)") // test hook
+            #endif
         },
         waitForBackupDelay: @escaping () async throws -> Void = {
             try await Task.sleep(nanoseconds: 5_000_000_000)
@@ -292,6 +302,9 @@ class BackupService {
     }
 
     private func runBackup(category: BackupCategory, isScheduled: Bool) async -> BackupOutcome {
+        #if DEBUG
+        ReviewBackupControl.log("capture-start \(category.rawValue)") // test hook
+        #endif
         Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService")
         var outcome = BackupOutcome.failed
         var requiredAtStart: UInt64 = 0
@@ -349,6 +362,9 @@ class BackupService {
             }
         }
         _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) }
+        #if DEBUG
+        ReviewBackupControl.log("backup-done \(category.rawValue) result=\(outcome)") // test hook
+        #endif
         return outcome
     }
 
@@ -857,6 +873,9 @@ class BackupService {
         while !Task.isCancelled {
             do {
                 if outcome == .temporarilyUnavailable {
+                    #if DEBUG // test hook
+                    ReviewBackupControl.log("retry \(category.rawValue) delay=\(retryDelay)") // test hook
+                    #endif // test hook
                     try await waitForTemporaryFailureDelay(retryDelay)
                     retryDelay = min(retryDelay * 2, 300_000_000_000)
                 } else {
@@ -873,6 +892,9 @@ class BackupService {
                 break
             }
         }
+        #if DEBUG
+        await ReviewBackupControl.hold("cleanup") // test hook
+        #endif
         updateBackupStatus(category: category) { status in
             BackupItemStatus(synced: status.synced, required: status.required, running: false)
         }
@@ -1017,6 +1039,9 @@ class BackupService {
 
         do {
             let data = try JSONEncoder().encode(encoded)
+            #if DEBUG
+            ReviewBackupControl.countWrite() // test hook
+            #endif
             defaults.set(data, forKey: backupStatusesKey)
 
             backupStatusesSubject.send(statuses)
diff --git a/Bitkit/Services/UsdtRecoveryBackupStore.swift b/Bitkit/Services/UsdtRecoveryBackupStore.swift
index f804d076..f546805c 100644
--- a/Bitkit/Services/UsdtRecoveryBackupStore.swift
+++ b/Bitkit/Services/UsdtRecoveryBackupStore.swift
@@ -27,7 +27,14 @@ struct UsdtRecoveryBackupStore {
         let encoder = JSONEncoder()
         encoder.outputFormatting = [.sortedKeys]
         let data = try encoder.encode(UsdtRecoveryBackupV1(version: 1, usdtWallet: snapshot, paykitUsdtState: paymentState))
+        #if DEBUG // test hook
+        await ReviewBackupControl.hold("upload-USDT_RECOVERY") // test hook
+        ReviewBackupControl.log("usdt-recovery-put") // test hook
+        #endif // test hook
         try await put(Self.key(for: data), data)
+        #if DEBUG // test hook
+        ReviewBackupControl.log("usdt-recovery-ack") // test hook
+        #endif // test hook
     }
 
     func snapshots() async throws -> [UsdtRecoveryBackupV1] {
diff --git a/Bitkit/Services/ReviewBackupControl.swift b/Bitkit/Services/ReviewBackupControl.swift
new file mode 100644
index 0000000..aac9b26
--- /dev/null
+++ b/Bitkit/Services/ReviewBackupControl.swift
@@ -0,0 +1,108 @@
+#if DEBUG // test hook
+import Foundation // test hook
+import LDKNode // test hook
+
+enum ReviewBackupControl { // test hook
+    static let enabled = ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1" // test hook
+    static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup") // test hook
+    static let lock = NSLock() // test hook
+    static var writes = 0 // test hook
+    static func countWrite() { if enabled { lock.withLock { writes += 1 } } } // test hook
+
+    static func log(_ message: String) { // test hook
+        guard enabled else { return } // test hook
+        lock.withLock { // test hook
+            try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+            let file = directory.appendingPathComponent("events.log") // test hook
+            var data = (try? Data(contentsOf: file)) ?? Data() // test hook
+            data.append(Data("\(Date().timeIntervalSince1970) \(message)\n".utf8)) // test hook
+            try? data.write(to: file) // test hook
+        } // test hook
+    } // test hook
+
+    static func hold(_ name: String) async { // test hook
+        guard enabled else { return } // test hook
+        let file = directory.appendingPathComponent("hold-\(name)") // test hook
+        guard FileManager.default.fileExists(atPath: file.path) else { return } // test hook
+        log("held \(name)") // test hook
+        while FileManager.default.fileExists(atPath: file.path) { // test hook
+            await Task.detached { try? await Task.sleep(nanoseconds: 100_000_000) }.value // test hook
+        } // test hook
+        log("released \(name)") // test hook
+    } // test hook
+
+    @MainActor static func start() { // test hook
+        guard enabled else { return } // test hook
+        log("control-ready head=1ad665f") // test hook
+        Task { // test hook
+            let end = Date().addingTimeInterval(1200) // test hook
+            while Date() < end { // test hook
+                let file = directory.appendingPathComponent("command") // test hook
+                if let command = try? String(contentsOf: file, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) { // test hook
+                    try? FileManager.default.removeItem(at: file) // test hook
+                    let service = BackupService.shared // test hook
+                    log("command \(command)") // test hook
+                    switch command { // test hook
+                    case "stop": Task { // test hook
+                        let before = Date() // test hook
+                        await service.stopObservingBackups() // test hook
+                        log("stop-done elapsed=\(Date().timeIntervalSince(before))") // test hook
+                    } // test hook
+                    case "start": service.startObservingBackups() // test hook
+                    case "wallet": // test hook
+                        do { // test hook
+                            var records = try WatchOnlyAccountStore.load() // test hook
+                            if records.isEmpty { // test hook
+                                records.append(WatchOnlyAccountRecord(id: UUID(), walletIndex: 0, accountIndex: 42, addressType: LDKNode.AddressType.nativeSegwit.stringValue, xpub: "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv", requestFingerprint: "review", createdAt: 1000, name: "review", isTrackingEnabled: false, setupState: .pendingDelivery)) // test hook
+                            } // test hook
+                            records[0].name = "review-\(Int(Date().timeIntervalSince1970))" // test hook
+                            try WatchOnlyAccountStore.save(records); log("wallet-record-written name=\(records[0].name)") // test hook
+                        } // test hook
+                        catch { log("wallet-record-error") } // test hook
+                    case "settings": SettingsViewModel.shared.swipeBalanceToHide.toggle() // test hook
+                    case "manual": Task { // test hook
+                        let result = await service.triggerBackup(category: .metadata) // test hook
+                        log("manual-done result=\(result)") // test hook
+                    } // test hook
+                    case "usdt": service.markBackupRequired(category: .usdt) // test hook
+                    case "vss-fault", "vss-normal": Task { // test hook
+                        let route = directory.appendingPathComponent("vss-fault") // test hook
+                        if command == "vss-fault" { try? Data().write(to: route) } // test hook
+                        else { try? FileManager.default.removeItem(at: route) } // test hook
+                        await VssBackupClient.shared.reset() // test hook
+                        log("route-ready \(command)") // test hook
+                    } // test hook
+                    case "pending-wallet": service.markBackupRequired(category: .wallet) // test hook
+                    case "status": // test hook
+                        log("writes=\(lock.withLock { writes })") // test hook
+                        for category in BackupCategory.allCases { // test hook
+                            let s = service.getBackupStatus(category: category) // test hook
+                            log("status \(category.rawValue) synced=\(s.synced) required=\(s.required) running=\(s.running)") // test hook
+                        } // test hook
+                    case "verify": Task { // test hook
+                        do { // test hook
+                            let settings = try await VssBackupClient.shared.getObject(key: "SETTINGS") // test hook
+                            let payload = try SettingsBackupV1.decode(from: settings!.value) // test hook
+                            let remote = payload.settings["enableSwipeToHideBalance"] as? Bool // test hook
+                            log("verify-settings matches=\(remote == SettingsViewModel.shared.swipeBalanceToHide) value=\(String(describing: remote))") // test hook
+                            let wallet = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+                            let data = try JSONDecoder().decode(WalletBackupV1.self, from: wallet!.value) // test hook
+                            log("verify-wallet matches=\(data.watchOnlyAccounts == (try WatchOnlyAccountStore.load()))") // test hook
+                        } catch { log("verify-error") } // test hook
+                    } // test hook
+                    case "burst": // test hook
+                        let count = lock.withLock { writes } // test hook
+                        let before = Date() // test hook
+                        for _ in 0 ..< 200 { CoreService.shared.activity.notifyActivitiesChanged() } // test hook
+                        log("burst-200 elapsed=\(Date().timeIntervalSince(before)) writes=\(lock.withLock { writes } - count)") // test hook
+                    case "end": log("control-ended"); return // test hook
+                    default: break // test hook
+                    } // test hook
+                } // test hook
+                try? await Task.sleep(nanoseconds: 100_000_000) // test hook
+            } // test hook
+            log("control-timeout") // test hook
+        } // test hook
+    } // test hook
+} // test hook
+#endif // test hook
Head connection-outage controls — local test hook, not pushed

Holds, logging, synthetic record edits and an endpoint-only outage control; the backup decisions and native transport results are unchanged.

diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -155,7 +155,17 @@
         backupData: ((BackupCategory) async throws -> Data)? = nil,
         usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(),
         uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in
+            #if DEBUG
+            await ReviewBackupControl.hold("upload-\(key)") // test hook
+            if key == "SETTINGS", let payload = try? SettingsBackupV1.decode(from: data) { // test hook
+                ReviewBackupControl.log("upload-settings swipe=\(payload.settings["swipeBalanceToHide"] ?? "missing")") // test hook
+            } // test hook
+            ReviewBackupControl.log("upload-start \(key)") // test hook
+            #endif
             _ = try await VssBackupClient.shared.putObject(key: key, data: data)
+            #if DEBUG
+            ReviewBackupControl.log("upload-ack \(key)") // test hook
+            #endif
         },
         waitForBackupDelay: @escaping () async throws -> Void = {
             try await Task.sleep(nanoseconds: 5_000_000_000)
@@ -292,6 +302,9 @@
     }
 
     private func runBackup(category: BackupCategory, isScheduled: Bool) async -> BackupOutcome {
+        #if DEBUG
+        ReviewBackupControl.log("capture-start \(category.rawValue)") // test hook
+        #endif
         Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService")
         var outcome = BackupOutcome.failed
         var requiredAtStart: UInt64 = 0
@@ -349,6 +362,9 @@
             }
         }
         _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) }
+        #if DEBUG
+        ReviewBackupControl.log("backup-done \(category.rawValue) result=\(outcome)") // test hook
+        #endif
         return outcome
     }
 
@@ -857,6 +873,9 @@
         while !Task.isCancelled {
             do {
                 if outcome == .temporarilyUnavailable {
+                    #if DEBUG // test hook
+                    ReviewBackupControl.log("retry \(category.rawValue) delay=\(retryDelay)") // test hook
+                    #endif // test hook
                     try await waitForTemporaryFailureDelay(retryDelay)
                     retryDelay = min(retryDelay * 2, 300_000_000_000)
                 } else {
@@ -873,6 +892,9 @@
                 break
             }
         }
+        #if DEBUG
+        await ReviewBackupControl.hold("cleanup") // test hook
+        #endif
         updateBackupStatus(category: category) { status in
             BackupItemStatus(synced: status.synced, required: status.required, running: false)
         }
@@ -1017,6 +1039,9 @@
 
         do {
             let data = try JSONEncoder().encode(encoded)
+            #if DEBUG
+            ReviewBackupControl.countWrite() // test hook
+            #endif
             defaults.set(data, forKey: backupStatusesKey)
 
             backupStatusesSubject.send(statuses)
diff --git a/Bitkit/Services/ReviewBackupControl.swift b/Bitkit/Services/ReviewBackupControl.swift
new file mode 100644
--- /dev/null
+++ b/Bitkit/Services/ReviewBackupControl.swift
@@ -0,0 +1,89 @@
+#if DEBUG // test hook
+import Foundation // test hook
+
+enum ReviewBackupControl { // test hook
+    static let enabled = ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1" // test hook
+    static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup") // test hook
+    static let lock = NSLock() // test hook
+    static var writes = 0 // test hook
+    static func countWrite() { if enabled { lock.withLock { writes += 1 } } } // test hook
+
+    static func log(_ message: String) { // test hook
+        guard enabled else { return } // test hook
+        lock.withLock { // test hook
+            try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+            let file = directory.appendingPathComponent("events.log") // test hook
+            var data = (try? Data(contentsOf: file)) ?? Data() // test hook
+            data.append(Data("\(Date().timeIntervalSince1970) \(message)\n".utf8)) // test hook
+            try? data.write(to: file) // test hook
+        } // test hook
+    } // test hook
+
+    static func hold(_ name: String) async { // test hook
+        guard enabled else { return } // test hook
+        let file = directory.appendingPathComponent("hold-\(name)") // test hook
+        guard FileManager.default.fileExists(atPath: file.path) else { return } // test hook
+        log("held \(name)") // test hook
+        while FileManager.default.fileExists(atPath: file.path) { // test hook
+            await Task.detached { try? await Task.sleep(nanoseconds: 100_000_000) }.value // test hook
+        } // test hook
+        log("released \(name)") // test hook
+    } // test hook
+
+    @MainActor static func start() { // test hook
+        guard enabled else { return } // test hook
+        log("control-ready head=1ad665f") // test hook
+        Task { // test hook
+            let end = Date().addingTimeInterval(1200) // test hook
+            while Date() < end { // test hook
+                let file = directory.appendingPathComponent("command") // test hook
+                if let command = try? String(contentsOf: file, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) { // test hook
+                    try? FileManager.default.removeItem(at: file) // test hook
+                    let service = BackupService.shared // test hook
+                    log("command \(command)") // test hook
+                    switch command { // test hook
+                    case "stop": Task { // test hook
+                        let before = Date() // test hook
+                        await service.stopObservingBackups() // test hook
+                        log("stop-done elapsed=\(Date().timeIntervalSince(before))") // test hook
+                    } // test hook
+                    case "start": service.startObservingBackups() // test hook
+                    case "wallet": // test hook
+                        do { try WatchOnlyAccountStore.save(WatchOnlyAccountStore.load()); log("wallet-record-written") } // test hook
+                        catch { log("wallet-record-error") } // test hook
+                    case "settings": SettingsViewModel.shared.swipeBalanceToHide.toggle() // test hook
+                    case "manual": Task { // test hook
+                        let result = await service.triggerBackup(category: .metadata) // test hook
+                        log("manual-done result=\(result)") // test hook
+                    } // test hook
+                    case "usdt": service.markBackupRequired(category: .usdt) // test hook
+                    case "vss-fault", "vss-normal": Task { // test hook
+                        let route = directory.appendingPathComponent("vss-fault") // test hook
+                        if command == "vss-fault" { try? Data().write(to: route) } // test hook
+                        else { try? FileManager.default.removeItem(at: route) } // test hook
+                        await VssBackupClient.shared.reset() // test hook
+                        log("route-ready \(command)") // test hook
+                    } // test hook
+                    case "pending-wallet": service.markBackupRequired(category: .wallet) // test hook
+                    case "status": // test hook
+                        log("writes=\(lock.withLock { writes })") // test hook
+                        for category in BackupCategory.allCases { // test hook
+                            let s = service.getBackupStatus(category: category) // test hook
+                            log("status \(category.rawValue) synced=\(s.synced) required=\(s.required) running=\(s.running)") // test hook
+                        } // test hook
+                    case "burst": // test hook
+                        let count = lock.withLock { writes } // test hook
+                        let before = Date() // test hook
+                        for _ in 0 ..< 200 { CoreService.shared.activity.notifyActivitiesChanged() } // test hook
+                        log("burst-200 elapsed=\(Date().timeIntervalSince(before)) writes=\(lock.withLock { writes } - count)") // test hook
+                    case "end": log("control-ended"); return // test hook
+                    default: break // test hook
+                    } // test hook
+                } // test hook
+                try? await Task.sleep(nanoseconds: 100_000_000) // test hook
+            } // test hook
+            log("control-timeout") // test hook
+        } // test hook
+    } // test hook
+} // test hook
+#endif // test hook
diff --git a/Bitkit/Services/UsdtRecoveryBackupStore.swift b/Bitkit/Services/UsdtRecoveryBackupStore.swift
--- a/Bitkit/Services/UsdtRecoveryBackupStore.swift
+++ b/Bitkit/Services/UsdtRecoveryBackupStore.swift
@@ -27,7 +27,14 @@
         let encoder = JSONEncoder()
         encoder.outputFormatting = [.sortedKeys]
         let data = try encoder.encode(UsdtRecoveryBackupV1(version: 1, usdtWallet: snapshot, paykitUsdtState: paymentState))
+        #if DEBUG // test hook
+        await ReviewBackupControl.hold("upload-USDT_RECOVERY") // test hook
+        ReviewBackupControl.log("usdt-recovery-put") // test hook
+        #endif // test hook
         try await put(Self.key(for: data), data)
+        #if DEBUG // test hook
+        ReviewBackupControl.log("usdt-recovery-ack") // test hook
+        #endif // test hook
     }
 
     func snapshots() async throws -> [UsdtRecoveryBackupV1] {
diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift
--- a/Bitkit/Constants/Env.swift
+++ b/Bitkit/Constants/Env.swift
@@ -372,7 +372,12 @@
     }
 
     static var vssServerUrl: String {
-        switch network {
+        #if DEBUG // test hook
+        if ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1", FileManager.default.fileExists(atPath: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup/vss-fault").path) { // test hook
+            return "http://127.0.0.1:1" // test hook
+        } // test hook
+        #endif // test hook
+        return switch network {
         case .bitcoin: "https://bitkit.to/vss_rs_auth"
         default: "https://bitkit.stag0.blocktank.to/vss_rs_auth"
         }
diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -83,6 +83,9 @@
             }
         }
 
+        #if DEBUG
+        ReviewBackupControl.start() // test hook
+        #endif
         return true
     }
 
Exact-base comparison controls — local test hook, not pushed

Holds, logging, synthetic record edits and an endpoint-only outage control; the backup decisions and native transport results are unchanged.

diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -262,6 +262,9 @@
     }
 
     private func runBackup(category: BackupCategory, isScheduled: Bool) async -> BackupOutcome {
+        #if DEBUG // test hook
+        ReviewBackupControl.log("capture-start \(category.rawValue)") // test hook
+        #endif // test hook
         Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService")
         var outcome = BackupOutcome.failed
         var requiredAtStart: UInt64 = 0
@@ -319,6 +322,9 @@
             }
         }
         _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) }
+        #if DEBUG // test hook
+        ReviewBackupControl.log("backup-done \(category.rawValue) result=\(outcome)") // test hook
+        #endif // test hook
         return outcome
     }
 
@@ -829,6 +835,9 @@
         while !Task.isCancelled {
             do {
                 if outcome == .temporarilyUnavailable {
+                    #if DEBUG // test hook
+                    ReviewBackupControl.log("retry \(category.rawValue) delay=\(retryDelay)") // test hook
+                    #endif // test hook
                     try await waitForTemporaryFailureDelay(retryDelay)
                     retryDelay = min(retryDelay * 2, 300_000_000_000)
                 } else {
@@ -988,6 +997,9 @@
 
         do {
             let data = try JSONEncoder().encode(encoded)
+            #if DEBUG // test hook
+            ReviewBackupControl.countWrite() // test hook
+            #endif // test hook
             defaults.set(data, forKey: backupStatusesKey)
 
             backupStatusesSubject.send(statuses)
diff --git a/Bitkit/Services/ReviewBackupControl.swift b/Bitkit/Services/ReviewBackupControl.swift
new file mode 100644
--- /dev/null
+++ b/Bitkit/Services/ReviewBackupControl.swift
@@ -0,0 +1,108 @@
+#if DEBUG // test hook
+import Foundation // test hook
+import LDKNode // test hook
+
+enum ReviewBackupControl { // test hook
+    static let enabled = ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1" // test hook
+    static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup") // test hook
+    static let lock = NSLock() // test hook
+    static var writes = 0 // test hook
+    static func countWrite() { if enabled { lock.withLock { writes += 1 } } } // test hook
+
+    static func log(_ message: String) { // test hook
+        guard enabled else { return } // test hook
+        lock.withLock { // test hook
+            try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+            let file = directory.appendingPathComponent("events.log") // test hook
+            var data = (try? Data(contentsOf: file)) ?? Data() // test hook
+            data.append(Data("\(Date().timeIntervalSince1970) \(message)\n".utf8)) // test hook
+            try? data.write(to: file) // test hook
+        } // test hook
+    } // test hook
+
+    static func hold(_ name: String) async { // test hook
+        guard enabled else { return } // test hook
+        let file = directory.appendingPathComponent("hold-\(name)") // test hook
+        guard FileManager.default.fileExists(atPath: file.path) else { return } // test hook
+        log("held \(name)") // test hook
+        while FileManager.default.fileExists(atPath: file.path) { // test hook
+            await Task.detached { try? await Task.sleep(nanoseconds: 100_000_000) }.value // test hook
+        } // test hook
+        log("released \(name)") // test hook
+    } // test hook
+
+    @MainActor static func start() { // test hook
+        guard enabled else { return } // test hook
+        log("control-ready base=ec20b264") // test hook
+        Task { // test hook
+            let end = Date().addingTimeInterval(1200) // test hook
+            while Date() < end { // test hook
+                let file = directory.appendingPathComponent("command") // test hook
+                if let command = try? String(contentsOf: file, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) { // test hook
+                    try? FileManager.default.removeItem(at: file) // test hook
+                    let service = BackupService.shared // test hook
+                    log("command \(command)") // test hook
+                    switch command { // test hook
+                    case "stop": Task { // test hook
+                        let before = Date() // test hook
+                        await service.stopObservingBackups() // test hook
+                        log("stop-done elapsed=\(Date().timeIntervalSince(before))") // test hook
+                    } // test hook
+                    case "start": service.startObservingBackups() // test hook
+                    case "wallet": // test hook
+                        do { // test hook
+                            var records = try WatchOnlyAccountStore.load() // test hook
+                            if records.isEmpty { // test hook
+                                records.append(WatchOnlyAccountRecord(id: UUID(), walletIndex: 0, accountIndex: 42, addressType: LDKNode.AddressType.nativeSegwit.stringValue, xpub: "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv", requestFingerprint: "review", createdAt: 1000, name: "review", isTrackingEnabled: false, setupState: .pendingDelivery)) // test hook
+                            } // test hook
+                            records[0].name = "review-\(Int(Date().timeIntervalSince1970))" // test hook
+                            try WatchOnlyAccountStore.save(records); log("wallet-record-written name=\(records[0].name)") // test hook
+                        } // test hook
+                        catch { log("wallet-record-error") } // test hook
+                    case "settings": SettingsViewModel.shared.swipeBalanceToHide.toggle() // test hook
+                    case "manual": Task { // test hook
+                        let result = await service.triggerBackup(category: .metadata) // test hook
+                        log("manual-done result=\(result)") // test hook
+                    } // test hook
+                    case "usdt": service.markBackupRequired(category: .usdt) // test hook
+                    case "vss-fault", "vss-normal": Task { // test hook
+                        let route = directory.appendingPathComponent("vss-fault") // test hook
+                        if command == "vss-fault" { try? Data().write(to: route) } // test hook
+                        else { try? FileManager.default.removeItem(at: route) } // test hook
+                        await VssBackupClient.shared.reset() // test hook
+                        log("route-ready \(command)") // test hook
+                    } // test hook
+                    case "pending-wallet": service.markBackupRequired(category: .wallet) // test hook
+                    case "status": // test hook
+                        log("writes=\(lock.withLock { writes })") // test hook
+                        for category in BackupCategory.allCases { // test hook
+                            let s = service.getBackupStatus(category: category) // test hook
+                            log("status \(category.rawValue) synced=\(s.synced) required=\(s.required) running=\(s.running)") // test hook
+                        } // test hook
+                    case "verify": Task { // test hook
+                        do { // test hook
+                            let settings = try await VssBackupClient.shared.getObject(key: "SETTINGS") // test hook
+                            let payload = try SettingsBackupV1.decode(from: settings!.value) // test hook
+                            let remote = payload.settings["enableSwipeToHideBalance"] as? Bool // test hook
+                            log("verify-settings matches=\(remote == SettingsViewModel.shared.swipeBalanceToHide) value=\(String(describing: remote))") // test hook
+                            let wallet = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+                            let data = try JSONDecoder().decode(WalletBackupV1.self, from: wallet!.value) // test hook
+                            log("verify-wallet matches=\(data.watchOnlyAccounts == (try WatchOnlyAccountStore.load()))") // test hook
+                        } catch { log("verify-error") } // test hook
+                    } // test hook
+                    case "burst": // test hook
+                        let count = lock.withLock { writes } // test hook
+                        let before = Date() // test hook
+                        for _ in 0 ..< 200 { CoreService.shared.activity.notifyActivitiesChanged() } // test hook
+                        log("burst-200 elapsed=\(Date().timeIntervalSince(before)) writes=\(lock.withLock { writes } - count)") // test hook
+                    case "end": log("control-ended"); return // test hook
+                    default: break // test hook
+                    } // test hook
+                } // test hook
+                try? await Task.sleep(nanoseconds: 100_000_000) // test hook
+            } // test hook
+            log("control-timeout") // test hook
+        } // test hook
+    } // test hook
+} // test hook
+#endif // test hook
diff --git a/Bitkit/Services/UsdtRecoveryBackupStore.swift b/Bitkit/Services/UsdtRecoveryBackupStore.swift
--- a/Bitkit/Services/UsdtRecoveryBackupStore.swift
+++ b/Bitkit/Services/UsdtRecoveryBackupStore.swift
@@ -27,7 +27,14 @@
         let encoder = JSONEncoder()
         encoder.outputFormatting = [.sortedKeys]
         let data = try encoder.encode(UsdtRecoveryBackupV1(version: 1, usdtWallet: snapshot, paykitUsdtState: paymentState))
+        #if DEBUG // test hook
+        await ReviewBackupControl.hold("upload-USDT_RECOVERY") // test hook
+        ReviewBackupControl.log("usdt-recovery-put") // test hook
+        #endif // test hook
         try await put(Self.key(for: data), data)
+        #if DEBUG // test hook
+        ReviewBackupControl.log("usdt-recovery-ack") // test hook
+        #endif // test hook
     }
 
     func snapshots() async throws -> [UsdtRecoveryBackupV1] {
diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift
--- a/Bitkit/Constants/Env.swift
+++ b/Bitkit/Constants/Env.swift
@@ -372,7 +372,12 @@
     }
 
     static var vssServerUrl: String {
-        switch network {
+        #if DEBUG // test hook
+        if ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1", FileManager.default.fileExists(atPath: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup/vss-fault").path) { // test hook
+            return "http://127.0.0.1:1" // test hook
+        } // test hook
+        #endif // test hook
+        return switch network {
         case .bitcoin: "https://bitkit.to/vss_rs_auth"
         default: "https://bitkit.stag0.blocktank.to/vss_rs_auth"
         }
diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -83,6 +83,9 @@
             }
         }
 
+        #if DEBUG
+        ReviewBackupControl.start() // test hook
+        #endif
         return true
     }
 

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

Copy link
Copy Markdown
Contributor Author

Addressed review 5657415320 in signed commit 494c949. The backup-only classifier recognizes the released VSS request-send UnknownError envelope without retrying arbitrary Unknown/auth/decode errors. Status updates still advance the capture revision before skipping unchanged persistence/publication.

Controlled before tests fail for both reported behaviors; final 2,912 units, full SwiftFormat 0/847, six released-native rejection/lost-ack prepare/resume/restore phases, and two independent final reviews pass. All four live-base commits verify locally and on GitHub. Complete diagnostics retain the AppIntents/QoS observations with unproven timing causes. The native phases prove acknowledgement/same-operation recovery, not a full live-endpoint automatic-backoff journey; the injected error tests cover that scheduler behavior. Current-head hosted checks are running. Android twin #1689 has the same narrow transport classifier; its separate stopped-observer explicit retry limitation remains disclosed and is being repaired.

Copy link
Copy Markdown
Contributor Author

Published startup follow-up ff999a5 after the hosted repeated-fulfillment failure. A controlled real SwiftUI case reproduced two completion callbacks when progress normalizes to 100; the shared completion boundary now requires Running and an unset existing latch. The original slow-retry assertions and deadlines are unchanged.

Final 2,913 selected unit tests and full SwiftFormat (0/847) pass, all frozen inputs match, and two consecutive independent reviews are clean. All five live-base commits verify locally and GitHub true/valid. Complete logs/nested diagnostics and the earlier disk-exhaustion failure are retained; the exact hosted observer ordering is not claimed proven. The PR body records both revision-scoped evidence and remaining manual-journey limits. Current-head hosted checks are pending. Existing reviewer requests are preserved.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Reviewed the full PR diff against merge base ec20b264, at ff999a50.

No new actionable code findings.

Stop and start share one MainActor observation task. Stop increments the scheduling generation, cancels queued and running backups, and waits for those tasks plus both writers before it returns. A schedule from the stopped generation exits. Changes received during cleanup stay required and upload after the replacement observation is admitted. Cancelling a settings backup keeps a newer required marker. Explicit USDT and manual backups wait through that drain and can finish after observation has stopped. Wallet initialization admits its completion callback once: both progress handlers share the existing latch, and completion still requires the node to be running.

The request-send classifier accepts the released VssError.UnknownError text recorded on 1ad665f8 in review 5657415320 (error sending request for url (http://127.0.0.1:1/putObjects)) and rejects the non-send, auth, and decode samples in BackupServiceTests. That device failure predates the classifier. Identical backup-status values are no longer rewritten; the in-memory capture revision still advances. The 200-write burst in that review was measured on 1ad665f. Restore suppression from the base is unchanged, and triggerBackup still performs the migration rewrite during restore.

Compared the shared drain, pending-change, transport-retry, and stopped explicit-USDT contracts with bitkit-android#1689 at 49e9a99d. bitkit-ios#1122 at ca10ace7 shares merge base 1ad665f; only that overlap was checked. Its profile-deletion commits were not reviewed.

GitHub Run Tests and SwiftFormat succeeded on this head. E2E jobs were skipped. The new backup, initialization, and USDT navigation tests were inspected and not executed in this review.

Device testing: not performed in this review. Manual cases 1–4 remain the device plan; case 2 needs a run on this head because the earlier outage result is from 1ad665f.

Ready for device testing.

Reviewer model: grok-4.7
Reasoning effort: high

@jvsena42 jvsena42 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Code scan of 494c949 and ff999a5: no findings.

Checked and clean:

  • The transport-failure classifier needs the request-error prefix, an http(s) URL with a host and the /putObjects path; HTTP status, decode, auth and cancellation errors stay permanent.
  • The coalesced status write doesn't change observer behaviour: backupStatusesPublisher already had removeDuplicates, and backupVersions still advances inside the update closure before the guard.
  • ff999a5 completes wallet initialization once: handleCompletion is guarded on shouldFinish && !shouldStopAnimation and both onChange handlers share it; reaching 100 % before .running, or .running first, both still complete.
  • Twin synonymdev/bitkit-android#1689 classifies the same /putObjects transport failures.

Performance-relevant (write coalescing). The 200-write burst was measured before 494c949, so it needs measuring again at this head.

Device gate (partial): ff999a5 — not run yet; pending the burst write count (expected ≤2 vs 200 before), manual case 2, and Home opening once after wallet creation. Device results will follow in a separate review.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve — the previously failed native-outage case now passes.

Findings: I found no actionable defect in the delta. The native VSS request-send error now receives automatic retry with observation stopped. Identical status updates retain capture revisions without rewriting the same durable status.

QA for ff999a5: iPhone 17 Pro simulator, iOS 26.5, Xcode 27. Initialization and component tests used the unmodified head. Lifecycle and burst checks used local Debug holds, endpoint routing and logging over that head, a disposable unfunded wallet and staging VSS. No payment was submitted.

Test 1: cleanup, manual admission and restart ✅ Stop and manual backup waited for cleanup; the latest wallet and settings state reached VSS.

I held scheduled cleanup, requested stop and a manual metadata backup, queued restart, and edited wallet/settings state. No capture started before release. I also canceled a held settings upload and changed settings during restart. Hook logs and decrypted VSS reads confirmed both final values matched local state.

lifecycle-12x.2x.mp4

Local lifecycle controls and their event log; the remote-payload checks appear in the overlay.

Test 2: stopped USDT recovery and native outage ✅ Recovery waited for cleanup and automatically completed after the VSS endpoint recovered, without restarting observation.

A recovery request made during cleanup completed after release. With observation still stopped, I made another request against an unreachable VSS endpoint. The released native binding returned the same UnknownError recorded in the earlier failed test. This head classified it as temporary and retried after ten seconds. Restoring the endpoint was the only intervention; no new recovery request or observation start was issued.

lifecycle-22x.2x.mp4

Endpoint-only outage and recovery; native outcomes and retry timing are checked through the displayed hook log.

backup-error USDT_RECOVERY UnknownError(errorDetails: "error sending request for url (http://127.0.0.1:1/putObjects)")
backup-done USDT_RECOVERY result=temporarilyUnavailable
retry USDT_RECOVERY delay=10000000000
route-ready vss-normal
usdt-recovery-ack
backup-done USDT_RECOVERY result=completed

The component regression separately checks the full 10/20/40/80/160/300-second capped sequence. The live outage run checks its first retry, not several minutes of backoff or payment acknowledgement.

Test 3: repeated observation lifecycle ✅ Stopped listeners ignored later wallet edits, and a received settings change survived restart.

I ran 25 start/stop cycles with a distinct wallet-record edit after each stop. Hook-log comparisons showed unchanged wallet status and no wallet capture after those edits. A settings event received before stop remained pending and uploaded after restart.

lifecycle-32x.2x.mp4

Lifecycle commands, distinct record edits and status comparisons in the local control overlay.

Test 4: initialization completion ✅ Both hosted SwiftUI regressions passed, and creating a fresh wallet opened Home normally.

InitializingWalletViewTests passed without skips. The threshold regression counted one completion callback; the slow/error retry case retained its existing assertions and deadlines. I also created a fresh wallet through the app UI on the unmodified head and reached Home. Callback cardinality is established by the hosted test, rather than inferred from the Home recording.

Hosted SwiftUI regressions Fresh wallet startup
initialization2x.2x.mp4
startup2x.2x.mp4

Hosted app execution checked against finalized XCResult; ordinary onboarding and Home on the right.

Test 5: backup component regressions ✅ The focused backup suite passed without skips.

BackupServiceTests passed on the unmodified head, including native-shaped error classification, capped retry with observation stopped, unchanged-status persistence, cancellation, final-write drain and pending changes during capture/upload. I checked the assertions and finalized XCResult; neither focused suite recorded a runtime warning.

regressions2x.2x.mp4

App-hosted XCTest execution; pass/failure and skip results were checked in XCResult.

Test 6: activity-notification burst ✅ Each burst of 200 activity notifications caused one status write, versus 200 in the earlier head measurement.

I drove the actual activity publisher twice. Each burst caused one durable status write and took about 0.05–0.06 seconds to dispatch. This satisfies the write-count check requested in the latest review. The comparison uses the earlier revision's recorded measurement; this measures notification handling, not a complete payment-history sync or universal UI latency.

performance-head2x.2x.mp4

Activity-publisher controls and counted status writes.

All four manual cases were exercised. I retained the earlier passing USDT navigation evidence: this delta leaves its implementation and UI tests untouched. I did not rerun funded payments, process-death recovery or cross-platform restore.

Delta review: 1ad665f..ff999a5, all five changed files and their lifecycle/status/initialization callers, plus the prior outage and write-count concerns. Runtime cases cover the changed Swift behavior; the changelog was inspected.

Twin: bitkit-android#1689 fix: preserve pending backups across restart at 49e9a99d matches the narrow request-send classifier and stopped explicit-retry contract; Android retains its own reset/job ownership.

Local Debug test controls — not pushed

Holds cleanup and uploads, edits disposable records, counts status writes, displays the event log and routes only custom VSS to an unreachable endpoint. Backup decisions, retry delays and native transport results are unchanged.

diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 961935aa..bfc5d58b 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -83,6 +83,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
             }
         }
 
+        #if DEBUG
+        ReviewBackupControl.start() // test hook
+        #endif
         return true
     }
 
diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift
index 76885696..44d6d41a 100644
--- a/Bitkit/Constants/Env.swift
+++ b/Bitkit/Constants/Env.swift
@@ -372,7 +372,12 @@ enum Env {
     }
 
     static var vssServerUrl: String {
-        switch network {
+        #if DEBUG // test hook
+        if ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1", FileManager.default.fileExists(atPath: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup/vss-fault").path) { // test hook
+            return "http://127.0.0.1:1" // test hook
+        } // test hook
+        #endif // test hook
+        return switch network {
         case .bitcoin: "https://bitkit.to/vss_rs_auth"
         default: "https://bitkit.stag0.blocktank.to/vss_rs_auth"
         }
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index 03f12571..05c58934 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -155,7 +155,17 @@ class BackupService {
         backupData: ((BackupCategory) async throws -> Data)? = nil,
         usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(),
         uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in
+            #if DEBUG
+            await ReviewBackupControl.hold("upload-\(key)") // test hook
+            if key == "SETTINGS", let payload = try? SettingsBackupV1.decode(from: data) { // test hook
+                ReviewBackupControl.log("upload-settings swipe=\(payload.settings["enableSwipeToHideBalance"] ?? "missing")") // test hook
+            } // test hook
+            ReviewBackupControl.log("upload-start \(key)") // test hook
+            #endif
             _ = try await VssBackupClient.shared.putObject(key: key, data: data)
+            #if DEBUG
+            ReviewBackupControl.log("upload-ack \(key)") // test hook
+            #endif
         },
         waitForBackupDelay: @escaping () async throws -> Void = {
             try await Task.sleep(nanoseconds: 5_000_000_000)
@@ -292,6 +302,9 @@ class BackupService {
     }
 
     private func runBackup(category: BackupCategory, isScheduled: Bool) async -> BackupOutcome {
+        #if DEBUG
+        ReviewBackupControl.log("capture-start \(category.rawValue)") // test hook
+        #endif
         Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService")
         var outcome = BackupOutcome.failed
         var requiredAtStart: UInt64 = 0
@@ -335,6 +348,9 @@ class BackupService {
                 )
             }
         } catch {
+            #if DEBUG // test hook
+            ReviewBackupControl.log("backup-error \(category.rawValue) \(error)") // test hook
+            #endif // test hook
             updateBackupStatus(category: category) { status in
                 BackupItemStatus(synced: status.synced, required: status.required, running: false)
             }
@@ -349,6 +365,9 @@ class BackupService {
             }
         }
         _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) }
+        #if DEBUG
+        ReviewBackupControl.log("backup-done \(category.rawValue) result=\(outcome)") // test hook
+        #endif
         return outcome
     }
 
@@ -870,6 +889,9 @@ class BackupService {
         while !Task.isCancelled {
             do {
                 if outcome == .temporarilyUnavailable {
+                    #if DEBUG // test hook
+                    ReviewBackupControl.log("retry \(category.rawValue) delay=\(retryDelay)") // test hook
+                    #endif // test hook
                     try await waitForTemporaryFailureDelay(retryDelay)
                     retryDelay = min(retryDelay * 2, 300_000_000_000)
                 } else {
@@ -886,6 +908,9 @@ class BackupService {
                 break
             }
         }
+        #if DEBUG
+        await ReviewBackupControl.hold("cleanup") // test hook
+        #endif
         updateBackupStatus(category: category) { status in
             BackupItemStatus(synced: status.synced, required: status.required, running: false)
         }
@@ -1032,6 +1057,9 @@ class BackupService {
 
         do {
             let data = try JSONEncoder().encode(encoded)
+            #if DEBUG
+            ReviewBackupControl.countWrite() // test hook
+            #endif
             defaults.set(data, forKey: backupStatusesKey)
 
             backupStatusesSubject.send(statuses)
diff --git a/Bitkit/Services/ReviewBackupControl.swift b/Bitkit/Services/ReviewBackupControl.swift
new file mode 100644
index 00000000..25579bbc
--- /dev/null
+++ b/Bitkit/Services/ReviewBackupControl.swift
@@ -0,0 +1,119 @@
+#if DEBUG // test hook
+import Foundation // test hook
+import LDKNode // test hook
+import UIKit // test hook
+
+enum ReviewBackupControl { // test hook
+    static let enabled = ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1" // test hook
+    static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup") // test hook
+    static let lock = NSLock() // test hook
+    static var writes = 0 // test hook
+    @MainActor static var display: UILabel? // test hook
+    static func countWrite() { if enabled { lock.withLock { writes += 1 } } } // test hook
+
+    static func log(_ message: String) { // test hook
+        guard enabled else { return } // test hook
+        lock.withLock { // test hook
+            try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+            let file = directory.appendingPathComponent("events.log") // test hook
+            var data = (try? Data(contentsOf: file)) ?? Data() // test hook
+            data.append(Data("\(Date().timeIntervalSince1970) \(message)\n".utf8)) // test hook
+            try? data.write(to: file) // test hook
+        } // test hook
+    } // test hook
+
+    static func hold(_ name: String) async { // test hook
+        guard enabled else { return } // test hook
+        let file = directory.appendingPathComponent("hold-\(name)") // test hook
+        guard FileManager.default.fileExists(atPath: file.path) else { return } // test hook
+        log("held \(name)") // test hook
+        while FileManager.default.fileExists(atPath: file.path) { // test hook
+            await Task.detached { try? await Task.sleep(nanoseconds: 100_000_000) }.value // test hook
+        } // test hook
+        log("released \(name)") // test hook
+    } // test hook
+
+    @MainActor static func start() { // test hook
+        guard enabled else { return } // test hook
+        log("control-ready head=ff999a5") // test hook
+        Task { // test hook
+            let end = Date().addingTimeInterval(1200) // test hook
+            while Date() < end { // test hook
+                if display == nil, let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = scene.windows.first(where: { $0.isKeyWindow }) { // test hook
+                    let label = UILabel(frame: CGRect(x: 12, y: 110, width: window.bounds.width - 24, height: 240)) // test hook
+                    label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) // test hook
+                    label.textColor = .white; label.backgroundColor = UIColor.black.withAlphaComponent(0.9) // test hook
+                    label.numberOfLines = 0; label.lineBreakMode = .byCharWrapping; label.isUserInteractionEnabled = false // test hook
+                    window.addSubview(label); display = label // test hook
+                } // test hook
+                let recent = (try? String(contentsOf: directory.appendingPathComponent("events.log"), encoding: .utf8)) ?? "" // test hook
+                display?.text = "ff999a5 · local review controls\n" + recent.split(separator: "\n").suffix(8).joined(separator: "\n") // test hook
+                let file = directory.appendingPathComponent("command") // test hook
+                if let command = try? String(contentsOf: file, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) { // test hook
+                    try? FileManager.default.removeItem(at: file) // test hook
+                    let service = BackupService.shared // test hook
+                    log("command \(command)") // test hook
+                    switch command { // test hook
+                    case "stop": Task { // test hook
+                        let before = Date() // test hook
+                        await service.stopObservingBackups() // test hook
+                        log("stop-done elapsed=\(Date().timeIntervalSince(before))") // test hook
+                    } // test hook
+                    case "start": service.startObservingBackups() // test hook
+                    case "wallet": // test hook
+                        do { // test hook
+                            var records = try WatchOnlyAccountStore.load() // test hook
+                            if records.isEmpty { // test hook
+                                records.append(WatchOnlyAccountRecord(id: UUID(), walletIndex: 0, accountIndex: 42, addressType: LDKNode.AddressType.nativeSegwit.stringValue, xpub: "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv", requestFingerprint: "review", createdAt: 1000, name: "review", isTrackingEnabled: false, setupState: .pendingDelivery)) // test hook
+                            } // test hook
+                            records[0].name = "review-\(Int(Date().timeIntervalSince1970))" // test hook
+                            try WatchOnlyAccountStore.save(records); log("wallet-record-written name=\(records[0].name)") // test hook
+                        } // test hook
+                        catch { log("wallet-record-error") } // test hook
+                    case "settings": SettingsViewModel.shared.swipeBalanceToHide.toggle() // test hook
+                    case "manual": Task { // test hook
+                        let result = await service.triggerBackup(category: .metadata) // test hook
+                        log("manual-done result=\(result)") // test hook
+                    } // test hook
+                    case "usdt": service.markBackupRequired(category: .usdt) // test hook
+                    case "vss-fault", "vss-normal": Task { // test hook
+                        let route = directory.appendingPathComponent("vss-fault") // test hook
+                        if command == "vss-fault" { try? Data().write(to: route) } // test hook
+                        else { try? FileManager.default.removeItem(at: route) } // test hook
+                        await VssBackupClient.shared.reset() // test hook
+                        log("route-ready \(command)") // test hook
+                    } // test hook
+                    case "pending-wallet": service.markBackupRequired(category: .wallet) // test hook
+                    case "status": // test hook
+                        log("writes=\(lock.withLock { writes })") // test hook
+                        for category in BackupCategory.allCases { // test hook
+                            let s = service.getBackupStatus(category: category) // test hook
+                            log("status \(category.rawValue) synced=\(s.synced) required=\(s.required) running=\(s.running)") // test hook
+                        } // test hook
+                    case "verify": Task { // test hook
+                        do { // test hook
+                            let settings = try await VssBackupClient.shared.getObject(key: "SETTINGS") // test hook
+                            let payload = try SettingsBackupV1.decode(from: settings!.value) // test hook
+                            let remote = payload.settings["enableSwipeToHideBalance"] as? Bool // test hook
+                            log("verify-settings matches=\(remote == SettingsViewModel.shared.swipeBalanceToHide) value=\(String(describing: remote))") // test hook
+                            let wallet = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+                            let data = try JSONDecoder().decode(WalletBackupV1.self, from: wallet!.value) // test hook
+                            log("verify-wallet matches=\(data.watchOnlyAccounts == (try WatchOnlyAccountStore.load()))") // test hook
+                        } catch { log("verify-error") } // test hook
+                    } // test hook
+                    case "burst": // test hook
+                        let count = lock.withLock { writes } // test hook
+                        let before = Date() // test hook
+                        for _ in 0 ..< 200 { CoreService.shared.activity.notifyActivitiesChanged() } // test hook
+                        log("burst-200 elapsed=\(Date().timeIntervalSince(before)) writes=\(lock.withLock { writes } - count)") // test hook
+                    case "end": log("control-ended"); return // test hook
+                    default: break // test hook
+                    } // test hook
+                } // test hook
+                try? await Task.sleep(nanoseconds: 100_000_000) // test hook
+            } // test hook
+            log("control-timeout") // test hook
+        } // test hook
+    } // test hook
+} // test hook
+#endif // test hook
diff --git a/Bitkit/Services/UsdtRecoveryBackupStore.swift b/Bitkit/Services/UsdtRecoveryBackupStore.swift
index f804d076..f546805c 100644
--- a/Bitkit/Services/UsdtRecoveryBackupStore.swift
+++ b/Bitkit/Services/UsdtRecoveryBackupStore.swift
@@ -27,7 +27,14 @@ struct UsdtRecoveryBackupStore {
         let encoder = JSONEncoder()
         encoder.outputFormatting = [.sortedKeys]
         let data = try encoder.encode(UsdtRecoveryBackupV1(version: 1, usdtWallet: snapshot, paykitUsdtState: paymentState))
+        #if DEBUG // test hook
+        await ReviewBackupControl.hold("upload-USDT_RECOVERY") // test hook
+        ReviewBackupControl.log("usdt-recovery-put") // test hook
+        #endif // test hook
         try await put(Self.key(for: data), data)
+        #if DEBUG // test hook
+        ReviewBackupControl.log("usdt-recovery-ack") // test hook
+        #endif // test hook
     }
 
     func snapshots() async throws -> [UsdtRecoveryBackupV1] {

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

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utAck

@jvsena42

Copy link
Copy Markdown
Member

Device gate (partial): ff999a5 — iPhone 17 simulator, base ec20b26 (the merge-base). Everything driven passed.

Burst: 200 × notifyActivitiesChanged() through a local Debug hook (not pushed), three runs per build.

Build backupStatuses writes during dispatch Writes per 10 s, incl. the ACTIVITY backup after Dispatch time
Head ff999a5 1 each run 5 0.043–0.050 s
Base ec20b26 (debounced) 0 6–8 0.001–0.002 s
Earlier head 1ad665f 200 – 0.206 s
  • Manual case 2 passes. A USDT request made while cleanup was held waited; stop finished in 8.0 s, then the USDT recovery backup completed with no observation restart. With observation stopped and VSS on a dead port, the native UnknownError was classed as temporary and retried after 10 s and 20 s; once VSS was back, usdt-recovery-ack and result=completed arrived with no new request.
  • A fresh wallet opens Home once: a single Wallet finished initializing but node state is running at 10:10:02.635 UTC. Base also logs it once, so the device run shows no regression; the hosted InitializingWalletViewTests cover the race itself.

Still pending: the head moved to 2416046 after these runs. Its merge brings VSS client 0.5.26-rc1 (on case 2's path) and Paykit 0.1.1-rc2; BackupService.swift and InitializingWalletView.swift are unchanged. Case 2 and the burst need a re-run on 2416046 before the final gate line.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Full reassessment of 24160466 against 39fcaec8, also its merge base. The comparison base changed and overlaps the USDT navigation tests; the complete current PR was inspected without inherited coverage.

No new actionable code findings.

Validation: git diff --check passed locally. Inspected CI test logs: all 22 backup cases, both initialization regressions and both affected USDT navigation cases passed. SwiftFormat 0.64.0 reported no formatting violations. The tested merge tree matches the pinned head. The native recovery fixture and fork-payment case were skipped, so these checks do not establish authenticated backup-fault acceptance.

The earlier native-outage and status-write concerns are addressed in the pinned source. The reported simulator retest explicitly tested ff999a5, before the current dependency composition; it remains evidence for that older revision.

Device testing: not performed in this review. Swift/Xcode are unavailable on this Linux host. Author manual cases 1–4 remain the current-head acceptance plan, using the disposable fault fixture described in the PR; full app/Paykit/deployed-contract recovery was not exercised here.

Suggested additional test cases

  • iOS component fixture: hold shutdown cleanup, start a manual metadata backup, cancel its caller before releasing cleanup, then release it. Expect no upload from the cancelled request.

Reviewer model: gpt-6.1-sol
Reasoning effort: high

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Full reassessment of the complete PR at 14152f41 against merge base 754a8991. No prior coverage was inherited: the comparison base changed from 39fcaec to 754a899 and no baseline inventory was supplied.

1 actionable finding — resolve or provide an evidence-backed rebuttal.

Stopping observation drops data-store subscriptions before in-flight backup work finishes, so an edit in that window can be left out and a put that still returns can record the older snapshot as synced. Wallet initialization now admits completion once. The VSS request-send classifier matches the released UnknownError text.

Validation: BackupServiceTests and InitializingWalletViewTests were inspected at this revision and were not executed here. Run Tests and SwiftFormat succeeded on this head. Device testing: not performed in this review.

Parity: Android 49e9a99d (synonymdev/bitkit-android#1689) uses the same /putObjects request-send rule and records drain-time edits after a replacement observation has subscribed. Companion bitkit-ios#1122 at 7f54d7b9 does not change this backup path. Dependency bitkit-ios#1121 is this merge base and was not re-reviewed.

Suggested additional test cases

  • iOS component test. Hold uploadBackup after capture for a required category. Call stopObservingBackups and do not call startObservingBackups. Change that category's source, and change a second category that was already synced, while the put is held. Release the put. Both categories stay required, and the next startObservingBackups uploads their latest values without another edit.

Findings

  • [MEDIUM] Record backup edits that arrive while observation is stopping — inline at Bitkit/Services/BackupService.swift:227.

Reviewer model: grok-4.7
Reasoning effort: high

Comment thread Bitkit/Services/BackupService.swift Resolved

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving. I re-ran the device gate on the head 14152f4, which brings VSS client 0.5.26-rc1 and Paykit 0.1.1-rc2 onto the tested path. Both checks match the earlier ff999a5 run.

  • Burst: 200 × notifyActivitiesChanged() through a local Debug hook (never pushed), three runs.
    • Each run made 1 backupStatuses write during dispatch, in 0.033–0.051 s. At ff999a5 it was 1 write in 0.043–0.050 s.
    • The 10 s window including the following ACTIVITY backup had 5, 6 and 5 writes (ff999a5: 5, 5, 5; base ec20b26: 6–8). Run 2's extra write was an idle write with no hook event.
  • Manual case 2: a USDT request made while cleanup was held waited, stop finished in 8.9 s, and the USDT recovery backup completed 5 s after release.
    • With observation stopped and VSS on a dead port, the 0.5.26-rc1 client still reports UnknownError(… error sending request …). It is classed as temporary and retried after 10 s and 20 s, and usdt-recovery-ack / result=completed arrived once VSS was back, with no new request.
    • No observation restart after stop.

Device gate: 14152f4. Journeys: none listed, manual cases 1–2 pass. Perf: burst 1 write in ~0.04 s (base 0 writes, debounced; the earlier head 1ad665f made 200). iOS simulator only; this is iOS-only code.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve — no new findings; all selected cases passed.

Findings: No actionable defect found. The shutdown-drain fix remains intact. The added allowance notification reaches both snapshot invalidation and pending-backup tracking; the activation test preserves its original return deadline.

QA for 1dd6d2f: iPhone 17 Pro simulator, iOS 26.5, Xcode 27. Existing regressions and navigation used the unmodified head. The allowance case used local test-only additions with unchanged production sources and injected capture/upload. No payment was submitted.

Backup lifecycle and foreground restart ✅ Existing backup, foreground recovery and initialization assertions passed.

Checked in finalized XCResult: shutdown drains, edits during acknowledged-upload cleanup, stopped USDT retry, restart recovery and single initialization completion passed without failures or skips. The recording accompanies app-hosted component execution; assertions are established by XCResult.

backup-restart2x.2x.mp4

Identity activation returns while publication is held ✅ The revised activation test passed with publication gated and the original return deadline intact.

Checked the test and finalized XCResult. Credential preparation has its own bounded setup wait; activation must still return within two seconds while publication stays held. The production publication cap is unchanged. Component outcomes are checked in XCResult rather than inferred from the recording.

identity-publication2x.2x.mp4

Allowance edits survive backup shutdown ✅ Real allowance-store changes invalidated capture and stayed pending through shutdown until restart.

Two local test additions exercise the production keychain store and notification stream. One changes allowance state during capture and verifies recapture. The other holds a settings upload, stops observation, saves an allowance edit, verifies no premature upload, then checks the latest stored state uploads after restart. Both passed in finalized XCResult. Capture/upload are injected; this does not establish encrypted VSS restore or an allowance payment journey.

allowance2x.2x.mp4

USDT receive navigation ✅ Receive amount commit/cancel, tab swipes, invalid-recipient handling and back navigation passed.

The original and replacement-recording runs passed in finalized XCResults. QoS warnings and capture gaps remain; the video does not establish animation timing.

usdt-receive-repeat2x.2x.mp4

USDT amount and recipient navigation ✅ Comma-region decimal entry, amount retention and recipient-preserving back navigation passed.

The original and replacement-recording runs passed in finalized XCResults. QoS warnings and capture gaps remain. The replacement's optional diagnostic collection stalled after the assertions completed and was stopped so the successful result could finalize; the clip omits that idle tail.

usdt-amount-final2x.2x.mp4

Retained manual cases 1–3, canceled manual admission and the no-restart shutdown-edit scenario from the e12df99 report. Their exercised lifecycle paths remain unchanged; the newly connected allowance source is checked above.

Retained the stopped-USDT native-outage retry and notification-burst evidence from the e12df99 device report. The native dependency, classifier, backoff and activity-notification handling are unchanged.

Retained expired-rate conversion from the 566b2ff report; the amount-screen conversion implementation is unchanged. No selected case remains unrun. Funded payments, process-death recovery, cross-platform restore and the merged upstream feature journeys were not driven in this pass.

Reviewed 566b2ff..1dd6d2f, focusing on the allowance notification, activation-test deadline and parent composition, plus the previously resolved shutdown finding. The separate terminal-identity/full-WALLET limitation remains in bitkit-ios#1121 fix: decouple wallet restore from profile refresh.

Twin: bitkit-android#1689 fix: preserve pending backups across restart at 1d3ba14 matches the retained-cleanup, bounded transport-retry and stopped explicit-USDT contracts; reset and observation ownership remain platform-specific.


Reviewed by gpt-6.1-sol-high

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utAck

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Re-reviewed the full PR at 1dd6d2fb after the comparison base moved from c0b1e86f to d0246d1c. Both of those commits change BackupService.swift and BackupForegroundRecoveryTests.swift, and this run had no coverage baseline to inherit. Coverage is the complete diff from merge base d0246d1c.

No new actionable code findings.

Observation shutdown still drops status observers and cancels queued work before it removes data-store subscriptions, and it waits for scheduled jobs, running uploads, and both backup writers. Edits that arrive during that drain stay required and are uploaded only after observation is running again. An explicit USDT backup can still retry after observation has stopped. The temporary VSS request-send rule matches the Android twin at 1d3ba149: typed unknown errors with an http(s) host and a putObjects path. Wallet initialization completion is admitted once when normalized progress reaches the running node.

Validation: source and unit-test inspection only; this review did not execute the suite. Run Tests and SwiftFormat succeeded on this head. End-to-end jobs on this head were skipped. Device testing: not performed in this review.

Companion bitkit-ios#1122 head bee8975a is not in this workspace. The only commit after its base 8947f493 changes PaykitSdkClientConfigTests.swift. The identity-restoration availability gap stays on dependency bitkit-ios#1121.

Ready for device testing. Manual cases 1–4 still need the dedicated fault fixture in the PR; this review did not drive them.

Reviewer model: grok-4.7
Reasoning effort: high

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-approving at 262a447. No new findings.

1dd6d2fb is test-only (PaykitSdkClientConfigTests.swift). Merges 262a4474 and 8947f493 fold the parent's waitForExisting/owned into the PR's admission loop correctly. No device check needed for this delta.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve — no new findings; all selected cases passed.

Findings: I found no actionable defect in the delta. The shutdown lost-edit fix remains intact. Metadata joins an existing wallet backup without owning its cancellation, and restore drains admitted metadata before importing address exclusions.

QA for 262a447: iPhone 17 Pro simulator, iOS 26.5, Xcode 27. Regression and navigation runs used the unmodified head. Live lifecycle cases used local Debug holds and logging, an unfunded regtest wallet and real staging VSS. No payment was submitted.

Backup shutdown and metadata ordering ✅ Backup, foreground recovery and initialization assertions passed.

Finalized XCResult confirms metadata waits for durable wallet exclusions, canceled metadata leaves another owner's wallet upload running, restore and shutdown drain admitted metadata, and newer requirements survive cleanup. The hosted SwiftUI tests also verify one initialization callback. Component outcomes come from assertions, not the recording.

backup-composition2x.2x.mp4

App-hosted component execution.

Public address and restore composition ✅ Focused sharing, address, restore and payment-request regressions passed.

Checked finalized XCResults for independent public/private preferences, address reservation and lookup, stale-session admission, payment coordination and Android backup decoding. The original and replacement composition runs passed; QoS warnings and recording gaps remain. These are component checks, not authenticated sharing or cross-platform restore journeys.

Sharing and restore guards Address lookup and payment coordination
parent-regressions2x.2x.mp4
parent-address-lookup2x.2x.mp4

App-hosted assertions checked in XCResult.

Manual backup waits for shutdown cleanup ✅ Stop and manual metadata waited for cleanup; latest settings and wallet values reached VSS after restart.

Held scheduled cleanup before its final status write, queued stop and manual metadata, then changed both sources while restart waited. The event log confirms no capture before release and matching decrypted payloads afterward. A second held drain confirms canceling manual admission starts no metadata capture or upload for that request.

manual-cleanup-repeat2x.2x.mp4

Live controls and event overlay; hidden-state assertions checked in the event log.

Edits during acknowledged upload remain pending ✅ With no restart queued, both edited categories stayed required through the drain and matched VSS after restart.

Held the return from a successful settings put, requested stop, then changed settings and an already-synced wallet record. The event log confirms both pending markers survived release, no new capture started while stopped, and decrypted SETTINGS and WALLET matched after observation restarted.

stop-drain-edits-repeat2x.2x.mp4

Acknowledged-upload hold, pending-state checks and remote comparisons.

Stopped listeners ignore later edits ✅ Post-stop wallet edits left status unchanged across 25 cycles; a pre-stop settings edit uploaded after restart.

Each cycle saved a distinct wallet-record value after stop returned and checked unchanged status with no new capture. The final settings notification remained pending through stop and matched VSS after restart. The intentionally unobserved wallet edits remained absent remotely, as expected. Assertions are checked in the event log; the clip shows the controls and overlay.

repeated-lifecycle-repeat2x.2x.mp4

Repeated lifecycle controls and status comparisons.

USDT receive navigation ✅ Receive amount commit/cancel, asset swipes, invalid-recipient handling and back navigation passed.

Original and recording runs passed in finalized XCResults. QoS warnings and capture gaps remain; the video does not establish animation timing.

usdt-receive-repeat2x.2x.mp4

Unmodified-head receive navigation.

USDT amount and recipient navigation ✅ Comma-region decimal entry, amount retention and recipient-preserving back navigation passed.

Original and recording runs passed in finalized XCResults. The original optional diagnostic collector stalled after assertions and was stopped so its successful result could finalize. QoS warnings and capture gaps remain; the replacement recording used unchanged assertions.

usdt-amount-repeat2x.2x.mp4

Unmodified-head amount and recipient navigation.

Retained manual case 2's stopped-USDT native-outage retry and the notification-burst measurement from the e12df99 device report. The bitkit-core, Paykit and VSS client pins, classifier, backoff, activity notification handling and fresh non-joining request behavior remain unchanged. The retained burst result keeps its original timing scope.

Retained expired-rate conversion from the 566b2ff report, and unchanged allowance-notification and activation-return coverage from the 1dd6d2f report.

No selected case remains unrun. Authenticated parent-feature journeys, funded payments, process-death recovery and cross-platform restore were not driven. The known complete-wallet availability limitation remains with bitkit-ios#1121 fix: decouple wallet restore from profile refresh.

Reviewed the contiguous 1dd6d2f..262a447 delta, including the merged parent changes and prior shutdown feedback. Other merged flows received source and focused component coverage. Changed Swift files passed SwiftFormat 0.64.0 lint; translation validation passed with its warnings retained.

Twin: bitkit-android#1689 fix: preserve pending backups across restart at 9cfe052 matches pending-state retention, stopped retry and durable address-exclusion contracts. Android serializes WALLET and METADATA with a shared mutex; iOS joins wallet work before metadata.


Reviewed by gpt-6.1-sol-high

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utAck

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve — no new findings; the focused recovery case passed and unchanged QA is retained.

Findings: I found no actionable defect in the delta. The shutdown lost-edit fix remains intact.

QA for fb25166: iPhone 17 Pro simulator, iOS 26.5, Xcode 27. Built the app and test targets from the unmodified head with the committed dependencies.

Profile recovery preserves unfinished setup ✅ Setup-pending recovery avoids a remote profile read, while healthy recovery and stale-result rejection still pass.

The new setup-pending regression and both existing recovery controls passed in finalized XCResult, with no failures or skips. I checked each result: repeated maintenance preserves the authenticated session and pending setup; an ordinarily missing profile reloads without reauthentication or waiting for the read; a result from cleared session state is discarded.

These are app-hosted component tests with an injected profile resolver and synthetic identity state. The assertions are established by XCTest and XCResult, not by the screen recording. This does not establish a live homeserver outage or a complete profile-setup journey.

profile-recovery2x.2x.mp4

Recording of the component run at 2x; capture gaps remain.

Check: retained QA coverage ✅ The exercised feature paths and native dependency pins remain unchanged.
  • Manual cases 1, 3 and 4, canceled manual admission, acknowledged-upload edits, metadata ordering and ordinary USDT navigation retain the 262a447 report. Backup observation, writers and initialization sources are byte-identical.
  • Manual case 2's stopped-USDT native-outage retry and the notification-burst measurement retain the e12df99 device report. Their classifier, retry, notification and native dependency paths are unchanged.
  • The imported allowance switch retains the paired device run at a226627: a covered request auto-pays while enabled, stays manual while disabled, and stored allowances survive re-enabling. Its executor, manager, tab and flag rule match this head.
  • Request Payment routing retains the complete linked-contact journey at 180987ff. Preparing-request colours and the USDT review icon retain the rail review at 375538f2. The exercised view implementations are unchanged.

These are attributed earlier executions, not fresh runs in this pass.

No selected case remains unrun. Funded payments, process-death recovery and cross-platform restore were not driven in this pass. The known complete-WALLET availability limitation remains with the parent.

Reviewed the contiguous 262a447..fb25166 delta, all changed files and affected callers, including parent/master composition and prior shutdown feedback. Other imported behaviour uses the retained reports above; the new profile guard received focused component coverage. Changed Swift files passed SwiftFormat 0.64.0 lint; translation validation passed with its existing warnings retained.

Twin: bitkit-android#1689 fix: preserve pending backups across restart at 9cfe052 matches pending-state retention and stopped explicit-retry contracts. Android serializes WALLET and METADATA with a shared mutex; iOS joins existing wallet work before metadata.


Reviewed by gpt-6.1-sol-high

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve — no new findings; the focused admission regression and its controls passed.

Findings: I found no actionable defect in the delta. The resolved backup shutdown finding remains fixed.

QA for 2c0c100: iPhone 17 Pro simulator, iOS 26.5, Xcode 27. I built the app and test targets from the unmodified head with the committed package resolution.

Disabling allowances cancels queued offer acceptance ✅ The held offer remained unanswered after pause, including when the same identity was reactivated before release.

PaykitAllowanceExecutorTests passed without failures or skips. I checked every case in finalized XCResult, including the new queued-offer regression, current-session admission, normal single acceptance/announcement, failed-accept retry and interrupted-accept recovery.

These are app-hosted component tests with an injected SDK, payer and in-memory store. The assertions come from XCTest and XCResult; the recording shows the test host launching. This does not establish authenticated contact negotiation or an actual payment.

allowance-admission2x.2x.mp4

Component run at 2x; capture gaps remain.

Check: retained backup and recovery coverage ✅ The previously exercised backup, initialization and profile-recovery paths remain unchanged.
  • Manual cases 1, 3 and 4, canceled manual admission, acknowledged-upload edits and metadata ordering retain the 262a447 report. Backup observation, writers and initialization sources are unchanged.
  • Manual case 2 and the activity-notification burst retain the e12df99 device report. The classifier, backoff and native dependency pins are unchanged.
  • Setup-pending profile recovery and stale-result rejection retain the fb25166 report. Its profile manager, test cases and recovery callers are unchanged.
  • Ordinary USDT navigation retains the 262a447 report. Its views and UI assertions are unchanged.

These are attributed earlier executions, not fresh device runs in this pass.

No selected case remains unrun. The queued-offer boundary received component coverage; no fresh authenticated allowance journey, funded payment, process-death recovery or cross-platform restore was driven. The known complete-WALLET availability limitation remains with the parent.

Reviewed the contiguous fb25166..2c0c100 delta, all five changed files and their acceptance/lifecycle callers, plus prior shutdown feedback. SwiftFormat 0.64.0 lint and the diff check passed. The changelog was inspected without a device case.

Twin: bitkit-android#1689 fix: preserve pending backups across restart at 9cfe052 matches pending-backup retention and stopped explicit retry; Android serializes WALLET and METADATA with a shared mutex, while iOS joins existing wallet work before metadata.


Reviewed by gpt-6.1-sol-high

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Full reassessment of the complete PR at 2c0c1002 against merge base d83a2172. The comparison base changed from d0246d1c to d83a2172, and that base updates BackupService.swift, BackupForegroundRecoveryTests.swift, and BackupServiceTests.swift, so earlier coverage was not inherited.

No new actionable code findings.

Observation start and stop are serialized on the main actor. Stopping cancels the current scheduling generation, drains scheduled and running backups plus both writers, and keeps data-store listeners until that drain finishes. Edits during cleanup stay required and resume when observation starts again. Manual backups and explicit USDT recovery wait for the drain and can finish after observation has stopped. Repeated notifications in the same second advance the in-memory capture revision and leave an identical persisted status unchanged. Wallet initialization admits its completion callback once after the node is running.

The earlier lost-edit report in this thread is fixed on this head. The released VSS retry matches reqwest 0.12.24’s display form error sending request for url (…), which has no trailing cause, when the URL is HTTP(S) and its path ends in /putObjects.

Compared with bitkit-android#1689 at a3693b0c: both retain pending edits across shutdown and retry that same request-send envelope. Android rejects a status write from an older observation generation inline; iOS admits the next observation only after the stopped one has drained. bitkit-ios#1122 does not change these backup contracts. bitkit-ios#1121’s terminal complete-WALLET availability gap remains on that dependency.

Validation: Inspected BackupServiceTests.swift, BackupForegroundRecoveryTests.swift, and InitializingWalletViewTests.swift. This review did not run the suite. Run Tests and SwiftFormat passed on this head.

Device testing: not performed in this review. Manual cases 1–4 remain the open fault-fixture checks.

Ready for device testing.

Reviewer model: grok-4.7
Reasoning effort: high

Copy link
Copy Markdown
Contributor Author

I reconciled the existing 262a447 and e12df99 lifecycle reports instead of treating Manual 1–3 as never executed. To credit those finite cases, please attach the already-produced nonsecret temporary-control diffs, build/install receipts (tested revision and installed executable SHA-256), and event/readback traces for held cleanup/final status, stopped-listener cycles and stopped-USDT native-outage retry. The 262a lifecycle/helper inputs match current ff3ba67; the older e12 composition needs its own scoped comparison. No new run or fixture access is requested. Please exclude credentials, tokens, recovery phrases and private wallet data. Manual 4's component evidence and the separate complete-WALLET limitation retain their current scope.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve — no new findings; both focused regression suites passed. Earlier lifecycle results retain their stated scope and provenance limits.

Findings: I found no actionable defect in the delta. The resolved backup shutdown finding remains fixed.

QA for ff3ba67: iPhone 17 Pro simulator, iOS 26.5, Xcode 27. Built the unmodified app and test targets with the committed package resolution. The installed executable and Debug library match the products from this build.

Closing the last channel withdraws the public invoice ✅ The new callback regression removes the public invoice without foreground maintenance while local Receive stays coherent.

ReceiveInvoiceRefreshTests passed without failures or skips. I checked the new last-channel-close result and the suite's existing publication, channel-availability, cancellation and stale-session controls in finalized XCResult.

These are app-hosted component tests with injected wallet/channel state and endpoint application. The assertions come from XCTest and XCResult. This does not establish live LDK channel closure or authenticated public-storage withdrawal.

channel-close.2x.mp4

Component-run recording at 2x; it shows test-host launch, not the hidden assertions. Capture gaps remain.

Recurring payment recovery uses the current billing month ✅ All three revised recurring-period cases and the existing proof/recovery controls passed.

PaykitPaymentProofServiceTests passed without failures or skips. Finalized XCResult confirms interrupted cancellation can finish after proof removal, an expired quote ends the active period, and recovery retains the original unpaid billing period and deadline. The new fixture keeps recurrence and quote boundaries in the current UTC month.

These are app-hosted component assertions using injected SDK, payer and in-memory stores. No payment was submitted.

billing-period.2x.mp4

Component-run recording at 2x; outcomes were checked in XCResult. Capture gaps remain.

Check: retained lifecycle and recovery coverage ✅ Earlier passing cases retain their original revisions; the relevant execution paths were compared with this head.
  • Manual cases 1, 3 and 4, canceled manual admission, acknowledged-upload edits, metadata ordering and ordinary USDT navigation retain the 262a447 report. Backup service, recovery store, VSS client, service queue, initialization view and backup regressions are byte-identical.
  • Manual case 2 and the activity-notification burst retain the e12df99 device report. Start/stop, scheduling, retry-loop and request-send-parser slices match. The later admission change joins existing WALLET work; fresh USDT requests still own their new task. The classifier adds only a metadata-dependency error. Core, Paykit and VSS pins match. LDK changed from rc.77 to rc.78, outside that stopped-USDT snapshot/put path; no fresh LDK behavior is inferred. Burst timings remain historical.
  • Setup-pending profile recovery retains the fb25166 report. Its profile manager and recovery tests are unchanged by this delta.
  • Queued-offer admission retains the 2c0c100 report. Its executor and tests are unchanged.

These are attributed earlier executions, not fresh lifecycle runs. The historical installed-binary binding requested in the artifact comment remains unavailable; the recovered controls and trace excerpts below do not replace it.

No selected component case remains unrun. Authenticated last-channel-close acceptance, funded payments, process-death recovery and cross-platform restore were not driven. The existing complete-WALLET availability limitation remains with the parent.

Reviewed the contiguous 2c0c100..ff3ba67 delta, all four changed files and their channel-event, receive, public/private publication and session-admission callers, plus prior shutdown feedback. The changelog received source inspection. SwiftFormat 0.64.0 lint and the diff check passed. The build retained two AppIntents metadata notices; neither focused result recorded a runtime warning.

Twin: bitkit-android#1689 fix: preserve pending backups across restart at 439b5d0 matches pending-backup retention and stopped explicit retry. Android serializes WALLET and METADATA with a shared mutex; iOS joins existing wallet work before metadata. The imported channel-close change is outside that backup twin's scope.

Current-head build/install binding

For this pass at ff3ba674464c796057415188d460e6009371496a, the checkout was clean and each installed member matched the built member:

Member SHA-256
Bitkit ac1b5caa9889da3ca1684146d2532a534ea192718689b8daee10e17dadb9b071
Bitkit.debug.dylib 0a62e437ced60fae14a3f44117cd4012a20fae6bfbf360f81a61324f37e7ca72

This receipt applies to the component executions above, not the earlier lifecycle runs.

Historical lifecycle controls and trace excerpts

In response to the artifact request, these are recovered artifacts from the earlier executions, not new current-head runs. The original archives contain controls and event/readback logs, but no contemporaneous installed-executable SHA-256 receipt. I cannot supply that missing historical build/install binding. The current-head receipt below binds only this pass.

The patches below were never pushed. The deterministic test xpub is redacted; no credential, recovery phrase or private wallet data is included. Epoch timestamps are preserved. Repeated polling and unrelated category status lines are omitted from the trace excerpts.

262a447 original control diff

Original unredacted patch SHA-256: bdb458b802f1bcfa28b612c5efb164ba35a2c55bd96b8db970238ca9b64b9a7c.

diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 5f028be3..7d2c36a3 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -83,6 +83,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
             }
         }
 
+        #if DEBUG
+        ReviewBackupControl.start() // test hook
+        #endif
         return true
     }
 
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index ff21c2c0..fa83002e 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -163,7 +163,15 @@ class BackupService {
         backupData: ((BackupCategory) async throws -> Data)? = nil,
         usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(),
         uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in
+            #if DEBUG
+            await ReviewBackupControl.hold("upload-\(key)") // test hook
+            ReviewBackupControl.log("upload-start \(key)") // test hook
+            #endif
             _ = try await VssBackupClient.shared.putObject(key: key, data: data)
+            #if DEBUG
+            ReviewBackupControl.log("upload-ack \(key)") // test hook
+            await ReviewBackupControl.hold("return-\(key)") // test hook
+            #endif
         },
         waitForBackupDelay: @escaping () async throws -> Void = {
             try await Task.sleep(nanoseconds: 5_000_000_000)
@@ -311,6 +319,7 @@ class BackupService {
         let stopping = Task {
             await previous?.value
             isObserving = false
+            ReviewBackupControl.log("stop-scheduling-disabled") // test hook
             statusListeners.removeAll()
             periodicCheckTask?.cancel()
             periodicCheckTask = nil
@@ -388,6 +397,7 @@ class BackupService {
     }
 
     private func runBackup(category: BackupCategory, isScheduled: Bool) async -> BackupOutcome {
+        ReviewBackupControl.log("capture-start \(category.rawValue)") // test hook
         Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService")
         var outcome = BackupOutcome.failed
         var requiredAtStart: UInt64 = 0
@@ -465,6 +475,7 @@ class BackupService {
             }
         }
         _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) }
+        ReviewBackupControl.log("backup-done \(category.rawValue) result=\(outcome)") // test hook
         return outcome
     }
 
@@ -1054,6 +1065,7 @@ class BackupService {
                 break
             }
         }
+        await ReviewBackupControl.hold("cleanup") // test hook
         updateBackupStatus(category: category) { status in
             BackupItemStatus(synced: status.synced, required: status.required, running: false)
         }
diff --git a/Bitkit/Services/ReviewBackupControl.swift b/Bitkit/Services/ReviewBackupControl.swift
new file mode 100644
index 00000000..799202a0
--- /dev/null
+++ b/Bitkit/Services/ReviewBackupControl.swift
@@ -0,0 +1,109 @@
+#if DEBUG // test hook
+import Foundation // test hook
+import BitkitCore // test hook
+import LDKNode // test hook
+import UIKit // test hook
+
+enum ReviewBackupControl { // test hook
+    static let enabled = ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1" // test hook
+    static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup") // test hook
+    static let lock = NSLock() // test hook
+    static var writes = 0 // test hook
+    @MainActor static var manualTask: Task<Void, Never>? // test hook
+    @MainActor static var display: UILabel? // test hook
+    static func countWrite() { if enabled { lock.withLock { writes += 1 } } } // test hook
+
+    static func log(_ message: String) { // test hook
+        guard enabled else { return } // test hook
+        lock.withLock { // test hook
+            try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+            let file = directory.appendingPathComponent("events.log") // test hook
+            var data = (try? Data(contentsOf: file)) ?? Data() // test hook
+            data.append(Data("\(Date().timeIntervalSince1970) \(message)\n".utf8)) // test hook
+            try? data.write(to: file) // test hook
+        } // test hook
+    } // test hook
+
+    static func hold(_ name: String) async { // test hook
+        guard enabled else { return } // test hook
+        let file = directory.appendingPathComponent("hold-\(name)") // test hook
+        guard FileManager.default.fileExists(atPath: file.path) else { return } // test hook
+        log("held \(name)") // test hook
+        while FileManager.default.fileExists(atPath: file.path) { // test hook
+            await Task.detached { try? await Task.sleep(nanoseconds: 100_000_000) }.value // test hook
+        } // test hook
+        log("released \(name)") // test hook
+    } // test hook
+
+    @MainActor static func start() { // test hook
+        guard enabled else { return } // test hook
+        log("control-ready head=262a447") // test hook
+        Task { // test hook
+            let end = Date().addingTimeInterval(7200) // test hook
+            while Date() < end { // test hook
+                if ProcessInfo.processInfo.environment["REVIEW_NO_OVERLAY"] != "1", display == nil, let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = scene.windows.first(where: { $0.isKeyWindow }) { // test hook
+                    let label = UILabel(frame: CGRect(x: 12, y: 110, width: window.bounds.width - 24, height: 240)) // test hook
+                    label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) // test hook
+                    label.textColor = .white; label.backgroundColor = UIColor.black.withAlphaComponent(0.9) // test hook
+                    label.numberOfLines = 0; label.lineBreakMode = .byCharWrapping; label.isUserInteractionEnabled = false // test hook
+                    window.addSubview(label); display = label // test hook
+                } // test hook
+                let recent = (try? String(contentsOf: directory.appendingPathComponent("events.log"), encoding: .utf8)) ?? "" // test hook
+                display?.text = "262a447 · local review controls\n" + recent.split(separator: "\n").suffix(8).joined(separator: "\n") // test hook
+                let file = directory.appendingPathComponent("command") // test hook
+                if let command = try? String(contentsOf: file, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) { // test hook
+                    try? FileManager.default.removeItem(at: file) // test hook
+                    let service = BackupService.shared // test hook
+                    log("command \(command)") // test hook
+                    switch command { // test hook
+                    case "stop": Task { // test hook
+                        let before = Date() // test hook
+                        await service.stopObservingBackups() // test hook
+                        log("stop-done elapsed=\(Date().timeIntervalSince(before))") // test hook
+                    } // test hook
+                    case "start": service.startObservingBackups() // test hook
+                    case "wallet": // test hook
+                        do { // test hook
+                            var records = try WatchOnlyAccountStore.load() // test hook
+                            if records.isEmpty { // test hook
+                                records.append(WatchOnlyAccountRecord(id: UUID(), walletIndex: 0, accountIndex: 42, addressType: LDKNode.AddressType.nativeSegwit.stringValue, xpub: "<synthetic test xpub omitted>", requestFingerprint: "review", createdAt: 1000, name: "review", isTrackingEnabled: false, setupState: .pendingDelivery)) // test hook
+                            } // test hook
+                            records[0].name = "review-\(UUID().uuidString)" // test hook
+                            try WatchOnlyAccountStore.save(records); log("wallet-record-written name=\(records[0].name)") // test hook
+                        } // test hook
+                        catch { log("wallet-record-error") } // test hook
+                    case "settings": SettingsViewModel.shared.swipeBalanceToHide.toggle() // test hook
+                    case "manual": manualTask = Task { // test hook
+                        let result = await service.triggerBackup(category: .metadata) // test hook
+                        log("manual-done result=\(result)") // test hook
+                    } // test hook
+                    case "cancel-manual": manualTask?.cancel(); log("manual-cancelled") // test hook
+                    case "pending-wallet": service.markBackupRequired(category: .wallet) // test hook
+                    case "status": // test hook
+                        log("writes=\(lock.withLock { writes })") // test hook
+                        for category in BackupCategory.allCases { // test hook
+                            let s = service.getBackupStatus(category: category) // test hook
+                            log("status \(category.rawValue) synced=\(s.synced) required=\(s.required) running=\(s.running)") // test hook
+                        } // test hook
+                    case "verify": Task { // test hook
+                        do { // test hook
+                            let settings = try await VssBackupClient.shared.getObject(key: "SETTINGS") // test hook
+                            let payload = try SettingsBackupV1.decode(from: settings!.value) // test hook
+                            let remote = payload.settings["enableSwipeToHideBalance"] as? Bool // test hook
+                            log("verify-settings matches=\(remote == SettingsViewModel.shared.swipeBalanceToHide) value=\(String(describing: remote))") // test hook
+                            let wallet = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+                            let data = try JSONDecoder().decode(WalletBackupV1.self, from: wallet!.value) // test hook
+                            log("verify-wallet matches=\(data.watchOnlyAccounts == (try WatchOnlyAccountStore.load()))") // test hook
+                        } catch { log("verify-error") } // test hook
+                    } // test hook
+                    case "end": log("control-ended"); return // test hook
+                    default: break // test hook
+                    } // test hook
+                } // test hook
+                try? await Task.sleep(nanoseconds: 100_000_000) // test hook
+            } // test hook
+            log("control-timeout") // test hook
+        } // test hook
+    } // test hook
+} // test hook
+#endif // test hook
e12df99 original control diff

Original unredacted patch SHA-256: 1550e1bef5552918269d26185c8642802ff3dc7eac4347f55b170505f7e5efa5.

diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 961935aa..bfc5d58b 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -83,6 +83,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
             }
         }
 
+        #if DEBUG
+        ReviewBackupControl.start() // test hook
+        #endif
         return true
     }
 
diff --git a/Bitkit/Managers/UsdtWalletManager.swift b/Bitkit/Managers/UsdtWalletManager.swift
index 52e7417d..def66486 100644
--- a/Bitkit/Managers/UsdtWalletManager.swift
+++ b/Bitkit/Managers/UsdtWalletManager.swift
@@ -193,6 +193,9 @@ final class UsdtWalletManager {
             if historyRefreshRequested, !isSendPresented, Date() >= nextHistoryRefresh {
                 historyRefreshRequested = false
                 nextHistoryRefresh = Date().addingTimeInterval(10)
+                #if DEBUG
+                if ProcessInfo.processInfo.environment["REVIEW_HISTORY_FAILURE"] == "1" { ReviewBackupControl.log("history-fault after successful balance"); throw URLError(.notConnectedToInternet) }
+                #endif
                 let complete = try await wallet.syncHistory()
                 historyComplete = historyComplete || complete
                 transfers = try await ServiceQueue.background(.core, wrapErrors: false) { try wallet.history() }
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index fbcbf056..83f7fe86 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -157,7 +157,18 @@ class BackupService {
         backupData: ((BackupCategory) async throws -> Data)? = nil,
         usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(),
         uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in
+            #if DEBUG
+            await ReviewBackupControl.hold("upload-\(key)") // test hook
+            if key == "SETTINGS", let payload = try? SettingsBackupV1.decode(from: data) { // test hook
+                ReviewBackupControl.log("upload-settings swipe=\(payload.settings["enableSwipeToHideBalance"] ?? "missing")") // test hook
+            } // test hook
+            ReviewBackupControl.log("upload-start \(key)") // test hook
+            #endif
             _ = try await VssBackupClient.shared.putObject(key: key, data: data)
+            #if DEBUG
+            ReviewBackupControl.log("upload-ack \(key)") // test hook
+            await ReviewBackupControl.hold("return-\(key)") // test hook
+            #endif
         },
         waitForBackupDelay: @escaping () async throws -> Void = {
             try await Task.sleep(nanoseconds: 5_000_000_000)
@@ -305,6 +316,7 @@ class BackupService {
         let stopping = Task {
             await previous?.value
             isObserving = false
+            ReviewBackupControl.log("stop-scheduling-disabled") // test hook
             statusListeners.removeAll()
             periodicCheckTask?.cancel()
             periodicCheckTask = nil
@@ -374,6 +386,9 @@ class BackupService {
     }
 
     private func runBackup(category: BackupCategory, isScheduled: Bool) async -> BackupOutcome {
+        #if DEBUG
+        ReviewBackupControl.log("capture-start \(category.rawValue)") // test hook
+        #endif
         Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService")
         var outcome = BackupOutcome.failed
         var requiredAtStart: UInt64 = 0
@@ -427,6 +442,9 @@ class BackupService {
                 )
             }
         } catch {
+            #if DEBUG // test hook
+            ReviewBackupControl.log("backup-error \(category.rawValue) \(error)") // test hook
+            #endif // test hook
             updateBackupStatus(category: category) { status in
                 BackupItemStatus(synced: status.synced, required: status.required, running: false)
             }
@@ -442,6 +460,9 @@ class BackupService {
             }
         }
         _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) }
+        #if DEBUG
+        ReviewBackupControl.log("backup-done \(category.rawValue) result=\(outcome)") // test hook
+        #endif
         return outcome
     }
 
@@ -965,6 +986,9 @@ class BackupService {
         while !Task.isCancelled {
             do {
                 if outcome == .temporarilyUnavailable {
+                    #if DEBUG // test hook
+                    ReviewBackupControl.log("retry \(category.rawValue) delay=\(retryDelay)") // test hook
+                    #endif // test hook
                     try await waitForTemporaryFailureDelay(retryDelay)
                     retryDelay = min(retryDelay * 2, 300_000_000_000)
                 } else {
@@ -981,6 +1005,9 @@ class BackupService {
                 break
             }
         }
+        #if DEBUG
+        await ReviewBackupControl.hold("cleanup") // test hook
+        #endif
         updateBackupStatus(category: category) { status in
             BackupItemStatus(synced: status.synced, required: status.required, running: false)
         }
@@ -1134,6 +1161,9 @@ class BackupService {
 
         do {
             let data = try JSONEncoder().encode(encoded)
+            #if DEBUG
+            ReviewBackupControl.countWrite() // test hook
+            #endif
             defaults.set(data, forKey: backupStatusesKey)
 
             backupStatusesSubject.send(statuses)
diff --git a/Bitkit/Services/CurrencyService.swift b/Bitkit/Services/CurrencyService.swift
index d269614a..15ac7700 100644
--- a/Bitkit/Services/CurrencyService.swift
+++ b/Bitkit/Services/CurrencyService.swift
@@ -13,6 +13,9 @@ class CurrencyService {
     init() {}
 
     func fetchLatestRates() async throws -> [FxRate] {
+        #if DEBUG
+        if ProcessInfo.processInfo.environment["REVIEW_OFFLINE_RATES"] == "1" { throw URLError(.notConnectedToInternet) }
+        #endif
         var lastError: Error?
 
         for attempt in 0 ..< maxRetries {
diff --git a/Bitkit/Services/ReviewBackupControl.swift b/Bitkit/Services/ReviewBackupControl.swift
new file mode 100644
index 00000000..0cf16712
--- /dev/null
+++ b/Bitkit/Services/ReviewBackupControl.swift
@@ -0,0 +1,136 @@
+#if DEBUG // test hook
+import Foundation // test hook
+import BitkitCore // test hook
+import LDKNode // test hook
+import UIKit // test hook
+
+enum ReviewBackupControl { // test hook
+    static let enabled = ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1" // test hook
+    static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup") // test hook
+    static let lock = NSLock() // test hook
+    static var writes = 0 // test hook
+    @MainActor static var manualTask: Task<Void, Never>? // test hook
+    @MainActor static var display: UILabel? // test hook
+    static func countWrite() { if enabled { lock.withLock { writes += 1 } } } // test hook
+
+    static func log(_ message: String) { // test hook
+        guard enabled else { return } // test hook
+        lock.withLock { // test hook
+            try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+            let file = directory.appendingPathComponent("events.log") // test hook
+            var data = (try? Data(contentsOf: file)) ?? Data() // test hook
+            data.append(Data("\(Date().timeIntervalSince1970) \(message)\n".utf8)) // test hook
+            try? data.write(to: file) // test hook
+        } // test hook
+    } // test hook
+
+    static func hold(_ name: String) async { // test hook
+        guard enabled else { return } // test hook
+        let file = directory.appendingPathComponent("hold-\(name)") // test hook
+        guard FileManager.default.fileExists(atPath: file.path) else { return } // test hook
+        log("held \(name)") // test hook
+        while FileManager.default.fileExists(atPath: file.path) { // test hook
+            await Task.detached { try? await Task.sleep(nanoseconds: 100_000_000) }.value // test hook
+        } // test hook
+        log("released \(name)") // test hook
+    } // test hook
+
+    @MainActor static func start() { // test hook
+        guard enabled else { return } // test hook
+        log("control-ready head=e12df99") // test hook
+        Task { // test hook
+            let end = Date().addingTimeInterval(7200) // test hook
+            while Date() < end { // test hook
+                if ProcessInfo.processInfo.environment["REVIEW_NO_OVERLAY"] != "1", display == nil, let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = scene.windows.first(where: { $0.isKeyWindow }) { // test hook
+                    let label = UILabel(frame: CGRect(x: 12, y: 110, width: window.bounds.width - 24, height: 240)) // test hook
+                    label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) // test hook
+                    label.textColor = .white; label.backgroundColor = UIColor.black.withAlphaComponent(0.9) // test hook
+                    label.numberOfLines = 0; label.lineBreakMode = .byCharWrapping; label.isUserInteractionEnabled = false // test hook
+                    window.addSubview(label); display = label // test hook
+                } // test hook
+                let recent = (try? String(contentsOf: directory.appendingPathComponent("events.log"), encoding: .utf8)) ?? "" // test hook
+                display?.text = "e12df99 · local review controls\n" + recent.split(separator: "\n").suffix(8).joined(separator: "\n") // test hook
+                let file = directory.appendingPathComponent("command") // test hook
+                if let command = try? String(contentsOf: file, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) { // test hook
+                    try? FileManager.default.removeItem(at: file) // test hook
+                    let service = BackupService.shared // test hook
+                    log("command \(command)") // test hook
+                    switch command { // test hook
+                    case "stop": Task { // test hook
+                        let before = Date() // test hook
+                        await service.stopObservingBackups() // test hook
+                        log("stop-done elapsed=\(Date().timeIntervalSince(before))") // test hook
+                    } // test hook
+                    case "start": service.startObservingBackups() // test hook
+                    case "wallet": // test hook
+                        do { // test hook
+                            var records = try WatchOnlyAccountStore.load() // test hook
+                            if records.isEmpty { // test hook
+                                records.append(WatchOnlyAccountRecord(id: UUID(), walletIndex: 0, accountIndex: 42, addressType: LDKNode.AddressType.nativeSegwit.stringValue, xpub: "<synthetic test xpub omitted>", requestFingerprint: "review", createdAt: 1000, name: "review", isTrackingEnabled: false, setupState: .pendingDelivery)) // test hook
+                            } // test hook
+                            records[0].name = "review-\(Int(Date().timeIntervalSince1970))" // test hook
+                            try WatchOnlyAccountStore.save(records); log("wallet-record-written name=\(records[0].name)") // test hook
+                        } // test hook
+                        catch { log("wallet-record-error") } // test hook
+                    case "settings": SettingsViewModel.shared.swipeBalanceToHide.toggle() // test hook
+                    case "manual": manualTask = Task { // test hook
+                        let result = await service.triggerBackup(category: .metadata) // test hook
+                        log("manual-done result=\(result)") // test hook
+                    } // test hook
+                    case "cancel-manual": manualTask?.cancel(); log("manual-cancelled") // test hook
+                    case "usdt": service.markBackupRequired(category: .usdt) // test hook
+                    case "vss-fault", "vss-normal": Task { // test hook
+                        let route = directory.appendingPathComponent("vss-fault") // test hook
+                        if command == "vss-fault" { try? Data().write(to: route) } // test hook
+                        else { try? FileManager.default.removeItem(at: route) } // test hook
+                        await VssBackupClient.shared.reset() // test hook
+                        log("route-ready \(command)") // test hook
+                    } // test hook
+                    case "pending-wallet": service.markBackupRequired(category: .wallet) // test hook
+                    case "status": // test hook
+                        log("writes=\(lock.withLock { writes })") // test hook
+                        for category in BackupCategory.allCases { // test hook
+                            let s = service.getBackupStatus(category: category) // test hook
+                            log("status \(category.rawValue) synced=\(s.synced) required=\(s.required) running=\(s.running)") // test hook
+                        } // test hook
+                    case "verify": Task { // test hook
+                        do { // test hook
+                            let settings = try await VssBackupClient.shared.getObject(key: "SETTINGS") // test hook
+                            let payload = try SettingsBackupV1.decode(from: settings!.value) // test hook
+                            let remote = payload.settings["enableSwipeToHideBalance"] as? Bool // test hook
+                            log("verify-settings matches=\(remote == SettingsViewModel.shared.swipeBalanceToHide) value=\(String(describing: remote))") // test hook
+                            let wallet = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+                            let data = try JSONDecoder().decode(WalletBackupV1.self, from: wallet!.value) // test hook
+                            log("verify-wallet matches=\(data.watchOnlyAccounts == (try WatchOnlyAccountStore.load()))") // test hook
+                        } catch { log("verify-error") } // test hook
+                    } // test hook
+                    case "hide-display": SettingsViewModel.shared.hideBalance = true; log("display-hidden") // test hook
+                    case "show-display": SettingsViewModel.shared.hideBalance = false; log("display-shown") // test hook
+                    case "seed-display": // test hook
+                        Task {
+                            do {
+                                guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else { throw URLError(.userAuthenticationRequired) }
+                                let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: 0))
+                                let address = try usdtAddress(mnemonic: mnemonic, passphrase: passphrase)
+                                UsdtDisplayBalanceCache(storageDirectory: Env.bitkitCoreStorage(walletIndex: 0), rpcUrl: Env.usdtRpcUrl, bundlerUrl: Env.usdtBundlerUrl).save(1_234_567, address: address)
+                                let rate = FxRate(symbol: "BTCUSD", lastPrice: "100000", base: "BTC", baseName: "Bitcoin", quote: "USD", quoteName: "US Dollar", currencySymbol: "$", currencyFlag: "", lastUpdatedAt: Date().addingTimeInterval(-3600).timeIntervalSince1970 * 1000)
+                                UserDefaults.standard.set(try JSONEncoder().encode([rate]), forKey: "cached_fx_rates")
+                                log("seed-display complete 1.234567 USDT rate 100000 aged 1h")
+                            } catch { log("seed-display failed: \(error)") }
+                        }
+                    case "burst": // test hook
+                        let count = lock.withLock { writes } // test hook
+                        let before = Date() // test hook
+                        for _ in 0 ..< 200 { CoreService.shared.activity.notifyActivitiesChanged() } // test hook
+                        log("burst-200 elapsed=\(Date().timeIntervalSince(before)) writes=\(lock.withLock { writes } - count)") // test hook
+                    case "end": log("control-ended"); return // test hook
+                    default: break // test hook
+                    } // test hook
+                } // test hook
+                try? await Task.sleep(nanoseconds: 100_000_000) // test hook
+            } // test hook
+            log("control-timeout") // test hook
+        } // test hook
+    } // test hook
+} // test hook
+#endif // test hook
diff --git a/Bitkit/Services/UsdtRecoveryBackupStore.swift b/Bitkit/Services/UsdtRecoveryBackupStore.swift
index f804d076..f546805c 100644
--- a/Bitkit/Services/UsdtRecoveryBackupStore.swift
+++ b/Bitkit/Services/UsdtRecoveryBackupStore.swift
@@ -27,7 +27,14 @@ struct UsdtRecoveryBackupStore {
         let encoder = JSONEncoder()
         encoder.outputFormatting = [.sortedKeys]
         let data = try encoder.encode(UsdtRecoveryBackupV1(version: 1, usdtWallet: snapshot, paykitUsdtState: paymentState))
+        #if DEBUG // test hook
+        await ReviewBackupControl.hold("upload-USDT_RECOVERY") // test hook
+        ReviewBackupControl.log("usdt-recovery-put") // test hook
+        #endif // test hook
         try await put(Self.key(for: data), data)
+        #if DEBUG // test hook
+        ReviewBackupControl.log("usdt-recovery-ack") // test hook
+        #endif // test hook
     }
 
     func snapshots() async throws -> [UsdtRecoveryBackupV1] {
diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift
index 3c7995d2..89122c90 100644
--- a/Bitkit/Services/VssBackupClient.swift
+++ b/Bitkit/Services/VssBackupClient.swift
@@ -77,7 +77,13 @@ class VssBackupClient {
         let passphraseRaw = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex))
         let passphrase = passphraseRaw?.isEmpty == true ? nil : passphraseRaw
         let storeId = try await VssStoreIdProvider.shared.getVssStoreId(walletIndex: walletIndex)
-        return (Env.vssServerUrl, storeId, mnemonic, passphrase, lnurlAuthServerUrl)
+        #if DEBUG
+        let fault = ReviewBackupControl.enabled && FileManager.default.fileExists(atPath: ReviewBackupControl.directory.appendingPathComponent("vss-fault").path)
+        let reviewVssUrl = fault ? "http://127.0.0.1:1" : Env.vssServerUrl
+        #else
+        let reviewVssUrl = Env.vssServerUrl
+        #endif
+        return (reviewVssUrl, storeId, mnemonic, passphrase, lnurlAuthServerUrl)
     }
 
     private func setup(walletIndex: Int = 0) async throws {
Manual 1 and canceled manual admission at 262a447

Original complete trace SHA-256: 62a220aa506d44e7020e08cae38cece698f7b848ec845f1ef18225c1fd2c8c5e.

1793396099.477833 command start
1793396100.045496 command wallet
1793396100.0519881 wallet-record-written name=<synthetic record>
1793396100.167038 command settings
1793396100.285053 status WALLET synced=1793396096 required=1793396100 running=true
1793396100.285647 status USDT_RECOVERY synced=1793395120 required=1793395115 running=false
1793396100.286275 status SETTINGS synced=1793396096 required=1793396100 running=true
1793396105.0747108 capture-start WALLET
1793396105.098247 upload-start WALLET
1793396105.184496 upload-ack WALLET
1793396105.1886702 backup-done WALLET result=completed
1793396105.196279 capture-start SETTINGS
1793396105.201933 upload-start SETTINGS
1793396105.247803 upload-ack SETTINGS
1793396105.2518911 backup-done SETTINGS result=completed
1793396105.435319 status WALLET synced=1793396105 required=1793396100 running=false
1793396105.436658 status SETTINGS synced=1793396105 required=1793396100 running=false
1793396105.5540771 command pending-wallet
1793396106.128171 command stop
1793396106.129189 stop-scheduling-disabled
1793396106.130173 held cleanup
1793396106.2442632 command manual
1793396106.914927 command start
1793396107.023442 command wallet
1793396107.028703 wallet-record-written name=<synthetic record>
1793396107.142908 command settings
1793396108.231285 released cleanup
1793396108.240924 stop-done elapsed=2.1118130683898926
1793396108.2413511 capture-start METADATA
1793396108.243969 capture-start WALLET
1793396108.249343 upload-start WALLET
1793396108.330651 upload-ack WALLET
1793396108.33529 backup-done WALLET result=completed
1793396108.3388581 upload-start METADATA
1793396108.386629 upload-ack METADATA
1793396108.3878 backup-done METADATA result=completed
1793396108.3881102 manual-done result=completed
1793396108.497676 status WALLET synced=1793396108 required=1793396107 running=false
1793396108.498345 status SETTINGS synced=1793396105 required=1793396107 running=true
1793396113.307796 capture-start SETTINGS
1793396113.3101192 upload-start SETTINGS
1793396113.365306 upload-ack SETTINGS
1793396113.370802 backup-done SETTINGS result=completed
1793396113.4288611 status SETTINGS synced=1793396113 required=1793396107 running=false
1793396113.545312 command verify
1793396113.589721 verify-settings matches=true value=Optional(false)
1793396113.631854 verify-wallet matches=true
1793396113.769784 command pending-wallet
1793396114.324524 command stop
1793396114.325005 stop-scheduling-disabled
1793396114.32551 held cleanup
1793396114.438869 command manual
1793396114.547377 command cancel-manual
1793396114.547796 manual-cancelled
1793396114.7560902 released cleanup
1793396114.760088 stop-done elapsed=0.4350661039352417
1793396114.76049 manual-done result=failed
1793396114.7731261 command start
1793396115.464478 status WALLET synced=1793396108 required=1793396113 running=true
1793396119.8309221 capture-start WALLET
1793396119.839212 upload-start WALLET
1793396119.890255 upload-ack WALLET
1793396119.892015 backup-done WALLET result=completed
1793396119.9406319 status WALLET synced=1793396119 required=1793396113 running=false
Acknowledged-upload drain edits at 262a447

Original complete trace SHA-256: 4d4747e0a1d7ac2eddbdbc55ea479ee6593f5619e597cd8d5ee39ffcb3d9d862.

1793396122.7842932 command start
1793396123.3508592 command wallet
1793396123.354994 wallet-record-written name=<synthetic record>
1793396123.473423 command settings
1793396123.58409 status WALLET synced=1793396119 required=1793396123 running=true
1793396123.584412 status USDT_RECOVERY synced=1793395120 required=1793395115 running=false
1793396123.584745 status SETTINGS synced=1793396113 required=1793396123 running=true
1793396128.4380631 capture-start WALLET
1793396128.44663 upload-start WALLET
1793396128.498266 upload-ack WALLET
1793396128.50492 backup-done WALLET result=completed
1793396128.548425 capture-start SETTINGS
1793396128.563088 status WALLET synced=1793396128 required=1793396123 running=false
1793396128.569874 upload-start SETTINGS
1793396128.610733 upload-ack SETTINGS
1793396128.6135092 backup-done SETTINGS result=completed
1793396129.16012 status SETTINGS synced=1793396128 required=1793396123 running=false
1793396129.27985 command verify
1793396129.322895 verify-settings matches=true value=Optional(true)
1793396129.3660989 verify-wallet matches=true
1793396129.509681 command settings
1793396134.634659 capture-start SETTINGS
1793396134.641409 upload-start SETTINGS
1793396134.688034 upload-ack SETTINGS
1793396134.690695 held return-SETTINGS
1793396134.755608 command stop
1793396134.7599878 stop-scheduling-disabled
1793396134.873444 command settings
1793396134.991387 command wallet
1793396134.996942 wallet-record-written name=<synthetic record>
1793396135.1237988 status WALLET synced=1793396128 required=1793396134 running=false
1793396135.1253629 status SETTINGS synced=1793396128 required=1793396134 running=true
1793396135.223943 released return-SETTINGS
1793396135.229196 backup-done SETTINGS result=completed
1793396135.243853 stop-done elapsed=0.48383402824401855
1793396135.473359 status SETTINGS synced=1793396129 required=1793396134 running=false
1793396136.619714 command start
1793396137.314434 status WALLET synced=1793396128 required=1793396134 running=true
1793396137.3151588 status SETTINGS synced=1793396129 required=1793396134 running=true
1793396141.648528 capture-start SETTINGS
1793396141.65026 capture-start WALLET
1793396141.651149 upload-start SETTINGS
1793396141.658877 upload-start WALLET
1793396141.705436 upload-ack SETTINGS
1793396141.708799 backup-done SETTINGS result=completed
1793396141.71207 upload-ack WALLET
1793396141.713654 backup-done WALLET result=completed
1793396141.9612331 status WALLET synced=1793396141 required=1793396134 running=false
1793396141.9619598 status SETTINGS synced=1793396141 required=1793396134 running=false
1793396142.073656 command verify
1793396142.1229072 verify-settings matches=true value=Optional(true)
1793396142.165434 verify-wallet matches=true
Manual 3 stopped-listener cycles at 262a447

Original complete trace SHA-256: 5c95c869e0a0e85bb329a2a57e4bbf8f4a9dfedec7b4d00d0624ce2d1f0ec028.

1793396144.778364 command start
1793396145.3660378 command stop
1793396145.3668442 stop-scheduling-disabled
1793396145.367758 stop-done elapsed=0.0009080171585083008
1793396145.490607 status WALLET synced=1793396141 required=1793396134 running=false
1793396145.491197 status USDT_RECOVERY synced=1793395120 required=1793395115 running=false
1793396145.491754 status SETTINGS synced=1793396141 required=1793396134 running=false
1793396145.612877 command wallet
1793396145.615298 wallet-record-written name=<synthetic record>
1793396145.97858 command start
1793396146.685199 command stop
1793396146.686733 stop-scheduling-disabled
1793396146.6880898 stop-done elapsed=0.0013300180435180664
1793396146.934792 command wallet
1793396146.9373732 wallet-record-written name=<synthetic record>
1793396147.408611 command start
1793396148.092153 command stop
1793396148.0930061 stop-scheduling-disabled
1793396148.093669 stop-done elapsed=0.0006740093231201172
1793396148.320657 command wallet
1793396148.322051 wallet-record-written name=<synthetic record>
1793396148.664859 command start
1793396149.351869 command stop
1793396149.352812 stop-scheduling-disabled
1793396149.353611 stop-done elapsed=0.0007969141006469727
1793396149.5802379 command wallet
1793396149.581377 wallet-record-written name=<synthetic record>
1793396150.052119 command start
1793396150.722783 command stop
1793396150.7233791 stop-scheduling-disabled
1793396150.724039 stop-done elapsed=0.0006750822067260742
1793396150.9689069 command wallet
1793396150.97161 wallet-record-written name=<synthetic record>
1793396151.3293939 command start
1793396151.919658 command stop
1793396151.920537 stop-scheduling-disabled
1793396151.9215631 stop-done elapsed=0.0010199546813964844
1793396152.159535 command wallet
1793396152.162167 wallet-record-written name=<synthetic record>
1793396152.5224972 command start
1793396153.104097 command stop
1793396153.1046438 stop-scheduling-disabled
1793396153.10527 stop-done elapsed=0.0006350278854370117
1793396153.3377662 command wallet
1793396153.3389459 wallet-record-written name=<synthetic record>
1793396153.793155 command start
1793396154.465343 command stop
1793396154.466207 stop-scheduling-disabled
1793396154.4669209 stop-done elapsed=0.0007050037384033203
1793396154.6961288 command wallet
1793396154.697295 wallet-record-written name=<synthetic record>
1793396155.0422158 command start
1793396155.741621 command stop
1793396155.7430282 stop-scheduling-disabled
1793396155.744156 stop-done elapsed=0.0011339187622070312
1793396155.980217 command wallet
1793396155.982049 wallet-record-written name=<synthetic record>
1793396156.4677181 command start
1793396157.16098 command stop
1793396157.1625218 stop-scheduling-disabled
1793396157.163944 stop-done elapsed=0.0014050006866455078
1793396157.402221 command wallet
1793396157.404937 wallet-record-written name=<synthetic record>
1793396157.771428 command start
1793396158.352388 command stop
1793396158.353065 stop-scheduling-disabled
1793396158.353921 stop-done elapsed=0.0008620023727416992
1793396158.597753 command wallet
1793396158.599959 wallet-record-written name=<synthetic record>
1793396158.9601212 command start
1793396159.535273 command stop
1793396159.535836 stop-scheduling-disabled
1793396159.536607 stop-done elapsed=0.0007380247116088867
1793396159.7712889 command wallet
1793396159.772438 wallet-record-written name=<synthetic record>
1793396160.118029 command start
1793396160.7941918 command stop
1793396160.7951689 stop-scheduling-disabled
1793396160.796103 stop-done elapsed=0.0009599924087524414
1793396161.020973 command wallet
1793396161.022588 wallet-record-written name=<synthetic record>
1793396161.375078 command start
1793396161.961814 command stop
1793396161.963048 stop-scheduling-disabled
1793396161.964635 stop-done elapsed=0.0016580820083618164
1793396162.205004 command wallet
1793396162.206759 wallet-record-written name=<synthetic record>
1793396162.565551 command start
1793396163.262193 command stop
1793396163.263938 stop-scheduling-disabled
1793396163.267046 stop-done elapsed=0.003144979476928711
1793396163.509474 command wallet
1793396163.511735 wallet-record-written name=<synthetic record>
1793396163.987698 command start
1793396164.655539 command stop
1793396164.656398 stop-scheduling-disabled
1793396164.6571279 stop-done elapsed=0.0007170438766479492
1793396164.887656 command wallet
1793396164.889108 wallet-record-written name=<synthetic record>
1793396165.2387528 command start
1793396165.818651 command stop
1793396165.819211 stop-scheduling-disabled
1793396165.820477 stop-done elapsed=0.0012680292129516602
1793396166.0557752 command wallet
1793396166.056903 wallet-record-written name=<synthetic record>
1793396166.5170112 command start
1793396167.2209759 command stop
1793396167.22228 stop-scheduling-disabled
1793396167.223619 stop-done elapsed=0.001291036605834961
1793396167.4568691 command wallet
1793396167.458599 wallet-record-written name=<synthetic record>
1793396167.824475 command start
1793396168.401361 command stop
1793396168.402015 stop-scheduling-disabled
1793396168.402865 stop-done elapsed=0.0008440017700195312
1793396168.63828 command wallet
1793396168.639857 wallet-record-written name=<synthetic record>
1793396168.997459 command start
1793396169.6889582 command stop
1793396169.689822 stop-scheduling-disabled
1793396169.690584 stop-done elapsed=0.0007109642028808594
1793396169.9291039 command wallet
1793396169.93027 wallet-record-written name=<synthetic record>
1793396170.4039679 command start
1793396171.0836291 command stop
1793396171.085092 stop-scheduling-disabled
1793396171.086004 stop-done elapsed=0.0009140968322753906
1793396171.3206959 command wallet
1793396171.322174 wallet-record-written name=<synthetic record>
1793396171.662626 command start
1793396172.257784 command stop
1793396172.258562 stop-scheduling-disabled
1793396172.259484 stop-done elapsed=0.0009379386901855469
1793396172.50029 command wallet
1793396172.50232 wallet-record-written name=<synthetic record>
1793396172.873866 command start
1793396173.462579 command stop
1793396173.46342 stop-scheduling-disabled
1793396173.464386 stop-done elapsed=0.0009810924530029297
1793396173.712476 command wallet
1793396173.715043 wallet-record-written name=<synthetic record>
1793396174.0844789 command start
1793396174.743835 command stop
1793396174.744704 stop-scheduling-disabled
1793396174.7454681 stop-done elapsed=0.0007600784301757812
1793396174.984674 command wallet
1793396174.986161 wallet-record-written name=<synthetic record>
1793396175.339544 command start
1793396175.9242501 command stop
1793396175.924821 stop-scheduling-disabled
1793396175.925538 stop-done elapsed=0.000720977783203125
1793396176.1602778 command wallet
1793396176.161443 wallet-record-written name=<synthetic record>
1793396176.5124311 command start
1793396177.2196689 command settings
1793396177.338732 command stop
1793396177.3425422 stop-scheduling-disabled
1793396177.345865 stop-done elapsed=0.0034329891204833984
1793396177.4665298 status SETTINGS synced=1793396141 required=1793396177 running=false
1793396178.658613 command start
1793396179.26119 status SETTINGS synced=1793396141 required=1793396177 running=true
1793396183.667417 capture-start SETTINGS
1793396183.669046 upload-start SETTINGS
1793396183.781874 upload-ack SETTINGS
1793396183.7838678 backup-done SETTINGS result=completed
1793396184.1460052 status SETTINGS synced=1793396183 required=1793396177 running=false
1793396184.262066 command verify
1793396184.340498 verify-settings matches=true value=Optional(false)
1793396184.382699 verify-wallet matches=false
Manual 2 native-outage retry at e12df99

Original complete trace SHA-256: c5396cefff96f8f800456e0ec3e8bf633d7ee922a8670812d8ff6d38f46f1b31.

1793151169.781187 command stop
1793151169.781996 stop-scheduling-disabled
1793151169.7849011 stop-done elapsed=0.0029120445251464844
1793151169.8945708 command pending-wallet
1793151170.352495 command stop
1793151170.3542628 stop-scheduling-disabled
1793151170.355133 held cleanup
1793151170.463388 command usdt
1793151171.601737 released cleanup
1793151171.605201 stop-done elapsed=1.2510039806365967
1793151176.6251988 capture-start USDT_RECOVERY
1793151176.630746 usdt-recovery-put
1793151176.9968228 usdt-recovery-ack
1793151177.00384 backup-done USDT_RECOVERY result=completed
1793151177.0751 command vss-fault
1793151177.077182 route-ready vss-fault
1793151177.187042 command usdt
1793151182.277916 capture-start USDT_RECOVERY
1793151182.2817621 usdt-recovery-put
1793151192.7743568 backup-error USDT_RECOVERY UnknownError(errorDetails: "error sending request for url (http://127.0.0.1:1/putObjects)")
1793151192.777415 backup-done USDT_RECOVERY result=temporarilyUnavailable
1793151192.778851 retry USDT_RECOVERY delay=10000000000
1793151192.91097 command vss-normal
1793151192.913923 route-ready vss-normal
1793151202.7849479 capture-start USDT_RECOVERY
1793151202.7899241 usdt-recovery-put
1793151203.209801 usdt-recovery-ack
1793151203.2155151 backup-done USDT_RECOVERY result=completed
1793151203.341978 status WALLET synced=1793151073 required=1793151169 running=false
1793151203.342474 status USDT_RECOVERY synced=1793151202 required=1793151177 running=false
1793151203.34298 status SETTINGS synced=1793151140 required=1793151135 running=false
1793151203.457084 command stop
1793151203.462419 stop-scheduling-disabled
1793151203.463469 stop-done elapsed=0.0011219978332519531

The older stopped-USDT case uses the same start/stop, mark, schedule, retry-loop, request-send parser, USDT recovery store and VSS client paths. The later admission tuple adds join/ownership for existing WALLET work; a fresh USDT category still owns its new task. The classifier adds only a metadata-dependency error case. Core, Paykit and VSS pins match; LDK moved from rc.77 to rc.78, outside the stopped-USDT snapshot/put path. The burst result retains its original timing scope.


Reviewed by gpt-6.1-sol-high

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Re-reviewed the full pull request at ff3ba674 after the saved assessment's merge base changed and no inherited coverage baseline was supplied. Coverage is the complete diff from merge base a6f233df.

No new actionable code findings.

Observation shutdown removes status observers and cancels queued work first, then keeps data-store listeners until cancelled backups and both wallet writers drain. Edits in that window stay required for the next observation, and the stopped generation does not upload them. The earlier lost-edit thread discussion r4390767903 is resolved; that omission is absent at this head. Manual backups and explicit USDT retries wait for cleanup and can finish after observation has stopped. Identical status notifications still advance the in-memory capture revision, and wallet initialization admits completion once.

The VSS request-send retry matches bitkit-android#1689 at 439b5d0e: a typed unknown error whose message is an http(s) URL with a host and a putObjects path. Android invalidates observation immediately and joins reset work in the repository; iOS waits on its shutdown task before resetting the VSS client. Both journeys record that difference. bitkit-ios#1122 at 5a5044a7 does not change this backup contract. The separate complete-WALLET availability gap on bitkit-ios#1121 stays outside this diff.

GitHub unit-tests and lint succeeded on this head. This review did not re-run them. Device testing: not performed. Manual cases 1–4 remain the runtime plan; historical executions cited on the pull request are not a pass of this revision.

Ready for device testing.

Reviewer model: grok-4.7
Reasoning effort: high

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve. The delta has no findings, and all five device cases passed at this head.

Findings: None. 4e6c06e now clears the finished scheduled job and publishes idle in the same backup-queue block. Foreground recovery can no longer see an idle category whose finished job still blocks admission. Cleanup still runs for jobs that stop cancels: the closure cannot throw, and the queue nesting matches what scheduleBackup already does. The status sink ignores running changes, so the idle write does not reschedule anything. Merge c633688 brings in only #1121's a6f233d..b9f3bbd cached-Blocktank change, which is already in the base.

QA for 4e6c06e: I used an iPhone 17 Pro simulator (iOS 26.5, Xcode 27), a disposable new wallet and staging VSS. No funds or payments were involved. B1 ran on the unmodified head. The lifecycle cases used the same head with local holds and logging (diff below); the backup decisions and native transport were left unchanged.

Backup lifecycle, foreground retry and cached-record regressions ✅ All 53 tests passed with no skips, including the new scheduled-idle regression.

The run covered BackupServiceTests, BackupForegroundRecoveryTests (with testScheduledIdlePublicationWaitsForForegroundAdmissionCleanup), BlocktankBackupTests and InitializingWalletViewTests. I checked the counts in the finalized XCResult. The Blocktank "no such table" errors in the log come from that suite's unreadable-record cases.

backup-regressions.2x.mp4

The recording shows the test host only; the results come from XCResult.

Manual backup waits for scheduled cleanup ✅ Stop and a manual metadata backup waited for the held cleanup, and the latest wallet and settings reached VSS.

I held the scheduled job just before its new combined cleanup block, then requested stop and a manual METADATA backup and queued a restart with edits. No capture started until I released the hold. Stop then returned after 2.0 s and the manual backup completed. Decrypted VSS settings and wallet records matched local state. A manual request cancelled during a second held cleanup returned failed and never captured or uploaded METADATA.

manual-cleanup.2x.mp4

The on-screen overlay is the control's event log. I checked the outcomes in that log and in VSS reads.

held cleanup
released cleanup
stop-done elapsed=2.0115081071853638
capture-start METADATA
manual-done result=completed
verify-settings matches=true value=Optional(false)
verify-wallet matches=true
Edits during an acknowledged upload stay pending ✅ Edits made during the drain stayed required and reached VSS after restart.

I held a SETTINGS upload after VSS acknowledged it, then stopped observation and edited settings and the wallet. Both categories stayed required and nothing was captured while observation was stopped. After restart, decrypted VSS matched both.

stop-drain-edits.2x.mp4

I checked the status values and VSS reads in the event log.

USDT recovery with observation stopped and a VSS outage ✅ The request waited for cleanup, and the 10 s scheduled retry recovered from a native connection failure.

A USDT recovery request made during the held cleanup started only after stop finished, and it completed with observation still stopped. I then pointed only the VSS endpoint at an unreachable loopback port. The native put failed with UnknownError, which was classified as temporary. I restored the endpoint, and the scheduled retry completed with no new request and no observation restart. USDT ended synced and idle.

usdt-outage.2x.mp4

The outcomes come from the event log.

held cleanup
command usdt
released cleanup
stop-done elapsed=2.202391028404236
backup-done USDT_RECOVERY result=completed
route-ready vss-fault
backup-error USDT_RECOVERY UnknownError(errorDetails: "error sending request for url (http://127.0.0.1:1/putObjects)")
backup-done USDT_RECOVERY result=temporarilyUnavailable
retry USDT_RECOVERY delay=10000000000
route-ready vss-normal
usdt-recovery-ack
backup-done USDT_RECOVERY result=completed
status USDT_RECOVERY synced=1793739664 required=1793739639 running=false
Stopped listeners ignore later edits ✅ Across 25 start/stop cycles, no wallet edit made after stop changed status or started a capture.

A settings edit received before stop stayed required and uploaded after restart. VSS matched it, and the wallet edits made while stopped were absent remotely, as intended.

repeated-lifecycle.2x.mp4

I checked the status comparisons and VSS reads in the event log.

Check: wallet initialization completes once ✅ This delta does not touch the initialization view or its tests.

InitializingWalletView.swift and its tests are unchanged since ff3ba67. InitializingWalletViewTests passed in the B1 run above.

I did not drive funded payments, cross-platform restore or the complete-WALLET restore path. The complete-WALLET availability limitation stays with #1121.

Review scope: the ff3ba67..4e6c06e delta, including the merge's parent change and the callers of the moved cleanup: foreground recovery, stop drain, status observers and BackupViewModel. The BlocktankBackupTests additions ran in B1. I read the changelog edits but did not test them.

Twin: bitkit-android#1689 at 8727da0 matches pending-backup retention and the stopped explicit retry. Its latest commit adds a VSS setup timeout for cold explicit retries. This iOS fix concerns iOS's own job map and has no Android counterpart in that PR.

Lifecycle controls (local test hook, not pushed)

These are holds, logging, synthetic wallet-record and settings edits, and an outage control that changes only the VSS endpoint. The test xpub is redacted.

diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 5f028be..7d2c36a 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -83,6 +83,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
             }
         }
 
+        #if DEBUG
+        ReviewBackupControl.start() // test hook
+        #endif
         return true
     }
 
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index d810c60..dab9eab 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -163,7 +163,18 @@ class BackupService {
         backupData: ((BackupCategory) async throws -> Data)? = nil,
         usdtRecoveryBackup: UsdtRecoveryBackupStore = UsdtRecoveryBackupStore(),
         uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in
+            #if DEBUG
+            await ReviewBackupControl.hold("upload-\(key)") // test hook
+            if key == "SETTINGS", let payload = try? SettingsBackupV1.decode(from: data) { // test hook
+                ReviewBackupControl.log("upload-settings swipe=\(payload.settings["enableSwipeToHideBalance"] ?? "missing")") // test hook
+            } // test hook
+            ReviewBackupControl.log("upload-start \(key)") // test hook
+            #endif
             _ = try await VssBackupClient.shared.putObject(key: key, data: data)
+            #if DEBUG
+            ReviewBackupControl.log("upload-ack \(key)") // test hook
+            await ReviewBackupControl.hold("return-\(key)") // test hook
+            #endif
         },
         waitForBackupDelay: @escaping () async throws -> Void = {
             try await Task.sleep(nanoseconds: 5_000_000_000)
@@ -311,6 +322,7 @@ class BackupService {
         let stopping = Task {
             await previous?.value
             isObserving = false
+            ReviewBackupControl.log("stop-scheduling-disabled") // test hook
             statusListeners.removeAll()
             periodicCheckTask?.cancel()
             periodicCheckTask = nil
@@ -388,6 +400,7 @@ class BackupService {
     }
 
     private func runBackup(category: BackupCategory, isScheduled: Bool) async -> BackupOutcome {
+        ReviewBackupControl.log("capture-start \(category.rawValue)") // test hook
         Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService")
         var outcome = BackupOutcome.failed
         var requiredAtStart: UInt64 = 0
@@ -447,6 +460,7 @@ class BackupService {
                 )
             }
         } catch {
+            ReviewBackupControl.log("backup-error \(category.rawValue) \(error)") // test hook
             updateBackupStatus(category: category) { status in
                 BackupItemStatus(synced: status.synced, required: status.required, running: false)
             }
@@ -465,6 +479,7 @@ class BackupService {
             }
         }
         _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) }
+        ReviewBackupControl.log("backup-done \(category.rawValue) result=\(outcome)") // test hook
         return outcome
     }
 
@@ -1038,6 +1053,7 @@ class BackupService {
         while !Task.isCancelled {
             do {
                 if outcome == .temporarilyUnavailable {
+                    ReviewBackupControl.log("retry \(category.rawValue) delay=\(retryDelay)") // test hook
                     try await waitForTemporaryFailureDelay(retryDelay)
                     retryDelay = min(retryDelay * 2, 300_000_000_000)
                 } else {
@@ -1054,6 +1070,7 @@ class BackupService {
                 break
             }
         }
+        await ReviewBackupControl.hold("cleanup") // test hook
         _ = try? await ServiceQueue.background(.backup) {
             self.backupJobs.removeValue(forKey: category)
             self.updateBackupStatus(category: category) { status in
@@ -1219,6 +1236,7 @@ class BackupService {
 
         do {
             let data = try JSONEncoder().encode(encoded)
+            ReviewBackupControl.countWrite() // test hook
             defaults.set(data, forKey: backupStatusesKey)
 
             return true
diff --git a/Bitkit/Services/ReviewBackupControl.swift b/Bitkit/Services/ReviewBackupControl.swift
new file mode 100644
index 0000000..f954984
--- /dev/null
+++ b/Bitkit/Services/ReviewBackupControl.swift
@@ -0,0 +1,117 @@
+#if DEBUG // test hook
+import Foundation // test hook
+import BitkitCore // test hook
+import LDKNode // test hook
+import UIKit // test hook
+
+enum ReviewBackupControl { // test hook
+    static let enabled = ProcessInfo.processInfo.environment["REVIEW_BACKUP_CONTROL"] == "1" // test hook
+    static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("review-backup") // test hook
+    static let lock = NSLock() // test hook
+    static var writes = 0 // test hook
+    @MainActor static var manualTask: Task<Void, Never>? // test hook
+    @MainActor static var display: UILabel? // test hook
+    static func countWrite() { if enabled { lock.withLock { writes += 1 } } } // test hook
+
+    static func log(_ message: String) { // test hook
+        guard enabled else { return } // test hook
+        lock.withLock { // test hook
+            try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // test hook
+            let file = directory.appendingPathComponent("events.log") // test hook
+            var data = (try? Data(contentsOf: file)) ?? Data() // test hook
+            data.append(Data("\(Date().timeIntervalSince1970) \(message)\n".utf8)) // test hook
+            try? data.write(to: file) // test hook
+        } // test hook
+    } // test hook
+
+    static func hold(_ name: String) async { // test hook
+        guard enabled else { return } // test hook
+        let file = directory.appendingPathComponent("hold-\(name)") // test hook
+        guard FileManager.default.fileExists(atPath: file.path) else { return } // test hook
+        log("held \(name)") // test hook
+        while FileManager.default.fileExists(atPath: file.path) { // test hook
+            await Task.detached { try? await Task.sleep(nanoseconds: 100_000_000) }.value // test hook
+        } // test hook
+        log("released \(name)") // test hook
+    } // test hook
+
+    @MainActor static func start() { // test hook
+        guard enabled else { return } // test hook
+        log("control-ready head=4e6c06e") // test hook
+        Task { // test hook
+            let end = Date().addingTimeInterval(7200) // test hook
+            while Date() < end { // test hook
+                if ProcessInfo.processInfo.environment["REVIEW_NO_OVERLAY"] != "1", display == nil, let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = scene.windows.first(where: { $0.isKeyWindow }) { // test hook
+                    let label = UILabel(frame: CGRect(x: 12, y: 110, width: window.bounds.width - 24, height: 240)) // test hook
+                    label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) // test hook
+                    label.textColor = .white; label.backgroundColor = UIColor.black.withAlphaComponent(0.9) // test hook
+                    label.numberOfLines = 0; label.lineBreakMode = .byCharWrapping; label.isUserInteractionEnabled = false // test hook
+                    window.addSubview(label); display = label // test hook
+                } // test hook
+                let recent = (try? String(contentsOf: directory.appendingPathComponent("events.log"), encoding: .utf8)) ?? "" // test hook
+                display?.text = "4e6c06e · local review controls\n" + recent.split(separator: "\n").suffix(8).joined(separator: "\n") // test hook
+                let file = directory.appendingPathComponent("command") // test hook
+                if let command = try? String(contentsOf: file, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) { // test hook
+                    try? FileManager.default.removeItem(at: file) // test hook
+                    let service = BackupService.shared // test hook
+                    log("command \(command)") // test hook
+                    switch command { // test hook
+                    case "stop": Task { // test hook
+                        let before = Date() // test hook
+                        await service.stopObservingBackups() // test hook
+                        log("stop-done elapsed=\(Date().timeIntervalSince(before))") // test hook
+                    } // test hook
+                    case "start": service.startObservingBackups() // test hook
+                    case "wallet": // test hook
+                        do { // test hook
+                            var records = try WatchOnlyAccountStore.load() // test hook
+                            if records.isEmpty { // test hook
+                                records.append(WatchOnlyAccountRecord(id: UUID(), walletIndex: 0, accountIndex: 42, addressType: LDKNode.AddressType.nativeSegwit.stringValue, xpub: "tpub…redacted", requestFingerprint: "review", createdAt: 1000, name: "review", isTrackingEnabled: false, setupState: .pendingDelivery)) // test hook
+                            } // test hook
+                            records[0].name = "review-\(Int(Date().timeIntervalSince1970))" // test hook
+                            try WatchOnlyAccountStore.save(records); log("wallet-record-written name=\(records[0].name)") // test hook
+                        } // test hook
+                        catch { log("wallet-record-error") } // test hook
+                    case "settings": SettingsViewModel.shared.swipeBalanceToHide.toggle() // test hook
+                    case "manual": manualTask = Task { // test hook
+                        let result = await service.triggerBackup(category: .metadata) // test hook
+                        log("manual-done result=\(result)") // test hook
+                    } // test hook
+                    case "cancel-manual": manualTask?.cancel(); log("manual-cancelled") // test hook
+                    case "usdt": service.markBackupRequired(category: .usdt) // test hook
+                    case "vss-fault", "vss-normal": Task { // test hook
+                        let route = directory.appendingPathComponent("vss-fault") // test hook
+                        if command == "vss-fault" { try? Data().write(to: route) } // test hook
+                        else { try? FileManager.default.removeItem(at: route) } // test hook
+                        await VssBackupClient.shared.reset() // test hook
+                        log("route-ready \(command)") // test hook
+                    } // test hook
+                    case "pending-wallet": service.markBackupRequired(category: .wallet) // test hook
+                    case "status": // test hook
+                        log("writes=\(lock.withLock { writes })") // test hook
+                        for category in BackupCategory.allCases { // test hook
+                            let s = service.getBackupStatus(category: category) // test hook
+                            log("status \(category.rawValue) synced=\(s.synced) required=\(s.required) running=\(s.running)") // test hook
+                        } // test hook
+                    case "verify": Task { // test hook
+                        do { // test hook
+                            let settings = try await VssBackupClient.shared.getObject(key: "SETTINGS") // test hook
+                            let payload = try SettingsBackupV1.decode(from: settings!.value) // test hook
+                            let remote = payload.settings["enableSwipeToHideBalance"] as? Bool // test hook
+                            log("verify-settings matches=\(remote == SettingsViewModel.shared.swipeBalanceToHide) value=\(String(describing: remote))") // test hook
+                            let wallet = try await VssBackupClient.shared.getObject(key: "WALLET") // test hook
+                            let data = try JSONDecoder().decode(WalletBackupV1.self, from: wallet!.value) // test hook
+                            log("verify-wallet matches=\(data.watchOnlyAccounts == (try WatchOnlyAccountStore.load()))") // test hook
+                        } catch { log("verify-error") } // test hook
+                    } // test hook
+                    case "end": log("control-ended"); return // test hook
+                    default: break // test hook
+                    } // test hook
+                } // test hook
+                try? await Task.sleep(nanoseconds: 100_000_000) // test hook
+            } // test hook
+            log("control-timeout") // test hook
+        } // test hook
+    } // test hook
+} // test hook
+#endif // test hook
diff --git a/Bitkit/Services/UsdtRecoveryBackupStore.swift b/Bitkit/Services/UsdtRecoveryBackupStore.swift
index f804d07..8fb9901 100644
--- a/Bitkit/Services/UsdtRecoveryBackupStore.swift
+++ b/Bitkit/Services/UsdtRecoveryBackupStore.swift
@@ -27,7 +27,10 @@ struct UsdtRecoveryBackupStore {
         let encoder = JSONEncoder()
         encoder.outputFormatting = [.sortedKeys]
         let data = try encoder.encode(UsdtRecoveryBackupV1(version: 1, usdtWallet: snapshot, paykitUsdtState: paymentState))
+        await ReviewBackupControl.hold("upload-USDT_RECOVERY") // test hook
+        ReviewBackupControl.log("usdt-recovery-put") // test hook
         try await put(Self.key(for: data), data)
+        ReviewBackupControl.log("usdt-recovery-ack") // test hook
     }
 
     func snapshots() async throws -> [UsdtRecoveryBackupV1] {
diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift
index 3c7995d..047ce89 100644
--- a/Bitkit/Services/VssBackupClient.swift
+++ b/Bitkit/Services/VssBackupClient.swift
@@ -77,7 +77,8 @@ class VssBackupClient {
         let passphraseRaw = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex))
         let passphrase = passphraseRaw?.isEmpty == true ? nil : passphraseRaw
         let storeId = try await VssStoreIdProvider.shared.getVssStoreId(walletIndex: walletIndex)
-        return (Env.vssServerUrl, storeId, mnemonic, passphrase, lnurlAuthServerUrl)
+        let fault = ReviewBackupControl.enabled && FileManager.default.fileExists(atPath: ReviewBackupControl.directory.appendingPathComponent("vss-fault").path) // test hook
+        return (fault ? "http://127.0.0.1:1" : Env.vssServerUrl, storeId, mnemonic, passphrase, lnurlAuthServerUrl) // test hook
     }
 
     private func setup(walletIndex: Int = 0) async throws {

Reviewed by claude-opus-5-5-medium

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utAck

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve. The delta has no findings, and all four cases passed at this head.

Findings: None. 11fb65d retries a VssError.AuthError only when the LNURL-auth token request never reached the server. vss-client-ng passes header-provider errors through as AuthError(e.to_string()), and a reqwest send failure reads error performing external request: error sending request for url (<url>). Refusals, invalid challenges and body-decode errors keep other prefixes, so they still fail without a retry. runBackup is the only caller. The stricter URL check still accepts the production and staging /putObjects URLs. The new tests use their own UserDefaults suites.

QA for 11fb65d: I used an iPhone 17 Pro simulator (iOS 26.5, Xcode 27), a disposable new wallet and staging VSS. No funds or payments were involved. The unit case ran on the unmodified head. The device cases ran the same head with the local controls from our 4e6c06e review, plus a local relay in front of the staging LNURL-auth endpoint (diff below). The relay passes the staging challenge through unchanged, so signing and the callback still go to staging. The backup decisions and native transport were left unchanged.

Backup lifecycle and failure-classification unit tests ✅ All 54 tests passed with no skips, including the three new auth tests.

The run covered BackupServiceTests (with testReleasedVssAuthTransportIsTemporaryWithoutRetryingAuthorizationFailures, testReleasedVssAuthTransportRetriesScheduledSettingsWithoutObservation and testReleasedVssAuthorizationDenialStaysPendingWithoutAutomaticRetry), BackupForegroundRecoveryTests and BlocktankBackupTests. The counts come from the xcodebuild log.

backup-regressions.2x.mp4

The recording shows the test host only.

Backup retries after the backup sign-in server is briefly unreachable ✅ The scheduled SETTINGS backup retried after 10 s and completed once the auth server came back at the same URL.

I took the relay down and dropped the cached token, then changed a setting. The scheduled put failed with the new transport error, was classified as temporary and scheduled the 10 s retry. SETTINGS stayed required. I brought the relay back on the same port without resetting the client or restarting observation. The retry fetched a new token through the relay and uploaded, and decrypted VSS settings and wallet matched local state.

auth-outage.2x.mp4

The on-screen overlay is the event log of the local controls. I checked the outcomes in that log, the relay log and the VSS reads.

command settings
backup-error SETTINGS AuthError(errorDetails: "error performing external request: error sending request for url (http://127.0.0.1:18081/lnurl_auth/auth)")
backup-done SETTINGS result=temporarilyUnavailable
retry SETTINGS delay=10000000000
status SETTINGS synced=1793746155 required=1793746159 running=false
relay up GET /lnurl_auth/auth -> 200
backup-done SETTINGS result=completed
status SETTINGS synced=1793746184 required=1793746159 running=false
verify-settings matches=true value=Optional(true)
verify-wallet matches=true
A sign-in failure that is not a network error is not retried ✅ An invalid auth challenge failed once, was not retried within 25 s and left SETTINGS pending.

The relay answered with a body that is not an LNURL. The put failed with AuthError(errorDetails: "invalid data: invalid lnurl: not-an-lnurl") and returned failed. In 25 s there was one capture and no app retry. The relay saw repeated token requests only during the native client's own retries inside that one attempt. SETTINGS stayed required and not running. With the relay healthy again, the next settings edit uploaded and VSS matched.

auth-refusal.2x.mp4

I checked the outcomes in the event log and the VSS reads.

USDT recovery backup still retries after a VSS upload outage ✅ The putObjects connection failure is still temporary under the stricter URL check, and the 10 s retry recovered.

With only the VSS endpoint pointed at a closed port, the USDT recovery put failed with UnknownError(errorDetails: "error sending request for url (http://127.0.0.1:1/putObjects)") and returned temporarilyUnavailable. After I restored the endpoint, the scheduled retry completed with observation still stopped and no new request. USDT ended synced and idle.

usdt-outage.2x.mp4

The outcomes come from the event log.

The cleanup-hold, stop-drain and repeated-listener cases from our 4e6c06e review were not re-driven, because this commit changes only the failure classifier. I did not drive a real VSS server rejection: the "authorization was refused" response comes from the callback on the staging host, which the relay does not front. The permission denied unit test covers it.

Review scope: the 4e6c06e..11fb65d delta, checked against vss-client-ng's header-provider error mapping and vss-rust-client-ffi 0.5.26-rc1's lazy token fetch. No device case drove the new rejection branches of the URL check (invalid host characters, IPv6 brackets, port range, encodingInvalidCharacters: false). The new unit test covers them.

Twin: bitkit-android#1689 at e7fb0ac matches in behaviour. It strips one error performing external request: prefix, accepts any http(s) send-failure URL for AuthException, keeps /putObjects for UnknownException and adds matching tests. It parses with java.net.URI only, while iOS also rejects malformed hosts and ports explicitly.

Added local controls (on top of the 4e6c06e controls, not pushed)
--- a/Bitkit/Services/VssBackupClient.swift
+++ b/Bitkit/Services/VssBackupClient.swift
-        return (Env.vssServerUrl, storeId, mnemonic, passphrase, lnurlAuthServerUrl)
+        let fault = ReviewBackupControl.enabled && FileManager.default.fileExists(atPath: ReviewBackupControl.directory.appendingPathComponent("vss-fault").path) // test hook
+        let auth = ReviewBackupControl.enabled ? (ProcessInfo.processInfo.environment["REVIEW_AUTH_RELAY"] ?? lnurlAuthServerUrl) : lnurlAuthServerUrl // test hook
+        return (fault ? "http://127.0.0.1:1" : Env.vssServerUrl, storeId, mnemonic, passphrase, auth) // test hook
--- a/Bitkit/Services/ReviewBackupControl.swift
+++ b/Bitkit/Services/ReviewBackupControl.swift
+                    case "auth-reset": Task { // test hook
+                        await VssBackupClient.shared.reset() // test hook
+                        log("auth-reset-done") // test hook
+                    } // test hook

REVIEW_AUTH_RELAY=http://127.0.0.1:18081/lnurl_auth/auth. The relay is a small HTTP server. In up mode it forwards GET /lnurl_auth/auth to https://bitkit.stag0.blocktank.to and returns the reply unchanged. In bad mode it answers 200 not-an-lnurl. In down mode it is stopped, so the port refuses connections.


Reviewed by claude-opus-5-5-medium

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utAck

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants