Skip to content

fix: keep Paykit inbox observation together - #1070

Merged
ovitrif merged 29 commits into
masterfrom
fix/paykit-inbox-observation
Oct 30, 2026
Merged

ovitrif merged 29 commits into
masterfrom
fix/paykit-inbox-observation

Conversation

@ben-kaufman

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

Copy link
Copy Markdown
Contributor

This PR prevents unrelated background publishing from delaying the request list after an incoming message is received. Automatic message receipt and its fresh Payment Request read now run in the same queued operation.

Follows merged #1066 and targets master. Related to #868. No measured native latency improvement is claimed.

Description

  • Keep the receive and fresh-read pair together for automatic Background refreshes, after the existing pending-message drain when needed.
  • Keep automatic contact-related forced refreshes at Background priority so payment review can defer them while retaining fresh-read bookkeeping and later polling.
  • Preserve receive backup accounting and intake diagnostics when the later read fails or yields to a payment review, cancellation, identity change, or wallet wipe.
  • Keep the progress ring centered when an ordinary payment confirmation returns after app unlock.

Out of Scope

None.

Design

N/A — no design available.

Preview

Native recording pending; no layout changes.

QA Notes

Setup

Use the journey guide and Payment Request setup at 7cd096f4 with disposable regtest wallets and a saved, linked fixture issuer. This source uses Paykit 0.1.1-rc1 and Core 0.8.0-rc10. 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.

Journeys

  • J1: updated automatic-presentation.xml — foreground resume receives each request once and keeps the current sheet intact.
  • J2: updated review-refresh.xml — a second request arriving during review leaves the first unchanged and becomes available after cancellation and ordinary synchronization.

Manual Tests

  • 1. regression: delay a saved-contact callback until Payment Request confirmation is open → release it, cancel the unpaid review, and allow normal foreground polling → the original review stays intact and the latest request becomes available without an outgoing payment — controlled saved-contact callback timing not in Capabilities.
  • 2. regression: authorize an ordinary Amount → Confirm on-chain send, keep the original operation pending across background and app-unlock PIN, and verify the spinner stays inside its knob until completion; a second swipe must not request payment authorization — requires a controlled pre-broadcast hold outside standard journey Capabilities. The unfunded rendered regression below covers geometry; a funded native replay was not run here.

Automated Checks

  • updated PaykitSdkOperationLockTests.swift — verifies receive/read ordering, receive-only backup accounting, cancellation, review and wipe admission, and identity/runtime rejection.
  • updated PaykitPaymentRequestServiceTests.swift — preserves the prior pending drain, reports and synchronization time; checks both ordinary and forced Background deferral while explicit recovery remains Ordered.

Evidence

Final integration validation

Integration with master 4530ff7b at 7cd096f4 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. Final-head CI after publication: pending; the previous-head #1070 launch failure remains disclosed below.

Gaps

The exact MainActor-hop wipe boundary is rechecked in source after the awaited admission check; tests do not force that interleaving with a new hook. Earlier geo-response/hosted-view messages and one shared-keychain status were disclosed without claiming identical baseline reproduction. At preceding head e16d13bc, both CI attempts failed with an XCTest app-launch background-assertion timeout; attempt 2 passed all 2,765 unit tests but failed one UI launch case. The cause remains unresolved and is not proven to be infrastructure; a local unit pass does not close this CI gap.

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. Cases J1, J2, 1 remain 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 3f4e60097668b5ffb5106a71a6e6e74d201dd8ca 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 ac364f01ff1b6cee95c12c5d978c27aaa3f23f09 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 ac364f01ff1b6cee95c12c5d978c27aaa3f23f09, unit/UI run 40412986788 passed on attempt 1: 2,937 unit tests, zero failures/skips; 43 UI executions, 39 passed and four existing prerequisite skips, zero failures. SwiftFormat 40412986734 passed: 0/844 files require formatting, 84 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 29 inherited app-lock guard

