Repository navigation
fix: defer proof delivery after durable handoff - #1458
Conversation
|
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. Bumps paykit to rc73, moves the contact and payment-request display refreshes onto the single observeState() transaction, and lets proven Lightning, on-chain and hardware completions return once the proof is persisted, leaving delivery to reconcile(). Reviewed 268293a, full tier. Reasoned from the code and CI; per the repo's agent rules this branch is not built here.
What I checked, and 4 candidates I ruled out
Read in full: the diff of all 17 files; persistAndSubmit, reconcile, reconcileProof, reconcileLightningProof, reconcileOnchainProof, reconcileHardwareOnchainProof, submitReady, completeOnchainPayment and paymentRequestStateChanges in PaykitPaymentProofRepo.kt; refresh and fetchRequestSnapshot in PaykitPaymentRequestRepo.kt; loadContacts, currentSignIn and isCurrent in PubkyRepo.kt; observeState, withPaykitKey and refreshPaykitKey in PaykitSdkService.kt; observe_state and state_observation_in_transaction in pubky/paykit-rs#183.
Call sites traced: completeLightningPayment (handlePaymentSuccessful), completeOnchainPayment (completeOnchainPaymentProofInBackground, completeRecoveredOnchainPayment), the hardware original-payment observer, every reconcile() caller (refreshIncomingPaykitPaymentRequests, the not-dispatched send error, BackupRepo).
CI: CI (build, unit tests), build-local, lint, detekt and Greptile green at this head; the e2e-tests-local shards were still pending when I read them.
Ruled out
- A persisted proof that nothing delivers: the persist bumps
PaykitPaymentProofStore.backupStateVersion,paymentRequestStateChangesseesproofDatamove from null to the preimage or txid, and the observer now passesreconcileProofs = true, soreconcile()reachessubmitReady. That covers the one-time on-chain path too, wherecompleteOnchainPaymentProofInBackgroundonly refreshes whenbillingPeriod != null. The Lightning path also gets the FULL refresh right aftercompleteLightningPayment. - Reconcile looping on its own writes:
submitReadyremoving the delivered proof changes the observed state once, thendistinctUntilChangedholds; an undeliverable proof is not re-persisted byreconcile(), so offline does not spin. Two concurrent reconciles are covered by thealreadyQueuedcheck underoperationMutex. - Changed meaning of
completedfor the Shop follow-up:persistAndSubmitalready returned true on a successful persist before this change, socompleteAcceptedShopFollowupfires on the same condition; only the wait forsubmitReadyis gone, and the persist-failure branch still delivers immediately. - Contacts on a session without the Paykit secret:
observe_staterequires the identity secret, while the oldcontact_records()needed only an initialized identity, so a restored session whose local key cannot be read (a Ring-adopted identity while the Ring provider fails the trust check) now fails the contacts load where it used to succeed. Warm contacts are kept and the trigger is a transient provider failure, so I ruled it out, narrowly.
Merge confidence: 4/5, the contacts candidate above was ruled out only narrowly and the e2e shards had not finished.
jvsena42
left a comment
There was a problem hiding this comment.
Code scan at 268293a: no HIGH or MEDIUM finding, 1 LOW inline. The device result follows in a second review; not approving until it has run.
Checked and clean:
- Durable handoff.
persistAndSubmit(..., awaitDelivery = false)returns true only afterpersist(allProofs)succeeded, so the completed proof is in the keychain before any caller clears or reports state. If the persist fails it falls back to the immediatesubmitReadyand a second persist; if both fail it returns false, the started proof is kept andpreparerejects a second payment. - No second payment.
prepare()treats a stored proof withproofDataas started, so a deferred proof blocks a new payment of the same request untilsubmitReadyremoves it. Subscriptions are also checked against the SDK record's proofs. - A paid request does not resurface. Every snapshot apply reads
completedRequestProofKindsAwaitingSubmissionandinFlightRequestIdsfrom the proof store, and the refresh checkpoint re-syncs when the store changed during the read. - Crash between broadcast and delivery. Lightning and default-wallet on-chain proofs are persisted under the mutex and delivered by
reconcile()on the next start; a Lightning proof that never got that far is completed from LDK's payment list. - Delivery trigger and retry. No new timer or polling: the persist changes
completedProofKinds, which runs a stored refresh withreconcileProofs = true; a change during payment submission is re-emitted when the gate clears. A failedsubmitReadyis not re-persisted, so there is no persist/reconcile loop, andreconcile()returns early when nothing is pending. - Cross-identity.
reconcile()filters to the SDK's current identity andsubmitReadyre-checks it under the same lock beforesubmitPaymentProof; another identity's proofs stay stored until that identity is active. - Cancellation. The new paths use
runSuspendCatching;observeStategoes throughcompleteSdkCall, so a cancelled observer cannot overlap the next native call. loadContacts. The sign-in generation check discards a load from an ended sign-in of the same identity; a failed load keeps the warm contacts.
paykit-android 0.1.0-rc73 binary. observeState does not exist in rc72, and I read the rc73 source diff but not the AAR, so "the observation returns what contactRecords() and listPaymentRequests() returned" rests on the source.
CI is green at this head.
Device gate (partial): 268293a — not run yet. Pending, against a master build on the same emulators: request-refresh time with the single observation, time from payment accepted to Success against the proof-delivery log line for a Lightning and a one-time on-chain request, reconcile runs per payment, and the airplane-mode restart check (one delivery, no duplicate activity). Performance-relevant: the PR states a speed purpose and adds a reconcile() to every proof-state change.
There was a problem hiding this comment.
No HIGH or MEDIUM finding at afbf14d, and no LOW left open: the double hardware observation from my code-scan review is fixed in afbf14d. Approving.
afbf14d read: observedHardwareProofs is added only after persistAndSubmit retained the completed proof, reconcileCompletedHardwareProof delivers a proof in the set with submitReady and sends any other through the observation as before, and the set is cleared in restoreBackup and on an empty store and pruned against every load and persist. It is only touched under operationMutex (reconcileProof runs inside the lock taken in reconcile()), and a restored verified proof still rebuilds its activity (restoring an observed hardware proof requires activity reconstruction again). CI is green at afbf14d.
The device run below was made at 268293a. The one commit since changes only the hardware proof path, which the run did not exercise.
Device gate: 268293a — journeys: none listed; perf: poll refresh 4.36 s median (master 7.59 s), snapshot read 1.63 s (master 4.54 s), Home-resume refresh 13.2 s (master 10.7 s, ranges overlap); Lightning accepted → Success 0.03 s (master 0.02 s); on-chain accepted → Success 0.02 s (master 0.02 s); Success → proof queued +26 s (master +15 to +30 s); 3–5 reconcile() runs per payment (master 1–3); idle 2 min on Home: 1 reconcile / 7 refreshes (master 2 / 5); background 1 min: 0 / 0 on both; no crash, ANR or OOM on either build; Figma: no screen changed.
Skipped — covered by CI at afbf14d: unit tests, lint, detekt, e2e (all green)
Setup: Pixel_9 (payer, fresh wallet and identity, 304,712-sat channel) and Pixel_Tablet (payee) linked as contacts, staging regtest, master (3cac93b) installed and measured first, then this head installed over it. The refresh and reconcile paths log nothing on success, so they were timed with non-suspending debugger logpoints; payment events come from the app log (UTC).
master |
this PR | |
|---|---|---|
| Periodic poll with 3 historic requests, total | 7.59 s (5.35–18.47), n=7 | 4.36 s (2.84–9.04), n=10 |
Snapshot read (allPaymentRequests / observeState → applied) |
4.54 s (3.42–7.14) | 1.63 s (1.09–3.54) |
| Lightning 1,000 sats, swipe → Success | 20.9 s | 96.4 s (pending branch, below) |
| Lightning 1,500 sats after 2 min idle, swipe → Success | 30.1 s | 42.0 s |
| On-chain 2,000 sats, swipe → Success | 40.8 s | 37.8 s |
The refresh claim holds: the single observation is about 2.8× faster on the read and the whole poll is faster. On both builds Success already arrives right after acceptance and the proof is queued 15–30 s later, so for node-wallet payments the ordering is the same as on master.
Durable handoff, on this build: paid a 1,200-sat Lightning request, turned on airplane mode 0.5 s after Success and force-stopped before any Queued line. After relaunch: one Queued a Paykit payment proof for private delivery (11:01:31.681), the proof store deleted at 11:01:49.871, no second Paying bolt11 or PaymentSuccessful, one "Sent" activity row on the payer, "Proof submitted" on the payee, and the request not payable again.
One slow run, unverified as a regression. The first Lightning payment on this build took 56.7 s from PaymentSuccessful to the Success screen. The staging LSP held the HTLC for 12.5 s, so the send went through the "Lightning payment pending" branch, which awaits a full refreshIncomingPaykitPaymentRequests() before navigating; that refresh ended after the proof delivery. The branch is the same code on master and master never took it during the run, so there is no baseline. A master run in which the payment stays pending past the send timeout would settle it.
Log lines for the slow run and the normal path
This PR, pending branch:
10:40:36.122 DEBUG [AppViewModel.kt:4460] Swipe to pay event, checking send confirmation conditions
10:40:57.972 VERBOSE [LightningRepo.kt:259] Operation called: 'payInvoice'
10:40:59.034 WARN [PaykitPaymentRequestRepo.kt:576] Failed to refresh incoming Paykit payment requests [RequestUnavailable='Payment request is unavailable']
10:41:00.108 DEBUG [LightningService.kt:1037] Paying bolt11: lnbcrt1p4vedacdq…
10:41:10.588 INFO [AppViewModel.kt:5060] Lightning payment pending
10:41:15.805 DEBUG [AppViewModel.kt:1822] LDK-node event received in AppViewModel: {"type":"PaymentSuccessful",…}
10:41:15.815 INFO [Keychain.kt:154] Upserted value for key 'PAYKIT_PENDING_PAYMENT_PROOFS'
10:41:46.735 INFO [PaykitPaymentProofRepo.kt:1200] Queued a Paykit payment proof for private delivery
10:41:54.883 DEBUG [Keychain.kt:170] Deleted value for key 'PAYKIT_PENDING_PAYMENT_PROOFS'
10:42:12.386 INFO [Keychain.kt:154] Upserted value for key 'PAYKIT_ACCEPTED_PAYMENT_REQUESTS'
reconcile() entered 10:41:10.941, 10:41:16.182, 10:41:16.223, 10:41:50.562; onSendSuccess 10:42:12.812 (logpoints, host clock)
Nothing is logged between 10:41:15.815 and 10:41:46.735 (31 s), nor between 10:41:54.883 and 10:42:12.386 (17.5 s).
master, normal path:
10:03:02.822 Swipe to pay event …
10:03:17.541 Operation called: 'payInvoice'
10:03:19.614 Paying bolt11: lnbcrt1p4vet65d…
10:03:23.706 LDK-node event received in AppViewModel: {"type":"PaymentSuccessful",…}
10:03:39.253 [PaykitPaymentProofRepo.kt:1197] Queued a Paykit payment proof for private delivery
10:03:48.002 Deleted value for key 'PAYKIT_PENDING_PAYMENT_PROOFS'
handlePaymentSuccessful 10:03:24.048, onSendSuccess 10:03:24.052 (logpoints, host clock)
Also seen, the same on both builds: Failed to finish accepted on-chain send locally [IllegalArgumentException='Winning transaction fee is unavailable'] on the on-chain payment (LightningRepo.kt:1679; master 10:07:40.400, this PR 10:46:51.816). Swipe → payInvoice took 14.7 s and 23.9 s on master and 21.9 s, 36.0 s and 22.8 s here; too few samples to call.
afbf14d changes). It needs a paykit-server issuer fixture, which is not available on my setup; that coverage is left to the e2e CI and the unit tests added in afbf14d.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full review of the complete PR diff against merge base 3cac93b, at afbf14d.
No new actionable code findings.
Successful Paykit payments persist the exact proof and return before remote delivery. Lightning completion and recurring on-chain completion then refresh, and that refresh reconciles and submits the stored proof. One-time on-chain and hardware proofs are submitted by the proof-state observer while polling is active, or by the next foreground full refresh. A failed persist still attempts immediate delivery. A different identity, or a cleared proof store, does not submit.
A hardware proof observed in this process is delivered without a second chain lookup. Restoring the wallet clears that handoff, so a restored proof is submitted only after the original transaction is observed again. Display refresh reads one rc73 observation (paykit-rs 0eff3d4, tag v0.1.0-rc73). Its payment requests use the same non-blocked filter as listPaymentRequests. Payment actions, eligibility, and proof submission still use the authoritative getters. The same display-observation and local-completion flow was compared with merged bitkit-ios#914 at 532eb15: iOS starts submission inside completion, and Android submits from reconcile. Contact loading uses that observation and ignores a result from a sign-in that has already ended. A failed observation leaves the current contact list in place.
Validation: CI, lint, and e2e succeeded on this head. The new repository tests were inspected, not executed in this review.
Device testing: not performed in this review. The existing device report on this PR was run on 268293a and left the hardware path changed in afbf14d unexercised.
Ready for device testing.
Suggested additional test cases
- Android, disposable regtest, paired hardware wallet and a Shop issuer. Pay one Shop on-chain request from that hardware wallet. Local completion finishes without waiting for issuer delivery, one Sent activity is stored, and after reconcile the payee shows one submitted proof and the request is not payable again.
- Force-stop the payer after local completion and before the proof is queued, then relaunch the same identity with the hardware wallet disconnected. No second payment and no duplicate activity appear. The proof stays unsent until that original transaction can be observed again, and it is then delivered once.
This PR:
Related work: SDK #183, iOS #914, and Server #78. This consumer pins the published rc73 prerelease.
Description
Proof persistence failure retains the existing immediate-delivery fallback. If neither persistence nor delivery succeeds, the unresolved follow-up and payment guard remain. Unknown payment outcomes remain unresolved; per-request execution claims, save-before-send ordering, exact proof/ciphertext handling, and duplicate-payment prevention are unchanged. Cancellation retains native-operation ownership until the native call completes; refreshes retain runtime, identity, sign-in, and key-generation guards.
The normal display request-snapshot read phase changes from three getter calls (identity validation, request list, and linked peers) to one SDK observation transaction. Message intake, authoritative action reads, and checkpoint-driven rereads remain separate and unchanged. Contact-only loading already used one transaction; reading a full observation adds projection/filtering work, so no contact speedup is claimed. Existing warm StateFlows, coalescing, and persistence are reused, with no new cache or cold-start instant-display promise. Observation failures preserve existing warm display; cold failures do not claim a successful refresh.
Out of Scope
Design
N/A — no UI changes.
Preview
QA Notes
Journeys
N/A — not drivable; see Manual Tests.
The payment-request journey README documents these fault-driven checks. Matching iOS coverage belongs to the iOS consumer change.
Manual Tests
Automated Checks
PaykitPaymentProofRepoTest.kt- verifies local completion after successful durable writes during blocked submission, exact-proof delivery after reconstruction, failure fallback, and identity/reset isolation.PaykitPaymentRequestRepoTest.kt- verifies readable warm flows during blocked refresh, identity clearing, warm-state retention on snapshot failure, and honest cold failure.PubkyRepoTest.kt- verifies failed observations preserve warm contacts and stale results from an ended same-identity sign-in stay hidden.PaykitSdkServiceTest.kt- verifies observation identity validation, error propagation, queued runtime replacement, and cancellation without overlapping native operations.PaykitKeyGenerationTest.kt- verifies observation key generation matches the active key.PaykitPaymentRequestRepoSubscriptionTest.kt,ContactSaveSessionChangeTest.kt, andAppViewModelSendFlowTest.kt- retains request/contact action coverage and verifies proof reconciliation remains lifecycle-owned and coalesced during active payment submission.PubkyRepoTest.kt- verifies cold contact failure ends loading without marking a successful observation.44cc0ad08e6689c5a820d399d49ad67f2d69f8ff3039f322107525fa97411725.One fresh combined source correctness and quality review and bounded integration follow-up are complete. Controlled payment/restart fault checks above remain incomplete, not passed.
Remote Measurements and Payment QA
Accepted, but immediate bookkeeping failed withWinning transaction fee is unavailablebefore this PR's changed deferred-delivery branch. The activity/fee path is byte-identical to base. The fixture omitted the normal post-Accepted wallet synchronization and periodic recovery, so it does not prove a persistent production blocker or explain the reported stuck-wallet incident. No successful payment-completion speedup was measured. The separate validated fee-retention fix is fix: retain prepared transaction fees #1459; unknown-outcome and duplicate-payment guards remain unchanged.Reviewer-Reported Device Coverage
afbf14d. Device measurements were made on268293aagainst3cac93b, using linked Pixel_9/Pixel_Tablet staging-regtest wallets and non-suspending debugger logpoints; the later hardware-only follow-up was not exercised. With three historic requests, periodic poll medians were 4.36 s versus 7.59 s, and snapshot-read medians were 1.63 s versus 4.54 s. This is a small sequential baseline/candidate comparison, not populated-history, full unlock, or payment-speedup evidence.Hardware Proof Handoff Follow-up
An exact hardware proof whose activity was successfully reconstructed in this process can proceed to delivery without repeating the remote transaction lookup. This evidence is process-local, bound to the complete immutable proof, cleared on backup restore/empty store and pruned when proofs change or leave the store. Restored proofs still reconstruct activity before delivery; identity, request, wallet and positive-acceptance checks are unchanged.
The second-lookup-unavailable regression failed before the correction. Final validation: 77 proof-repository tests and all 3,956 unit tests passed; one test added and one existing test extended, none removed. Final compile passed; complete Detekt/format findings remain 1,980 versus 1,981 on verified base with no introduced findings, and incremental compiler diagnostics match baseline. One fresh combined review and its bounded lint-correction recheck found no actionable issues. No device or payment test was run for this follow-up. Existing performance and device gates remain open.