Skip to content

fix: deliver Paykit payment proofs promptly - #1025

Open
ben-kaufman wants to merge 17 commits into
masterfrom
fix/paykit-proof-latency
Open

ben-kaufman wants to merge 17 commits into
masterfrom
fix/paykit-proof-latency

Conversation

@ben-kaufman

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

Copy link
Copy Markdown
Contributor

Related to #868, #989 and #1033.

Description

  • Deliver completed proofs through each recipient’s ordered queue, releasing submission serialization after durable SDK enqueue. Lightning completion returns once its preimage is saved; persistence failures retain immediate-enqueue recovery.
  • Defer routine inbox, display and automatic proof-recovery work during foreground payment preparation, review, PIN entry and submission. Recheck admission after identity lookup, release deferral on cancellation/backgrounding, and retain deferred links and refreshes for retry.
  • Claim new inline requests atomically during acceptance, skip recovery work already handed to delivery, and coalesce concurrent on-chain resolution until activity association finishes. Early claims before versioned endpoint consumption remain intact.
  • Retry only a contended standalone identity or peer read once after a cancellable 250 ms delay. Mutation and uncertain-write errors are not replayed. Skip remote hardware-recovery identity lookup only when local storage has no signed receipt for the exact request and billing period; positive candidates still receive fresh identity and receipt checks.
  • Prioritize durable native payment handoff and explicitly authorized original-payment retries while preserving passive recovery, identity/cleanup barriers and queue fairness. Protect Pending fee, abandon and authentication review without blocking idle Pending observation.
  • Start USDT reconciliation after confirmation and add debug-only duration diagnostics without payment or contact contents.
  • Adopt published Paykit 0.1.1-rc1 and Core 0.8.0-rc10. Use fresh combined identity/request reads for proof work and one fresh scheduling snapshot, preserving the existing SDK admission, identity and generation checks. Retry acquisition failures once; never replay mutations or uncertain writes.

Exact amounts, quote expiry, authorization, wallet/identity/period isolation, durable recovery and duplicate-payment protection remain enforced.

Out of Scope

Design

N/A — no UI changes.

Preview

N/A — no layout changes.

QA Notes

Setup

Use the journey guide and Payment Request setup at 01e4769a with disposable regtest wallets and a saved, linked fixture issuer. This source uses Paykit 0.1.1-rc1 and Core 0.8.0-rc10. Keep ordinary payment authorization enabled. The controlled transport/admission faults named below require additional fixture control; compatible issuer/homeserver access and staging merchant/Locks unlock must be established separately. Missing prerequisites leave the affected cases blocked.

Journeys

  • J1: new review-refresh.xml — review and PIN cancellation, background/reopen and later request arrival preserve one-payment behavior.
  • J2: updated success-dismissal.xml — closing payment success preserves proof delivery and reopening does not send another payment.
  • J3: updated onchain-original-payment-retry.xml — fee and PIN waiting/cancellation retain the original Pending payment; explicit retry still needs fresh approval.

Manual Tests

  • 1. Create a controlled unresolved original send → cancel fee, abandon and available biometric review, then background/reopen → retain the original payment without authorizing replacement or holding idle Pending — controlled native broadcast/receipt faults not in Capabilities.
  • 2. Hold an unrelated recipient’s transport → pay an established recipient → its ordered proof delivery proceeds independently and failed delivery stays retryable — recipient transport fault injection not in Capabilities.
  • 3. Collect a staging payment through unlock → distinguish local queue wait, SDK work and merchant completion — correlated staging latency capture not in Capabilities.

Automated Checks

  • added PaykitSdkIdentityCheckTests.swift cases — combined reads retain runtime ownership, retry only acquisition contention, and respect cancellation and wallet wipe.
  • updated PrivatePaykitServiceTests.swift and PaykitPaymentRequestServiceTests.swift — scheduling snapshot admission, generation invalidation and retry intent survive a failed read.
  • updated PaykitPaymentProofServiceTests.swift — durable enqueue priority, recovery overlap, negative hardware-receipt prefilter, Background delivery, restart and identity/period guards.
  • updated PaykitSdkOperationLockTests.swift — actual admission, payment starting during identity lookup, deferral without backup changes, resumption, reset/wipe invalidation and cancellation.
  • updated PaykitPaymentActivityTests.swift and TransferServiceActivityTests.swift — Pending authentication scene policy and idle recovery admission.
  • updated PaykitPaymentRequestServiceTests.swift and ContactsManagerTests.swift — inline acceptance, versioned consumption, resolution coalescing, nested review, fresh authority and retry.
  • updated 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 4530ff7b at 01e4769a retains both USDT-warning cancellation and the payment-review callback. Bottom #1025: all-target build passed (19 targets), 378 focused tests passed, 0 failures/skips. One combined #1070 + #1081 + #1084 + #1085 composition, tree 8f1e72d3 from #1070 7cd096f4 plus #1081 8967dff8, #1084 b7561c08 and #1085 310e689a, covers the stack and those sibling changes: all-target build passed (19 targets) and 2,779 tests passed, 0 failures/skips using the six existing CI exclusions. These are not eight independent full-suite runs.

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

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

  • Planning/scoping: gpt-6-astra (reasoning: xhigh)
  • Implementation: gpt-6-astra (reasoning: xhigh)
  • Review: 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.

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

