Repository navigation
fix: deliver Paykit payment proofs promptly - #1025
ben-kaufman wants to merge 17 commits into
Conversation
|
Triage: milestone 3.1.0, the iOS twin of synonymdev/bitkit-android#1591. Paykit proof delivery starts promptly after the payment is persisted. It's an improvement, not a release blocker, and it closes no issue, so this PR carries the milestone itself. |
[Medium impact] The changes affect Paykit proof delivery and USDT payment confirmation within the app. The PR appears safe to merge, with a non-blocking improvement needed in the concurrency test. Findings
SummaryThis PR sends payment proofs through the intended recipient’s queue, retries already-queued proofs, and starts USDT reconciliation sooner.
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Payment proof ready] --> B[Wait for submission lock]
B --> C[Check identity and request]
C --> D{Exact proof already queued?}
D -- No --> E[Queue proof in SDK]
D -- Yes --> F[Drain intended recipient queue]
E --> F
F --> G[Keep failed delivery queued for retry]
G --> H[Release submission lock]
Reviews (1) · Last reviewed commit: "fix: deliver Paykit payment proofs witho..." · Reviewed by Greptile |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. Delivers proofs through the recipient's own outbound queue, serialises overlapping proof submissions behind a second lock, starts USDT reconciliation on confirmation and adds duration-only SDK diagnostics. Reviewed 74919dd, full tier, reasoned from the code and CI (iOS does not build on my side).
What I checked, and 5 candidates I ruled out
Read in full: the PaykitPaymentProofService.swift diff with submit, submitReady, submitInBackground, reconcile, removeRequestProofs and PaykitProofMutationLock; the PubkyService.swift changes to processOutboundPrivateMessages, withSdk and withStateRevisionTracking; the UsdtSendView.swift task, send() and PaykitUsdtPaymentService.reconcile; the test changes up to 74919dd.
Call sites traced: every submit( / submitInBackground( caller (lines 981, 983, 1079, 1165, 1343, 1570, 1590), and every mutationLock.withLock body for a nested submit or remove.
CI: validate green; Run Tests and Run Integration Tests were still running on 74919dd.
Ruled out
- Lock-order deadlock between
submissionLockandmutationLock:submittakessubmissionLockthen, throughremoveRequestProofs,mutationLock; nomutationLockbody callssubmit,submitInBackgroundor a remove helper, so the reverse order never occurs. - Delivery under a switched identity: the new
expectedIdentityoverload checksidentityStatus()inside the SDK lock and throwsidentityChanged, andsubmitReadyalready re-checks the identity before submitting. - Lost delivery when the flush fails: the error is logged and the proof is still treated as queued in the SDK, which is the durable outbox; the updated test retries an already-queued proof to the same recipient without a second submission.
- Early USDT reconcile racing the send:
reconcilereturns early whilesending,preparingorreconcilingis set, and the task still runs the finalreconcileafter the polling loop, so a no-op early call loses nothing. - Skipping the sleep after a status change: the new guard only breaks the loop once the status leaves the pending set, which the
whilecondition would have done after the sleep anyway.
Merge confidence: 4/5, no findings, but the unit and integration runs on 74919dd had not finished, and the lock ordering was checked by reading, not by a build.
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: no findings.
Checked and clean:
- No lock inversion.
submissionLockandmutationLockare always taken in that order, and nomutationLockbody callssubmit, so there's no inversion or re-entry. The lock also closes a pre-existing iOS window where a background submit and reconcile could both submit the same proof. UsdtSendViewchanges only cut latency. TheTask { reconcile }on.confirmedbefore the celebration sleep, and the early break when the status leaves pending, are no-ops if a reconcile is already running (guard !reconciling, !sending, !preparing).
success-dismissal.xml matches synonymdev/bitkit-android#1591.
Device gate (partial): not run yet. Perf-relevant: the same extra debug log volume per SDK operation as Android, and reconcile() now waits behind an in-flight background submit including its network flush. Pending: proof latency and log lines per minute against master, plus success-dismissal.xml. The device result will follow.
|
The unit-test failure was The same test passes locally on the unchanged PR head with Xcode 27. I have restarted the failed Xcode 16.4 CI job to check for a repeat; no proof-delivery change is justified by this failure. |
|
@ben-kaufman on the flaky |
b17c070 to
ae8d401
Compare
|
@ovitrif out of scope: tracked in #1033 for 3.1.0, because the intermittent tag-return transition assertion is in unchanged base code and the current hosted unit run is green. The follow-up covers your test-scoped animation suggestion while preserving the cancellation, resumed-preparation and final-route assertions. This is separate from #989's custom-fee navigation failure. I've linked it in this PR's Out of Scope section. |
|
@jvsena42 The current head guards these diagnostics with The latest change also removes the redundant early claim for new inline requests, using the existing atomic claim-and-accept operation. Early claims remain before versioned endpoint consumption and accepted retries. All 406 focused simulator tests pass. Staging timing is recorded in the description with its limitations; matched final-change measurements remain in progress. |
There was a problem hiding this comment.
Verdict:
The PR moved to 0634b16 after this run; these results are for a5aafe1.
Findings: None at a5aafe1.
QA for a5aafe1: Debug build on two iOS 26.5 simulators, using disposable staging identities and regtest funds.
Test 1: Payment Request success dismissal ✅ The 6,007-sat payment delivered its proof, restart kept one outgoing payment, and closing a later 5,000-sat request left it unpaid.
The requester showed Proof submitted for the original request. I observed both dismissals for five seconds and checked the UI snapshots: no error toast, reopened sheet or additional payment. The recording shows the payer on the left and requester on the right, aligned at 2×.
J1-success-dismissal.2x.mp4
Diagnostics recorded durable proof enqueue 2.9 seconds after Lightning settlement. The recipient delivery call returned 11.1 seconds after settlement; its queue wait was 1.3 seconds and work took 6.9 seconds. This is one sample, not a before/after benchmark.
Test 2: Recipient transport isolation and retry ⏭️ Not started before the head changed.
The PR advanced to 0634b16 while Test 1 was running. Transport-stall isolation and failed-delivery recovery remain unverified on-device.
Test 3: Staging payment through content unlock ⏭️ No merchant purchase or content unlock was reached.
The staging Shop redirected to an HTTP 401 page. An alternate merchant fixture encountered a build error; its retry ended when the PR advanced. Merchant completion latency remains unmeasured.
Automated regression coverage relies on the author's focused simulator-test report; I did not rerun that suite.
The Android pair, synonymdev/bitkit-android#1591 fix: deliver completed Paykit proofs promptly, matched proof delivery and inline claiming at 35b83d5; iOS also advances USDT reconciliation.
Full review of b45b22a…a5aafe1, covering all nine changed files and reached callers. USDT confirmation timing was reviewed statically only. The newer head 0634b16 is outside this review.
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
201dd22 to
780713e
Compare
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Reviewed the full PR diff against its merge base, at 2142997f.
No new actionable code findings.
A persisted Lightning or on-chain proof is handed to the SDK queue without holding the submission lock across private delivery. A proof already removed after that durable enqueue is not read from the SDK again, and a failed local save still attempts immediate enqueue. Acceptance without a versioned payment list claims inside claimAndAccept; a versioned list is still claimed before consumption. A blocked peer fails that acceptance before the payment is approved, and a later authorization rechecks the peer. Confirm, PIN, hardware signing, and a quoted USDT payment defer background inbox refresh and release that hold when the app is backgrounded. Identity and linked-peer reads retry one contended attempt while holding the SDK lock.
Compared with bitkit-android#1591 at 537d35e1: per-recipient proof delivery, inline claiming, review release when the app stops, and both payment-request journeys match. iOS also starts USDT reconciliation when the transfer is confirmed, then reconciles again after the status loop.
Validation: Run Tests and Run Integration Tests succeeded on this revision. This review inspected those results and the test sources. Device testing: not performed in this review. A success-dismissal pass was reported for a5aafe1, before the review-deferral and read-contention changes. Ready for device testing.
jvsena42
left a comment
There was a problem hiding this comment.
Code scan at a95f85a: no issues found. The branch was rebased, so I reviewed the full diff against cf2b97e with a focus on what's new. Both items from my last review are fixed in 0fa3035:
- the debug log volume is behind
#if DEBUG; submissionLocknow covers only the durable enqueue, and transport runs in a detachedTask(PaykitPaymentProofService.swift:1677), soreconcile()no longer waits behind a network flush.
Checked and clean, reviewed as a funds change:
- Locks and partial failure:
- Lock order stays
submissionLock→mutationLock, andreconcilecallssubmitoutside anymutationLockbody. onlyIfRetainedskips proofs another worker already handed off.- A failed Lightning persist falls back to an immediate
submit.
- Lock order stays
- Inline claim:
prepareForPaymentskipsensurePaymentAllowed/claimForPaymentonly for requests that need acceptance and have no versioned list.service.accept(the SDK claim-and-accept) still runs before dispatch.SendSheetpasses the consumption closure only whenpaymentListVersionis set.
- Review hold:
SendSheet.cleanup()andSendPendingScreen.onDisappearrelease it, it's released on.background, andendReviewwakes theincludingReviewswaiters. - Deferred contacts:
ContactsManagerrethrows, andMainNavView.swift:913-921waits withwaitUntilIdle(includingReviews: true)and retries. The deep-link failure I'm raising on the Android twin (synonymdev/bitkit-android#1591) doesn't exist here. - Cross-identity: transport
expectedIdentityis checked inside the SDK lock. - Journeys: the four files are identical to Android.
Perf-relevant: proof transport re-timed to fire-and-forget, Background refresh deferred during review, contended reads retried, on-chain resolution coalesced.
Device gate (partial): a95f85a — not run yet. Pending:
review-refresh.xml,success-dismissal.xmlandonchain-original-payment-retry.xmlcross-device with Android;- timing from
Queued a Paykit payment proof for private deliverytoFinished Paykit operation processOutboundPrivateMessages, and on to "Proof submitted" on the requester, against master.
Device results will follow in a separate review.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up since 2142997f, using baseline review 5581590217, at a95f85ae. This pass covered interactive enqueue for Lightning paymentSuccessful and public on-chain confirmation, the ordered persist-failure fallback, background delivery, hardware follow-up remaining ordered, Pending recovery review, and the updated on-chain retry journey. Inherited baseline coverage remains the per-recipient delivery and skip-after-handoff behavior, coalesced resolution, inline claim, confirm/PIN/hardware/USDT review deferral, identity and linked-peer read retry, contact deep-link retry, USDT reconciliation timing, review-refresh.xml and success-dismissal.xml, and the Android comparison of those unchanged contracts at 537d35e1.
1 actionable finding — resolve or provide an evidence-backed rebuttal.
An authorized on-chain Paykit retry still enqueues its proof through the ordered private completion. Android's recovered completion at a28a0772 uses interactive priority for a default-wallet proof. Identity and payment-list reads that precede submitPaymentProof stay ordered on both sides.
Validation: Run Integration Tests and SwiftFormat passed on this revision. Run Tests was still in progress. The new priority and recovery-review tests were inspected and not executed here. Device testing: not performed in this review. The success-dismissal pass reported on a5aafe1 predates this priority and recovery-review change.
Recommended before device testing: wait for the in-progress unit-test run on this revision.
Suggested additional test cases
- iOS. Use a disposable regtest wallet, one Paykit shop request, and a retained unresolved original on-chain send, with a background Paykit refresh queued. Authorize the original-payment retry through the fee dialog, the exact prepared fee, and the payment PIN until broadcast is accepted. The proof should be enqueued once at interactive priority ahead of that background refresh, for the original recipient and amount.
Findings
- [MEDIUM] Enqueue an authorized on-chain retry proof ahead of background refresh — inline at
Bitkit/Services/PaykitPaymentProofService.swift:1137.
There was a problem hiding this comment.
Verdict:
Findings: No new findings. I confirmed the existing MEDIUM retry-proof priority finding at PaykitPaymentProofService.swift:1137: an authorized original-payment retry still reaches proof enqueue at .ordered.
QA for a95f85a:
Test 1: payment review lifecycle ✅ Review and PIN waits, cancellation and background/reopen preserved the original request; it paid once with proof delivery.
The review and PIN each stayed open for at least 30 seconds. The original 1,000-sat request paid once; the later 2,000-sat request remained unpaid. I checked the transaction and balances against the backend.
J1-combined-2x.2x.mp4
Test 2: success dismissal ✅ Closing Lightning success and restarting retained one payment; closing a later unpaid request sent nothing.
The 6,007-sat request reached Proof submitted. Closing success for five seconds produced no error; restart retained the payment. A later 5,000-sat request closed without payment or a balance change.
J2-combined-2x.2x.mp4
Test 3: original onchain retry ✅ Fee and PIN cancellation retained the original; one authorized retry spent its original inputs and recipient amount, checked against the backend.
I used a controlled unresolved state that prevented the initial broadcast. Fee entry, prepared-fee and PIN waits/cancellation, plus background/reopen, retained that original. The actual retry used the original inputs, paid 1,300 sats and charged the approved 281-sat fee; the requester reached Proof submitted. Invalid fee input disabled Continue. This did not simulate native broadcast-response loss.
J3-combined-2x.2x.mp4
Test 4: Pending recovery cancellation ✅ Retry and abandon biometric cancellation, confirmation cancellation and release-fee cancellation preserved the same original payment.
A controlled unresolved 1,500-sat payment remained intact through those cancellations and background/unlock. Trace checks showed passive refresh deferred during abandon review and admitted on idle Pending. The biometric overlay made the scene inactive, so no passive polling attempt was observed there. A separately authorized 422-sat self-return cleared the ended payment afterward.
M1p-compact-2x.2x.mp4
Test 5: recipient isolation and delivery recovery ⏭️ Restart recovery passed, but the injection did not establish recipient network isolation.
A 1,303-sat Lightning payment settled while injected recipient delivery failure left the requester Pending. Payer restart recovered its proof without another payment. A separate recipient's proof also completed while unrelated app transport calls were suspended. However, another native SDK path delivered the held recipient's proof, so the required sustained network backlog was not proved.
M2-2x.2x.mp4
Test 6: merchant content unlock ✅ One 1,000-sat payment completed the original order and unlocked its content; invoice, bundle and file bytes were checked against the backend.
The original invoice reached amount_matched and proof_submitted; its original Locks bundle completed and the same order became paid. Authenticated access returned the original 43-byte file, with integrity verified in the UI.
Trace/backend timing for this single run: proof queued at 14:04:59.555 CEST; delivery waited 6.04 seconds for admission and spent 7.07 seconds in the SDK, returning at 14:05:12.671 CEST. Locks completed at 14:05:07.295 CEST and the marketplace recorded completion at 14:05:27.338 CEST. These are separate observations, not a latency benchmark. This used a compatible staging-service fixture after the deployed Shop returned HTTP 401.
M3-combined-2x.2x.mp4
M3-unlocked-2x.2x.mp4
Test 7: confirmed USDT reconciliation ✅ One USDT transfer confirmed; after success dismissal the requester verified payment, and payer restart retained one transfer.
A $1 request accepted only USDT. Review approved 1 USDT plus a maximum 1.60-USDT fee; the final displayed fee was 0.83 USDT. I checked the successful canonical receipt and exact 1-USDT recipient transfer on the local test fork. After dismissal the requester reached Payment verified, and restart retained one debit and a 23.17-USDT balance. Sender transport had returned before dismissal; recipient verification completed afterward.
U1-2x.2x.mp4
Twin: synonymdev/bitkit-android#1591 fix: deliver completed Paykit proofs promptly at a28a0772 matches the core proof-delivery and review-lifecycle contracts; the existing iOS retry-priority difference remains.
Coverage: I reviewed the full current cf2b97e..a95f85a diff after the rebase, plus a5aafe1..a95f85a. Identity barriers, persistence-failure fallbacks and scheduler ordering were checked in source; device runs did not inject native broadcast-response loss or a crash. Current-head unit tests and integration tests passed; local E2E CI remains pending.
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
|
The current SwiftFormat failure comes from the PR merge checkout: both settings files reproduce the same findings on master |
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up of the delta since a95f85a, reviewed at d7600d5. Unchanged coverage is inherited from the baseline review. This pass inspected background outbound delivery after the expected-identity read, the shared backup-revision deferral path, the proof-delivery caller, and the matching Android behavior at a28a077.
1 actionable finding — resolve or provide an evidence-backed rebuttal.
Background delivery that finds a payment active after identityStatus() throws OperationDeferral.paymentActive, leaves the SDK lock, and retries after the payment without clearing the backup snapshot or bumping backupStateVersion. Resume reads identity again and then sends. Cancellation, clearState, and wallet wipe while that wait is outstanding do not send. PaykitPaymentProofService starts this work with the payment identity and does not observe the deferral; the call waits inside processOutboundPrivateMessages. Android at the comparison pin returns null from revision tracking instead of throwing, which also avoids the failed-write backup update. Interactive and ordered delivery still do not wait.
Validation is source inspection only. testBackgroundOutboundDeliveryRechecksPaymentAfterIdentityRead in PaykitSdkOperationLockTests.swift covers resume, cancel, reset, and wipe, and this review did not run it. Run Tests was still in progress on this head. SwiftFormat failed on unchanged Electrum and RGS settings screens, outside this delta. Device testing: not performed in this review.
Recommended before device testing: wait for Run Tests on d7600d5, the automated check for this deferral.
Earlier findings reconciled at this revision: 1 still present, 1 verified fixed.
Findings
- [MEDIUM] Enqueue an authorized on-chain retry proof ahead of background refresh — now at
Bitkit/Services/PaykitPaymentProofService.swift:1137. Still present from the earlier review.
jvsena42
left a comment
There was a problem hiding this comment.
Delta at d7600d5: no issues found.
.background outbound delivery now re-checks PaykitPaymentActivity.shared.isActive after the expectedIdentity read (PubkyService.swift:1092-1095) and throws the private OperationDeferral.paymentActive before any SDK write.
withSdkcatches the deferral and retries afterwaitUntilIdle(), so it never reaches a caller.- It fires only while a payment is in progress, not during reviews.
- Every
begin()has adefer end(), so cancel, Back and errors all release it. - The proof stays in the durable SDK outbound queue.
withBackupStateRevisionTrackingskipsonChangeonly for this deferral.
This matches the Android recheck (PaykitSdkService.kt:992). It doesn't reproduce the contact-deeplink failure I raised on synonymdev/bitkit-android#1591.
Device gate (partial): d7600d5 — not run yet. Pending:
review-refresh.xml,success-dismissal.xmlandonchain-original-payment-retry.xmlcross-device with an Android master build;- proof-delivery timing against master, including paying B right after A.
Device results will follow in a separate review.
4022aa7 to
bc2fb60
Compare
jvsena42
left a comment
There was a problem hiding this comment.
Delta at bc2fb60: no HIGH/MEDIUM. One LOW (journey docs) is inline. The rebase is clean (range-diff all =), and the dropped style commit is on master.
Checked and clean, reviewed as a funds change. bc2fb60 adopts Paykit 0.1.1-rc1 and Core 0.8.0-rc10, the same pins as synonymdev/bitkit-android#1591.
- Prepare (:415-447): one
readPaymentRequests(counterparty:, expectedIdentity: nil)replacesidentityStatus()pluspaymentRequests(). The capability, identity and already-paid checks are kept, and the SDK returns every record for the counterparty. - Submit (:1652-1667): passes
expectedIdentity: pendingProof.identity. A mismatch returns empty, so submit returns false. TheisAlreadyQueuedandonlyIfRetainedchecks are unchanged. - Priority and retry: both stay at Ordered with no payment-admission deferral. The retry fires once after 250 ms, only on acquisition-time
SharedStateBusy, and cancellation propagates. - Scheduling snapshot: keeps the background waits,
isCurrentand identity re-inspection. It now discards reads that span a generation change, and callers keep their retry intent (testFailedSchedulingReadRetainsRetryIntent).
Perf-relevant: proof prepare and submit drop from two lock acquisitions to one each, and drain scheduling from two reads to one.
Device gate (partial): bc2fb60 — needs a rebuild, since the SDK binaries and the prepare / submit / drain reads changed. Pending: review-refresh.xml, success-dismissal.xml, onchain-original-payment-retry.xml (step 6 should now pass as written) and proof timing ×3 against master, paired with Android 766f4a8. Results that stand from 4022aa7:
- the retry dialog, Cancel and 30 s on PIN with no broadcast;
- header Back from PIN;
- the same-coins / approved-fee backend check;
- the Lightning dismissal with no toast;
- the step 13 spinner, a master bug.
Device results will follow in a separate review.
|
Device gate (partial 7): proof timing at
Requester-side paid times (≈77 s) aren't comparable across these sets, because the Android build differed. Still running: |
|
Device gate (partials 8-10) at
Still running: |
jvsena42
left a comment
There was a problem hiding this comment.
Approving. No HIGH/MEDIUM. The journey-description LOW from my last review stays open; it can be fixed here or in a follow-up, your choice.
Device gate: bc2fb60 — journeys: 44 passed, 1 failed on a master bug (review-refresh.xml 16/17, success-dismissal.xml 13/13, onchain-original-payment-retry.xml 15/15); perf: broadcast → proof queued 5.8 / 6.9 / 5.9 s (master 10.8-13.4 s), payment successful → Queued 7.5 s on Lightning (4022aa7 13.9 s), open → Selected fee rate median 6.5 s (4022aa7 9.1 s); Figma: n/a (no new UI); l10n: no UI text changed; Android: cross-device with synonymdev/bitkit-android#1591, both on Paykit 0.1.1-rc1.
Ran on the iPhone 17 simulator, PIN and "Require PIN for payments" on.
- The
review-refresh.xmlfailure is step 13. It's the stuck spinner after a PIN background cycle, which reproduces on master and is posted as a separate out-of-scope note. Closing and reopening the request recovers it. - The on-chain retry used a real dropped broadcast: a local Electrum proxy swallowed
blockchain.transaction.broadcast.- The retry spends the same original inputs and pays exactly 2 000 sats, with the approved fee (188 sats).
- Above what the original coins can cover, it shows a readable "This fee cannot be paid from the original coins…" error and sends nothing.
- Step 6 passes as written ("verify Continue is disabled").
- Header Back from PIN keeps Pending.
- Operation counts:
readPaymentRequestsruns once in admission and once in submit, never during confirmation preparation.- The scheduling snapshot runs only after the confirmation is ready.
- The 13-42 s opens seen in one run came from SDK queueing during back-to-back payments; with no build running, the median was 6.5 s.
⚠️ Not run:- the hardware-wallet variant of the retry journey (no Trezor paired on this simulator);
- the
prepared-fee-recovery.mdcases that need history and persistence faults.
e2e-tests-local is still pending.
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: None.
QA for bc2fb60:
Test 1: payment review lifecycle ✅ Review and PIN cancellation retained one original payment; Bitcoin RPC confirmed its exact output and fee.
Held confirmation and PIN for at least 30 seconds, cancelled both, received a later request live, and returned through background/unlock. The original request paid 1,000 sats once with a 143-sat fee; the later 2,000-sat request stayed unpaid. Returning from background needed the close/reopen workaround for the known master preview issue.
Payer left; requester right. PIN entry is omitted.
J1-combined-2x.2x.mp4
Test 2: Lightning success dismissal ✅ Closing success and restarting retained one payment and delivered proof; dismissing another request paid nothing.
Paid 6,007 sats once. Close and the later unpaid-request dismissal each showed no error during five-second observations. Restart retained the payment and balances. LND independently recorded one forwarding event with 6,007,000 msat out and a 1,006-msat fee.
Payer left; requester right. PIN entry is omitted.
J2-combined-2x.2x.mp4
Test 3: original onchain retry ✅ A controlled Pending payment survived fee and PIN cancellation; the real retry preserved its original input and 1,300-sat recipient amount.
The initial no-dispatch fixture retained the prepared receipt and returned Unknown. This is labelled state injection. Explicit retry and its native broadcast were unmodified.
Fee entry, exact prepared fee and PIN were each held for at least 30 seconds before cancellation. At 1,000 sat/vB, Continue was disabled. Background/unlock and reopening retained the original. The final 2-sat/vB retry required fresh approval of the 281-sat fee and PIN. Bitcoin RPC verified the complete original input set, exact recipient output and winning fee; the original request received proof.
Payer left; requester right. PIN entry is omitted. Native broadcast-response-loss and hardware-signer variants were not exercised in this case.
J3-combined-2x.2x.mp4
Test 4: recipient transport isolation and restart recovery ✅ B received proof while A's native transport failed; restart and fault release recovered A without another payment.
A recipient-scoped HTTP 503 blocked A's private proof writes while B's writes succeeded. A stayed waiting while B showed proof submitted. After payer restart, A's same queued write retried and failed; releasing the fault let the ordinary retry deliver it. Bitcoin RPC verified the 1,101- and 1,202-sat outputs and their 143-sat fees. Outgoing payment entries remained unchanged.
Payer left; B middle; A right. PIN entry is omitted.
M2-combined-2x.2x.mp4
Test 5: merchant content unlock ✅ One payment completed the original invoice, proof, Locks bundle and Shop order, then unlocked the exact product.
Paid 1,000 sats with a 143-sat fee on staging regtest. Backend records show the original invoice amount matched, its SDK request reached proof_submitted, its Locks bundle completed and the same order became paid. Authenticated unlock produced the original 46-byte file and an integrity-verified UI.
Correlated timestamps: broadcast accepted → proof queued 2.14 s; delivery queue 0.38 s; SDK delivery work 9.14 s; broadcast accepted → order paid 29.33 s. These are one transaction's boundaries, not a latency benchmark.
The deployed Shop returned HTTP 401, so this used the repository's owned Shop/Locks/Paykit fixture with staging identities and the staging regtest chain. Buyer left; seller middle; Shop right. The second clip shows the unlocked content.
M3-combined-2x.2x.mp4
M3-unlocked-2x.2x.mp4
Test 6: confirmed USDT reconciliation ✅ One fixed USDT payment remained verified after success dismissal and payer restart.
The USD 1 request accepted only USDT. On an isolated Arbitrum fork, the transaction receipt shows exactly 1 USDT to the requester and a 0.829191-USDT fee below the approved 1.60-USDT maximum. After dismissal and restart, the requester still showed Payment verified and the payer retained one outgoing transfer and 23.17 USDT.
Payer left; requester right. PIN entry is omitted.
U1-combined-2x.2x.mp4
Test 7: combined-read and scheduling regressions ✅ Focused regression suites passed, verified in the Xcode test-result bundle.
Covered combined runtime ownership, acquisition-only retry, cancellation/wipe, generation invalidation, durable enqueue priority and overlapping recovery. The device runner recording is supplementary; assertions were checked in the test result.
A1-2x.2x.mp4
Retained the earlier Pending biometric and abandon-cancellation coverage: authorization, abandonment and scene paths are unchanged in this delta. The independent current-head device review also passed the software retry journey using a real dropped broadcast and found no open-to-ready regression. Hardware and history/persistence-fault variants remain outside this device pass.
Reviewed the delta from 4022aa7 to bc2fb60, accounting for the rebase: official dependency pins, combined proof reads, scheduling snapshots and existing review threads. Identity/reset, cancellation and scheduler-generation branches were checked in code and focused tests.
Twin: bitkit-android#1591 fix: deliver completed Paykit proofs promptly matches the official dependency pins and proof/recovery contracts.
CI: unit tests, integration tests and SwiftFormat passed. The local E2E CI job was still queued at this check.
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 pull request at bc2fb60 after the comparison base changed since the saved assessment. Coverage is the complete diff from merge base 039c7b51, with no inherited baseline.
No new actionable code findings.
Proof submission enqueues under its own lock and starts per-recipient delivery only after the SDK queue accepts the proof. Lightning completion returns once the preimage is stored; a failed save still enqueues immediately at ordered priority. An accepted default-wallet retry skips passive reconciliation and uses interactive enqueue only after the proof is durable. Payment review defers background inbox, display, and pending-message refresh, then releases that hold when the review closes or the app backgrounds. Idle Pending observation stays available. Inline acceptance claims inside the SDK accept call; a versioned payment list is still consumed first, and a peer blocked while acceptance is in progress is rejected. Combined request reads and one scheduling snapshot replace the split reads. A shared-state acquisition failure is retried once and is not applied to payment writes.
The same admission, inline-claim, read-retry, and accepted-payment retention behavior matches bitkit-android#1591 at 766f4a89. The iOS retry journey does not require Android's invalid-fee message; that documentation note remains in #1053.
Validation: inspected the added proof, lock, identity, and request tests. Run Tests succeeded on this revision. This review did not execute that suite.
Device testing: not performed in this review.
Ready for device testing.
There was a problem hiding this comment.
Verdict:
The PR moved to 01e4769 after this run; these results are for d702597.
These results cover d702597. The newer head 01e4769 is outside this review.
Findings: None in the reviewed delta.
QA for d702597: iPhone 17 Pro simulators on iOS 26.5; disposable staging identities, regtest and an isolated Arbitrum fork.
Test 1: hosted USDT address routing ✅ Manual and pasted recipients stayed in one sheet; Back retained the address and dismissal submitted nothing.
Reviewed 1 USDT from manual entry and 1.25 USDT from a pasted payment URI. The original address returned on Back, and the payer retained its 25-USDT balance and one incoming activity. The fixture recorded zero submissions.
D1-2x.2x.mp4
Test 2: contact USDT review and reconciliation ✅ Cancellation and quote expiry submitted nothing; fresh approval paid the original request once and survived restart.
Held payment PIN for 32 seconds, cancelled, and checked zero submissions. Background/unlock required the known master close/reopen workaround. The expired fee quote returned to Amount and required fresh review.
The successful receipt independently confirms exactly 1 USDT to the requester and a 0.829191-USDT fee below the approved 1.60-USDT maximum. After success dismissal and payer restart, the requester still showed Payment verified, the payer retained one outgoing transfer and 23.17 USDT, and the fixture counted one submission.
Payer left; requester right. PIN entry is omitted.
D2-complete-2x.2x.mp4
Test 3: payment and recovery regressions ✅ Focused simulator suites passed; assertions were checked in the Xcode result bundle.
Covered combined reads, SDK admission, identity/wipe and cancellation fences, generation invalidation, durable proof enqueue, fee and backup recovery fixtures, confirmation lifecycle and merged scanner routing. The recording shows the test runner; the assertions come from the test result.
A1-2x.2x.mp4
Test 4: native prepared-fee fault matrix ⏭️ Not started before the head advanced.
Native transaction-history withholding, interruption, original/replacement winner and persistence-fault variants remain unverified. The service fixtures in Test 3 do not establish that native coverage.
Test 5: hardware original-payment retry ⏭️ Not started before the head advanced.
The test signer was paired, but retained-signature retry and recovery were not driven.
Check 1: retained Bitcoin coverage ✅ The reached payment, proof and recovery sources are unchanged from the previous passing report.
Retained the Bitcoin review/cancellation, Lightning dismissal, software original-payment retry, recipient isolation/restart and merchant unlock results from the prior device review at bc2fb60, plus its linked biometric/abandon coverage. Those results ran on their stated earlier heads.
Reviewed bc2fb60..d702597, including the master merge, both USDT conflict resolutions and existing feedback. Device checks covered USDT routing and review; receive-tab and CI imports were inspected only.
Twin: synonymdev/bitkit-android#1591 fix: deliver completed Paykit proofs promptly at 766f4a8 matches the dependency pins and proof/admission contracts; hosted address routing is iOS-specific.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
|
The current E2E failure happened before the tests started: Namespace environment creation timed out after 60 seconds ( Update: the single workflow retry completed successfully at unchanged head I inspected the complete log and the exact E2E assertion. The original environment-creation failure is resolved; the LNURL presentation timeout’s cause remains unestablished. No source change or further workflow retry was made. This gate result does not provide new Paykit/Locks latency or merchant-unlock measurements. |
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: None.
QA for 01e4769: All five cases passed. I reviewed the contiguous d702597..01e4769 delta, its callers and prior feedback. Merchant headings were checked in source and classifier regressions; the merchant unlock device result is inherited below.
Test 1: USDT warning and PIN continuation ✅ Cancellation reset the swipe without submission; fresh authorization paid the contact once.
The balance and amount warnings appeared in order. Cancelling either warning or PIN returned to review. A fresh contact payment produced one submission and “Payment verified · 1 USDT” for the requester, including after restart. I checked the independent chain receipt: exactly 1 USDT to the requester's address, with a 0.829191 USDT fee below the approved 1.6 USDT maximum. Submission counts came from the backend ledger.
Warnings and cancellation, 2×:
U1-warnings-2x.2x.mp4
Contact payment, payer left and requester right, aligned at 2×:
U1-contact-2x.2x.mp4
Restart, payer left and requester right, aligned at 2×:
U1-restart-2x.2x.mp4
Test 2: Biometric lifecycle ✅ Failed Face ID allowed PIN fallback and manual retry; backgrounding retained the lock until fresh authentication.
I used the simulator's enrolled Face ID with the real system authentication prompt. Failure exposed PIN fallback without automatically reopening biometrics. A separate run backgrounded the active prompt, reopened locked, selected fallback and then unlocked with a fresh biometric match. Scene transitions were also checked in the app trace.
Failure and fallback, 2×:
B1-auth-2x.2x.mp4
Background, reopen and fresh match, 2×:
B1-clean-2x.2x.mp4
Test 3: Native fee and durable recovery faults ✅ Original and replacement winners kept their own exact fees; persistence, identity and backup guards held.
This case used the DEBUG fault hooks shown below. Real native transactions and chain acceptance were preserved. I checked amounts, inputs and fees against decoded backend transactions; the older winner also had an independent Merkle proof. Broadcast counts and interruption points were checked against the proxy ledger and app trace.
- Failing prepared-receipt persistence prevented broadcast. A later accepted original retained its 143-sat fee with fee-history lookup unavailable.
- Killing after the durable receipt retained the original as Unknown. Signing out after fee approval prevented dispatch despite PIN entry. After identity restoration, a freshly authorized same-input replacement paid the original 1,502 sats with a 422-sat fee. Killing after acceptance and restarting completed that same payment and proof without another broadcast.
- A retained older candidate confirmed with a 143-sat fee while its 562-sat successor was refused. Recovery completed the older transaction and original request proof with the older fee.
- The actual backup encode/decode/restore path preserved the retained fee data. Altered amount, candidate and completion progress were rejected, leaving the original attempt intact.
Persistence fault and accepted original, payer left and requester right, aligned at 2×:
F1-save-2x.2x.mp4
F1-original-2x.2x.mp4
Recovery review and identity guard, 2×:
F1-restart-2x.2x.mp4
F1-identity-guard-2x.2x.mp4
Restart after replacement acceptance, payer left and requester right, aligned at 2×:
F1-accepted-restart-2x.2x.mp4
Older candidate wins, payer left and requester right, aligned at 2×:
F1-older-won-2x.2x.mp4
DEBUG fault hooks and deciding trace
The hooks disable fee lookup, fail receipt persistence, pause after real durable receipt/acceptance writes, sign out the disposable identity before retry authorization, and exercise the existing backup guard. They do not substitute a native send result or chain acceptance. Other cases used the pristine reviewed build.
diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift
index 6883fa43..12eca06f 100644
--- a/Bitkit/Services/OnchainSendAttemptService.swift
+++ b/Bitkit/Services/OnchainSendAttemptService.swift
@@ -356,6 +356,12 @@ protocol OnchainSendLocalFollowupHandling {
struct OnchainSendLocalFollowup: OnchainSendLocalFollowupHandling {
static func observedFee(_ attempt: OnchainSendAttempt) async throws -> UInt64? {
+ #if DEBUG
+ if ProcessInfo.processInfo.arguments.contains("--qa-no-fee-history") { // test hook
+ Logger.info("QA fee history unavailable", context: "FeeRecoveryQA") // test hook
+ return nil // test hook
+ }
+ #endif
let service = LightningService.shared
guard attempt.walletId == OnchainSendAttemptService.walletId(index: service.currentWalletIndex),
let node = service.onchainDispatchNode as? Node, let txid = attempt.txid
@@ -447,6 +453,14 @@ struct OnchainSendAttemptStore: OnchainSendAttemptStoring {
}
func save(_ attempts: [OnchainSendAttempt]) throws {
+ #if DEBUG
+ if ProcessInfo.processInfo.arguments.contains("--qa-fail-prepared-save"), // test hook
+ attempts.first?.status == .pending, attempts.first?.recoveryContext != nil // test hook
+ {
+ Logger.info("QA prepared receipt save failed before dispatch", context: "FeeRecoveryQA") // test hook
+ throw NSError(domain: "FeeRecoveryQA", code: 1) // test hook
+ }
+ #endif
try Keychain.upsert(key: .onchainSendAttempts, data: JSONEncoder().encode(attempts))
Self.backupDataChanged.send()
}
@@ -648,6 +662,10 @@ actor OnchainSendAttemptService {
)
try store.save([attempt])
knownAttempt = attempt
+ #if DEBUG
+ Logger.info("QA prepared receipt txid=\(prepared.txid) fee=\(String(describing: prepared.miningFeeSats)) amount=\(prepared.recipientAmountSats)", context: "FeeRecoveryQA") // test hook
+ try await qaHold("receipt") // test hook
+ #endif
}
try await beforeBroadcastAttempt()
try checkWallet(lightningService, index: walletIndex, node: dispatchNode)
@@ -685,6 +703,9 @@ actor OnchainSendAttemptService {
} catch {
Logger.warn("Could not persist the known on-chain outcome; the durable attempt still blocks another send", context: "OnchainSendAttempt")
}
+ #if DEBUG
+ if case .accepted = result { try await qaHold("accepted") } // test hook
+ #endif
return (try? winningResult(attemptId: attemptId)) ?? result
}
@@ -776,6 +797,12 @@ actor OnchainSendAttemptService {
try Task.checkCancellation()
try await approvePrepared(prepared)
try Task.checkCancellation()
+ #if DEBUG
+ if ProcessInfo.processInfo.arguments.contains("--qa-signout-before-retry-authorization") { // test hook
+ try await PaykitSdkService.shared.signOut() // test hook
+ Logger.info("QA signed out before retry authorization", context: "FeeRecoveryQA") // test hook
+ }
+ #endif
try await authorize(attempt, authorizedFeeRate)
try Task.checkCancellation()
try checkWallet(sender, index: index, node: node)
@@ -807,6 +834,9 @@ actor OnchainSendAttemptService {
}
do { try record(result, attemptId: original.id) }
catch { Logger.warn("Could not persist the known recovery outcome; original inputs remain guarded", context: "OnchainSendAttempt") }
+ #if DEBUG
+ if case .accepted = result { try await qaHold("accepted") } // test hook
+ #endif
return (try? winningResult(attemptId: original.id)) ?? result
}
@@ -930,6 +960,17 @@ actor OnchainSendAttemptService {
try store.save([attempt])
}
+ #if DEBUG
+ private func qaHold(_ stage: String) async throws { // test hook
+ guard ProcessInfo.processInfo.arguments.contains("--qa-hold-" + stage) else { return } // test hook
+ let file = Env.appStorageUrl.appendingPathComponent("qa-hold-" + stage) // test hook
+ Logger.info("QA holding " + stage, context: "FeeRecoveryQA") // test hook
+ while FileManager.default.fileExists(atPath: file.path) { // test hook
+ try await Task.sleep(for: .milliseconds(100)) // test hook
+ }
+ }
+ #endif
+
private func currentAttempt() throws -> OnchainSendAttempt? {
let attempts = try store.load()
guard attempts.count <= 1 else { throw OnchainSendAttemptError.unresolved }
@@ -1006,6 +1047,9 @@ actor OnchainSendAttemptService {
}
let isOriginal = recovery.candidateTxids.first?.caseInsensitiveCompare(txid) == .orderedSame
let retainedFee = recovery.candidateFeeSats?[txid.lowercased()] ?? (isOriginal && context.feeSats > 0 ? context.feeSats : nil)
+ #if DEBUG
+ Logger.info("QA winning fee txid=\(txid) retained=\(String(describing: retainedFee))", context: "FeeRecoveryQA") // test hook
+ #endif
let observed: UInt64? = if let retainedFee {
retainedFee
} else {
@@ -1172,7 +1216,46 @@ actor OnchainSendAttemptService {
try currentAttempt()?.requestId == requestId
}
+ #if DEBUG
+ private var qaBackupProbeStarted = false // test hook
+ private func qaProbeBackup() async { // test hook
+ do { // test hook
+ guard nativeDispatchInProgress == nil, let original = try currentAttempt(), original.blocksNewSend else { // test hook
+ qaBackupProbeStarted = false // test hook
+ return // test hook
+ } // test hook
+ let proofs = try await PaykitPaymentProofStore().load() // test hook
+ let wallet = try await PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0) // test hook
+ guard let wire = try backupSnapshot(wallet: wallet, proofs: proofs) else { return } // test hook
+ let bytes = try JSONEncoder().encode(wire) // test hook
+ try bytes.write(to: Env.appStorageUrl.appendingPathComponent("qa-backup-active.json")) // test hook
+ let decoded = try JSONDecoder().decode(PaykitPaymentStateBackup.ActiveOnchainAttempt.self, from: bytes) // test hook
+ let (restored, _) = try decoded.restored(wallet: wallet, proofs: proofs) // test hook
+ let before = try store.load() // test hook
+ try restoreBackup(restored) // test hook
+ Logger.info("QA identical backup replay preserved fees=\(try store.load() == before)", context: "FeeRecoveryQA") // test hook
+ var amount = restored // test hook
+ amount.amountSats += 1 // test hook
+ var candidate = restored // test hook
+ candidate.recoveryContext?.candidateTxids.append(String(repeating: "a", count: 64)) // test hook
+ var progress = restored // test hook
+ progress.localFollowupComplete = true // test hook
+ for (name, altered) in [("amount", amount), ("candidate", candidate), ("progress", progress)] { // test hook
+ var rejected = false // test hook
+ do { try restoreBackup(altered) } catch { rejected = true } // test hook
+ Logger.info("QA altered backup \(name) rejected=\(rejected) originalRetained=\(try store.load() == before)", context: "FeeRecoveryQA") // test hook
+ } // test hook
+ } catch { Logger.error("QA backup probe failed: \(error)", context: "FeeRecoveryQA") } // test hook
+ }
+ #endif
+
private func currentAttemptForPending() throws -> OnchainSendAttempt? {
+ #if DEBUG
+ if ProcessInfo.processInfo.arguments.contains("--qa-backup-probe"), !qaBackupProbeStarted { // test hook
+ qaBackupProbeStarted = true // test hook
+ Task { await qaProbeBackup() } // test hook
+ }
+ #endif
do { return try currentAttempt() }
catch {
guard let knownAttempt else { throw error }Deciding app trace, with timestamps and call-site suffixes omitted:
QA prepared receipt save failed before dispatch
QA prepared receipt txid=9242f277704aa6a592e719c004fff82462a1dd7c7f74faef7a91d7ea21d10a6c fee=Optional(143) amount=1501
QA winning fee txid=9242f277704aa6a592e719c004fff82462a1dd7c7f74faef7a91d7ea21d10a6c retained=Optional(143)
QA holding receipt
QA signed out before retry authorization
QA holding accepted
QA winning fee txid=18ec689f4f471fd898be8977a96343bc49ed1a73f63c1cc00e1985a69d5e08a9 retained=Optional(422)
QA identical backup replay preserved fees=true
QA altered backup amount rejected=true originalRetained=true
QA altered backup candidate rejected=true originalRetained=true
QA altered backup progress rejected=true originalRetained=true
QA winning fee txid=98c97f136e5f4abe4bb5faeabb6bc555074a005e4622fc2286305faf41c87ba5 retained=Optional(143)
Test 4: Retained hardware original ✅ Fresh authorization recovered identical signed bytes, the original recipient and amount, without another hardware signature or payment.
A Bridge-connected Trezor emulator signed the 100,001-sat request. After the actual broadcast was refused, I left the signing screen and restarted. Cancelling the new PIN prompt caused no broadcast; fresh PIN approval retried the saved transaction without another device approval. I compared both raw broadcast payloads byte for byte and decoded the accepted backend transaction: the original address received 100,001 sats with a 141-sat fee. The requester received the original proof. Another restart restored the paid row without a Pay action or additional broadcast.
Refused original, requester left and hardware payer right, aligned at 2×:
H1b-refusal-2x.2x.mp4
Fresh authorization cancellation, requester left and hardware payer right, aligned at 2×:
H1b-auth-2x.2x.mp4
Authorized retry and proof, requester left and hardware payer right, aligned at 2×:
H1b-success-2x.2x.mp4
Paid state after restart, requester left and hardware payer right, aligned at 2×:
H1b-final-2x.2x.mp4
Test 5: Focused simulator regressions ✅ Payment, scheduling, confirmation, biometric and recovery suites passed.
I verified the Xcode result bundle for the focused suites covering payment activity, SDK operation admission and identity checks, private delivery, proof/request services, confirmation lifecycle, on-chain attempts, migrations, scanner classification and biometric policy. No test failed or was skipped. Xcode reported one runtime QoS priority-inversion warning.
Simulator run, 2×:
A1-2x.2x.mp4
Check 1: Inherited coverage and twin ✅ Unchanged paths retain the earlier demonstrated results; the twin matches the shared proof and admission contracts.
I relied on these reports for paths unchanged by this delta:
- Review at bc2fb60: review/PIN cancellation, Lightning success dismissal and restart, software retry, recipient transport isolation and merchant unlock. That report records the existing master spinner workaround for its review journey.
- Earlier recovery review: Pending abandonment and recovery authorization. The imported authentication lifecycle was exercised again in Test 2.
- Review at d702597: hosted USDT address routing. The changed USDT confirmation continuation was exercised again in Test 1.
The native fee, persistence, backup and hardware cases previously left unrun are covered by Tests 3 and 4. No planned case remains unrun. This review does not establish a latency improvement or physical Bluetooth hardware coverage.
Twin: synonymdev/bitkit-android#1591 fix: deliver completed Paykit proofs promptly, checked at 766f4a8, uses the same published Paykit 0.1.1-rc1 and Bitkit Core 0.8.0-rc10 and matches the shared proof/admission contracts. The iOS biometric and USDT continuation changes are platform-specific and covered above.
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
Related to #868, #989 and #1033.
Description
Exact amounts, quote expiry, authorization, wallet/identity/period isolation, durable recovery and duplicate-payment protection remain enforced.
Out of Scope
journeys/send/onchain-original-payment-retry.xml and journeys/README.md: document the iOS-only invalid-fee expectation: #1053, deferred from review under the repository LOW-finding policy.
Deployed server/Locks and remaining end-to-end latency investigation: Contact payment latency on staging with the shared Paykit runtime: pin paykit-rs#171 and re-measure #868. No settlement or polling-policy changes.
Design
N/A — no UI changes.
Preview
N/A — no layout changes.
QA Notes
Setup
Use the journey guide and Payment Request setup at
01e4769awith 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
review-refresh.xml— review and PIN cancellation, background/reopen and later request arrival preserve one-payment behavior.success-dismissal.xml— closing payment success preserves proof delivery and reopening does not send another payment.onchain-original-payment-retry.xml— fee and PIN waiting/cancellation retain the original Pending payment; explicit retry still needs fresh approval.Manual Tests
Automated Checks
PaykitSdkIdentityCheckTests.swiftcases — combined reads retain runtime ownership, retry only acquisition contention, and respect cancellation and wallet wipe.PrivatePaykitServiceTests.swiftandPaykitPaymentRequestServiceTests.swift— scheduling snapshot admission, generation invalidation and retry intent survive a failed read.PaykitPaymentProofServiceTests.swift— durable enqueue priority, recovery overlap, negative hardware-receipt prefilter, Background delivery, restart and identity/period guards.PaykitSdkOperationLockTests.swift— actual admission, payment starting during identity lookup, deferral without backup changes, resumption, reset/wipe invalidation and cancellation.PaykitPaymentActivityTests.swiftandTransferServiceActivityTests.swift— Pending authentication scene policy and idle recovery admission.PaykitPaymentRequestServiceTests.swiftandContactsManagerTests.swift— inline acceptance, versioned consumption, resolution coalescing, nested review, fresh authority and retry.SendConfirmationLifecycleTests.swift— synchronize custom-fee and Tags-return preparation with the visible route while retaining cancellation, resumption and final navigation assertions.Evidence
Final integration validation
Integration with master
4530ff7bat01e4769aretains 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.Gaps
Earlier successful native payments, interrupted proof delivery and merchant-unlock runs remain historical evidence; they do not establish coverage or a latency improvement for these integrated heads. Earlier SDK-constructor/DHT QoS notices were inspected, but their occurrence on an identical baseline host was not reproduced.
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, J3, 1, 2, 3 remain unchecked and unexecuted on this integrated head. Native USDT-warning, PIN and biometric flows have not been verified by this integration validation. Staging merchant/Locks unlock remains blocked; SDK enqueue or a sent report does not establish merchant unlock.
Models used
gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)Metadata confirms this fork and its linked reviews. For earlier contributions, the model is
Unknown(reasoning:Not exposed): the original session metadata was not recoverable.