Skip to content

fix: restore payment review after app unlock - #1039

Merged
ovitrif merged 18 commits into
masterfrom
fix/paykit-review-app-lock
Oct 29, 2026
Merged

ovitrif merged 18 commits into
masterfrom
fix/paykit-review-app-lock

Conversation

@ben-kaufman

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

Copy link
Copy Markdown
Contributor

This PR restores incoming Bitcoin payment review after app unlock and keeps an in-flight payment busy when the confirmation is recreated. Parent #1025 is merged; this PR now targets master.

Description

  • Retain the current Bitcoin confirmation even when the request originally opened on the amount/payment-method screen. Restore the confirmation route and reload fees and funding setup after unlock under the existing session and payment-context checks.

  • Keep amount selection available during synchronization, while confirmation and PIN routes retain their synchronization safeguards.

  • Preserve the current request, amount and terms while app lock temporarily hides Amount or another send step outside the retained Bitcoin confirmation flow. Actual dismissal still clears the request.

  • Clear dismissed reviews in either callback order. Preserve the existing handling of submitted payments, hardware signing, USDT and Pending recovery.

  • Keep the restored pay control busy during an in-flight payment and ignore another authorization entry while the original operation completes.

Out of Scope

  • Preserve fee speed and tags across app-lock restoration: #1059, tracked for 3.2.0 (review thread).

  • New USDT and hardware review restoration behavior beyond preserving the existing presentation, financial retries and authorization changes.

  • The proof-delivery and Pending recovery-prompt changes inherited from fix: deliver Paykit payment proofs promptly #1025.

Design

N/A — reuses the existing confirmation screen.

Preview

Updated device journey recording pending.

QA Notes

Setup

Use the journey guide and Payment Request setup at aae380fc with disposable regtest wallets and a saved, linked fixture issuer. The current source pins Paykit 0.1.1-rc2 and Core 0.8.0-rc11; resolve the committed Package.resolved without updating versions. Keep ordinary payment authorization enabled. The controlled transport/admission faults named below require additional fixture control; compatible issuer/homeserver access and staging merchant/Locks unlock must be established separately. Missing prerequisites leave the affected cases blocked.

For manual case 1, use the controlled post-prepare/pre-broadcast hold described in review R1, with an independently authorized disposable regtest fixture. No hold hook or funding permission is added by this PR; without that controlled fixture the native case remains blocked.

Journeys

  • J1: updated review-refresh.xml — open a request through amount/payment-method selection, choose Bitcoin, lock/unlock during confirmation, then cancel PIN and complete a later single authorized payment. The original request, ready control and fee review must remain available. Also open a fresh unpaid 1,000-sat request on Amount, lock/unlock, check the same amount and Savings wallet, continue to confirmation, then close it without paying and confirm the request remains pending.

Manual Tests

  • 1. Hold one already-authorized regtest payment after durable transaction preparation and before broadcast. Lock/background the payer, reopen it, and complete only app-unlock PIN. The restored confirmation must show the existing busy spinner; swiping must not open another payment PIN or biometric prompt. Release the original hold once and verify its normal result and exactly one outgoing payment/proof. Use only the independently authorized controlled fixture described in Setup.

Automated Checks

  • updated PaykitPaymentRequestServiceTests.swift — actual teardown/remount restores both direct-confirmation and amount→confirmation routes; dismissal works in either callback order. Session, context, sheet ownership and active-submission changes prevent retention; hardware and USDT exclusions remain. The added regression preserves Amount and a synthetic USDT-exclusion case through teardown/remount, then verifies real-dismissal cleanup and no acceptance; this is not a native USDT payment test.
  • updated SendConfirmationLifecycleTests.swift — retain required setup/synchronization checks, one successful coin preparation and cancellation of an earlier task when a second enters.

Evidence

October 29 in-flight submission fix at aae380fc

Addresses review R1: the retained submission flag now drives the swipe spinner and blocks another authorization entry while the original operation retains completion ownership. This fixes an existing UI condition exposed by reconstruction; the preceding reviewer cancelled the second PIN and observed one payment, with no duplicate debit.

Author validation on Xcode 27.1 / SDK 27.1 / iOS 26.5, with the committed dependency pins:

  • All-target build-for-testing passed: 19 targets.
  • Both focused suites passed: 26 tests, zero failures/skips, including retained manual busy state, recreated confirmation refusing another PIN, unchanged navigation/owner flag, and the existing idle authorization case.
  • Removing only the entry guard made the new hosted regression fail at both PIN-entry and navigation assertions. The source was restored byte for byte before the final full run. No payment was dispatched by this test.
  • Canonical BitkitTests, with the current four CI integration exclusions: 2,832 passed, zero failed, one skipped. UsdtNativeRecoveryIntegrationTests/testEncryptedRecoveryAgainstLocalProtocolFixture skipped because its separate loopback recovery fixture was unavailable; its source is unchanged from the pre-fix head and actual master. That coverage remains unverified.
  • Full SwiftFormat 0.64.0 passed: 0/822 files require formatting; git diff --check passed. No translation source changed.
  • Complete diagnostics inspected: five native SDK/Pubky/DHT-construction priority warnings, existing fault-fixture/hosted-appearance and platform output remain retained. Relevant emitter/caller sources were compared against the pre-fix head and actual master; the changed-file warning contexts are unchanged. The build also emits two App Intents metadata warnings. This is not a warning-free result or an exact-base runtime replay.
  • Independent source review covered correctness, authorization/concurrency, codebase fit and test quality. The signed fix preserves all earlier commits; all 18 local current-master-range signatures verify, and every GitHub PR commit reports verified=true / reason=valid.

Earlier local attempts stalled before test execution in asset compilation or simulator startup. Their failures were retained; the passing runs above followed Ben's explicitly approved shared Simulator-service restart. Hosted checks for this published head are monitored separately; no manual CI rerun was requested.

Remaining native acceptance: manual case 1 has not been run by this author on aae380fc and requires the authorized controlled transport fixture. Prior J1a/J1b and R1 recordings remain attributed to 90730855. No new payment, funded USDT, physical-hardware, field latency, merchant-unlock or final Android206/iOS215 measurement is claimed. Existing pending human requests and review opinions are preserved.

Current follow-up at 69b41810 — October 26

Fixes the app-lock state-loss finding. The current presentation now returns before setup cancellation and final cleanup when retained Bitcoin confirmation restoration does not apply. The suspension marker remains until remount or actual dismissal so existing session/context invalidation still works.

Validation on Xcode 26.1 / SDK 26.1 / iOS 26.5:

  • Exact fix: restore payment review after app unlock #1039: 19-target build, 30 focused tests and 2,900 canonical hosted unit tests passed, zero failures/skips.
  • Combined fix: keep Paykit inbox observation together #1070 + fix: avoid inbox waits after preparation errors #1081 + perf: search only loaded wallet accounts #1084 + perf: keep request creation ahead of refreshes #1085 tree 8e9a0341f5314e60777f874a6757760623ce10d3: 19-target build and 2,924 canonical hosted unit tests passed, zero failures/skips. These are two full suites, not seven independent runs. Both select BitkitTests with the exact six existing CI integration exclusions.
  • All seven affected heads passed full SwiftFormat 0.64: 0/843 files requiring formatting. No translation source changed.
  • Final negative control on the published production code reproduced the regression with 16 post-lock failure records; the corrected candidate passes. An initial test-readiness failure and a combined validation-copy composition failure were corrected and retained in the evidence.
  • Complete diagnostics inspected: two App Intents notices per build; 548 candidate / 533 combined broad-scan records, including four / six SDK-construction QoS warnings plus fixture and hosted-appearance output. Emitter/context sources were compared with the base/pre-fix composition. This is not a full-base runtime replay or warning-free result.
  • Independent final review found no actionable issue. The signed fix was propagated through six ordinary signed merge commits, preserving stacked bases and prior child changes. Full local ranges and every GitHub PR-range commit were verified cryptographically.

Current-head hosted CI is pending after publication. The new native journey steps remain unrun; no payment, physical-hardware, final latency or merchant-unlock result is claimed. The prior #1025 hardware-transfer and #1054 preparation/UI-launch CI incidents remain unresolved; this correction does not establish their causes or fixes. #1098 remains a separate unmerged dependency.

Historical integration validation at 0df5ebfb

Integration with master 4530ff7b at 0df5ebfb retains both USDT-warning cancellation and the payment-review callback. Bottom #1025: all-target build passed (19 targets), 378 focused tests passed, 0 failures/skips. One combined #1070 + #1081 + #1084 + #1085 composition, tree 8f1e72d3 from #1070 7cd096f4 plus #1081 8967dff8, #1084 b7561c08 and #1085 310e689a, covers the stack and those sibling changes: all-target build passed (19 targets) and 2,779 tests passed, 0 failures/skips using the six existing CI exclusions. These are not eight independent full-suite runs.

All eight heads passed SwiftFormat. Translation checks report 0 errors and 15 warnings, all matching master by key, language, file and occurrence count, including security__use_pin. Final toolchain/runtime and complete diagnostic disposition: Xcode 26.1 / iOS 27.0 simulator, serial tests; complete diagnostics inspected, with six SDK-construction QoS warnings and retained fixture/appearance messages matching baseline evidence; no introduced finding. CI was pending at that historical publication; see the current follow-up above for this head.

Gaps

The updated app-lock journey remains unexecuted. The diagnostic CI rerun passed at preceding head c1e06c20; it does not verify this new integration head.

The final Android 206 / iOS 215 comparison cohort was blocked before financial authorization, with no debit. It supplies no final payment-latency result or native benefit claim. Case J1 remains unchecked and unexecuted on this integrated head. Native USDT-warning, PIN and biometric flows have not been verified by this integration validation.

October 26 master integration

Published integration head c77f1179a0a0cc135e84f90c2dc6414f93e73222 incorporates actual master 2ce210252be8ae7ea130d2422a2fa704a191ef7d through ordinary signed merges, preserving the stacked base and prior child changes. The two parent conflict resolutions retain review-state ownership, root-safe subscription Back navigation, one outer USDT status task, prompt recipient-scoped proof reconciliation and the terminal-refresh exit. No local assertion/timeout weakening or new test suppression was introduced.

Validation: two 19-target builds and two canonical hosted unit suites passed: 2,910 on the parent and 2,937 on the combined tree, zero failures/skips with the exact six CI integration exclusions. This PR is covered by the combined stack/sibling tree f471e8a6 (2,937 units), including this branch; these are two suites, not eight independent runs. Full SwiftFormat0.64 passed on all eight final trees (0/844); translations had zero errors and 15 warnings matching execution on complete actual-master source. Independent frozen integration review found no actionable issue. Local environment: Xcode26.1/SDK26.1, iOS26.5 simulator.

Complete diagnostics retained: two App Intents notices per build; parent1,082/combined1,099 runtime scan records, including four/six SDK/Pubky/DHT QoS stacks, fault fixtures and hosted appearance/identity notices. Source comparisons are not exact-base runtime replay or warning-free evidence. New-head hosted CI follows publication. Master's #1130 retired Blocktank skips and E2E285 channel/migration omissions remain coverage gaps, not fixes for original #1109. Historical #1054 preparation/UI-launch causes remain unresolved. Existing QA case IDs and native gaps remain: no new payment, funded USDT, physical hardware, latency or merchant-unlock journey was run by this integration.

October 26 lifecycle integration and camera fixture

Published head 88f75ea4b66ef2e8b9b79f44e4892497502ab632 includes approved and merged #1098 through master b8b7f3d9902de5669c025bb839232c1d7e98d2df. The overlapping lifecycle test retains one owned switch task per current context, holds resumed preparation until confirmation is visible, and keeps the exact-one-success assertion, cancellation/context guards and retained presentation/app-lock behavior. Seven ordinary signed merges preserve stacked bases and prior child changes; #1084/#1085 also retain merged #1081 through #1070.

The existing unfunded USDT UI case now resets camera permission before launch, explicitly waits for the camera alert, allows it and waits for dismissal before manual entry. This adds six test-only lines and two assertions; no test, assertion or timeout was removed or weakened. Production camera behavior and the merged Receive blank-band checks are preserved.

Final validation passed two 19-target builds, 2,913 parent canonical units and 2,940 units on combined tree db653be56e228dec55f4e6c0fd57e42e7d8475c2, with zero failures/skips and the exact six CI integration exclusions. This branch is included in that combined composition. These are two suites, not seven independent runs. The combined unfunded UI case passed, including both Receive blank-band swipes and explicit camera handling, using runner-only loopback endpoint settings. Full SwiftFormat 0.64 passed on all seven final trees (0/844); translations are unchanged. Independent final review found no actionable issue. All 151 distinct commits across local current/prospective base ranges verified cryptographically; all 129 distinct commits across the final published GitHub ranges report verified=true/reason=valid.

Complete diagnostics remain retained: parent 1,107/combined 1,125 broad records, including four/six SDK/Pubky/DHT QoS stacks, App Intents notices, hosted appearance and fault-fixture messages. The combined shared-keychain -25300 publication diagnostic has unchanged emitter/test source; its exact runtime cause remains unestablished. Source comparison is not exact-base runtime replay or warning-free evidence.

The old Receive-dismissal failure in #1070 run 40394372963 did not reproduce in the controlled original-head run; that run passed and used XCTest's default camera interruption handler. The camera-fixture comparison passed with an identical 157-file production app bundle. Neither result establishes the Receive failure's cause or repair. Local Xcode 26.1/SDK 26.1/iOS 26.5 differs from hosted CI; the completed hosted results are recorded below. The daemon launch issue #1110, original hardware-transfer issue #1109 and earlier coverage gaps remain. No new payment, funded USDT, physical-hardware, final latency or merchant-unlock result is claimed. João, Piotr and Ovi remain requested; existing pending requests are preserved without duplicate notification.

October 26 hosted CI confirmation

For published head 88f75ea4b66ef2e8b9b79f44e4892497502ab632, unit/UI run 40412990424 passed on attempt 1: 2,916 unit tests, zero failures/skips; 43 UI executions, 39 passed and four existing prerequisite skips, zero failures. SwiftFormat 40412990399 passed: 0/844 files require formatting, 80 skipped. These workflows tested GitHub's PR merge commit using Xcode 16.4/SDK 18.5 on iOS 26.2.

Complete workflow diagnostics were inspected. The 571 duplicate-symbol records match the retained passing #1025 run 40376172874; workflow/cache-action sources match actual master. This is retained-log/source comparison, not an exact-base runtime replay or warning-free result. The remaining red aggregate contains cancelled duplicate jobs; the replacement unit/UI and formatting workflows passed.

No manual CI rerun was requested. Historical Receive-dismissal/daemon-launch causes and original hardware-transfer issue #1109 remain unresolved; existing skipped/deferred coverage and final Android 206/iOS 215 payment/unlock measurement remain incomplete. Existing human review requests are preserved.

October 27 E2E after retargeting to master

For unchanged head 88f75ea4b66ef2e8b9b79f44e4892497502ab632, the automatic retarget workflow dispatched E2E run 40447918061. It passed on attempt 1: 13 cases passed, one existing hardware-transfer case skipped, zero failures. The first test step passed; retry steps 2 and 3 were skipped. The app build used CI merge 86ee3bcb980dc0649d35dfd2859dd1fbdd40238e over master b666ccb183837d9b49cf01809dad41166bd04702, Xcode 26.2/SDK 26.2 and iPhone 17/iOS 26.2. E2E source was ec01c02ff611797c112e65b5c87c936d25720bac; the existing transfer-to-spending skip does not resolve #1109.

Complete logs and diagnostics were inspected: 68 stale-element warnings, five npm deprecations, Buffer/build/setup notices and four log-capture restarts (143/137/124/124). The 82 diagnostic records use the same message groups retained from the earlier passing E2E log, with two fewer stale-element warnings and no path-filter base-input warning; 11 additional App Intents informational notices are retained separately. E2E/retarget workflow, project and package-pin blobs match the exact head, CI merge and current master. This is retained-log/source comparison, not exact-base runtime replay or warning-free output; log-capture interruptions limit absence-of-error conclusions.

No manual rerun was requested. Existing native app-lock/PIN/biometric journeys, historical Receive/daemon/hardware-transfer causes and final payment/unlock measurements remain outside this result. João's effective changes request and all three pending human requests are preserved.

Models used

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

@ben-kaufman
ben-kaufman marked this pull request as ready for review October 20, 2026 15:24
@ovitrif ovitrif added this to the 3.1.0 milestone Oct 20, 2026
@ovitrif

ovitrif commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Triage: milestone 3.1.0, with #1025 it is stacked on. It closes no issue, so this PR carries the milestone itself. Not a release blocker.

@greptile-apps

greptile-apps Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change reaches the iOS incoming Bitcoin payment review and send-sheet lifecycle.

Fix restoration for Bitcoin reviews reached through the amount page before merging.

Findings

  1. P1 Some Bitcoin reviews stay broken ▶

Summary

Preserves matching unpaid Bitcoin reviews while app lock removes the send view, then reloads setup after unlock.

  • An unpaid Bitcoin payment review returns after app unlock.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Incoming Bitcoin request] --> B{Original route}
  B -->|confirm| C[Review]
  B -->|amount| D[Choose Bitcoin]
  D --> E[Review; original route stays amount]
  C --> F[App locks]
  F --> G[Retain matching unpaid review]
  G --> H[Unlock and reload setup]
  E --> I[App locks]
  I --> J[Retention skipped]
  J --> K[Payment state cleared; setup cannot restart]
Loading

Reviews (1) · Last reviewed commit: "chore: rename changelog fragment" · Reviewed by Greptile

Comment thread Bitkit/AppScene.swift Resolved

@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.

