fix: expose rejected incoming payment requests - #1217
Conversation
This comment has been minimized.
This comment has been minimized.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA LGTM.
Tested latest (95788b9) Pixel emu against iOS codex/paykit-payment-proofs, regtest. Incoming 1 sat + 27k from iOS. Tap Pay on an unresolvable request:
- 15
resolution_failedattempts at ~2s Stopped retrying requested incoming Paykit payment request after '15' presentation attempts- Toast: "Payment Request" / "The payment request is no longer available."
- Row stays with Pay and Dismiss
Opening a still-resolvable request goes to Confirm with swipe disabled. That is the master isAmountInputValid hole, not this PR. Already fixed on #1178 (e04115003); standalone: #1218 / #1221. Not a blocker for this toast/retry path.
|
Please resolve conflicts. |
95788b9 to
800b902
Compare
piotr-iohk
left a comment
There was a problem hiding this comment.
QA LGTM.
Latest (800b902d) after the conflict rebase.
I already ran the full unresolvable-request journey on 95788b9 (15 × ~2s, redacted resolution_failed, unavailable toast, row stays with Pay/Dismiss). The only commit since that QA is the valid-pubky redaction test. Rebase onto master (incl. #1178) does not change the toast/retry path.
This pass:
- Installed
800b902don Pixel_6 emu. Wallet restored (Alice /pubkyff…qyqnsuy), contact payments on, Paykit session re-signed and publishedbtc-regtest-p2wpkh. - Focused unit tests pass:
PaykitPaymentRequestDiagnosticsTest,PaykitPaymentRequestRepoTest,PublicPaykitRepoTest,AppViewModelSendFlowTest. - Journey XML parses.
CI green on this head, including local + staging E2E.
Happy to approve.
|
Pls resolve conflicts |
|
@piotr-iohk Merged current Fixed in 36c4404 |
jvsena42
left a comment
There was a problem hiding this comment.
All three of my earlier findings are still fixed after the master merge — I checked rather than assumed, since 36c440497 brought in #1186 and touched these exact blocks:
- The generation bump is still inside the guard (
AppViewModel.kt:1116-1120); the merge only renamedclearRequestedPaymentRequestPresentation()→clearRequestedPaymentRequest(), andfinishUnavailablePaymentRequestPresentation:1132mirrors it. Automatic-batch behaviour intact: expired A → no bump → B still passesisCurrentPaymentRequestPresentationand presents. Expired("expired", shouldLogIncomingRejection = false)survives at:97.if (clearIncomingPaymentRequestTarget()) returnstill precedes the generic toast at:3317.
fix: simplify expiry condition is behaviour-identical, which I traced end to end: synchronizeLocked samples clock.now() once (:643) for both paths, and expiresAt != null && expiresAt <= now ≡ expiresAt?.let { it <= now } == true — same operator, same clock. It actually restores verbatim what master already had; the merge had hand-expanded it, which is what tripped detekt. PaykitPaymentRequest.isExpired:111-112 uses the same predicate, and scheduleExpirationLocked delays by (nextExpiration - clock.now()).coerceAtLeast(ZERO) so the discard fires at or after expiresAt, never before. No boundary shift, and iOS's parsedExpiration > now keep-condition is the same boundary. Injected Clock throughout, no System.currentTimeMillis() in the Paykit files.
Two LOW notes inline. Both port to the iOS twin (synonymdev/bitkit-ios#721).
jvsena42
left a comment
There was a problem hiding this comment.
No HIGH, no MEDIUM. One LOW inline — a merge artefact, not a security issue.
This pass focused on the delta since my last one (486fbf7 expiry simplification, 9c1fa8c unsupported_recurrence split) and on whether the new logic behaves over the state the master merge brought in.
What I traced clean:
toPaykitSubscription()now runs insideparsePaykitPaymentRequeston every recurring record (PaykitPaymentRequestRepo.kt:1099). It cannot throw and take downrefresh(): every validation exitsreturn null,every.toInt()is guarded by the> Int.MAX_VALUE.toUInt()check, and the only throwing call (metadata.exportText()) sits insiderunCatching. A hostile recurring record cannot DoS the pending list.- The recurring split is behaviour-preserving for the accept set. Both
RecurringRequestandUnsupportedRecurrencereturnRejected; a malformed recurrence still never reachesoneTimeIncoming. The history path maps toUnsupportedRecurrence→null, identical to base'srecurrence != null → null. Only logging changed. - Fund safety re-traced after the merge. Amount and counterparty are pinned at open time (
beginContactPaymentpassesPaymentAmountContext(it.amountValue, "btc")from the pinned request,:555); pay time checks equality against_sendUiState.amount(:3994), bolt11 msat match (:4003-4006),isPending(:4019), single-flight (:4770), andupdateRequestmoves the id intoprocessingRequestIdsand out of_pendingRequestsbefore the send (:844-861). A failed SDK reject leaves the request pending and visible; nothing auto-pays. - Redaction is closed on every path.
scanLogIdreturns a fixed string whenever the context carries a request (:2183,:2227); decode-failure, decode-success and unhandled-variant branches all use fixed messages;logPresentationFailureemits onlyerror::class.simpleName. No peer-supplied bytes reach the log, both toasts are fixed resources, and no failure reason is sent back to the counterparty. - StateFlow conflation on
pendingRequestsis safe —retainPaymentRequestPresentationStateis state-based (id !in requestIds), not event-based, so a dropped intermediate emission cannot produce a false unavailable toast. - Docs and journey match head. 1 + 14 × 2 s (
:5408) = 15 attempts / 28 s, inside the journey's 35 s; automatic presentation falls back toREFRESH_INTERVALS.last()= 120 s; silent reasons matchshouldLogIncomingRejection = false; toast title andPaymentRequestsScreentag both resolve. The journey starts on the full-screen route whereshouldRestorePaymentRequestSheetis false, so "remains visible" is the correct assertion. runCatchingin the diff is only on non-suspend calls (Instant.parse,Bolt11Invoice.fromStr); suspend paths userunSuspendCatching.
Parity with synonymdev/bitkit-ios#721: iOS has no unsupported_recurrence — its ParseFailure still has 12 cases (PaykitPaymentRequestService.swift:5-22), and its shouldLogIncomingRejection silences only outgoingRequest/nonActionableState, so it logs recurring_request for every recurring record and leans on per-(record, reason, counterparty) dedupe to stay quiet. Net: iOS has no triage blind spot for malformed recurrences, but the two wire taxonomies now diverge (13 vs 12 reasons) and Android is silent where iOS logs once. Worth a decision on which side moves — not a defect on either.
jvsena42
left a comment
There was a problem hiding this comment.
Fix confirmed at 20f8c55e. wallet__payment_request_timestamp is gone from strings.xml:1284, git grep payment_request_timestamp -- app/src returns zero hits at the new head, and alphabetical ordering is preserved around the deletion (..._time → ..._unavailable). Nothing else changed in the commit.
That was the only finding I had. Clean from my side.
jvsena42
left a comment
There was a problem hiding this comment.
fix: cancel expired payment request scan reviewed — this is new work rather than a response to my findings (the dead string was the only one and it's already gone). The change is right in intent; one LOW inline about collateral damage, gated.
The swap at :1118 from a bare paymentRequestPresentationGeneration++ to invalidatePaymentRequestPresentation() means an expired requested payment request now also tears down a scan that's still queued for it, instead of leaving one to fire later against a request the user was just told had expired. That's the correct fix for the case the new test pins.
I checked the three remaining bare paymentRequestPresentationGeneration++ sites to see whether the fix was applied incompletely, and I don't think it was:
:929— the Send sheet is visible andmarkPresentedjust succeeded. This is the success path; there shouldn't be a queued scan to cancel, and cancelling one would be wrong.:1076— retries exhausted, "unavailable" toast. Arguable, but the request stays pending and the automatic presentation path can still pick it up, so a hard scan teardown would be more aggressive than the state warrants.:2953— interrupted request withretryIncomingRequest == false, which immediatelymarkPresenteds. Same reasoning.
So the single call-site change looks deliberate rather than partial. The helper itself and its other eight references pre-date this commit; only :1118 changed.
|
@ovitrif conflicts again — this one re-conflicted after your 09-09 resolution, so the earlier "resolved" note no longer holds.
Flagging because I reviewed |
jvsena42
left a comment
There was a problem hiding this comment.
Rebased and mergeable again at 97a5c34c — and the LOW I raised is fixed, in exactly the narrow shape rather than a broad one.
invalidatePaymentRequestPresentation now takes a requestId and scopes the teardown through a local predicate (:1205-1223):
1209 fun targetsRequest(context: ContactPaymentContext?): Boolean {
1210 val scanRequestId = context?.incomingPaymentRequest?.id ?: return false
1211 return requestId == null || scanRequestId == requestId
1212 }The requestId == null branch preserves the old broad behaviour, so the seven pre-existing call sites are unchanged — that was the part I cared about, since a blanket narrowing there could have been its own regression. And the expiry site now passes the id (:1118), so request A expiring can no longer nil a deferred scan belonging to request B. The scenario I described is closed.
You also added behaviour I didn't ask for and it looks right: the helper returns whether the expired request owned the active context with the Send sheet open, and :1120 hides the sheet in that case. ownsActiveContext compares against the same requestId, so it can't dismiss a sheet belonging to a different request — worth stating explicitly since that was the obvious way to get it wrong.
Rebase looks clean: the PR-authored delta against master is intact (14 files), and nothing from the earlier review was dropped.
Fixes #1209
Description
Preview
pr1209-terminal-recovery-preview.mp4
QA Notes
Manual Tests
Automated Checks
PaykitPaymentRequestDiagnosticsTest.kt: verify parse and resolution diagnostics redact valid and invalid counterparties and Throwable messages while retaining a stable error type.PaykitPaymentRequestRepoTest.ktandPublicPaykitRepoTest.kt: cover stable parse and resolution failure reasons and suppress repeated expired-record diagnostics.AppViewModelSendFlowTest.kt: cover 15 explicit attempts, final redacted resolution diagnostics, decoded-target log redaction, localized terminal feedback, expiration during backoff or resolution, automatic-batch continuation, and request-sheet restoration without replacing unrelated sheets.PaymentRequestsScreenTest.kt: cover stable request, Pay, and Dismiss accessibility tags; the focused class passes 5/5 on API 37 and 5/5 on API 36.AppViewModelSendFlowTestpassed 202/202;just compile,just test, andjust lintpassed.The full two-wallet journey passed on API 37 before the latest master merge: a delivered 1-sat request became unresolvable after its sender disabled Paykit, produced 15 redacted
resolution_failedattempts, showed terminal feedback, and returned to the request sheet with the same row actionable. The prior head passed GitHub's full local and staging E2E matrix, and the focused payment-request UI class passed on API 37 and API 36.