ovitrif commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

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.

@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[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

  1. P2 Overlap test can miss overlap ▶

Summary

This PR sends payment proofs through the intended recipient’s queue, retries already-queued proofs, and starts USDT reconciliation sooner.

  • Payment proofs drain the intended recipient’s private queue.
  • Paykit logs separate SDK wait time from work time.
  • USDT confirmation starts reconciliation without an extra polling wait.

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]
Loading

Reviews (1) · Last reviewed commit: "fix: deliver Paykit payment proofs witho..." · Reviewed by Greptile

Comment thread BitkitTests/PaykitPaymentProofServiceTests.swift

@talosmachina talosmachina left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 submissionLock and mutationLock: submit takes submissionLock then, through removeRequestProofs, mutationLock; no mutationLock body calls submit, submitInBackground or a remove helper, so the reverse order never occurs.
  • Delivery under a switched identity: the new expectedIdentity overload checks identityStatus() inside the SDK lock and throws identityChanged, and submitReady already 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: reconcile returns early while sending, preparing or reconciling is set, and the task still runs the final reconcile after 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 while condition 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 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Code scan: no findings.

Checked and clean:

  • No lock inversion. submissionLock and mutationLock are always taken in that order, and no mutationLock body calls submit, 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.
  • UsdtSendView changes only cut latency. The Task { reconcile } on .confirmed before 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.

@ben-kaufman

Copy link
Copy Markdown
Contributor Author

The unit-test failure was testPendingWalletSwitchResumesAfterTags: the appearance check timed out while the navigation transition still reported Tag and Confirm. The later payment-preparation and final-route assertions passed. Both that test file and SendConfirmationView are identical to current master.

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.

@ovitrif

ovitrif commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman on the flaky testPendingWalletSwitchResumesAfterTags: could you disable transitions in the iOS unit tests, as the Android tests already do? Then the appearance check no longer races the navigation transition. For example, UIView.setAnimationsEnabled(false) in the test setup, or a test-only flag that turns the navigation animations off.

Copy link
Copy Markdown
Contributor Author

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

@ben-kaufman

Copy link
Copy Markdown
Contributor Author

@jvsena42 The current head guards these diagnostics with #if DEBUG, so they do not reach release logs. Proof serialization now ends after durable SDK enqueue; recipient transport runs outside that lock. The regression tests separately suspend enqueue and transport and cover recovery after failed delivery.

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.

@ben-kaufman
ben-kaufman requested a review from jvsena42 October 20, 2026 00:24

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ⚠️ Not verified — two manual cases remain unrun, and the PR advanced during testing.

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

@ben-kaufman
ben-kaufman force-pushed the fix/paykit-proof-latency branch from 201dd22 to 780713e Compare October 20, 2026 05:11

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Reviewed the full PR diff against 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 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Code scan at 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;
  • submissionLock now covers only the durable enqueue, and transport runs in a detached Task (PaykitPaymentProofService.swift:1677), so reconcile() no longer waits behind a network flush.

Checked and clean, reviewed as a funds change:

  • Locks and partial failure:
    • Lock order stays submissionLock → mutationLock, and reconcile calls submit outside any mutationLock body.
    • onlyIfRetained skips proofs another worker already handed off.
    • A failed Lightning persist falls back to an immediate submit.
  • Inline claim:
    • prepareForPayment skips ensurePaymentAllowed / claimForPayment only for requests that need acceptance and have no versioned list. service.accept (the SDK claim-and-accept) still runs before dispatch.
    • SendSheet passes the consumption closure only when paymentListVersion is set.
  • Review hold: SendSheet.cleanup() and SendPendingScreen.onDisappear release it, it's released on .background, and endReview wakes the includingReviews waiters.
  • Deferred contacts: ContactsManager rethrows, and MainNavView.swift:913-921 waits with waitUntilIdle(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 expectedIdentity is 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.xml and onchain-original-payment-retry.xml cross-device with Android;
  • timing from Queued a Paykit payment proof for private delivery to Finished Paykit operation processOutboundPrivateMessages, and on to "Proof submitted" on the requester, against master.

Device results will follow in a separate review.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: 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.

Comment thread Bitkit/Services/PaykitPaymentProofService.swift

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ⚠️ Not verified — recipient network isolation remains unverified, and the existing retry-proof priority gap remains open.

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

@ben-kaufman

Copy link
Copy Markdown
Contributor Author

The current SwiftFormat failure comes from the PR merge checkout: both settings files reproduce the same findings on master bcaa461b. The branch itself passes pinned SwiftFormat 0.64.0. I opened #1040 as a separate formatting-only correction for those base findings and the existing Activity conditional, keeping unrelated settings changes out of this PR. No new scheduling-fix formatting violations were found.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Follow-up 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 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

  • withSdk catches the deferral and retries after waitUntilIdle(), so it never reaches a caller.
  • It fires only while a payment is in progress, not during reviews.
  • Every begin() has a defer end(), so cancel, Back and errors all release it.
  • The proof stays in the durable SDK outbound queue.
  • withBackupStateRevisionTracking skips onChange only 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.xml and onchain-original-payment-retry.xml cross-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.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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) replaces identityStatus() plus paymentRequests(). 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. The isAlreadyQueued and onlyIfRetained checks 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, isCurrent and 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.