No findings. Keeps an unpaid Bitcoin payment-request review alive across app lock: the preparation is flagged on background, SendSheet.cleanup skips its reset while the review sits on confirm or PIN, and the remounted sheet re-runs setup; a dismissal in either callback order resets the retained state. Reviewed b93acf7 against fix/paykit-proof-latency, full tier, reasoned from the code and CI (iOS does not build on this reviewer's host).

What I checked, and 6 candidates I ruled out

Read in full: IncomingPaykitPaymentRequestPreparation and the background, presentation and invalidation paths in AppScene.swift; SendSheet .task(id:), cleanup, setUpSend, requestPinCheck; SendConfirmationView.submitPayment/performPayment; SheetViewModel.hideSheet and sendSheetItem; PaykitPaymentRequestManager.finishPayment, markPresentedIfPending, the approval set; both new tests
CI: SwiftFormat and Greptile green; Run Tests still pending at review time

Ruled out

  • Lock while on the PIN route leaves the old authorization waiting: the old pinCheckContinuations never resume, but performPayment (and with it PaykitPaymentActivity and the approval) only starts after PIN success, so nothing is submitted and the remounted sheet starts at confirm. That leak predates this PR.
  • Disappear-then-dismiss skips finishPayment: retention requires no submission, and finishPayment returns early for a request not in acceptedRequestIds; a retried accepted request stays in pendingRequests and paymentRequestForRetry already dropped its approval. Your test asserts the request is pending and re-presentable after either order.
  • Dismiss-then-disappear double reset: dismiss() clears the request, so ownsResolvedContext fails in cleanup and the normal reset runs on already-cleared state.
  • SwiftUI fires the send sheet's onDismiss when MainNavView leaves for AuthCheck, clearing the config before remount: the bug this fixes (a review stuck loading after unlock) shows the sheet config does survive the lock on device. Narrow: only the listed journey, still unexecuted on this head, would show it on a real presenter; the unit test swaps the host's root view instead.
  • setUpSend refuses the remount: markPresentedIfPending only needs the request in pendingRequests, which retention does not change.
  • Lock on fee-rate, tag or UTXO screens still leaves the review stuck: those routes are not retained and the remount guard returns before setup, but the behaviour is unchanged from the base branch and outside the routes this PR claims.

Merge confidence: 4/5, the remount after a real app lock is covered only by a host-swap test until the updated journey runs, and Run Tests was still pending.

@jvsena42

Copy link
Copy Markdown
Member

@ben-kaufman conflicts

@ben-kaufman
ben-kaufman force-pushed the fix/paykit-proof-latency branch from 4022aa7 to bc2fb60 Compare October 20, 2026 18:54
@ben-kaufman
ben-kaufman force-pushed the fix/paykit-review-app-lock branch from b93acf7 to e9b45c2 Compare October 20, 2026 19:29

@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 at e9b45c2.

Findings: None. I verified the amount-selection retention finding is fixed in source and on-device.

QA for e9b45c2: iOS 26.5 simulators, disposable identities and Bitcoin regtest funds. In the paired recordings, payer is left and requester right, aligned at 2×.

Test 1: direct Bitcoin review after unlock ✅ The original review resumed without reopening and completed one freshly authorized payment.

Confirmation and payment PIN each stayed open for over 30 seconds. PIN cancellation and dismissal sent nothing; a later 2,000-sat request arrived without restarting. After reopening the original and locking the app, app-unlock PIN restored its 1,000-sat confirmation, ready swipe and working details with a 143-sat fee. Fresh payment PIN reached success once. The requester showed Proof submitted, while the second request stayed pending. Bitcoin backend data confirmed the exact 1,000-sat output and 143-sat fee; closing success left one outgoing activity.

J1.2x.mp4
Test 2: amount selection and payment-PIN lock ✅ Both locks restored the unpaid Bitcoin review and required fresh payment authorization.

A requester-only test hook issued native SDK terms for 1 USD, accepting Bitcoin and USDT at fixed rates of 0.00001 BTC and 1 USDT. The payer ran the unchanged reviewed binary. Amount showed Savings and 1,000 sats; Continue opened confirmation. Lock/unlock from confirmation and from payment PIN each returned directly to that same Bitcoin review with a prepared 143-sat fee and ready swipe. Entering only app-unlock PIN sent nothing. A new swipe required a new payment PIN; cancelling and closing preserved the balance and the sole earlier outgoing payment. The issuer remained Waiting for payment, and the Bitcoin backend confirmed zero received at this request's recipient address. No USDT payment was made.

J2.2x.mp4
Test 3: hosted lifecycle regressions ✅ App-lock and confirmation lifecycle suites passed.

Verified from the Xcode result: teardown/remount from direct and amount-entry routes, both dismissal callback orders, session/context/sheet ownership and submission exclusions, plus confirmation setup and cancellation. The recording shows hosted test execution; assertions were checked in the test result.

A1.2x.mp4

Coverage: full bc2fb605..e9b45c2, all six changed files and reached callers. Unavailable-node synchronization and hardware/USDT retention exclusions have source or hosted-test coverage only. Inherited proof-delivery and recovery coverage remains in the parent's QA report.

Twin: synonymdev/bitkit-android#1591 fix: deliver completed Paykit proofs promptly shares the review/PIN deferral contract; Android keeps content beneath its lock overlay, so this view-remount fix is iOS-specific.

The full CI unit-test job was still running at completion; the focused local suites above passed.


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 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. No HIGH/MEDIUM. One LOW is inline; it can be fixed here or in a follow-up, your choice.

This fixes the master bug I posted as out-of-scope on #1025 (stuck spinner after a PIN background cycle). The confirmation is retained on app lock, and setup runs again on unlock.

Checked and clean, reviewed as an auth and payment change:

  • Retention scope: only an unsubmitted, non-hardware, non-USDT Bitcoin confirmation (or its payment PIN) owned by the same session, sheet, context and request.
  • No payment without re-confirmation: after unlock a payment needs a new swipe and a new payment PIN. The old view's PIN continuations are never resumed, and the app-unlock PIN authorizes nothing. A lock during an in-flight payment isn't retained.
  • Fresh terms: the amount is re-derived and the fee reloaded. Expired conversion terms return to Amount.
  • Session or ownership change while locked: invalidates and dismisses the request (isIncomingPaymentRequestPreparationInvalid).
  • Dismissal and the review hold: dismissal in either callback order resets exactly once. The review hold stays balanced across the re-host, with one token per sheet instance. It doesn't worsen my LOW on #1054.
  • Android parity: the bug can't happen there, because ContentView stays composed under AuthCheckView. synonymdev/bitkit-android#1591 review-refresh.xml lacks the new lock/unlock steps, so port them, or note the overlay difference in the README, to keep the spec diffable.

Device gate: no run by me — journeys: review-refresh.xml lock/unlock steps already driven at this head; perf: one setUpSend per unlock with a retained review; Figma: n/a; l10n: no UI text changed.
Skipped — already driven this round: payment-requests/review-refresh.xml lock/unlock, direct and amount → confirm entry (iOS, by @ovi-reviewer at e9b45c2, passed: #1039 (review))

The PR body lists SendConfirmationLifecycleTests.swift as updated, but only PaykitPaymentRequestServiceTests.swift changed.

@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 at 0df5ebf.

Findings: None in the delta. The bitkit-ios#1059 fix: retain fee speed and tags after app unlock remains deferred as agreed in the review thread.

QA for 0df5ebf: iOS 26.5 simulators, disposable identities and Bitcoin regtest funds. The payer ran the unmodified reviewed build. Recordings play at 2×; paired recordings show payer left and requester right.

Test 1: direct Bitcoin review after unlock ✅ The original review resumed directly and completed one freshly authorized payment.

Confirmation and payment PIN each stayed open for over 30 seconds. Cancelling PIN and dismissing sent nothing; a second 2,000-sat request arrived without restarting. After reopening the original and locking the app, app-unlock PIN restored its 1,000-sat confirmation with a ready swipe and working details showing a 143-sat fee. A fresh swipe and payment PIN reached success once. Closing success left one outgoing activity; the requester showed Proof submitted and the second request stayed pending. Bitcoin RPC independently confirmed the funding output stayed unspent before authorization, then the exact 1,000-sat output and 143-sat fee.

J1.2x.mp4

Direct review, cancellation, app unlock and the single authorized payment.

Test 2: amount selection and payment-PIN lock ✅ Both locks restored unpaid Bitcoin confirmation and required fresh payment authorization.

A requester-only fixture hook supplied native SDK terms for 1 USD, accepting Bitcoin and USDT with fixed rates of 0.00001 BTC and 1 USDT; those terms were checked in the hook, and the synchronized requester showed the 1 USD request. The unchanged payer entered Amount with Savings and 1,000 sats, then continued to Bitcoin confirmation. Lock/unlock from confirmation and from payment PIN each returned directly to the same unpaid review; restored details showed a prepared 143-sat fee. App-unlock PIN sent nothing. A new swipe required a new payment PIN; cancelling and closing retained 98,857 sats and the sole earlier outgoing activity. The requester remained Waiting for payment, and Bitcoin RPC confirmed zero received at this fixture-owned destination. No USDT payment was made.

J2.2x.mp4

Fixed-price amount entry and both lock boundaries.

Test 3: biometric app unlock and cancellation ✅ PIN fallback, manual Face ID retry and background interruption preserved the unpaid review.

With simulated Face ID enrolled, a failed match allowed Use PIN code fallback and restored the unpaid 2,000-sat review. Cancelling a later attempt exposed the app's Use Face ID button; a successful manual retry restored that review. Backgrounding another pending challenge and sending a match while backgrounded did not bypass app lock: returning showed a fresh challenge. A new match restored the same amount and a prepared 143-sat fee. Dismissal retained 98,857 sats and the sole earlier outgoing activity; Bitcoin RPC independently confirmed the entire remaining funding output stayed unspent.

J3.2x.mp4

Simulator Face ID failure, fallback, retry and interrupted app unlock.

Check 1: retained hosted regressions ✅ The previous passing app-lock and confirmation-lifecycle assertions remain applicable.

I retained the hosted results from the review at e9b45c2: their test bodies and Bitcoin retention/setup logic are unchanged. They cover both dismissal callback orders, session/context/sheet ownership, active submission, hardware/USDT exclusions and setup cancellation. The changed app-unlock authentication was driven above. These suites were not rerun here.

Coverage: contiguous e9b45c2..0df5ebf, including both integration merges and reached callers. Device coverage above exercises Bitcoin review and app authentication; imported USDT warning/routing, receive-tab, merchant-label and CI changes were inspected in code. Earlier inherited USDT routing and reconciliation evidence is in the parent's report at d702597; it does not prove the later warning integration. Hardware signing and unavailable-node synchronization were not driven in this pass.

Twin: synonymdev/bitkit-android#1591 fix: deliver completed Paykit proofs promptly shares the payment-review deferral contract. Android keeps content beneath its lock overlay; this review-remount fix is iOS-specific.

The full CI unit-test job remained pending at completion.


Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest

@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 at 83ae2ef.

Findings: None in the delta. Fee-speed and tag retention remains deferred to synonymdev/bitkit-ios#1059 fix: retain fee speed and tags after app unlock, as agreed in the review thread.

QA for 83ae2ef: iOS 26.5 simulators, built with Xcode 27.0, disposable identities and Bitcoin regtest funds. The payer ran the unmodified reviewed binary. Paired recordings play at 2×, with payer left and requester right, aligned by recording start time.

Test 1: J1 direct Bitcoin review after unlock ✅ The original review resumed directly and completed one freshly authorized payment.

Confirmation and payment PIN each stayed open for at least 30 seconds. Cancelling PIN and closing review sent nothing; a second 2,000-sat request arrived without restarting. App-unlock PIN restored the original 1,000-sat confirmation with working details and a prepared 143-sat fee. A new swipe required a new payment PIN and completed once. Closing success left one outgoing activity; the requester showed Proof submitted and the second request stayed pending. Bitcoin RPC independently confirmed the funding output stayed unspent before authorization, then the original input, exact 1,000-sat output and 143-sat fee.

J1.2x.mp4

Direct review, cancellation, app unlock and the original authorized payment.

Test 2: J1 amount selection and payment-PIN lock ✅ Both locks restored the same unpaid Bitcoin review and required fresh payment authorization.

A requester-only fixture hook issued a native SDK request for 1 USD, accepting Bitcoin and USDT at fixed rates of 0.00001 BTC and 1 USDT. I checked the decoded stored SDK terms in the diagnostic below; synchronized requester history showed the 1 USD request. The unchanged payer entered Amount with Savings and 1,000 sats. Locking from confirmation and from payment PIN each restored that Bitcoin review directly; details showed a prepared 143-sat fee. App-unlock PIN sent nothing. A fresh swipe required another payment PIN; cancellation and dismissal retained 98,857 sats and the sole earlier outgoing activity. The requester remained Waiting for payment. Bitcoin RPC confirmed zero received at the fixture destination and the entire remaining funding output unspent. No USDT payment was made.

J1b.2x.mp4

Fixed-price amount entry, both lock boundaries and cancellation without payment.

Requester fixture hook

Not pushed. The requester supplies fixed conversion terms its ordinary UI cannot issue, then logs decoded stored terms. The payer is unmodified.

diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift
index 888b44b5..9fb75d4f 100644
--- a/Bitkit/Services/PaykitPaymentRequestService.swift
+++ b/Bitkit/Services/PaykitPaymentRequestService.swift
@@ -149,6 +149,11 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
 
         guard record.state != .activeRecurring else { return .failure(.recurringRequest) }
         guard let terms = record.terms else { return .failure(.missingTerms) }
+        if CommandLine.arguments.contains("-QA_FIXED"), terms.amount.asset == "usd",
+           let conversion = terms.conversion, case let .fixed(rates) = conversion {
+            let qaRates = rates.map { "\($0.asset)=\($0.value)" }.joined(separator: ",")
+            Logger.info("QA decoded request=\(record.paymentRequestId) amount=\(terms.amount.value) asset=\(terms.amount.asset) endpoints=\(terms.acceptedPaymentEndpointIdentifiers.joined(separator: ",")) rates=\(qaRates)", context: "ReviewLockQA")
+        }
         guard terms.recurrence == nil else { return .failure(.recurringRequest) }
         let paymentDeadline: PaykitPreciseInstant?
         if let deadline = terms.paymentDeadline {
@@ -876,21 +881,25 @@ struct PaykitPaymentRequestService {
         let metadataText = String(decoding: metadataData, as: UTF8.self)
         let rates = try await PaykitRequestPricing.rates(requested: draft.amount.asset,
                                                          endpoints: acceptedPaymentEndpointIdentifiers, market: marketRate(), at: proposalDate)
+        let qaFixed = CommandLine.arguments.contains("-QA_FIXED")
+        let qaUsdt = CommandLine.arguments.first(where: { $0.hasPrefix("-QA_USDT=") })?.dropFirst(9).description ?? ""
+        let qaAddress = CommandLine.arguments.first(where: { $0.hasPrefix("-QA_ADDRESS=") })?.dropFirst(12).description ?? ""
         let terms = try Paykit.PaymentRequestTerms(
             amount: Paykit.PaymentRequestAmount(
-                value: draft.amount.value,
-                asset: draft.amount.asset.rawValue
+                value: qaFixed ? "1" : draft.amount.value,
+                asset: qaFixed ? "usd" : draft.amount.asset.rawValue
             ),
             paymentReference: Paykit.PaymentReference(text: "bitkit-\(UUID().uuidString)"),
             proposalExpiresAt: Self.timestamp(draft.expiresAt),
             recurrence: nil,
-            acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers,
-            paymentEndpoints: nil,
+            acceptedPaymentEndpointIdentifiers: qaFixed ? ["btc-regtest-p2wpkh", "usdt-arbitrum-address"] : acceptedPaymentEndpointIdentifiers,
+            paymentEndpoints: qaFixed ? ["btc-regtest-p2wpkh": "{\"value\":\"\(qaAddress)\"}", "usdt-arbitrum-address": "{\"value\":\"\(qaUsdt)\"}"] : nil,
             requiredAppId: "bitkit",
-            conversion: rates.isEmpty ? nil : .fixed(rates: rates),
+            conversion: qaFixed ? .fixed(rates: [.init(asset: "btc", value: "0.00001"), .init(asset: "usdt", value: "1")]) : (rates.isEmpty ? nil : .fixed(rates: rates)),
             paymentDeadline: .at(timestamp: Self.timestamp(draft.expiresAt)),
             metadata: Paykit.PrivateJsonObject(text: metadataText)
         )
+        if qaFixed { Logger.info("QA fixed proposal terms: \(terms)", context: "ReviewLockQA") }
         let record = try await sdk.proposePaymentRequest(
             counterparty: target.publicKey,
             terms: terms,
QA decoded request=456d6b5c-e029-4565-9718-11369461c29a amount=1 asset=usd endpoints=btc-regtest-p2wpkh,usdt-arbitrum-address rates=btc=0.00001,usdt=1
Check 1: retained biometric and hosted lifecycle coverage ✅ Earlier passing results still cover unchanged paths.

I retained biometric cancellation, PIN fallback, manual retry and background-interruption evidence from the review at 0df5ebf: the app-authentication implementation is unchanged. The hosted results at e9b45c2 retain coverage of both dismissal callback orders, session/context/sheet ownership, active submission, hardware/USDT exclusions and setup cancellation. Those test bodies and retention predicates are unchanged; they were not rerun here.

Check 2: inherited integration coverage ✅ Passing parent regressions apply to the identical imported implementation.

The parent report at 2ca5d02 supplies passing focused coverage for imported payment admission, allowance execution, backup, expiry and toast behavior. I inspected the imported allowance UI, expiry toasts and USDT amount layouts in source; these surfaces were not driven as separate device journeys. The changed manual payment path was exercised freshly in Test 1.

Coverage: contiguous 0df5ebf..83ae2ef, all 59 delta files and reached callers, plus the prior open review disposition. Device coverage here is the Bitcoin review and app-lock journey; retained checks above identify the other coverage used.

Twin: synonymdev/bitkit-android#1591 fix: deliver completed Paykit proofs promptly, checked at 766f4a8, matches the shared payment-review deferral contract. Android keeps content beneath its lock overlay; this view-remount fix is iOS-specific.

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 changed the base branch from fix/paykit-proof-latency to master October 27, 2026 04:06
@ovitrif
ovitrif dismissed their stale review October 27, 2026 04:06

The base branch was changed.

@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 pull request diff from merge-base b8b7f3d9 through 88f75ea4. The comparison base moved from 3b27520 to cc5f81fb, and there was no verified coverage baseline to inherit.

No new actionable code findings.

An unpaid Bitcoin confirmation, including one opened from amount selection, stays on the current send sheet across app lock and is set up again on unlock. Amount, USDT, and hardware screens keep that presentation until a real dismissal. Inline acceptance calls claimAndAcceptPaymentRequest, and a blocked peer is still rejected before send. The local proof record is removed after the SDK queues it; recipient delivery runs in the background and does not hold the submission lock. Restoring confirmation still resets fee speed and tags; that remains bitkit-ios#1059.

Validation: unit/UI run 40412990424 and SwiftFormat run 40412990399 passed at this head. E2E run 40447918061 reports success at this head and does not cover the app-lock journey. This review did not execute those workflows or J1. Paykit 0.1.1-rc1 Rust for claimAndAcceptPaymentRequest was not retrieved; the app wrapper and payment-request tests were inspected. Android was not compared at a pinned revision; the journey already records that iOS removes the send view on lock.

Device testing: not performed in this review.

Ready for device testing.

Reviewer model: grok-4.7
Reasoning effort: high

@ovitrif
ovitrif dismissed jvsena42’s stale review October 27, 2026 19:36

Dismissed per @ovitrif: this review was on an older head, its threads are resolved, and the PR has moved on. @jvsena42 is off until 1 Nov; the current head needs a fresh review from @ben-kaufman or @piotr-iohk.

@ovitrif
ovitrif requested review from piotr-iohk and removed request for jvsena42 October 27, 2026 19:36
@ben-kaufman
ben-kaufman requested a review from jvsena42 October 27, 2026 21:58
ovitrif
ovitrif previously approved these changes Oct 29, 2026

@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

@ben-kaufman
ben-kaufman dismissed ovitrif’s stale review October 29, 2026 02:10

The merge-base changed after approval.

@ovitrif

ovitrif commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman Updated this branch with master: the ruleset dismisses approvals on a branch behind its base, so the review approval could not stick. The new head needs its re-review before merge.

@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 — the requested in-flight lock check still exposes an active payment swipe after unlock.

Findings: No newly introduced defect confirmed in the delta. The failed QA case below is an existing UI condition: SendConfirmationView.swift and SwipeButton.swift are unchanged from this PR's base, e763422. I am not reporting it as a new regression or evidence of a duplicate debit.

QA for 9073085: iPhone 17 Pro simulator, iOS 26.5, staging identities and Bitcoin regtest. App PIN and payment PIN enabled; biometrics disabled. All planned cases ran.

Test 1: hosted lifecycle regressions (A1) ✅ The focused suite passed; assertions were checked in the Xcode result bundle.

Built the unmodified head and ran payment-request, send-sheet, confirmation, activity and on-chain admission regressions. This includes both app-lock dismissal orders, retained and excluded routes, ownership/session changes, submission exclusions, wallet-switch cancellation and recovered-success state.

The recording shows a short rerun of the three app-lock lifecycle tests; the result bundle, rather than the screen recording, establishes their assertions.

A1-lock.2x.mp4

App-lock regression subset, 2×.

Test 2: direct Bitcoin review and fresh authorization (J1a) ✅ Timed accessibility captures and chain decoding confirmed retained review, fresh payment authorization and one payment.

On the unmodified payer, the 1,000-sat confirmation and payment PIN each remained open for over 30 seconds. Cancelling and dismissing left no outgoing payment. A later 2,000-sat request arrived without restarting the app. Reopening the original request, backgrounding and entering app-unlock PIN restored its unpaid 1,000-sat confirmation and 143-sat fee. A fresh swipe and payment PIN completed it once; closing success retained the activity.

The verification clip shows the sole outgoing payment. Separate requester accessibility captures showed Proof submitted for that request and the later request still waiting. Independent raw-transaction decoding verified the original funding input, a 1,000-sat recipient output and 143-sat fee: 0b41005bd12e06233c1c6eee0a2087ac0b23eab5c443a0754581c7cc3eaa3878.

J1a-verification.2x.mp4

Subsequent activity and balance verification, 2×; the timed holds and unlock steps were checked with accessibility captures.

Test 3: fixed-USD Bitcoin review and payment-PIN lock (J1b) ✅ Accessibility captures confirmed both locks restored the same unpaid 1,000-sat review; recipient chain history remained empty.

The requester-only hook issued a real signed Paykit request for USD 1, accepting Bitcoin and USDT with fixed rates of BTC 0.00001/USD and USDT 1/USD. The payer ran the unmodified head. Choosing Savings showed 1,000 sats and a 143-sat fee. Locking at confirmation and again at payment PIN restored the same unpaid confirmation. App unlock sent nothing; a new swipe still required payment PIN. Cancelling and dismissing left the outgoing activity unchanged, and the quoted Bitcoin destination had zero balance and no transaction history.

The shorter verification recording repeats the confirmation lock and fresh-PIN boundary on the unmodified payer. The payment-PIN lock boundary was checked in the original recording and retained accessibility captures.

J1b-verification.2x.mp4

Fixed-quote confirmation, app unlock and fresh payment PIN, 2×.

Requester test hook

Not pushed. Only the requester used this hook; it supplies the fixed-price request shape and logs the stored terms. No USDT payment was attempted.

diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift
index 888b44b5..af0c9e71 100644
--- a/Bitkit/Services/PaykitPaymentRequestService.swift
+++ b/Bitkit/Services/PaykitPaymentRequestService.swift
@@ -149,6 +149,13 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
 
         guard record.state != .activeRecurring else { return .failure(.recurringRequest) }
         guard let terms = record.terms else { return .failure(.missingTerms) }
+        #if DEBUG || E2E_BUILD // test hook
+        if CommandLine.arguments.contains("-QA_FIXED"), terms.amount.asset == "usd",
+           let conversion = terms.conversion, case let .fixed(rates) = conversion {
+            let qaRates = rates.map { "\($0.asset)=\($0.value)" }.joined(separator: ",")
+            Logger.info("QA decoded request=\(record.paymentRequestId) amount=\(terms.amount.value) asset=\(terms.amount.asset) endpoints=\(terms.acceptedPaymentEndpointIdentifiers.joined(separator: ",")) rates=\(qaRates)", context: "ReviewLockQA")
+        }
+        #endif // test hook
         guard terms.recurrence == nil else { return .failure(.recurringRequest) }
         let paymentDeadline: PaykitPreciseInstant?
         if let deadline = terms.paymentDeadline {
@@ -876,21 +883,29 @@ struct PaykitPaymentRequestService {
         let metadataText = String(decoding: metadataData, as: UTF8.self)
         let rates = try await PaykitRequestPricing.rates(requested: draft.amount.asset,
                                                          endpoints: acceptedPaymentEndpointIdentifiers, market: marketRate(), at: proposalDate)
+        #if DEBUG || E2E_BUILD // test hook
+        let qaFixed = CommandLine.arguments.contains("-QA_FIXED") // test hook
+        #else // test hook
+        let qaFixed = false // test hook
+        #endif // test hook
+        let qaUsdt = CommandLine.arguments.first(where: { $0.hasPrefix("-QA_USDT=") })?.dropFirst(9).description ?? ""
+        let qaAddress = CommandLine.arguments.first(where: { $0.hasPrefix("-QA_ADDRESS=") })?.dropFirst(12).description ?? ""
         let terms = try Paykit.PaymentRequestTerms(
             amount: Paykit.PaymentRequestAmount(
-                value: draft.amount.value,
-                asset: draft.amount.asset.rawValue
+                value: qaFixed ? "1" : draft.amount.value,
+                asset: qaFixed ? "usd" : draft.amount.asset.rawValue
             ),
             paymentReference: Paykit.PaymentReference(text: "bitkit-\(UUID().uuidString)"),
             proposalExpiresAt: Self.timestamp(draft.expiresAt),
             recurrence: nil,
-            acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers,
-            paymentEndpoints: nil,
+            acceptedPaymentEndpointIdentifiers: qaFixed ? ["btc-regtest-p2wpkh", "usdt-arbitrum-address"] : acceptedPaymentEndpointIdentifiers,
+            paymentEndpoints: qaFixed ? ["btc-regtest-p2wpkh": "{\"value\":\"\(qaAddress)\"}", "usdt-arbitrum-address": "{\"value\":\"\(qaUsdt)\"}"] : nil,
             requiredAppId: "bitkit",
-            conversion: rates.isEmpty ? nil : .fixed(rates: rates),
+            conversion: qaFixed ? .fixed(rates: [.init(asset: "btc", value: "0.00001"), .init(asset: "usdt", value: "1")]) : (rates.isEmpty ? nil : .fixed(rates: rates)),
             paymentDeadline: .at(timestamp: Self.timestamp(draft.expiresAt)),
             metadata: Paykit.PrivateJsonObject(text: metadataText)
         )
+        if qaFixed { Logger.info("QA fixed proposal terms: \(terms)", context: "ReviewLockQA") }
         let record = try await sdk.proposePaymentRequest(
             counterparty: target.publicKey,
             terms: terms,
[2026-10-29 04:02:25.962 UTC] INFOℹ️: QA decoded request=628bb443-e517-4cd8-ac92-ae2191d3abac amount=1 asset=usd endpoints=btc-regtest-p2wpkh,usdt-arbitrum-address rates=btc=0.00001,usdt=1 - ReviewLockQA [PaykitPaymentRequestService.swift: parse(record:expectedRole:now:network:requiresActionableRequest:) line: 156]
Test 4: app lock after payment authorization (R1) ❌ The active swipe reopened payment PIN while the original send was held; logs and accessibility captures confirmed the prior authorization and app unlock.

This drives the requested authorized-send check. After the real payment PIN succeeded, a debug hook held the original on-chain operation after durable transaction preparation and immediately before broadcast. While held, I backgrounded the app, reopened it and entered only app-unlock PIN. The same request returned with an arrow and active Swipe To Pay; another swipe opened payment PIN again.

I cancelled that second PIN without authorizing another payment. Releasing the original operation completed successfully. The activity list gained exactly one outgoing payment for this case, and requester accessibility capture showed Proof submitted. Raw transaction decoding confirmed one 1,000-sat recipient output and a 143-sat fee: 1a3c9f5a1f7278071286bc7503906530cb4159b8b87ee9dfb3df0deb49e60a3e. No duplicate debit was observed.

The failed invariant is the active payment control while the original operation is running. Source comparison confirms the condition predates this PR: the manual swipe does not receive the retained isSubmittingPayment state, and submitPayment() does not check that flag before asking for PIN. The changed cleanup path preserves the pre-existing in-flight operation. There is no introduced diff line for an inline finding.

R1-verification.2x.mp4

After app unlock: active swipe, second PIN cancelled and the original send completes, 2×. Hook logs establish when the hold began and ended; accessibility captures establish the unlock boundary, and chain decoding and activity captures establish the one-payment result.

Dispatch timing test hook

Not pushed. Holds after real authorization and durable preparation, then releases through a test-owned file. It leaves request checks, financial guards and native broadcast intact.

--- a/Bitkit/Services/OnchainSendAttemptService.swift
+++ b/Bitkit/Services/OnchainSendAttemptService.swift
@@ -666,6 +666,17 @@
             throw OnchainSendAttemptError.preDispatch(preDispatchError)
         }
 
+        #if DEBUG // test hook
+            if CommandLine.arguments.contains("-QA_HOLD_DISPATCH") { // test hook
+                let gate = Env.appStorageUrl.appendingPathComponent("v4-1039-release-dispatch") // test hook
+                let evidence = "txid: \(prepared.txid)\namount: \(prepared.recipientAmountSats)\naddress: \(address)\nrequest: \(String(describing: requestId))\ninputs: \(prepared.inputs)\n" // test hook
+                try evidence.write(to: Env.appStorageUrl.appendingPathComponent("v4-1039-held.yaml"), atomically: true, encoding: .utf8) // test hook
+                Logger.info("QA dispatch held after authorization and durable preparation", context: "ReviewLockQA") // test hook
+                while !FileManager.default.fileExists(atPath: gate.path) { try await Task.sleep(for: .milliseconds(200)) } // test hook
+                Logger.info("QA dispatch released", context: "ReviewLockQA") // test hook
+            } // test hook
+        #endif // test hook
+
         if let winner = try winningResult(attemptId: attemptId) {
             return winner
         }
[2026-10-29 04:18:26.942 UTC] INFOℹ️: QA dispatch held after authorization and durable preparation - ReviewLockQA [OnchainSendAttemptService.swift: send(using:address:amountSats:satsPerVbyte:utxosToSpend:isMaxAmount:requestId:orderId:paymentIdentity:followupContext:transferContext:paymentDeadline:beforeBroadcastAttempt:onPreDispatchFailure:) line: 671]
[2026-10-29 04:19:18.566 UTC] INFOℹ️: QA dispatch released - ReviewLockQA [OnchainSendAttemptService.swift: send(using:address:amountSats:satsPerVbyte:utxosToSpend:isMaxAmount:requestId:orderId:paymentIdentity:followupContext:transferContext:paymentDeadline:beforeBroadcastAttempt:onPreDispatchFailure:) line: 673]
Check 1: retained evidence, review scope and twin

Reviewed the contiguous delta 69b4181..9073085 and prior feedback, including imported master changes and their reached payment, recovery, backup and Blocktank callers. Native journeys here cover payment-review restoration and authorized sending; imported USDT recovery, backup, allowance execution and Blocktank migration flows were inspected in source but did not receive separate device journeys.

Current-head CI reports unit, integration, E2E, formatting and translation checks successful; optional migration was skipped. No test in this run's plan remains unrun.


Reviewed by gpt-6.1-sol-high

@ben-kaufman
ben-kaufman requested a review from ovitrif October 29, 2026 06:01

@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 pull request diff from merge base e7634224 through 90730855. No coverage baseline was supplied after the comparison base changed, so nothing earlier was inherited. The verified merge base is the current base.

No new actionable code findings.

An unpaid Bitcoin confirmation, including one opened from amount selection, stays on the current send sheet across app lock. Unlock runs setup again only while the same session and payment context still own that sheet; a real dismissal still clears the request. Amount, USDT, and hardware steps keep the request, amount, and terms until dismissal. Restoring confirmation still resets the fee speed and clears tags, which remains deferred in bitkit-ios#1059 (thread). The active swipe after unlock during an authorized in-flight send is the existing recreated swipe control: submission state is local to that control, and this diff does not retain an in-flight payment as a new review. The latest device check cancelled the second PIN and recorded one payment.

This review inspected the app-lock tests and did not run them. On this revision, unit tests and SwiftFormat passed. Device testing: not performed. Android a56c75be draws the lock screen over the existing content, so it does not recreate the send sheet; the iOS journey already records that difference, and the Android review-refresh.xml at that revision does not include the iOS lock steps.

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 previously failing in-flight lock case now keeps the payment control busy and blocks another authorization.

Findings: None in 9073085..aae380f.

QA for aae380f: Xcode 27.0, iPhone 17 Pro simulator, iOS 26.5, disposable staging identities and Bitcoin regtest funds. App PIN and payment PIN enabled; biometrics disabled.

App unlock during an authorized payment (manual 1) ✅ The restored spinner ignored another swipe, and releasing the original send completed one payment and requester proof.

After real payment-PIN authorization, the disclosed debug hook held the native on-chain operation after durable transaction preparation and immediately before broadcast. Backgrounding and entering only app-unlock PIN restored the same 1,000-sat confirmation with its busy spinner. Dragging the swipe control did not open another PIN prompt. Releasing the hold once reached Bitcoin Sent.

This passed twice with separate requests. Each added exactly one outgoing activity and one 1,000-sat recipient output with a 143-sat fee; each recipient's chain history contained only its matching transaction. The requester showed Proof submitted for each request. The video is the shorter second execution; its existing outgoing activity belongs to the first execution. Activity snapshots and raw-transaction decoding independently verified both payments.

Payer Requester
R1-repeat-p2x.2x.mp4

Payer: authorization, app unlock, ignored second swipe and completion, 2×. Requester: proof for the recorded second request.

Dispatch timing test hook

Not pushed. Only the payer used this hook. It holds the real operation until an explicit release; payment validation, submission state, native broadcast and completion remain unchanged.

diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift
index f222a4d8..fc04320a 100644
--- a/Bitkit/Services/OnchainSendAttemptService.swift
+++ b/Bitkit/Services/OnchainSendAttemptService.swift
@@ -663,6 +663,17 @@ actor OnchainSendAttemptService {
             throw OnchainSendAttemptError.preDispatch(preDispatchError)
         }
 
+        #if DEBUG // test hook
+            if CommandLine.arguments.contains("-QA_HOLD_DISPATCH") { // test hook
+                let gate = Env.appStorageUrl.appendingPathComponent("v4-1039-release-dispatch") // test hook
+                let evidence = "txid: \(prepared.txid)\namount: \(prepared.recipientAmountSats)\naddress: \(address)\nrequest: \(String(describing: requestId))\ninputs: \(prepared.inputs)\n" // test hook
+                try evidence.write(to: Env.appStorageUrl.appendingPathComponent("v4-1039-held.yaml"), atomically: true, encoding: .utf8) // test hook
+                Logger.info("QA dispatch held after authorization and durable preparation", context: "ReviewLockQA") // test hook
+                while !FileManager.default.fileExists(atPath: gate.path) { try await Task.sleep(for: .milliseconds(200)) } // test hook
+                Logger.info("QA dispatch released", context: "ReviewLockQA") // test hook
+            } // test hook
+        #endif // test hook
+
         if let winner = try winningResult(attemptId: attemptId) {
             return winner
         }
[2026-10-29 08:45:36.238 UTC] INFOℹ️: QA dispatch held after authorization and durable preparation
[2026-10-29 08:45:51.686 UTC] INFOℹ️: QA dispatch released
Confirmation submission and app-lock regressions ✅ The focused suites passed on the unmodified head; assertions were verified in the Xcode result bundle.

Ran both confirmation lifecycle/swipe suites and the three request app-lock regressions. They cover retained busy state, recreated confirmation refusing another PIN, unchanged navigation and submission ownership, idle authorization, teardown/remount, both dismissal orders, and session/context/submission exclusions. The Amount/USDT-flag regression is a hosted exclusion check, not a funded USDT test.

A1-t2x.2x.mp4

Hosted regression execution, 2×; assertion evidence comes from the result bundle.

Check: retained journey evidence and scope ✅ Earlier passing reports cover the unchanged unpaid-review paths.

J1 direct and fixed-USD entry, confirmation/payment-PIN lock, cancellation, fresh authorization and single-payment evidence remain in the review at 9073085. Its failed authorized-send case is replaced by the passing current-head case above. The new guard leaves idle submission unchanged, also checked in the focused suite.

The Amount-screen report at 69b4181 still covers Savings/amount retention, continuation, unpaid dismissal and reopening: Amount, preparation and cleanup are unchanged in this delta. These retained device cases were not repeated. Fee-speed and tag retention remain deferred to bitkit-ios#1059 fix: retain fee speed and tags after app unlock.

Reviewed the contiguous 9073085..aae380f delta and reached swipe, authorization, retained-presentation and completion callers. Native testing covers the changed in-flight manual payment path; automatic subscription re-entry received hosted coverage. Biometric-specific authorization, hardware and USDT flows were not separately driven in this delta. No planned case remains unrun.

Twin: bitkit-android#1591 fix: deliver completed Paykit proofs promptly, checked at 2f0e03d, matches the shared intent. Android retains its loading state beneath the authentication overlay; iOS reconstructs the confirmation and now derives its busy state from the retained submission flag.


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: Full reassessment of the complete PR diff at aae380fc against verified base and merge base e7634224, as explicitly requested. No earlier coverage was inherited.

No new actionable code findings.

The retained submission flag now controls both the restored swipe's busy state and its authorization entry guard. The earlier amount-entry and nonretained-screen cleanup concerns are fixed in source. Restoring an unpaid confirmation still resets selected fee speed and tags; this remains tracked in #1059.

Validation: diff whitespace checks and changed-journey XML parsing passed locally. Native tests and SwiftFormat were not run locally because Swift/Xcode are unavailable. The CI unit workflow passed on merge 63700e5f, which combines this head with base 383491f2, rather than the supplied assessment base. Android comparison at a56c75be confirms that its content remains beneath the lock overlay; visual equivalence was not tested.

Device testing: not performed in this review. A separate current-head report records manual case 1 passing with a disclosed pre-broadcast timing hook; its unpaid-review cases rely on older revisions.

Suggested additional test cases

  • iOS: with app PIN enabled and a short-lived, unpaid Bitcoin request on confirmation, background until its deadline expires, then unlock. Expect dismissal or unavailable-request feedback, with no payable stale review and no outgoing payment.

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

@ovitrif

ovitrif commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman Merging this PR on the approvals of head aae380f, with ovi-reviewer's device run on that head, checks green and no open threads. The PR stacked on this branch is retargeted to master first.

@ovitrif
ovitrif merged commit 031625d into master Oct 29, 2026
22 checks passed
@ovitrif

ovitrif commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman Merged, as it was approved and green.

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.

Investigate hardware transfer E2E staying on progress after Continue

5 participants