Published d9566ad8 carries the four-file R1 correction from #1039 at aae380fc: a recreated confirmation uses the retained submission flag for its busy state and refuses another authorization while the original payment is in flight. This branch retains its existing Core 0.8.0-rc10, LDK 0.7.0-rc.77, Paykit 0.1.1-rc1 and VSS 0.5.23 pins, stacked base and prior history.

On this exact branch candidate, Xcode 27.1 / SDK 27.1 / iOS 26.5 passed the complete selected unit suite: 2,939 passed, 0 failed, 0 skipped, with the six existing unit-workflow integration exclusions. Both new confirmation regression tests passed. Full SwiftFormat 0.64.0 passed: 0/844 files require formatting; 84 existing exclusions. The shared private package copy was corrected before this branch's first test invocation; the sibling #1054's initial build-path failure remains retained. This branch's first full test run passed with unchanged source and dependency pins.

Complete diagnostics were inspected: two AppIntents metadata notices, six native SDK/Pubky/mainline DHT construction QoS warnings, and retained fault-fixture/host output. Forty-five emitter/test files were compared with this branch's pre-fix head; the warning-emitting methods in the two changed files are unchanged. No introduced actionable finding was found. This is source and retained-evidence comparison, not an exact-base runtime replay or warning-free claim. The identical production patch reuses the two independent R1 source reviews and guard negative control from #1039, with the older child callers inspected separately.

All 28 commits in the actual current-parent PR range verified cryptographically locally and report GitHub verified=true / reason=valid after the normal push. New-head hosted checks remain pending and validate their own recorded PR-merge inputs separately from this local older-pin branch.

Existing cases J1, J2 and manual 1 and their documented fixture/admission prerequisites remain unchecked on this new head. The external native R1 report on #1039 reports two successful controlled regtest executions at aae380f; it is attributed parent evidence, not a device run on this child. No new financial journey, binary adoption or final Android206/iOS215 latency/unlock result is claimed.

October 30 restored progress feedback

Fix commit: 85ef8d54.

The repeating animation now applies only to rotation. Navigation restoration can no longer repeatedly displace the indicator’s frame; existing loading predicates, opacity/fade, PIN checks and submission ownership are unchanged.

The same scene-backed, animation-enabled NavigationStack regression failed on the unchanged head and passed with this correction. Its captured initial and restored frames show the ring displaced above the knob before the fix and centered afterward.

Final local unit/UI checks and complete diagnostics: 2,950 distinct passes, 0 failures and 4 explicit fixture skips; configured launch variants expand this to 2,979 passing executions. The four skips are three disabled AI-only Trezor emulator cases and the missing local Arbitrum fork case. Full SwiftFormat 0.64.0 passed 0/844, with 84 existing exclusions. One combined independent correctness, codebase-fit, simplicity and test-quality review found no actionable issue. One rendering regression was added; no new hook, state, fixture or dependency was added. All 29 commits against current master 888fce91 verified cryptographically locally before the normal push, and GitHub reports verified=true / reason=valid for every commit afterward.

Complete diagnostics retain nine runtime QoS issues (ten emitted native checker events), newly observed framework layout and shared-keychain notices, and service/environment messages. Their source contexts and unchanged native emitters were inspected; no introduced actionable finding was established. These observations are not presented as warning-free or an exact-base runtime replay. The detailed test list omitted one passing record that the XCTest result and raw log retain; the counts above use the reconciled result.

Current-base hosted unit/UI, integration and E2E checks are running on merge 7b82c927, ordered parents master 888fce91 and head 85ef8d54, tree 16ade8d0. This composition inherits master’s Core rc11 / LDK rc78 / Paykit rc2 / VSS 26-rc1 pins; the local branch result above retains rc10 / rc77 / rc1 / VSS 0.5.23. Runtime acceptance and complete diagnostics on those hosted inputs remain pending. The matching E2E branch resolves to the already validated companion bitkit-e2e-tests#288 at 0affc996; the actual consumer revision will be checked.

This is unfunded hosted-window rendering evidence, not an app-PIN payment or broadcast run. Manual case 2 remains unrun here because the controlled dispatch fixture and financial authorization are unavailable; existing J1, J2 and manual case 1 prerequisites remain unchanged. No native timing, profile-creation latency or final Android206/iOS215 payment/unlock result is claimed.

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 21, 2026 02:05
@greptile-apps

greptile-apps Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] This PR appears safe to merge; no actionable issues were found.

Summary

Keeps automatic Background message receipt and its fresh Payment Request read in one queued operation, after any pending-message drain.

  • Automatic inbox refresh keeps message receipt and fresh state reading together.
  • Saved-contact request refreshes yield to active payment reviews.

No actionable issues found. Tests were inspected, not run.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Automatic Background refresh] --> B{Full refresh?}
    B -->|Yes| C[Drain pending messages]
    B -->|No| D[Acquire SDK operation lock]
    C --> D
    D --> E[Check admission and identity]
    E --> F[Receive messages]
    F --> G[Record backup changes and intake reports]
    G --> H{Still admitted?}
    H -->|Yes| I[Read fresh state under the same lock]
    H -->|No| J[Stop and allow later polling]
    I --> K[Release lock and update request list]
Loading

Reviews (1) · Last reviewed commit: "fix: keep Paykit inbox observation toget..." · Reviewed by Greptile