Comment thread journeys/send/onchain-original-payment-retry.xml
@jvsena42

jvsena42 commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Device gate (partial 7): proof timing at bc2fb60 (Paykit 0.1.1-rc1, Core rc10). On-chain payments to an Android synonymdev/bitkit-android#1591 requester.

build broadcast → Queued Queued → processOutboundPrivateMessages done
bc2fb60 5.8, 6.9, 5.9 s 19.2, 17.9, 16.9 s
4022aa7 25.8, 11.3, 11.4 s 8.8-18.4 s
master 5ce560f 11.0, 10.8, 13.4, 11.6 s — (no trace)
  • Operation counts: readPaymentRequests(counterparty:expectedIdentity:) runs exactly twice per payment, once in admission and once in submit, and never during confirmation preparation. privateMessageSchedulingSnapshot(priority:) runs once after each Queued, plus every 15-60 s while idle (≈1.05-1.26 s work each).
  • Open → Selected fee rate: a re-measure with no build running found no regression. The median is 6.5 s on bc2fb60 and 9.1 s on 4022aa7; see partials 8-10. The 13-42 s samples here came from SDK queueing during back-to-back payments.

Requester-side paid times (≈77 s) aren't comparable across these sets, because the Android build differed.

Still running: review-refresh.xml, success-dismissal.xml and onchain-original-payment-retry.xml at bc2fb60, and the repeat of the open timing.

@jvsena42

Copy link
Copy Markdown
Member

Device gate (partials 8-10) at bc2fb60 (Paykit 0.1.1-rc1, Core rc10), paired with an Android synonymdev/bitkit-android#1591 requester, iOS PIN on.

  • review-refresh.xml: 16/17 passed. Step 13 is again the known master bug (the spinner after a PIN background cycle). It passed after close and reopen: ready in 3 s, paid with PIN, one outgoing −1 200, the requester row +₿1 000, and the 2 000 request still pending. The proof was queued 5.5 s after broadcast.

  • success-dismissal.xml: 13/13 passed. Lightning payment of 6 007, Close shows no toast, one −6 008 activity, the requester row turned paid, relaunch adds nothing, and a dismissed second request stays unpaid. Payment successful → Queued took 7.5 s, against 13.9 s at 4022aa7.

  • Open → ready, re-measured with no build running (5 opens each, same request):

    build Showing sheet send → Selected fee rate → swipe armed
    bc2fb60 6.5, 12.8, 12.9, 6.0, 4.4 s (median 6.5) median 9 s
    4022aa7 29.1, 13.1, 5.3, 12.8, 4.6 s (median of runs 2-5: 9.1) median 11.5 s

    There's no regression. Open latency is bimodal on both builds and follows prepareAndResolvePrivatePaymentRequest work (≈3.5 s or ≈11.5 s of homeserver round trips). privateMessageSchedulingSnapshot runs only after the confirmation is ready, never before it. The 13-42 s opens in partial 7 came from SDK queueing during back-to-back payments.

Still running: onchain-original-payment-retry.xml at bc2fb60.

jvsena42
jvsena42 previously approved these changes Oct 20, 2026

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.xml failure 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:
    • readPaymentRequests runs 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.md cases that need history and persistence faults.

e2e-tests-local is still pending.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for 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

ovitrif
ovitrif previously approved these changes Oct 20, 2026

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utAck

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Re-reviewed the full pull request at 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.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ⚠️ Not verified — native fee-fault and hardware retry cases remain unrun, and the PR advanced during testing.

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

@ben-kaufman

ben-kaufman commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

The current E2E failure happened before the tests started: Namespace environment creation timed out after 60 seconds (context deadline exceeded). The app artifact downloaded successfully. The failing shard and its aggregate status are being retried once at the unchanged 01e4769a head; no app-code change is indicated by this failure.

Failed job

Update: the single workflow retry completed successfully at unchanged head 01e4769a; Namespace setup succeeded and all 14 selected @ios_gate cases eventually passed. This was not a clean first test pass: 13 cases passed initially, while the LNURL msat pay/withdraw case twice timed out waiting five seconds for ReviewAmount-primary, then passed on the workflow’s third built-in test attempt. The already-passing 13 cases were skipped on those targeted retries.

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.

Completed E2E job

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for 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

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utAck

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants