Repository navigation
fix: defer passive proof display refreshes - #1066
ben-kaufman wants to merge 12 commits into
Conversation
c361c6b to
6153563
Compare
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: No actionable findings.
QA for 6153563: iPhone 17 Pro simulators on iOS 26.5 (payer) and 26.4 (requester), disposable Bitcoin-regtest wallets and staging Pubky.
Test 1: proof display admission and foreground recovery ✅ Background admission deferred during review, preserved warm rows, and foreground return recovered the display.
I emitted a local debug proof event and paused the actual AppScene observer after its idle check, before its display refresh. Opening a separate 1,000-sat Bitcoin confirmation and releasing the pause made real Background SDK admission defer. The trace confirmed that the observer returned false without cancellation, keeping one pending request and one history row.
The requester sent a second, 2,000-sat request while confirmation stayed open. After cancelling review and backgrounding/reopening the app, the retained observer completed a fresh read and cleared its token. Existing inbox refresh discovered the second request. The recording ends with both unpaid rows visible; no payment was authorized. SDK responses and request records were real; the hook supplied the event, pause and diagnostic lines.
proof-display.2x.mp4
Payer on the left, requester on the right, synchronized at 2×. Admission and row retention were checked in the SDK/observer trace.
Local test controls and deciding trace
Not pushed. These debug controls emit the proof event, hold the observer until an explicit release, and log the existing admission decision. They do not replace SDK results or request records.
trigger warm=1 history=1
held after idle before display refresh
released review=true
SDK admission deferred Background
observer refreshed=false warm=1 history=1 cancelled=false
...
observer refreshed=true warm=1 history=1 cancelled=false
token completed
The successful observer read occurred after foreground return; the second incoming request was then loaded by existing inbox refresh.
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index a0163e0b..6d880974 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -639,6 +639,18 @@ struct AppScene: View {
private var paykitEventContent: some View {
configuredContent
+ .task { // test hook
+ #if DEBUG
+ guard ProcessInfo.processInfo.arguments.contains("-proof-display-qa") else { return } // test hook
+ while !Task.isCancelled { // test hook
+ if ProofDisplayQAHook.consume("proof-trigger") { // test hook
+ ProofDisplayQAHook.log("trigger warm=\(paykitPaymentRequestManager.pendingRequests.count) history=\(paykitPaymentRequestManager.historyRequests.count)") // test hook
+ PaykitPaymentProofService.qaTriggerProofStateChange() // test hook
+ } // test hook
+ try? await Task.sleep(for: .milliseconds(250)) // test hook
+ } // test hook
+ #endif
+ }
.onChange(of: pubkyProfile.authState, initial: true) { _, authState in
receivedPaymentBackfillCache.invalidate()
if authState == .authenticated, let pk = pubkyProfile.publicKey {
@@ -701,11 +713,20 @@ struct AppScene: View {
try await PaykitPaymentActivity.shared.waitUntilIdle(includingReviews: true)
} catch { return }
guard !Task.isCancelled, request == pendingPaykitProofRefresh else { return }
+ #if DEBUG
+ await ProofDisplayQAHook.holdIfArmed() // test hook
+ #endif
let refreshed = await refreshIncomingPaykitPaymentRequests(
mode: .stored, forceFresh: true, refreshPriority: .background, automatic: false
)
+ #if DEBUG
+ ProofDisplayQAHook.log("observer refreshed=\(refreshed) warm=\(paykitPaymentRequestManager.pendingRequests.count) history=\(paykitPaymentRequestManager.historyRequests.count) cancelled=\(Task.isCancelled)") // test hook
+ #endif
guard !Task.isCancelled, refreshed else { return }
paykitProofRefresh.complete(request)
+ #if DEBUG
+ ProofDisplayQAHook.log("token completed") // test hook
+ #endif
}
.onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in
Task { @MainActor in
@@ -2091,3 +2112,33 @@ struct AppScene: View {
)
}
}
+
+#if DEBUG
+@MainActor
+final class ProofDisplayQAHook { // test hook
+ static var enabled: Bool { ProcessInfo.processInfo.arguments.contains("-proof-display-qa") } // test hook
+ static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] // test hook
+ static func consume(_ name: String) -> Bool { // test hook
+ let path = directory.appendingPathComponent(name) // test hook
+ guard enabled, FileManager.default.fileExists(atPath: path.path) else { return false } // test hook
+ try? FileManager.default.removeItem(at: path) // test hook
+ return true // test hook
+ } // test hook
+ static func log(_ text: String) { // test hook
+ guard enabled else { return } // test hook
+ let url = directory.appendingPathComponent("proof-qa.log") // test hook
+ let data = Data(("\(Date().timeIntervalSince1970) \(text)\n").utf8) // test hook
+ if let handle = try? FileHandle(forWritingTo: url) { // test hook
+ _ = try? handle.seekToEnd(); try? handle.write(contentsOf: data); try? handle.close() // test hook
+ } else { try? data.write(to: url) } // test hook
+ } // test hook
+ static func holdIfArmed() async { // test hook
+ guard consume("proof-arm") else { return } // test hook
+ log("held after idle before display refresh") // test hook
+ while !Task.isCancelled, !consume("proof-release") { // test hook
+ try? await Task.sleep(for: .milliseconds(100)) // test hook
+ } // test hook
+ log("released review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ } // test hook
+} // test hook
+#endif
diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift
index d86f7905..b047d1ee 100644
--- a/Bitkit/Services/PaykitPaymentProofService.swift
+++ b/Bitkit/Services/PaykitPaymentProofService.swift
@@ -234,6 +234,10 @@ actor PaykitPaymentProofService {
private static let proofStateChangedSubject = PassthroughSubject<Void, Never>()
private static let onchainPaymentResolutionSubject = CurrentValueSubject<PaykitOnchainPaymentResolution?, Never>(nil)
+ #if DEBUG
+ static func qaTriggerProofStateChange() { proofStateChangedSubject.send() } // test hook
+ #endif
+
nonisolated static var proofStateChangedPublisher: AnyPublisher<Void, Never> {
proofStateChangedSubject.eraseToAnyPublisher()
}
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 1fd0a3b4..ee21b12f 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1545,6 +1545,9 @@ actor PaykitSdkService {
private func checkRefreshAdmission(_ priority: PaykitSdkOperationLock.Priority) async throws {
if priority == .background, await PaykitPaymentActivity.shared.shouldDeferRefresh {
+ #if DEBUG
+ await ProofDisplayQAHook.log("SDK admission deferred Background") // test hook
+ #endif
throw PaykitPaymentRequestError.operationInProgress
}
}Test 2: pristine build and affected hosted regressions ✅ The unmodified head built and its focused suites passed.
I ran PaykitPaymentRequestServiceTests, PaykitPaymentActivityTests, PaykitSdkOperationLockTests and PaykitSdkIdentityCheckTests. The XCTest result bundle confirms execution without failures or skips, covering forced Background reads after admitted snapshots, preserved warm rows, stale identity/cancellation, explicit Ordered recovery and review-scoped refresh tokens.
focused.2x.mp4
Simulator recording during hosted execution, at 2×; assertion outcomes were checked in the XCTest result bundle, not inferred from the recording.
Coverage: full 7ed5b69…6153563, all five changed files and reached lifecycle/admission callers. Concurrent snapshots, identity/cancellation and explicit Ordered recovery have source and automated coverage. The native case used an injected proof event; it does not establish payment settlement or a latency improvement.
Twin: synonymdev/bitkit-android#1639 fix: defer passive proof display refreshes at ce4db93 matches forced Background display admission, warm-state retention and existing-refresh recovery. iOS also waits for review idle and retains a session-scoped refresh token.
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
|
Milestone 3.2.0: it is the base of the Paykit latency stack (#1070, #1081, #1084), all on 3.2.0. Twin: synonymdev/bitkit-android#1639. |
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryAutomatic proof-triggered request updates now wait for payment review and submission to finish.
ben-kaufman explicitly leaves immediate-idle retries and preemption of admitted work out of scope. No finding is raised for those choices. Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Proof state changes] --> B[Keep session-scoped refresh token]
B --> C[Wait for payment and review to end]
C --> D[Request a fresh Background read]
D --> E{Read succeeds?}
E -->|Yes| F[Update request rows and complete token]
E -->|No| G[Keep token and usable rows]
G --> H[Existing foreground or new-event opportunity]
H --> C
Reviews (1) · Last reviewed commit: "merge: carry current send routes through..." · Reviewed by Greptile |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. Makes the proof-triggered display refresh wait for review as well as submission, and run with Background admission so a payment that starts in between wins. Reviewed 5e801df, full tier, reasoned from the code and CI: iOS does not build on this host.
What I checked, and 4 candidates I ruled out
Read in full: the diff; PaykitPaymentActivity (waitUntilIdle, endReview, resumeIdleWaiters), PaykitPaymentProofRefreshState, PaykitPaymentRequestManager.refresh (public and private), PubkyService.checkRefreshAdmission
Call sites traced: refreshIncomingPaykitPaymentRequests(refreshPriority:) (only the proof task passes it), beginReview (SendSheet, SendPendingScreen)
CI: SwiftFormat and Greptile green; Run Tests still pending at review time
Ruled out
- Waiter never woken when review ends while a payment is active:
endReviewresumes waiters only throughresumeIdleWaiters, which returns while payments remain;endresumes them later and thewhileloop re-checks reviews, so no lost wake-up. - Token consumed by a deferred read:
paykitProofRefresh.completenow runs only when the refresh returned true, so a Background deferral keeps the session-scoped request for the next pass. - Forced freshness lost behind an in-flight refresh: the private
refreshwaits for the running task, then re-runs becauseforceFreshis set, carrying the resolved Background priority; the new test checks it. - Explicit recovery downgraded:
refreshPriority ?? (forceFresh ? .ordered : messagePriority)leaves every caller that does not pass it unchanged.
Merge confidence: 4/5, Run Tests is still pending and the branch was reasoned about, not built here.
Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed (the runner does not report it)
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full review of the complete PR diff against merge base 2bbd2d2, at 5e801df.
No new actionable code findings.
The proof-display task waits until payment submission and payment review are both idle, then forces a stored read at Background priority. A deferred or cancelled read leaves the session-scoped token and the current request rows in place, and a forced read still runs again after an in-flight snapshot. Forced reads that do not pass a priority stay on the Ordered lane, so explicit recovery is unchanged.
Source comparison with bitkit-android#1639 at ce4db93 matches that Background yield and Ordered recovery. iOS also waits for review idle and keeps the same session token until a later successful read; Android's observer waits on submission and uses Background admission while a review is open.
unit-tests and SwiftFormat passed on this revision. The new service and activity tests were inspected here; that unit-tests run executed the suite that contains them. Device testing: not performed in this review. The author's regression case needs admission fault injection, which is outside the journey capabilities, so this pass leaves that runtime race unexecuted.
|
The current test failure is The CI merge tree matches This is the test tracked in #1033, although this occurrence timed out at a different expectation from the previously reported appearance assertion. The evidence does not yet establish the same underlying cause or justify a production change. Update: the single retry passed at the unchanged |
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: No actionable findings.
QA for 2051eee: Xcode 27.0, iPhone 17 Pro simulator on iOS 26.5. Manual case 1 retains its passing evidence from 6153563; the affected hosted suites were run again on 2051eee.
Test 1: proof display admission and foreground recovery ✅ Carried from the recorded passing run at 6153563; the exercised behavior is unchanged.
The earlier review proved that the paused proof observer yields to real Background admission during Bitcoin review, preserves warm pending/history rows, and recovers current requests after cancellation and foreground return. It used a local proof-event/pause hook with real regtest request data and staging Paykit responses; no payment was authorized.
I compared the observer, activity/token state, admission check, request-manager refresh, foreground recovery and dependency pins with 6153563. They are unchanged. The send-route merge preserves the Bitcoin review path used in that run. This case was not replayed on 2051eee; its recording and deciding trace remain in the linked review.
Test 2: current-head build and affected hosted regressions ✅ The pristine head built and the affected hosted suites passed.
I ran PaykitPaymentRequestServiceTests, PaykitPaymentActivityTests, PaykitSdkOperationLockTests and PaykitSdkIdentityCheckTests. The result bundle and individual test results confirm execution without failures, skips or runtime warnings, including fresh Background reads after admitted snapshots, preserved warm rows after deferral, stale identity/cancellation rejection, explicit Ordered recovery and review-scoped refresh tokens.
hosted.2x.mp4
Hosted XCTest execution at 2×; assertion outcomes were checked in the result bundle, not inferred from the recording.
Coverage: delta 6153563…2051eee, merge integration and the five-file net PR diff. No open review findings remained. Merge-inherited USDT routing, warning, PIN and biometric UI changes were inspected in source; those native flows were not driven in this review. No payment-settlement or latency result is claimed.
Twin: synonymdev/bitkit-android#1639 fix: defer passive proof display refreshes at ce4db93 matches forced Background display admission, warm-state retention and existing-refresh recovery. iOS also waits for review idle and retains a session-scoped refresh token.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Re-reviewed the full PR at 2051eee after the comparison base changed from 2bbd2d2 to a161084d. That base range changes the payment-request refresh implementation and its tests, so this pass covered the complete diff and inherited no earlier coverage.
No new actionable code findings.
The proof-display task waits until payment submission and payment review are both idle, then forces a stored read at Background priority. A deferred or cancelled read leaves the session token and the current request rows in place. Forced reads without an explicit priority stay on the Ordered lane, so explicit recovery is unchanged. After a failed automatic read the token stays pending; the next read is a foreground return, a new proof event, or the existing 10-second inbox poll.
Source comparison with bitkit-android#1639 at ce4db93 matches the Background yield and Ordered recovery. iOS also waits for review to be idle before starting the read and keeps the same session token until a later read succeeds. Android starts the Background read once submission is idle, including while a review remains open. This is source comparison only; the Android PR was not assessed.
unit-tests and SwiftFormat passed on this revision. The changed service and activity tests were inspected here; that unit-tests run executes the suite that contains them. They were not run again locally. Device testing: not performed in this review. Manual case 1 still needs an admission pause that the journey capabilities do not provide.
Ready for device testing.
Suggested additional test cases
- iOS. Setup: the manual case 1 wallet, with one warm incoming request and the proof-display read paused before SDK admission. Action: open payment review, release the paused read so Background admission defers, cancel the review, dismiss the send sheet, and stay in the foreground for one 10-second inbox poll without backgrounding the app or emitting another proof event. Expected: the warm rows remain when the read defers, and the current request state appears from that poll.
Reviewer model: grok-4.7
Reasoning effort: Not exposed (run metadata names grok-4.7 and does not record a reasoning setting).
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Re-reviewed the full PR at 50a33e3 after the comparison base changed from a161084d to 753a4c6b. That base range changes the payment-request refresh implementation and its tests, so this pass covered the complete diff against merge base 753a4c6b and inherited no earlier coverage.
No new actionable code findings.
The proof-display task waits until payment submission and payment review are both idle, then forces a stored read at Background priority. A deferred or cancelled read leaves the session token and the current request rows in place. Forced reads without an explicit priority stay on the Ordered lane, so explicit recovery is unchanged. After a failed automatic read, the next read is a foreground return, a new proof event, or the existing 10-second inbox poll. Allowance processing from the new base runs in the shared refresh after that read; the token still completes only when the proof read itself succeeds.
Source comparison with bitkit-android#1639 at ce4db93 matches the Background yield and Ordered recovery. iOS also waits for review to be idle before starting the read and keeps the same session token until a later read succeeds. Android starts the Background read once submission is idle, including while a review remains open. This is source comparison only; the Android PR was not assessed.
unit-tests and SwiftFormat passed on this revision. The changed service and activity tests were inspected here; that unit-tests run executes the suite that contains them. They were not run again locally. Device testing: not performed in this review. Manual case 1 still needs an admission pause that the journey capabilities do not provide.
Suggested additional test cases
- iOS. Setup: the manual case 1 wallet, with one warm incoming request and the proof-display read paused before SDK admission. Action: open payment review, release the paused read so Background admission defers, cancel the review, dismiss the send sheet, and stay in the foreground for one 10-second inbox poll without backgrounding the app or emitting another proof event. Expected: the warm rows remain when the read defers, and the current request state appears from that poll.
Reviewer model: grok-4.7
Reasoning effort: xhigh
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: no findings.
Checked and clean:
- The proof-state observer, the manager refresh, SDK admission and the operation lock: a passive proof display refresh waits for review to go idle instead of competing with the pay path.
- Review begin and end, including cancel and Back: the held token is released on every exit and the deferred refresh runs then; scene reactivation and the 10 s poll recover any miss.
- Cancellation and the allowance auto-pay guards are unchanged.
- Twin synonymdev/bitkit-android#1639 reads immediately and relies on the poll and Home resume, as discussed in the threads.
Performance-relevant (re-timed background read on the payment path). The gate times Continue → confirm and Confirm → success with a proof event arriving during the payment, head vs master, and how long a row takes to update after review is cancelled (bound 10 s).
Device gate (partial): 50a33e3 — not run yet; pending that measurement against master. Device results will follow in a separate review.
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: No actionable findings.
QA for b421181: Xcode 27.0, iPhone 17 Pro simulators on iOS 26.5, disposable Bitcoin-regtest wallets and staging Pubky. Native cases used the reviewed head plus the unpushed debug hooks below; the build and hosted regressions used the pristine head. Ordinary payment PIN authorization remained enabled.
Test 1: proof display admission and foreground recovery ✅ The display snapshot yielded during review, retained warm rows and completed a fresh read after foreground return, as checked in the admission/token trace.
I paused the actual proof observer after its idle check, opened a separate Bitcoin confirmation and released the pause. Real Background snapshot admission deferred. The observer returned false without cancellation, preserving two pending requests and two history rows. A third request arrived during review. After the poll-only segment in Test 3, backgrounding and reopening the app retried the retained token: the observer read three current rows and completed it. No payment was authorized in this case.
This proves display-snapshot deferral. The shared refresh still performed inherited allowance work with Ordered access while review was active; it does not establish that every operation in the observer yields.
https://github.com/user-attachments/assets/6fd9af5c-cfa6-4184-90e0-c2cfaf804f4d
Payer left, requester right, synchronized at 2×. Admission, row counts and token completion were checked in the trace.
Unpushed test hooks and deciding trace for Tests 1, 3 and the head in Test 4
The hooks emit actual proof events, hold the observer until an explicit release, and log existing decisions and payment milestones. They do not replace SDK responses, request records, admission decisions or PIN authorization. The payment timing controls are used only in Test 4.
trigger warm=2 history=2
held after idle before display refresh
released review=true
SDK admission deferred Background
allowance Ordered admitted review=true
observer refreshed=false warm=2 history=2 cancelled=false
... after foreground return ...
observer refreshed=true warm=3 history=3 cancelled=false
token completed
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 9d302151..5921efec 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -648,6 +648,18 @@ struct AppScene: View {
private var paykitEventContent: some View {
configuredContent
+ .task { // test hook
+ #if DEBUG
+ guard ProcessInfo.processInfo.arguments.contains("-proof-display-qa") else { return } // test hook
+ while !Task.isCancelled { // test hook
+ if ProofDisplayQAHook.consume("proof-trigger") { // test hook
+ ProofDisplayQAHook.log("trigger warm=\(paykitPaymentRequestManager.pendingRequests.count) history=\(paykitPaymentRequestManager.historyRequests.count)") // test hook
+ PaykitPaymentProofService.qaTriggerProofStateChange() // test hook
+ } // test hook
+ try? await Task.sleep(for: .milliseconds(250)) // test hook
+ } // test hook
+ #endif
+ }
.onChange(of: pubkyProfile.authState, initial: true) { _, authState in
receivedPaymentBackfillCache.invalidate()
if authState == .authenticated, let pk = pubkyProfile.publicKey {
@@ -712,11 +724,20 @@ struct AppScene: View {
try await PaykitPaymentActivity.shared.waitUntilIdle(includingReviews: true)
} catch { return }
guard !Task.isCancelled, request == pendingPaykitProofRefresh else { return }
+ #if DEBUG
+ await ProofDisplayQAHook.holdIfArmed() // test hook
+ #endif
let refreshed = await refreshIncomingPaykitPaymentRequests(
mode: .stored, forceFresh: true, refreshPriority: .background, automatic: false
)
+ #if DEBUG
+ ProofDisplayQAHook.log("observer refreshed=\(refreshed) warm=\(paykitPaymentRequestManager.pendingRequests.count) history=\(paykitPaymentRequestManager.historyRequests.count) cancelled=\(Task.isCancelled)") // test hook
+ #endif
guard !Task.isCancelled, refreshed else { return }
paykitProofRefresh.complete(request)
+ #if DEBUG
+ ProofDisplayQAHook.log("token completed") // test hook
+ #endif
}
.onReceive(PaykitAllowanceExecutor.eventPublisher.receive(on: DispatchQueue.main)) { event in
handlePaykitAllowanceEvent(event)
@@ -1487,7 +1508,13 @@ struct AppScene: View {
)
// Deleting the profile withdraws the payment lists an automatic payment would consume.
if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile {
+ #if DEBUG
+ ProofDisplayQAHook.log("allowance refresh start after refreshed=\(refreshed) review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
await paykitAllowanceManager.refresh()
+ #if DEBUG
+ ProofDisplayQAHook.log("allowance refresh end review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
let isAutomaticSession = { pubkyProfile.automaticPaykitSession == session && !pubkyProfile.isDeletingProfile }
let acceptedOffers = await paykitAllowanceManager.acceptOffersFromAllowers(
canContinue: { canRefresh() && isAutomaticSession() },
@@ -1602,7 +1629,13 @@ struct AppScene: View {
await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey)
await retryPendingPaykitEndpointRemoval()
}
+ #if DEBUG
+ ProofDisplayQAHook.log("poll start mode=\(mode) warm=\(paykitPaymentRequestManager.pendingRequests.count)") // test hook
+ #endif
await refreshIncomingPaykitPaymentRequests(mode: mode, messagePriority: .background, onlyWhileActive: true)
+ #if DEBUG
+ ProofDisplayQAHook.log("poll end mode=\(mode) warm=\(paykitPaymentRequestManager.pendingRequests.count)") // test hook
+ #endif
guard mode == .full,
!Task.isCancelled,
scenePhase == .active,
@@ -2221,3 +2254,33 @@ struct AppScene: View {
)
}
}
+
+#if DEBUG
+@MainActor
+final class ProofDisplayQAHook { // test hook
+ static var enabled: Bool { ProcessInfo.processInfo.arguments.contains("-proof-display-qa") } // test hook
+ static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] // test hook
+ static func consume(_ name: String) -> Bool { // test hook
+ let path = directory.appendingPathComponent(name) // test hook
+ guard enabled, FileManager.default.fileExists(atPath: path.path) else { return false } // test hook
+ try? FileManager.default.removeItem(at: path) // test hook
+ return true // test hook
+ } // test hook
+ static func log(_ text: String) { // test hook
+ guard enabled else { return } // test hook
+ let url = directory.appendingPathComponent("proof-qa.log") // test hook
+ let data = Data(("\(Date().timeIntervalSince1970) \(text)\n").utf8) // test hook
+ if let handle = try? FileHandle(forWritingTo: url) { // test hook
+ _ = try? handle.seekToEnd(); try? handle.write(contentsOf: data); try? handle.close() // test hook
+ } else { try? data.write(to: url) } // test hook
+ } // test hook
+ static func holdIfArmed() async { // test hook
+ guard consume("proof-arm") else { return } // test hook
+ log("held after idle before display refresh") // test hook
+ while !Task.isCancelled, !consume("proof-release") { // test hook
+ try? await Task.sleep(for: .milliseconds(100)) // test hook
+ } // test hook
+ log("released review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ } // test hook
+} // test hook
+#endif
diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift
index 5163999f..1f760599 100644
--- a/Bitkit/Services/PaykitPaymentProofService.swift
+++ b/Bitkit/Services/PaykitPaymentProofService.swift
@@ -238,6 +238,10 @@ actor PaykitPaymentProofService {
private static let proofStateChangedSubject = PassthroughSubject<Void, Never>()
private static let onchainPaymentResolutionSubject = CurrentValueSubject<PaykitOnchainPaymentResolution?, Never>(nil)
+ #if DEBUG
+ static func qaTriggerProofStateChange() { proofStateChangedSubject.send() } // test hook
+ #endif
+
nonisolated static var proofStateChangedPublisher: AnyPublisher<Void, Never> {
proofStateChangedSubject.eraseToAnyPublisher()
}
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index e8c51278..1a2912aa 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1268,7 +1268,10 @@ actor PaykitSdkService {
func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] {
try await operationLock.withLock {
- try await handle().listAllowances(filter: filter)
+ #if DEBUG
+ await ProofDisplayQAHook.log("allowance Ordered admitted review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
+ return try await handle().listAllowances(filter: filter)
}
}
@@ -1670,6 +1673,9 @@ actor PaykitSdkService {
private func checkRefreshAdmission(_ priority: PaykitSdkOperationLock.Priority) async throws {
if priority == .background, await PaykitPaymentActivity.shared.shouldDeferRefresh {
+ #if DEBUG
+ await ProofDisplayQAHook.log("SDK admission deferred Background") // test hook
+ #endif
throw PaykitPaymentRequestError.operationInProgress
}
}
diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift
index 3cae227c..834eb064 100644
--- a/Bitkit/Views/Wallets/Send/SendAmountView.swift
+++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift
@@ -341,6 +341,10 @@ struct SendAmountView: View {
}
private func onContinue() async {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing continue") // test hook
+ if ProofDisplayQAHook.consume("proof-at-continue") { PaykitPaymentProofService.qaTriggerProofStateChange() } // test hook
+ #endif
guard !isContinuing else { return }
isContinuing = true
defer { isContinuing = false }
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index 22df05bc..bbe0608d 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -253,6 +253,9 @@ struct SendConfirmationView: View {
guard localFollowupComplete else {
return await onchainPendingRoute(txid: txid, requestId: requestId, using: attempts)
}
+ #if DEBUG
+ ProofDisplayQAHook.log("timing success route") // test hook
+ #endif
return .success(paymentId: txid)
}
@@ -402,6 +405,9 @@ struct SendConfirmationView: View {
}
await calculateTransactionFee()
await calculateRoutingFee()
+ #if DEBUG
+ ProofDisplayQAHook.log("timing confirmation fees ready") // test hook
+ #endif
guard !Task.isCancelled else { return }
// PIN navigation can cancel the view task while authorization is awaiting its result.
Task { @MainActor in await startAutomaticPaymentIfNeeded() }
@@ -806,6 +812,10 @@ struct SendConfirmationView: View {
}
private func submitPayment(isAutomatic: Bool = false) async throws {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing submit") // test hook
+ if ProofDisplayQAHook.consume("proof-at-submit") { PaykitPaymentProofService.qaTriggerProofStateChange() } // test hook
+ #endif
guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() }
if isAutomatic, isFeeRateMissing {
guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else {
@@ -1154,6 +1164,9 @@ struct SendConfirmationView: View {
}
private func performPayment(isAutomatic: Bool) async throws {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing authorized perform") // test hook
+ #endif
let paymentActivity = PaykitPaymentActivity.shared.begin()
defer { PaykitPaymentActivity.shared.end(paymentActivity) }
isSubmittingPayment = trueTest 2: pristine build and affected hosted regressions ✅ The unmodified head built and its focused suites passed, as checked in the XCTest result bundle.
I ran PaykitPaymentRequestServiceTests, PaykitPaymentActivityTests, PaykitSdkOperationLockTests and PaykitSdkIdentityCheckTests. The result bundle confirms execution without failures, skips or runtime warnings. This covers forced Background reads waiting behind admitted snapshots, preserved warm rows, stale identity/cancellation, explicit Ordered recovery and review-scoped refresh tokens. SwiftFormat also passed for the changed Swift files.
https://github.com/user-attachments/assets/83063578-b342-4d9f-a7b8-76b2b2d2a3e5
Hosted execution at 2×; assertion outcomes come from the XCTest result bundle.
Test 3: poll-only recovery after cancelling review ✅ The next foreground inbox poll discovered the new request without another proof event or background return, as checked in the poll trace and recording.
I drove the additional case from @piotr-iohk's review. This is the foreground-only segment of Test 1, using the same unpushed hook above. With two warm rows retained after deferral, the requester sent a real 3,000-sat request while review remained open. I pressed Back, dismissed the send sheet and stayed in the foreground. The new request appeared about eight seconds after Back on the recording; the next inbox poll completed with three warm rows. The recording then shows all three unpaid requests and ends before the foreground-return part of Test 1.
poll start mode=inbox warm=2
SDK admission deferred Background
poll end mode=inbox warm=2
... Back and dismissal; no new proof event or foreground return ...
poll start mode=inbox warm=2
poll end mode=inbox warm=3
https://github.com/user-attachments/assets/373be034-1f9f-44a5-bcc1-8431c64b9ac2
Payer left, requester right, synchronized at 2×; shared execution with Test 1.
Test 4: payment timing with concurrent proof events ✅ Both builds completed a staging payment with PIN approval; debug timestamps measured the requested intervals.
I ran the comparison requested in @jvsena42's review: one 10,000-sat on-chain contact payment on b421181 and one on master 07610b3, on the same payer simulator. An actual proof-state event was emitted at Continue and another at submission on both builds. Both reached Bitcoin Sent after ordinary PIN entry; the recipient subsequently showed both incoming transactions.
| Trace interval | b421181 | master 07610b3 |
|---|---|---|
| Continue → confirmation fees ready | 58 ms | 127 ms |
| Submit → success route, including PIN entry | 4.429 s | 4.375 s |
| Authorized payment execution → success route | 319 ms | 175 ms |
These are single samples, not evidence of a latency improvement or regression. The builds also pin different Core and Paykit versions: head uses Core 0.8.0-rc10 / Paykit 0.1.1-rc1; master uses Core 0.8.0-rc9 / Paykit 0.1.0. The endpoint is the app's successful on-chain route, not mined confirmation. PIN-entry time is included only in the middle row.
| Reviewed head | Master comparison |
|---|---|
timing-head.2x.mp4 |
timing-master.2x.mp4 |
| Each video shows payer left and recipient right, synchronized at 2×. Timings come from debug timestamps, not playback duration. |
Timing traces and unpushed master hook
The head uses the patch in Test 1. Master uses the same event injection and milestone logging below; its observer and payment decisions remain unchanged.
head:
1792720267.165207 timing continue
1792720267.2233238 timing confirmation fees ready
1792720285.869999 timing submit
1792720289.980612 timing authorized perform
1792720290.2993078 timing success route
master:
1792720385.0504808 timing continue
1792720385.17751 timing confirmation fees ready
1792720390.4824982 timing submit
1792720394.68256 timing authorized perform
1792720394.857823 timing success route
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 266b617c..2f9da6f5 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -607,6 +607,18 @@ struct AppScene: View {
private var paykitEventContent: some View {
configuredContent
+ .task { // test hook
+ #if DEBUG
+ guard ProcessInfo.processInfo.arguments.contains("-proof-display-qa") else { return } // test hook
+ while !Task.isCancelled { // test hook
+ if ProofDisplayQAHook.consume("proof-trigger") { // test hook
+ ProofDisplayQAHook.log("trigger warm=\(paykitPaymentRequestManager.pendingRequests.count) history=\(paykitPaymentRequestManager.historyRequests.count)") // test hook
+ PaykitPaymentProofService.qaTriggerProofStateChange() // test hook
+ } // test hook
+ try? await Task.sleep(for: .milliseconds(250)) // test hook
+ } // test hook
+ #endif
+ }
.onChange(of: pubkyProfile.authState, initial: true) { _, authState in
receivedPaymentBackfillCache.invalidate()
if authState == .authenticated, let pk = pubkyProfile.publicKey {
@@ -2168,3 +2180,33 @@ struct AppScene: View {
)
}
}
+
+#if DEBUG
+@MainActor
+final class ProofDisplayQAHook { // test hook
+ static var enabled: Bool { ProcessInfo.processInfo.arguments.contains("-proof-display-qa") } // test hook
+ static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] // test hook
+ static func consume(_ name: String) -> Bool { // test hook
+ let path = directory.appendingPathComponent(name) // test hook
+ guard enabled, FileManager.default.fileExists(atPath: path.path) else { return false } // test hook
+ try? FileManager.default.removeItem(at: path) // test hook
+ return true // test hook
+ } // test hook
+ static func log(_ text: String) { // test hook
+ guard enabled else { return } // test hook
+ let url = directory.appendingPathComponent("proof-qa.log") // test hook
+ let data = Data(("\(Date().timeIntervalSince1970) \(text)\n").utf8) // test hook
+ if let handle = try? FileHandle(forWritingTo: url) { // test hook
+ _ = try? handle.seekToEnd(); try? handle.write(contentsOf: data); try? handle.close() // test hook
+ } else { try? data.write(to: url) } // test hook
+ } // test hook
+ static func holdIfArmed() async { // test hook
+ guard consume("proof-arm") else { return } // test hook
+ log("held after idle before display refresh") // test hook
+ while !Task.isCancelled, !consume("proof-release") { // test hook
+ try? await Task.sleep(for: .milliseconds(100)) // test hook
+ } // test hook
+ log("released review=\(PaykitPaymentActivity.shared.isActive)") // test hook
+ } // test hook
+} // test hook
+#endif
diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift
index 2323ef78..2e5aae29 100644
--- a/Bitkit/Services/PaykitPaymentProofService.swift
+++ b/Bitkit/Services/PaykitPaymentProofService.swift
@@ -234,6 +234,10 @@ actor PaykitPaymentProofService {
private static let proofStateChangedSubject = PassthroughSubject<Void, Never>()
private static let onchainPaymentResolutionSubject = CurrentValueSubject<PaykitOnchainPaymentResolution?, Never>(nil)
+ #if DEBUG
+ static func qaTriggerProofStateChange() { proofStateChangedSubject.send() } // test hook
+ #endif
+
nonisolated static var proofStateChangedPublisher: AnyPublisher<Void, Never> {
proofStateChangedSubject.eraseToAnyPublisher()
}
diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift
index 3cae227c..834eb064 100644
--- a/Bitkit/Views/Wallets/Send/SendAmountView.swift
+++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift
@@ -341,6 +341,10 @@ struct SendAmountView: View {
}
private func onContinue() async {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing continue") // test hook
+ if ProofDisplayQAHook.consume("proof-at-continue") { PaykitPaymentProofService.qaTriggerProofStateChange() } // test hook
+ #endif
guard !isContinuing else { return }
isContinuing = true
defer { isContinuing = false }
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index 22df05bc..bbe0608d 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -253,6 +253,9 @@ struct SendConfirmationView: View {
guard localFollowupComplete else {
return await onchainPendingRoute(txid: txid, requestId: requestId, using: attempts)
}
+ #if DEBUG
+ ProofDisplayQAHook.log("timing success route") // test hook
+ #endif
return .success(paymentId: txid)
}
@@ -402,6 +405,9 @@ struct SendConfirmationView: View {
}
await calculateTransactionFee()
await calculateRoutingFee()
+ #if DEBUG
+ ProofDisplayQAHook.log("timing confirmation fees ready") // test hook
+ #endif
guard !Task.isCancelled else { return }
// PIN navigation can cancel the view task while authorization is awaiting its result.
Task { @MainActor in await startAutomaticPaymentIfNeeded() }
@@ -806,6 +812,10 @@ struct SendConfirmationView: View {
}
private func submitPayment(isAutomatic: Bool = false) async throws {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing submit") // test hook
+ if ProofDisplayQAHook.consume("proof-at-submit") { PaykitPaymentProofService.qaTriggerProofStateChange() } // test hook
+ #endif
guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() }
if isAutomatic, isFeeRateMissing {
guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else {
@@ -1154,6 +1164,9 @@ struct SendConfirmationView: View {
}
private func performPayment(isAutomatic: Bool) async throws {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing authorized perform") // test hook
+ #endif
let paymentActivity = PaykitPaymentActivity.shared.begin()
defer { PaykitPaymentActivity.shared.end(paymentActivity) }
isSubmittingPayment = trueTwin: bitkit-android#1639 fix: defer passive proof display refreshes at 8668828 matches Background snapshot yield and warm-state preservation. iOS also waits for review to become idle and retains a session token; Android gates allowance work on a successful refresh. This was a source comparison, not Android device QA.
Coverage: delta 2051eee…b421181, all 86 paths including merged changes, plus the five-file net PR diff and reached lifecycle/admission callers. No planned case remains unrun. Native USDT, biometric authorization, allowance execution and other inherited UI journeys were not exercised by these cases.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the complete PR diff against merge base fff8042f, at b421181. The comparison base moved from 753a4c6 to fff8042 and that range touches Bitkit/AppScene.swift, so this pass re-read the whole diff and inherited no earlier coverage.
1 actionable finding — resolve or provide an evidence-backed rebuttal.
A deferred background proof read keeps the session token and the current rows, then the shared refresh still runs allowance listing, automatic payment, and presentation. Callers that omit refreshPriority still force an Ordered read.
Run Tests and SwiftFormat passed on this revision. The changed service and activity tests were inspected and were not executed locally. Device testing: not performed in this review. An attributed simulator trace on this revision recorded ordered allowance admission while review was active after background admission had already deferred (review); that run did not authorize a payment.
Source comparison with bitkit-android#1639 at 86688287 shows the same background yield, and allowance work there runs only after the refresh succeeds. That PR was not fully reviewed. No matching GitHub issue was found for this mechanism.
Suggested additional test cases
- iOS. Setup: Paykit UI enabled, an active allowance for contact A, one proposed request from A, and a separate Bitcoin confirmation for a different payment. Mark A's request in-flight with a local proof, then hold the proof-display refresh after its idle wait. Action: open the unrelated confirmation so payment review is active, then release the refresh. Expected: allowance listing and automatic payment do not start for A's request while the read is deferred, and no pay sheet opens for it. Cancel the review and return to the foreground. Expected: while A's local proof is in flight or completed, A is not offered as payable.
Findings
- [MEDIUM] Deferred proof refresh still pays from warm rows — inline at
Bitkit/AppScene.swift:716.
Reviewer model: grok-4.7
Reasoning effort: high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Re-reviewed the full pull request at 5277d370 after the comparison base changed (fff8042f → d404f925). Full reassessment of the diff against merge base d404f925. No earlier coverage was inherited.
No new actionable code findings.
The passive proof-display task waits for payment review and submission, then force-reads stored requests at background priority. A deferred or cancelled read keeps the session token and warm rows, and returns before allowance processing, automatic payment, contact backfill, and presentation. Explicit ordered recovery is unchanged. Compared with bitkit-android#1639 at 866882879: both yield that passive read to review or submission and recover on a later foreground refresh. iOS waits for review idle before starting; Android defers at background admission and reconciles proofs inside that refresh.
unit-tests and SwiftFormat passed on 5277d370. This review inspected the changed tests and did not run them. Device testing: not performed. Manual case 1 remains blocked on proof-display admission fault injection, which journeys cannot drive.
Suggested additional test cases
- iOS, disposable regtest wallet, Paykit UI on, saved linked fixture issuer, and the admission fault from manual case 1.
- Pause the automatic proof display refresh before SDK admission, begin payment review, then release the refresh so it defers.
- End review and stay in the foreground.
- Warm rows remain at deferral, and that read does not refresh allowances, pay automatically, backfill contacts, or present a request. Within one poll (about 10 seconds) the list matches current request state.
Earlier findings reconciled at this revision: 1 verified fixed.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: No new actionable findings. I verified the fix from the deferred-refresh thread: Bitkit/AppScene.swift:1491 returns before the warm-row consumers when the read defers. Test 5 checks the covered, locally in-flight request case.
QA for 5277d37: Xcode 27.0, iPhone 17 Pro simulators on iOS 26.5, disposable Bitcoin-regtest wallets and staging Pubky. The build and hosted tests used the pristine head. Native cases used the head plus the unpushed controls recorded below; ordinary PIN authorization remained enabled.
Test 1: proof display admission and foreground recovery ✅ The deferred read retained warm rows and skipped downstream work; foreground return completed a fresh read, checked in the admission/token trace.
I held the actual proof observer after its idle check, opened an unrelated Bitcoin confirmation, and released the hold. Real Background admission deferred. The trace records refreshed=false, two pending/history rows retained, and the early return before allowance processing, backfill and presentation. A third real request arrived during review. After the poll-only segment in Test 3, backgrounding and reopening the app completed the retained token with three current rows. No payment was authorized in this case.
native.2x.mp4
Payer left, requester right, synchronized at 2×. Admission and token completion were checked in the trace.
Test 2: pristine build and affected hosted regressions ✅ The unmodified head built and the selected suites passed, checked in the XCTest result bundle.
I ran PaykitPaymentRequestServiceTests, PaykitPaymentActivityTests, PaykitSdkOperationLockTests and PaykitSdkIdentityCheckTests without failures or skips. They cover forced Background reads behind admitted snapshots, warm-row retention, stale identity/cancellation rejection, explicit Ordered recovery and review-scoped tokens. SwiftFormat passed for the delta's Swift files; translation validation reported zero errors and its existing warnings.
hosted.2x.mp4
Hosted execution at 2×; assertion outcomes come from the result bundle.
Test 3: poll-only recovery after cancelling review ✅ The next foreground poll loaded the third request without another proof event or background return, checked in the poll trace and recording.
This shares Test 1's admission/cancellation sequence. After the requester sent a real 3,000-sat request during review, I pressed Back, dismissed the send sheet and stayed in the foreground. The next poll loaded it; all three unpaid rows appeared. This drives the additional case from the QA review.
poll-only.2x.mp4
Payer left, requester right, synchronized at 2×; this segment ends before foreground return.
Test 4: contact payment with concurrent proof events ✅ A 10,000-sat staging on-chain payment reached Bitcoin Sent after ordinary PIN approval; debug timestamps measured the requested intervals.
I emitted proof events at Continue and submission. The display read deferred during review, and the retained token completed after payment. Continue → confirmation fees ready took 55 ms; submission → success, including PIN entry, took 4.368 s. These are single-sample UI milestones. The earlier paired master run supplies historical comparison; this current-head replay establishes successful execution. No latency improvement or mined-finality claim is made.
timing-head.2x.mp4
Payer left, recipient right, synchronized at 2×; intervals come from the debug trace.
Test 5: covered in-flight request during deferral ✅ The deferred read skipped every downstream stage, and recovery kept the protected request out of the payable list, checked in the trace and UI identifiers.
The peer accepted a real allowance through the SDK. Its newest 4,000-sat request was covered and remained among four warm rows. A local control called the real proof service's preparation and mark-started methods to stage an unbroadcast on-chain attempt for that request.
I held the resulting proof observer, opened an unrelated Bitcoin confirmation, and released it. Background admission deferred; the observer retained all four rows and returned before allowance refresh, automatic processing, backfill or presentation. After cancellation and foreground return, the fresh read kept four history records but only three pending requests. The protected request had no row or Pay action in the pending list. No new payment was authorized in this case.
inspect activeAllowances=1 coveredWarmRows=1 warm=4
protected request count=1 warm=4 allowanceCovered=true
released review=true
SDK admission deferred Background
refresh gate returned before allowance/process/backfill/presentation
observer refreshed=false warm=4 history=4 cancelled=false
... foreground return ...
observer refreshed=true warm=3 history=4 cancelled=false
token completed
inspect activeAllowances=1 coveredWarmRows=0 warm=3
safety.2x.mp4
Payer at 2×. The allowance, protection state and skipped stages were checked in the trace; the recording ends with the three other pending requests.
Unpushed controls for Tests 1, 3 and 4
Emits actual proof events, holds the observer until explicit release, and logs admission, the early return and payment milestones. SDK responses and authorization remain real.
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 1db93432..043f4678 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -514,7 +514,10 @@ struct AppScene: View {
},
onchainAddresses: { @MainActor address, outputAddresses in
let identity = pubkyProfile.publicKey
- let contacts = paykitPaymentRequestManager.receivedPaymentContacts
+ #if DEBUG
+ ProofDisplayQAHook.log("backfill reached") // test hook
+ #endif
+ let contacts = paykitPaymentRequestManager.receivedPaymentContacts
let reservations = PrivatePaykitAddressReservationStore.shared
let reservationRevision = await reservations.attributionRevision
guard let combined = try? await contacts.includingReservations(for: outputAddresses, lookup: {
@@ -648,6 +651,33 @@ struct AppScene: View {
private var paykitEventContent: some View {
configuredContent
+ .task { // test hook
+ #if DEBUG
+ guard ProcessInfo.processInfo.arguments.contains("-proof-display-qa") else { return } // test hook
+ while !Task.isCancelled { // test hook
+ if ProofDisplayQAHook.consume("proof-trigger") { // test hook
+ ProofDisplayQAHook.log("trigger warm=\(paykitPaymentRequestManager.pendingRequests.count) history=\(paykitPaymentRequestManager.historyRequests.count)") // test hook
+ PaykitPaymentProofService.qaTriggerProofStateChange() // test hook
+ } // test hook
+ if ProofDisplayQAHook.consume("allowance-propose"), let contact = contactsManager.contacts.first { // test hook
+ do { // test hook
+ try await paykitAllowanceManager.propose(to: contact, limits: .init(perPaymentUsd: 1, monthlyUsd: 10, perPaymentSats: 10000, monthlySats: 100000)) // test hook
+ ProofDisplayQAHook.log("allowance proposed via real SDK") // test hook
+ } catch { ProofDisplayQAHook.log("allowance propose error \(error)") } // test hook
+ } // test hook
+ if ProofDisplayQAHook.consume("protect-request"), let request = paykitPaymentRequestManager.pendingRequests.first { // test hook
+ do { // test hook
+ try await PaykitPaymentProofService.shared.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh).rawValue, kind: .onchain) // test hook
+ let address = try String(contentsOf: ProofDisplayQAHook.directory.appendingPathComponent("protect-address"), encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) // test hook
+ try await PaykitPaymentProofService.shared.markOnchainPaymentStarted(request, address: address) // test hook
+ let protected = await PaykitPaymentProofService.shared.inFlightRequestIds(identity: pubkyProfile.publicKey ?? "") // test hook
+ ProofDisplayQAHook.log("protected request count=\(protected.count) warm=\(paykitPaymentRequestManager.pendingRequests.count)") // test hook
+ } catch { ProofDisplayQAHook.log("protect error \(error)") } // test hook
+ } // test hook
+ try? await Task.sleep(for: .milliseconds(250)) // test hook
+ } // test hook
+ #endif
+ }
.onChange(of: pubkyProfile.authState, initial: true) { _, authState in
receivedPaymentBackfillCache.invalidate()
if authState == .authenticated, let pk = pubkyProfile.publicKey {
@@ -715,11 +745,20 @@ struct AppScene: View {
try await PaykitPaymentActivity.shared.waitUntilIdle(includingReviews: true)
} catch { return }
guard !Task.isCancelled, request == pendingPaykitProofRefresh else { return }
+ #if DEBUG
+ await ProofDisplayQAHook.holdIfArmed() // test hook
+ #endif
let refreshed = await refreshIncomingPaykitPaymentRequests(
mode: .stored, forceFresh: true, refreshPriority: .background, automatic: false
)
+ #if DEBUG
+ ProofDisplayQAHook.log("observer refreshed=\(refreshed) warm=\(paykitPaymentRequestManager.pendingRequests.count) history=\(paykitPaymentRequestManager.historyRequests.count) cancelled=\(Task.isCancelled)") // test hook
+ #endif
guard !Task.isCancelled, refreshed else { return }
paykitProofRefresh.complete(request)
+ #if DEBUG
+ ProofDisplayQAHook.log("token completed") // test hook
+ #endif
}
.onReceive(PaykitAllowanceExecutor.eventPublisher.receive(on: DispatchQueue.main)) { event in
handlePaykitAllowanceEvent(event)
@@ -1488,10 +1527,21 @@ struct AppScene: View {
mode: mode, forceFresh: forceFresh, messagePriority: messagePriority,
refreshPriority: refreshPriority, canRefresh: canRefresh
)
- guard refreshed else { return false }
+ guard refreshed else {
+ #if DEBUG
+ ProofDisplayQAHook.log("refresh gate returned before allowance/process/backfill/presentation") // test hook
+ #endif
+ return false
+ }
// Deleting the profile withdraws the payment lists an automatic payment would consume.
if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile {
+ #if DEBUG
+ ProofDisplayQAHook.log("allowance refresh start after refreshed=\(refreshed) review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
await paykitAllowanceManager.refresh()
+ #if DEBUG
+ ProofDisplayQAHook.log("allowance refresh end review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
let isAutomaticSession = { pubkyProfile.automaticPaykitSession == session && !pubkyProfile.isDeletingProfile }
let acceptedOffers = await paykitAllowanceManager.acceptOffersFromAllowers(
canContinue: { canRefresh() && isAutomaticSession() },
@@ -1500,6 +1550,9 @@ struct AppScene: View {
announceAcceptedAllowanceOffers(acceptedOffers)
// A request whose manual sheet is already open stays with the user.
let manualRequestId = incomingPaymentRequestPreparation.flatMap { $0.ownsSheet(sheets) ? $0.request?.id : nil }
+ #if DEBUG
+ ProofDisplayQAHook.log("automatic processing reached review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
let handledAutomatically = await paykitAllowanceManager.processIncomingRequests(
paykitPaymentRequestManager.pendingRequests.filter { $0.id != manualRequestId }
)
@@ -1524,6 +1577,9 @@ struct AppScene: View {
Logger.warn("Failed to attribute received Paykit payments: \(error)", context: "AppScene")
}
if presentItems, canRefresh(), pubkyProfile.automaticPaykitSession == session {
+ #if DEBUG
+ ProofDisplayQAHook.log("presentation reached") // test hook
+ #endif
await presentNextIncomingPaykitItem()
}
if mode == .full, canRefresh() {
@@ -1606,7 +1662,13 @@ struct AppScene: View {
await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey)
await retryPendingPaykitEndpointRemoval()
}
+ #if DEBUG
+ ProofDisplayQAHook.log("poll start mode=\(mode) warm=\(paykitPaymentRequestManager.pendingRequests.count)") // test hook
+ #endif
await refreshIncomingPaykitPaymentRequests(mode: mode, messagePriority: .background, onlyWhileActive: true)
+ #if DEBUG
+ ProofDisplayQAHook.log("poll end mode=\(mode) warm=\(paykitPaymentRequestManager.pendingRequests.count)") // test hook
+ #endif
guard mode == .full,
!Task.isCancelled,
scenePhase == .active,
@@ -2225,3 +2287,34 @@ struct AppScene: View {
)
}
}
+
+#if DEBUG
+@MainActor
+final class ProofDisplayQAHook { // test hook
+ static var enabled: Bool { ProcessInfo.processInfo.arguments.contains("-proof-display-qa") } // test hook
+ static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] // test hook
+ static func consume(_ name: String) -> Bool { // test hook
+ let path = directory.appendingPathComponent(name) // test hook
+ guard enabled, FileManager.default.fileExists(atPath: path.path) else { return false } // test hook
+ try? FileManager.default.removeItem(at: path) // test hook
+ return true // test hook
+ } // test hook
+ static func log(_ text: String) { // test hook
+ guard enabled else { return } // test hook
+ let url = directory.appendingPathComponent("proof-qa.log") // test hook
+ let data = Data(("\(Date().timeIntervalSince1970) \(text)\n").utf8) // test hook
+ if let handle = try? FileHandle(forWritingTo: url) { // test hook
+ _ = try? handle.seekToEnd(); try? handle.write(contentsOf: data); try? handle.close() // test hook
+ } else { try? data.write(to: url) } // test hook
+ } // test hook
+ static func holdIfArmed() async { // test hook
+ guard enabled, FileManager.default.fileExists(atPath: directory.appendingPathComponent("proof-arm").path) else { return } // test hook
+ log("held after idle before display refresh") // test hook
+ while !Task.isCancelled, !consume("proof-release") { // test hook
+ try? await Task.sleep(for: .milliseconds(100)) // test hook
+ } // test hook
+ _ = consume("proof-arm") // test hook
+ log("released review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ } // test hook
+} // test hook
+#endif
diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift
index 5163999f..1f760599 100644
--- a/Bitkit/Services/PaykitPaymentProofService.swift
+++ b/Bitkit/Services/PaykitPaymentProofService.swift
@@ -238,6 +238,10 @@ actor PaykitPaymentProofService {
private static let proofStateChangedSubject = PassthroughSubject<Void, Never>()
private static let onchainPaymentResolutionSubject = CurrentValueSubject<PaykitOnchainPaymentResolution?, Never>(nil)
+ #if DEBUG
+ static func qaTriggerProofStateChange() { proofStateChangedSubject.send() } // test hook
+ #endif
+
nonisolated static var proofStateChangedPublisher: AnyPublisher<Void, Never> {
proofStateChangedSubject.eraseToAnyPublisher()
}
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index e8c51278..1a2912aa 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1268,7 +1268,10 @@ actor PaykitSdkService {
func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] {
try await operationLock.withLock {
- try await handle().listAllowances(filter: filter)
+ #if DEBUG
+ await ProofDisplayQAHook.log("allowance Ordered admitted review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
+ return try await handle().listAllowances(filter: filter)
}
}
@@ -1670,6 +1673,9 @@ actor PaykitSdkService {
private func checkRefreshAdmission(_ priority: PaykitSdkOperationLock.Priority) async throws {
if priority == .background, await PaykitPaymentActivity.shared.shouldDeferRefresh {
+ #if DEBUG
+ await ProofDisplayQAHook.log("SDK admission deferred Background") // test hook
+ #endif
throw PaykitPaymentRequestError.operationInProgress
}
}
diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift
index 3cae227c..834eb064 100644
--- a/Bitkit/Views/Wallets/Send/SendAmountView.swift
+++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift
@@ -341,6 +341,10 @@ struct SendAmountView: View {
}
private func onContinue() async {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing continue") // test hook
+ if ProofDisplayQAHook.consume("proof-at-continue") { PaykitPaymentProofService.qaTriggerProofStateChange() } // test hook
+ #endif
guard !isContinuing else { return }
isContinuing = true
defer { isContinuing = false }
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index 22df05bc..5297aef3 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -253,6 +253,9 @@ struct SendConfirmationView: View {
guard localFollowupComplete else {
return await onchainPendingRoute(txid: txid, requestId: requestId, using: attempts)
}
+ #if DEBUG
+ ProofDisplayQAHook.log("timing success route") // test hook
+ #endif
return .success(paymentId: txid)
}
@@ -402,6 +405,9 @@ struct SendConfirmationView: View {
}
await calculateTransactionFee()
await calculateRoutingFee()
+ #if DEBUG
+ ProofDisplayQAHook.log("timing confirmation fees ready") // test hook
+ #endif
guard !Task.isCancelled else { return }
// PIN navigation can cancel the view task while authorization is awaiting its result.
Task { @MainActor in await startAutomaticPaymentIfNeeded() }
@@ -806,6 +812,10 @@ struct SendConfirmationView: View {
}
private func submitPayment(isAutomatic: Bool = false) async throws {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing submit") // test hook
+ if ProofDisplayQAHook.consume("proof-at-submit") { PaykitPaymentProofService.qaTriggerProofStateChange() } // test hook
+ #endif
guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() }
if isAutomatic, isFeeRateMissing {
guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else {Unpushed controls for Test 5
Adds allowance proposal/inspection and local unbroadcast proof preparation, retains the hold across superseding proof events, and logs the protected request and downstream stages. SDK responses and admission decisions remain real.
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 1db93432..f061df66 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -648,6 +648,38 @@ struct AppScene: View {
private var paykitEventContent: some View {
configuredContent
+ .task { // test hook
+ #if DEBUG
+ guard ProcessInfo.processInfo.arguments.contains("-proof-display-qa") else { return } // test hook
+ while !Task.isCancelled { // test hook
+ if ProofDisplayQAHook.consume("proof-trigger") { // test hook
+ ProofDisplayQAHook.log("trigger warm=\(paykitPaymentRequestManager.pendingRequests.count) history=\(paykitPaymentRequestManager.historyRequests.count)") // test hook
+ PaykitPaymentProofService.qaTriggerProofStateChange() // test hook
+ } // test hook
+ if ProofDisplayQAHook.consume("allowance-propose"), let contact = contactsManager.contacts.first { // test hook
+ do { // test hook
+ try await paykitAllowanceManager.propose(to: contact, limits: .init(perPaymentUsd: 1, monthlyUsd: 10, perPaymentSats: 10000, monthlySats: 100000)) // test hook
+ ProofDisplayQAHook.log("allowance proposed via real SDK") // test hook
+ } catch { ProofDisplayQAHook.log("allowance propose error \(error)") } // test hook
+ } // test hook
+ if ProofDisplayQAHook.consume("inspect-state") { // test hook
+ let active = paykitAllowanceManager.allowances.filter { $0.isAllower && $0.status(at: Date()) == .active }.count // test hook
+ let covered = paykitPaymentRequestManager.pendingRequests.filter { paykitAllowanceManager.coversRequest($0) }.count // test hook
+ ProofDisplayQAHook.log("inspect activeAllowances=\(active) coveredWarmRows=\(covered) warm=\(paykitPaymentRequestManager.pendingRequests.count)") // test hook
+ } // test hook
+ if ProofDisplayQAHook.consume("protect-request"), let request = paykitPaymentRequestManager.pendingRequests.first { // test hook
+ do { // test hook
+ try await PaykitPaymentProofService.shared.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh).rawValue, kind: .onchain) // test hook
+ let address = try String(contentsOf: ProofDisplayQAHook.directory.appendingPathComponent("protect-address"), encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) // test hook
+ try await PaykitPaymentProofService.shared.markOnchainPaymentStarted(request, address: address) // test hook
+ let protected = await PaykitPaymentProofService.shared.inFlightRequestIds(identity: pubkyProfile.publicKey ?? "") // test hook
+ ProofDisplayQAHook.log("protected request count=\(protected.count) warm=\(paykitPaymentRequestManager.pendingRequests.count) allowanceCovered=\(paykitAllowanceManager.coversRequest(request)) requestId=\(request.paymentRequestId)") // test hook
+ } catch { ProofDisplayQAHook.log("protect error \(error)") } // test hook
+ } // test hook
+ try? await Task.sleep(for: .milliseconds(250)) // test hook
+ } // test hook
+ #endif
+ }
.onChange(of: pubkyProfile.authState, initial: true) { _, authState in
receivedPaymentBackfillCache.invalidate()
if authState == .authenticated, let pk = pubkyProfile.publicKey {
@@ -715,11 +747,20 @@ struct AppScene: View {
try await PaykitPaymentActivity.shared.waitUntilIdle(includingReviews: true)
} catch { return }
guard !Task.isCancelled, request == pendingPaykitProofRefresh else { return }
+ #if DEBUG
+ await ProofDisplayQAHook.holdIfArmed() // test hook
+ #endif
let refreshed = await refreshIncomingPaykitPaymentRequests(
mode: .stored, forceFresh: true, refreshPriority: .background, automatic: false
)
+ #if DEBUG
+ ProofDisplayQAHook.log("observer refreshed=\(refreshed) warm=\(paykitPaymentRequestManager.pendingRequests.count) history=\(paykitPaymentRequestManager.historyRequests.count) cancelled=\(Task.isCancelled)") // test hook
+ #endif
guard !Task.isCancelled, refreshed else { return }
paykitProofRefresh.complete(request)
+ #if DEBUG
+ ProofDisplayQAHook.log("token completed") // test hook
+ #endif
}
.onReceive(PaykitAllowanceExecutor.eventPublisher.receive(on: DispatchQueue.main)) { event in
handlePaykitAllowanceEvent(event)
@@ -1488,10 +1529,21 @@ struct AppScene: View {
mode: mode, forceFresh: forceFresh, messagePriority: messagePriority,
refreshPriority: refreshPriority, canRefresh: canRefresh
)
- guard refreshed else { return false }
+ guard refreshed else {
+ #if DEBUG
+ ProofDisplayQAHook.log("refresh gate returned before allowance/process/backfill/presentation") // test hook
+ #endif
+ return false
+ }
// Deleting the profile withdraws the payment lists an automatic payment would consume.
if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile {
+ #if DEBUG
+ ProofDisplayQAHook.log("allowance refresh start after refreshed=\(refreshed) review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
await paykitAllowanceManager.refresh()
+ #if DEBUG
+ ProofDisplayQAHook.log("allowance refresh end review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
let isAutomaticSession = { pubkyProfile.automaticPaykitSession == session && !pubkyProfile.isDeletingProfile }
let acceptedOffers = await paykitAllowanceManager.acceptOffersFromAllowers(
canContinue: { canRefresh() && isAutomaticSession() },
@@ -1500,6 +1552,9 @@ struct AppScene: View {
announceAcceptedAllowanceOffers(acceptedOffers)
// A request whose manual sheet is already open stays with the user.
let manualRequestId = incomingPaymentRequestPreparation.flatMap { $0.ownsSheet(sheets) ? $0.request?.id : nil }
+ #if DEBUG
+ ProofDisplayQAHook.log("automatic processing reached review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
let handledAutomatically = await paykitAllowanceManager.processIncomingRequests(
paykitPaymentRequestManager.pendingRequests.filter { $0.id != manualRequestId }
)
@@ -1511,6 +1566,9 @@ struct AppScene: View {
guard pubkyProfile.authState == .authenticated,
PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey)
else { return false }
+ #if DEBUG
+ ProofDisplayQAHook.log("backfill reached") // test hook
+ #endif
let contacts = paykitPaymentRequestManager.receivedPaymentContacts
do {
try await CoreService.shared.activity.backfillReceivedPaykitContacts(
@@ -1524,6 +1582,9 @@ struct AppScene: View {
Logger.warn("Failed to attribute received Paykit payments: \(error)", context: "AppScene")
}
if presentItems, canRefresh(), pubkyProfile.automaticPaykitSession == session {
+ #if DEBUG
+ ProofDisplayQAHook.log("presentation reached") // test hook
+ #endif
await presentNextIncomingPaykitItem()
}
if mode == .full, canRefresh() {
@@ -1606,7 +1667,13 @@ struct AppScene: View {
await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey)
await retryPendingPaykitEndpointRemoval()
}
+ #if DEBUG
+ ProofDisplayQAHook.log("poll start mode=\(mode) warm=\(paykitPaymentRequestManager.pendingRequests.count)") // test hook
+ #endif
await refreshIncomingPaykitPaymentRequests(mode: mode, messagePriority: .background, onlyWhileActive: true)
+ #if DEBUG
+ ProofDisplayQAHook.log("poll end mode=\(mode) warm=\(paykitPaymentRequestManager.pendingRequests.count)") // test hook
+ #endif
guard mode == .full,
!Task.isCancelled,
scenePhase == .active,
@@ -2225,3 +2292,35 @@ struct AppScene: View {
)
}
}
+
+#if DEBUG
+@MainActor
+final class ProofDisplayQAHook { // test hook
+ static var enabled: Bool { ProcessInfo.processInfo.arguments.contains("-proof-display-qa") } // test hook
+ static let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] // test hook
+ static func consume(_ name: String) -> Bool { // test hook
+ let path = directory.appendingPathComponent(name) // test hook
+ guard enabled, FileManager.default.fileExists(atPath: path.path) else { return false } // test hook
+ try? FileManager.default.removeItem(at: path) // test hook
+ return true // test hook
+ } // test hook
+ static func log(_ text: String) { // test hook
+ guard enabled else { return } // test hook
+ let url = directory.appendingPathComponent("proof-qa.log") // test hook
+ let data = Data(("\(Date().timeIntervalSince1970) \(text)\n").utf8) // test hook
+ if let handle = try? FileHandle(forWritingTo: url) { // test hook
+ _ = try? handle.seekToEnd(); try? handle.write(contentsOf: data); try? handle.close() // test hook
+ } else { try? data.write(to: url) } // test hook
+ } // test hook
+ static func holdIfArmed() async { // test hook
+ guard enabled, FileManager.default.fileExists(atPath: directory.appendingPathComponent("proof-arm").path) else { return } // test hook
+ log("held after idle before display refresh") // test hook
+ while !Task.isCancelled, !consume("proof-release") { // test hook
+ try? await Task.sleep(for: .milliseconds(100)) // test hook
+ } // test hook
+ guard !Task.isCancelled else { return } // test hook
+ _ = consume("proof-arm") // test hook
+ log("released review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ } // test hook
+} // test hook
+#endif
diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift
index 5163999f..1f760599 100644
--- a/Bitkit/Services/PaykitPaymentProofService.swift
+++ b/Bitkit/Services/PaykitPaymentProofService.swift
@@ -238,6 +238,10 @@ actor PaykitPaymentProofService {
private static let proofStateChangedSubject = PassthroughSubject<Void, Never>()
private static let onchainPaymentResolutionSubject = CurrentValueSubject<PaykitOnchainPaymentResolution?, Never>(nil)
+ #if DEBUG
+ static func qaTriggerProofStateChange() { proofStateChangedSubject.send() } // test hook
+ #endif
+
nonisolated static var proofStateChangedPublisher: AnyPublisher<Void, Never> {
proofStateChangedSubject.eraseToAnyPublisher()
}
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index e8c51278..1a2912aa 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1268,7 +1268,10 @@ actor PaykitSdkService {
func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] {
try await operationLock.withLock {
- try await handle().listAllowances(filter: filter)
+ #if DEBUG
+ await ProofDisplayQAHook.log("allowance Ordered admitted review=\(PaykitPaymentActivity.shared.shouldDeferRefresh)") // test hook
+ #endif
+ return try await handle().listAllowances(filter: filter)
}
}
@@ -1670,6 +1673,9 @@ actor PaykitSdkService {
private func checkRefreshAdmission(_ priority: PaykitSdkOperationLock.Priority) async throws {
if priority == .background, await PaykitPaymentActivity.shared.shouldDeferRefresh {
+ #if DEBUG
+ await ProofDisplayQAHook.log("SDK admission deferred Background") // test hook
+ #endif
throw PaykitPaymentRequestError.operationInProgress
}
}
diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift
index 3cae227c..834eb064 100644
--- a/Bitkit/Views/Wallets/Send/SendAmountView.swift
+++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift
@@ -341,6 +341,10 @@ struct SendAmountView: View {
}
private func onContinue() async {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing continue") // test hook
+ if ProofDisplayQAHook.consume("proof-at-continue") { PaykitPaymentProofService.qaTriggerProofStateChange() } // test hook
+ #endif
guard !isContinuing else { return }
isContinuing = true
defer { isContinuing = false }
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index 22df05bc..5297aef3 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -253,6 +253,9 @@ struct SendConfirmationView: View {
guard localFollowupComplete else {
return await onchainPendingRoute(txid: txid, requestId: requestId, using: attempts)
}
+ #if DEBUG
+ ProofDisplayQAHook.log("timing success route") // test hook
+ #endif
return .success(paymentId: txid)
}
@@ -402,6 +405,9 @@ struct SendConfirmationView: View {
}
await calculateTransactionFee()
await calculateRoutingFee()
+ #if DEBUG
+ ProofDisplayQAHook.log("timing confirmation fees ready") // test hook
+ #endif
guard !Task.isCancelled else { return }
// PIN navigation can cancel the view task while authorization is awaiting its result.
Task { @MainActor in await startAutomaticPaymentIfNeeded() }
@@ -806,6 +812,10 @@ struct SendConfirmationView: View {
}
private func submitPayment(isAutomatic: Bool = false) async throws {
+ #if DEBUG
+ ProofDisplayQAHook.log("timing submit") // test hook
+ if ProofDisplayQAHook.consume("proof-at-submit") { PaykitPaymentProofService.qaTriggerProofStateChange() } // test hook
+ #endif
guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() }
if isAutomatic, isFeeRateMissing {
guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else {Twin: bitkit-android#1639 fix: defer passive proof display refreshes at 8668828 matches Background display admission, warm-state retention and the successful-refresh gate before allowance/presentation work. iOS also waits for review idle and keeps a session-scoped refresh token. This was a source comparison.
Coverage: delta b421181..5277d37, all 49 changed paths including merged changes, the five-file net PR diff, reached callers and the prior open finding. All planned cases ran. Inherited USDT, biometric, scanner, LNURL and other navigation paths were inspected in source and were not driven by these native cases.
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
This PR lets automatic proof-triggered payment-request display updates yield to payment review and submission while retaining a fresh read on the next existing refresh opportunity.
Stacked on #1054. No measured latency improvement is claimed.
Description
Out of Scope
Design
N/A — no UI changes.
Preview
N/A — no visual changes.
QA Notes
Setup
Use the journey guide and Payment Request setup at
2051eee1with disposable regtest wallets and a saved, linked fixture issuer. This source uses Paykit0.1.1-rc1and Core0.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
N/A — not drivable; see Manual Tests.
Manual Tests
Automated Checks
PaykitPaymentRequestServiceTests.swift— forced Background reads wait for admitted snapshots, preserve warm rows after deferral, reject stale identity/cancellation, and let explicit Ordered recovery and a later current read succeed.PaykitPaymentActivityTests.swift— the session-scoped refresh token survives while review remains active after submission ends and newer invalidations coalesce.Evidence
Final integration validation
Integration with master
4530ff7bat2051eee1retains 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, tree8f1e72d3from #10707cd096f4plus #10818967dff8, #1084b7561c08and #1085310e689a, 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.October 23 maintenance validation
Current head
5277d370includes masterf650cbb4and the deferred-refresh guard from review 4356902947. Both 19-target builds passed; exact #1025 passed 2,897 canonical hosted unit tests and the pure combined stack passed 2,923, with zero failures/skips and the six existing CI live-integration exclusions. The 16 focused lifecycle cases also passed. All eight SwiftFormat scans are 0/843; translation checks have zero errors and 15 warnings matching executed master f650cbb. Complete runtime diagnostics were inspected, including retained SDK-construction QoS and hosted-view/fixture notices; this is not a warning-free runtime claim.Combined tree
d8fd91e2includes #1070974be754and sibling heads #1081c26e8b82, #10842b3b7aa2, #108589f05b40. These are two full suites, not eight independent full-suite runs. The first bottom attempt exposed an inactive-scene setup in the new lifecycle test; only that fixture was corrected, and the final focused/canonical runs passed. Independent source review covered the deferred guard and presentation/token/app-lock integration. Native case 1 remains unexecuted; new-head CI is pending.Gaps
Earlier full-suite SDK-construction QoS and hosted UIKit appearance warnings remain recorded separately. Recovery uses the existing foreground/remount/new-event paths and regular polling; this change does not promise immediate retry when review ends.
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 1 remains unchecked and unexecuted on this integrated head. Native USDT-warning, PIN and biometric flows have not been verified by this integration validation.
Models used
gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)