@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. Automatic Background inbox refreshes now run message receipt and the fresh observeState read in one queued SDK operation, so a queued background publication can no longer land between them; saved-contact forced refreshes drop to Background priority so a payment review can defer them. Reviewed b1d65a7, full tier, reasoned from the code and CI (iOS does not build on this reviewer's Linux host).

What I checked, and 6 candidates I ruled out

Read in full: PaykitSdkService.receiveAndObserveState, withSdk, both withStateRevisionTracking overloads, withBackupStateRevisionTracking, checkRefreshAdmission, PaykitSdkOperationLock (withLock, withoutLock, withWalletWipe, admit/validate), PaykitPaymentRequestService.synchronize, PaykitPaymentRequestManager.refresh (both) and performRefresh, AppScene.refreshIncomingPaykitPaymentRequests and the saved-contacts publisher.
Call sites traced: refresh(... refreshPriority:) from AppScene.swift:682 and :705; synchronize from performRefresh, where priority and messagePriority are always equal, so the combined path is taken exactly for non-forced Background refreshes with an active identity.
CI: SwiftFormat green; Run Tests still pending at review time.

Ruled out

  • Observation outside the lock: operationLock.withoutLock runs inside the withSdk closure, so the lock acquired for receive is still held; withoutLock only adds wipe admission (admit/validate), which is what makes the wipe case throw wallet_wipe_in_progress.
  • Backup version bumped by the read: withStateRevisionTracking(sdk:) wraps only receivePrivateMessagesFromLinkedPeers; the observation runs after the revision comparison, and a deferral raised from beforeOperation propagates before the do/catch that marks the backup changed.
  • Stale key between receive and observe: no other operation can take the lock in between, and the sdk ===, cachedPaykitKey public key and generation checks plus the post-read observation.keyGeneration check cover an identity error that cleared the cache.
  • Intake diagnostics or sync time lost when the read fails: onReceive runs logIntakeFailures and takes now() before the second admission check, matching the old order (receive, log, date, observe).
  • Saved-contact forced refresh lost during review: at Background it now defers, but forced refreshes keep the separate receive/observe path and ordinary polling picks the request up later; this is the stated intent and the journeys were updated to drop the immediate-arrival assumption.
  • Overlapping refresh drops the flag: the recursive upgrade in refresh(excludingProtectedRequestId:...) forwards combineReceiveObservation.

The MainActor-hop wipe boundary after the awaited admission check is not forced by a test, as the PR body says; the post-read validate in withoutLock covers it in source.

Merge confidence: 4/5, no findings, but Run Tests had not finished and the change could not be built here.

@ovitrif ovitrif added this to the 3.2.0 milestone Oct 21, 2026
@ovitrif

ovitrif commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

Milestone 3.2.0, matching its Android twin synonymdev/bitkit-android#1645: it is stacked on #1066 and its device measurements are still in progress, so it isn't a 3.1.0 fix. #868 stays on 3.1.0 for now; this PR refers to it rather than closing it.

@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

Findings: None.

QA for b1d65a7: Two iPhone 17 Pro simulators on iOS 26.5, disposable Bitcoin-regtest wallets and a local USDT fork. Recordings show payer left and requester right at 2×.

Test 1: automatic payment request presentation ✅ Cancellation, retry, sheet ownership and insufficient-balance handling passed.

The 21,000-sat request stayed pending after cancellation during preparation, then became ready in the same confirmation on explicit retry. Receive retained ownership while a 5,000-sat request arrived; closing Receive presented it. Both pending rows remained unique and unpaid. The drawer also retained ownership. An unaffordable request showed insufficient-balance feedback once automatically and again on explicit retry, without payment.

A debug gate controlled preparation timing; SDK records and responses were real. Foreground resume was checked in the lifecycle trace before the retained recording.

automatic-presentation.2x.mp4

Preparation cancellation, retry, presentation ownership and insufficient-balance feedback.

Test 2: payment review refresh deferral ✅ Review survived cancellation and app lock, the original paid once, and the USD review required fresh authorization.

On the pristine build, the 1,000-sat confirmation remained unchanged during a 30-second wait and a second, 2,000-sat request. Payment PIN stayed open for another 30 seconds. Cancelling PIN and review sent nothing; ordinary polling then presented the second request. Reopening the original and unlocking the app preserved its amount, prepared 143-sat fee and working details. Authorization completed one payment: the requester received 1,000 sats and recorded “Proof submitted”; the second request remained unpaid. Regtest block decoding confirmed the payment and fee.

The additional USD 1 request used fixed rates btc=0.00001 and usdt=1. Its 1,000-sat Bitcoin confirmation survived app lock, including backgrounding at payment PIN. Unlock returned to unpaid confirmation and required a new swipe and payment PIN. Cancelling left the balance and outgoing Activity unchanged. The issuer used a controlled rate; the payer remained pristine. The local USDT fixture recorded zero submissions.

Bitcoin review and payment USD review and app lock
review-refresh.2x.mp4
fixed-price-lock.2x.mp4

Both recordings retain the original amounts through app lock and authorization changes.

Test 3: delayed saved-contact callback during review ✅ Background admission deferred, the original review stayed intact, and polling recovered the new request.

A debug gate held the real saved-contact callback before its refresh. With the 21,000-sat confirmation open, the requester sent a 3,000-sat request. Releasing the callback preserved the original review; the admission trace confirmed Background deferral with the three warm requests retained. After cancelling review, ordinary polling presented the new request without restarting or sending a payment. SDK records and responses were real.

saved-contact-callback.2x.mp4

Original review retained during callback release, followed by ordinary polling recovery.

Test 4: pristine build and focused regressions ✅ The unmodified head built and both selected hosted suites passed.

I ran PaykitSdkOperationLockTests and PaykitPaymentRequestServiceTests. The XCTest result bundle confirms execution without failures or skips, including receive/read ordering, receive-only backup accounting, admission, cancellation, identity rejection and later refresh recovery.

focused-tests.2x.mp4

Hosted test execution at 2×; assertion outcomes were checked in the XCTest result bundle.

Full review: 6153563…b1d65a7, all nine changed files and reached callers. All planned cases passed. Wipe, identity, cancellation and backup interleavings have source and automated coverage; latency against the base remains unmeasured. Earlier source report: review.

Pair: bitkit-android#1645 fix: keep incoming request refreshes together at 8564ba2 matches receive/read composition and Background contact-refresh deferral in source.


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

@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 review of the complete PR diff against merge base 6153563f7f73b44ed1a5f99c8606a3fab89e2bf4, at b1d65a7f0787717f03cb59a8922610a017113ed1. No earlier assessment baseline was available.

No new actionable code findings.

Automatic Background inbox refreshes receive linked-peer messages and read fresh Payment Request state in one queued SDK operation, after the existing full-refresh pending-message drain. A queued background publication waits until that pair finishes. Saved-contact forced refreshes stay on the separate receive/read path at Background priority, so an open payment review defers them and a later foreground poll (10-second inbox interval) shows the request. Receipt still records backup changes and intake diagnostics when the following read is deferred, cancelled, rejected for identity or wipe, or fails. Review admission is the send sheet’s confirm, PIN, and hardware-sign routes.

The shared refresh contract matches bitkit-android#1645 at 8564ba2a1a96e4d1ac56c4b084143f619968d48a: combined automatic receive/read, Background contact-refresh deferral, and later polling. iOS still parses requests with the timestamp taken immediately after receipt.

Validation: Run Tests and SwiftFormat succeeded on this head. Those runs and the new lock and service tests were inspected; this review did not execute them. Device testing: not performed in this review.

Ready for device testing. Use the updated automatic-presentation.xml and review-refresh.xml journeys and the saved-contact callback manual case.

Suggested additional test cases

  • iOS, two linked regtest wallets, payer confirmation open for a 1,000 sat request. Send a 2,000 sat request, then background and app-lock the payer before that request is shown. Unlock. The restored confirmation still shows 1,000 sats. After ordinary foreground polling, the 2,000 sat request appears once and stays unpaid.

@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 at b1d65a7: no issues found. Reviewed this PR's own commit on top of #1066 (6153563). Reviewed as a Paykit sync change.

  • No deadlock: the SDK lock isn't reentrant, and nothing inside the combined receive+observe hold takes it again.
  • Stale-state checks: wipe, runtime and identity generations are checked before the lock, before receive and before observe. The key generation is checked against the observation, which is stricter than the old observe path.
  • Narrow scope: only non-forced Background refreshes use the combined path. Forced and explicit refreshes keep the separate calls.
  • Partial failure: if observe defers or fails after a successful receive, nothing is lost or shown twice. The refresh generation was already reset, so the next inbox poll still runs.
  • No payment path touched.
  • Parity: this side moves one forced refresh to Background (AppScene.swift:682). Android moves two, because only Android forces a refresh after endpoint refresh. See synonymdev/bitkit-android#1645.

Device gate (partial): b1d65a7 — not run yet. Pending:

  • automatic-presentation.xml and review-refresh.xml;
  • time how long an incoming request takes to appear while a background publication is queued;
  • re-check the resync time after closing review.

Device results will follow in a separate review.

@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 diff against merge base 5e801df, at e16d13bc, after the merge base changed since the saved assessment. No prior coverage was inherited.

No new actionable code findings.

Automatic Background inbox refreshes receive linked-peer messages and read fresh Payment Request state in one queued SDK operation, after the full-refresh pending drain. A queued background publication waits until that pair finishes. Saved-contact forced refreshes use Background priority on the separate receive/read path, so an open payment review on confirm, PIN, or hardware sign defers them. The 10-second foreground poll applies the request after review ends. A completed receive still records backup changes and intake diagnostics when the following read is deferred, cancelled, rejected for identity or wipe, or fails. The open confirmation keeps the original request context.

The shared refresh contract matches bitkit-android#1645 at 8564ba2: combined automatic receive/read and Background contact-refresh deferral. Android also defers a forced refresh after endpoint refresh. iOS leaves endpoint refresh on the existing poll schedule.

Validation: SwiftFormat passed on this head. Run Tests was still in progress. The new lock and service tests were inspected and not executed here. Device testing: not performed in this review. A simulator pass is attributed to b1d65a7 in review 5595146723. The feature files are unchanged by the merge onto the current base; this review did not rerun those journeys. Inbox latency against a queued publication was not measured.

Recommended before device testing: wait for the in-progress Run Tests workflow on this head.

jvsena42
jvsena42 previously approved these changes Oct 27, 2026

@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-reviewed the new commit 6dd46e4 (avoid inbox waits after preparation errors). No findings.

  • After a failed prepareForPayment, the .stored refresh only re-reads SDK state.
  • New messages still arrive on the 10 s foreground .full poll, so a request can be delayed by one poll at most, never dropped.
  • The refresh recursion runs at most once, and a concurrent .full poll still re-runs because its mode ranks higher.

Device gate: not needed for this delta.

@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: Follow-up at d9566ad8, covering the delta since ac364f01 (baseline review). This pass inspected the recreated confirmation's retained submission flag, its automatic-start and PIN-stage interaction, and the new confirmation tests. Inherited coverage remains the background inbox refresh, saved-contact deferral, receive backup accounting, preparation-failure refresh, refresh coalescing, the payment-request journeys and README limits, and the shared refresh contract in bitkit-android#1645 at bad5ae85.

No new actionable code findings.

A recreated confirmation keeps the swipe busy from the sheet's retained isSubmittingPayment value and leaves submitPayment before another PIN check or performPayment. App unlock during payment PIN entry still sees that flag clear, matching review-refresh.xml: the unpaid review must accept a new swipe and authorization. Request failure retry still closes the sheet and presents again.

SwiftFormat passed for this head. Unit tests were still running; this review did not execute them. The author reports a local unit pass that includes the two new confirmation tests. Device testing: not performed in this review. Earlier device results remain tied to their own revisions.

Recommended before device testing: wait for the in-progress unit-tests run on this head.

Suggested additional test cases

  • iOS, with payment PIN enabled, biometrics disabled, and a funded regtest wallet. Start a send that has already passed payment authorization and is still submitting. Background the app and complete app unlock. The confirmation stays busy, does not request payment authorization again, and the original payment completes once.

Reviewer model: grok-4.7
Reasoning effort: high

@ovitrif ovitrif modified the milestones: 3.1.0, 3.2.0 Oct 29, 2026
@ovitrif

ovitrif commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman Moving this PR to 3.2.0 per the 3.1.0 cut line (https://synonymworkspace.slack.com/archives/C07BJ7DNPCG/p1793307568629859): it sits on #1066, which is in 3.2.0.

@ovitrif ovitrif modified the milestones: 3.1.0, 3.2.0 Oct 29, 2026
Base automatically changed from fix/paykit-proof-refresh-display to master October 30, 2026 02:02
@ovitrif
ovitrif dismissed stale reviews from jvsena42, piotr-iohk, and themself October 30, 2026 02:02

The base branch was changed.

@ovitrif

ovitrif commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman Retargeted this PR to master: its base, #1066, merged as 888fce9.

@ovitrif
ovitrif self-requested a review October 30, 2026 02:02

@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 — loading feedback disappears after unlock during an ordinary send.

Findings:

  • LOW — Bitkit/Views/Wallets/Send/SendConfirmationView.swift:386: an ordinary Amount → Confirm payment restores a blank orange swipe knob after app unlock. The control stays blocked, but its spinner is missing and “Swipe To Pay” remains visible. This reproduced twice; duplicate authorization and the original payment's completion work.

QA for d9566ad: Xcode 27.0, iPhone 17 Pro simulators on iOS 26.5, disposable Bitcoin regtest wallets. App and payment PIN enabled; biometrics disabled. Videos play at 2×. Capture stutter remains after recording retries; the published copies retain all captured changed frames.

App unlock during an authorized payment ❌ The restored ordinary-send control loses its spinner; the second swipe is ignored and the original payment completes once.

I authorized a 1,000-sat ordinary on-chain send through Amount → Confirm. A debug timing gate held the real native operation after durable preparation and immediately before broadcast. The spinner was visible before backgrounding. After entering only app-unlock PIN, the same confirmation returned with a blank orange knob. The ring stayed absent throughout the hold, including after another swipe. No second payment authorization opened. Releasing the original operation reached Bitcoin Sent.

This reproduced in two executions with separate recipient addresses. Each added exactly one outgoing activity and one 1,000-sat recipient output with a 143-sat fee. Raw-transaction decoding and each recipient address's chain history independently verified those payments. The recording shows the second execution; its existing outgoing activity belongs to the first execution.

in-flight.2x.mp4

Visible spinner before lock, blank restored knob, ignored second swipe and original completion.

Dispatch timing test hook

Not pushed. Holds the original native send until an explicit file release. Authorization, submission ownership, the swipe rendering and native broadcast decisions are unchanged.

diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift
index eefaac4f..bd95a8df 100644
--- a/Bitkit/Services/OnchainSendAttemptService.swift
+++ b/Bitkit/Services/OnchainSendAttemptService.swift
@@ -666,6 +666,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-1070-release-dispatch") // test hook
+                let evidence = "txid: \(prepared.txid)\namount: \(prepared.recipientAmountSats)\naddress: \(address)\n" // test hook
+                try evidence.write(to: Env.appStorageUrl.appendingPathComponent("v4-1070-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-30 02:38:38.137 UTC] INFOℹ️: QA dispatch held after authorization and durable preparation
[2026-10-30 02:39:07.181 UTC] INFOℹ️: QA dispatch released
[2026-10-30 02:39:07.256 UTC] INFOℹ️: Onchain send result txid: 99dd4d4a5b06bfec62e408e71feb41e080059b1de20ba2ce9042bbcd3eb8d86b [SendConfirmationView.swift: performPayment(isAutomatic:) line: 1431]
Confirmation submission and app-lock regressions ✅ The signed unmodified head and focused regression suites passed, verified in the Xcode result bundles.

Ran confirmation lifecycle and swipe tests, payment-request service tests, and SDK operation-lock tests. The two new regressions pass: retained submission keeps the loading predicate true, and recreated automatic confirmation requests no additional PIN while preserving navigation and submission ownership. Existing tests cover idle authorization, request app-lock restoration, callback deferral, receive/read ordering, cancellation, identity and wipe admission. The video is the confirmation-only repeat; assertion results were checked in the result bundles. These hosted assertions do not establish the spinner's rendered visibility after native navigation.

confirmation.2x.mp4

Hosted confirmation regression execution; assertion evidence comes from the result bundle.

Check: retained journey coverage ✅ Earlier passing reports remain applicable to unchanged inbox and unpaid-review paths.

The J1, J2 and manual-case-1 report at 974be75 covers request intake, competing sheets, insufficient-balance retry, saved-contact callback deferral and unpaid Bitcoin review. Those paths are unchanged by this delta; unpaid review leaves the submission flag clear. The Amount and ordinary incoming-USDT app-lock report at 7a3cc5a and queued-publication ordering report at 7cd096f also remain applicable. These cases were not repeated.

The parent's incoming-request submission report at aae380f shows the spinner restored on its request-root route. The new device failure above is on this child's ordinary Amount → Confirm route.

Unit/UI CI and SwiftFormat passed. Separate E2E/migration jobs were skipped; they are not passing device evidence.

Coverage: contiguous ac364f0..d9566ad, all four changed files and reached submission, PIN, swipe, retained-presentation and completion callers. No planned case is unrun. Automatic subscription re-entry received hosted coverage; biometric, hardware-signing and funded-USDT flows were not separately driven. This run makes no latency comparison.

Twin: bitkit-android#1645 fix: keep incoming request refreshes together, merged at bad5ae8, matches combined receive/read and Background refresh deferral. This confirmation-recreation change is iOS-specific.


Reviewed by gpt-6.1-sol-high

Comment thread Bitkit/Views/Wallets/Send/SendConfirmationView.swift

@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

Findings: None. The previous spinner finding is fixed in this head.

QA for 85ef8d5: Xcode 27.0, iPhone 17 Pro simulators on iOS 26.5, disposable Bitcoin regtest wallets. App and payment PIN enabled; biometrics disabled. Videos play at 2×.

App unlock during an authorized ordinary payment ✅ The restored spinner stays centered and rotates; another swipe opens no authorization and the original payment completes once.

I authorized a 1,000-sat ordinary on-chain send through Amount → Confirm and held its native operation immediately before broadcast. After backgrounding and entering only app-unlock PIN, the restored confirmation kept its rotating spinner throughout the hold, including after another swipe. Releasing the original operation reached Bitcoin Sent. Payer Activity showed one send; the recipient showed one receipt. Raw-transaction decoding and the fresh recipient address's chain history independently verified one 1,000-sat output and a 143-sat fee.

in-flight-unlock.2x.mp4

Payment authorization, visible progress before and after app unlock, ignored second swipe and original completion.

Dispatch timing test hook

Not pushed. Holds the real native operation after authorization and durable preparation until explicit release. Spinner rendering, submission ownership and broadcast decisions are unchanged.

diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift
index eefaac4f..bd95a8df 100644
--- a/Bitkit/Services/OnchainSendAttemptService.swift
+++ b/Bitkit/Services/OnchainSendAttemptService.swift
@@ -666,6 +666,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-1070-release-dispatch") // test hook
+                let evidence = "txid: \(prepared.txid)\namount: \(prepared.recipientAmountSats)\naddress: \(address)\n" // test hook
+                try evidence.write(to: Env.appStorageUrl.appendingPathComponent("v4-1070-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
         }
Restored spinner and confirmation regressions ✅ The signed unmodified head and selected confirmation suites passed, verified in the Xcode result bundles.

The new rendering regression passed for initial navigation and a reconstructed confirmation. I inspected its retained screenshots: both show the spinner inside the orange knob. Existing assertions also passed for retained submission, rejected duplicate authorization, fee loading, manual coin selection and confirmation lifecycle. An unchanged repeat passed. The recording shows hosted execution; result bundles establish the assertions. Some capture stutter remains after the recording retry; the published copy retains every captured changed frame.

confirmation-regressions.2x.mp4

Hosted confirmation regression execution on the unmodified head.

Check: retained journey coverage ✅ Earlier passing evidence remains applicable to the unchanged inbox and unpaid-review paths.

These journeys were not repeated. This delta changes the shared spinner's animation; the rendering and native cases above check that change.

Changed-file SwiftFormat passed. Hosted unit/UI and integration were still running at the final check; hosted SwiftFormat passed.

Coverage: contiguous d9566ad..85ef8d5, all three changed files and reached spinner/swipe callers. No planned case is unrun. Other shared-spinner surfaces, biometric prompts, hardware signing and funded-USDT submission were not separately driven. No latency comparison was made.

Twin: bitkit-android#1645 fix: keep incoming request refreshes together, merged at bad5ae8, matches receive/read grouping and Background refresh deferral. This spinner correction is iOS-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

@ovitrif

ovitrif commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman Merging this PR: approved on head 85ef8d5 with a device run of its cases on that head, checks green and no open threads. It merges before the 3.1.0 cut, so it ships in 3.1.0.

@ovitrif
ovitrif merged commit b23f305 into master Oct 30, 2026
22 checks passed
@ovitrif
ovitrif deleted the fix/paykit-inbox-observation branch October 30, 2026 06:52
@ovitrif

ovitrif commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

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

@ovitrif

ovitrif commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Placing this PR in 3.1.0: it merged before the 3.1.0 cut, so it ships in 3.1.0.

@ovitrif ovitrif modified the milestones: 3.2.0, 3.1.0 Oct 30, 2026
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