fix: expose rejected incoming payment requests - #1217
Conversation
Greptile SummaryThe PR adds categorized, redacted diagnostics for rejected incoming Paykit requests and bounded retries with localized terminal feedback while preserving the request for manual action.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt | Replaces nullable request parsing with categorized results and redacted diagnostics while preserving active-request and history eligibility. |
| app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt | Maps payment-opening outcomes to stable resolution and presentation failure reasons. |
| app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | Adds bounded manual presentation retries, reason-specific diagnostics, terminal feedback, and conditional request-sheet restoration. |
| app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt | Adds stable test and accessibility tags to request rows and incoming-request actions. |
| app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | Covers the 15-attempt terminal path, diagnostics, localized feedback, sheet restoration, and full-screen behavior. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Incoming Paykit request] --> B[Parse request terms]
B -->|Rejected| C[Log redacted parse reason]
B -->|Accepted| D[Show actionable request]
D --> E[User selects Pay]
E --> F[Resolve payment details]
F -->|Opened| G[Normal send flow]
F -->|Unavailable| H[Log redacted failure]
H --> I{Attempts remaining?}
I -->|Yes| J[Retry after delay]
J --> F
I -->|No| K[Show localized terminal feedback]
K --> L[Restore request surface when applicable]
L --> M[Request remains available for retry or dismissal]
Reviews (3): Last reviewed commit: "test: cover valid pubky redaction" | Re-trigger Greptile
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.
27440b2 to
cbfe1c6
Compare
jvsena42
left a comment
There was a problem hiding this comment.
Diffed this against the iOS counterpart (#721) semantically. The port is faithful in the parts that carry weight: same 12-reason parse taxonomy and wire strings, same two suppressed reasons, same ULong.MAX/1000 cap, exhaustive when on the presentation taxonomy with no permissive default, same redaction (counterparty is the only peer-supplied value that reaches a log, and it is bounded). No rejection reason is sent back to the sender, so there is no privacy leak to the requesting party, and no attacker-supplied error text reaches the UI unescaped.
The one behavioural gap I found was already filed by @ben-kaufman on the AppViewModel.kt:882 thread, so I have not duplicated it. One low convention item below.
cbfe1c6 to
b79c43d
Compare
jvsena42
left a comment
There was a problem hiding this comment.
The parsePaykitPaymentRequest refactor is behaviour-preserving against base for every reason (role/state/terms/asset/amount/endpoints/expiry all produce the same accept-reject set), IncomingPaykitPaymentRequestFailureReason covers all PublicPaykitPaymentResult cases, and the diagnostics logger correctly redacts the counterparty and never emits the Throwable message.
One regression worth fixing before merge, plus two low notes.
Regression test — expired request discards the payable request behind itReproduces the Fails on head: Passes once Harness note: on unfixed code the re-entry loop recurses forever because the mocked pr1217-regression.diffdiff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
index 30fa70582..3a8a36478 100644
--- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
+++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
@@ -1509,6 +1509,49 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value)
}
+ @Test
+ fun `expired request does not discard a later payable request`() = test {
+ val expiredRequest = paymentRequest()
+ val payableRequest = expiredRequest.copy(paymentRequestId = "payable-request")
+ val bolt11 = "lnbcrt1payableafterexpired"
+ val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL)
+ var payableAttempts = 0
+ whenever(privatePaykitRepo.beginPaymentRequest(expiredRequest))
+ .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired))
+ whenever(privatePaykitRepo.beginPaymentRequest(payableRequest)).doSuspendableAnswer {
+ payableAttempts++
+ if (payableAttempts > 1) awaitCancellation()
+ Result.success(
+ PublicPaykitPaymentResult.Opened(
+ paymentRequest = bolt11,
+ privatePaymentContext = privateContext,
+ ),
+ )
+ }
+ stubLightningScan(bolt11 = bolt11, amountSats = 0u)
+ balanceState.value = BalanceState(maxSendLightningSats = 100_000u)
+ pendingPaykitPaymentRequests.value = listOf(expiredRequest, payableRequest)
+ isPaykitEnabled.value = true
+ pubkyPublicKey.value = testPublicKey
+ whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit))
+
+ sut.startPaykitPaymentRequestPolling()
+ advanceTimeBy(30.seconds.inWholeMilliseconds)
+ runCurrent()
+ sut.stopPaykitPaymentRequestPolling()
+
+ assertEquals(
+ expected = 1,
+ actual = payableAttempts,
+ message = "expired request invalidated the automatic presentation, so the payable request " +
+ "was resolved again instead of being shown",
+ )
+ verify(privatePaykitRepo).beginPaymentRequest(expiredRequest)
+ verify(privatePaykitRepo).beginPaymentRequest(payableRequest)
+ assertEquals(payableRequest, activeContactPaymentContext()?.incomingPaymentRequest)
+ assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value)
+ }
+
@Test
fun `cancelled request resolution releases the presentation guard`() = test {
val request = paymentRequest() |
b79c43d to
73df07f
Compare
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed at 73df07f. No HIGH/MEDIUM — not blocking. Two LOW observations inline; both are dev-gated, take or leave them.
I spent most of this pass confirming your pushed fixes are actually correct rather than merely present, and they are:
- Generation bump is now inside
if (requestedPaymentRequestId == request.id). I traced the case I was worried about — an automatic batch with expired A ahead of payable B:finishExpired(A)no longer bumps,clearPaymentRequestPresentationRetry(A)returns false, the loop reaches B,isCurrentPaymentRequestPresentation(B)passes,openContactPayment(B)runs. B is no longer swallowed. - Expiry during backoff and in-flight both resolve to exactly one toast. Backoff: the retry job is cancelled, then one toast + restore. In-flight: the generation bump makes
beginPaymentRequestreturn early soopenContactPaymentis never called and no second toast fires. The reverse race (RequestExpiredthrown before the repo prunes) clears the requested id, so the later emission findsrequestedRequest == null. No duplicate either way. hideSheetafter restore:clearIncomingPaymentRequestTargetsnapshotscurrentSheet is Sendbefore clearing, and retry attempts only run withcurrentSheet == null, so the 15th-failureshowSheet(PaymentRequests)is never followed by ahideSheet()that would undo it.- Final-layer logging:
logPresentationFailurenow emits only the error class name plus the redacted pubkey, noThrowable.message, andscanLogIdreturns a fixed string whenever the context carries a request — sosafeLogInput ?: inputis only reachable whenisPaymentRequest == false. That closes what I raised.
Fund safety traced clean. Amount and counterparty are pinned at open time in ContactPaymentContext; at pay time onConfirmPay single-flights on isSubmittingPaymentRequest, validateIncomingPaymentRequest re-checks acceptsPaymentAmount by equality plus the bolt11 msat match plus isPending, and accept() → updateRequest adds to processingRequestIds under operationMutex and removes from _pendingRequests before the send. A request can't be paid twice, after expiry, or at an amount other than the one shown — structurally equivalent to what iOS #721 does with markPresentedIfPending + processingRequestIds. A 15th-failure request stays in the sheet but any re-tap goes through the full Send confirm and pay-time validation again.
Also clean: both new toasts are fixed string resources, so no counterparty or error text reaches the UI; nothing seed-derived is touched; ParseFailure and the failure-reason enum aren't persisted, so there's no migration concern; the new runCatching uses are all non-suspend (Instant.parse, Bolt11Invoice.fromStr) with the suspend paths on runSuspendCatching; and synchronizePaykitContacts clears requested state before repo.clear(), so the new expiry path can't toast for a previous identity's request.
One pre-existing thing I'm noting rather than filing: handleScan runs on bgDispatcher, so clearIncomingPaymentRequestTarget → deferPaymentRequestPresentation mutates the plain mutableMapOf retry maps and the generation counter off-main while the collectors run on main. Base already did this; this PR adds two more fields to the same unsynchronised set without changing the shape. Worth a separate look someday, not here.
| val requestedRequest = requestedPaymentRequest | ||
| if (requestedRequest != null && requestedRequest.id !in requestIds) { | ||
| if (paykitPaymentRequestRepo.isExpired(requestedRequest)) { | ||
| finishExpiredPaymentRequestPresentation(requestedRequest) |
There was a problem hiding this comment.
Non-blocking. This finishExpiredPaymentRequestPresentation call can restore the PaymentRequests sheet on top of an unrelated sheet the user is mid-flow in.
The other two callers (:870, :896) run downstream of isPaymentRequestPresentationBlocked(), so currentSheet == null is guaranteed there. This one runs inside the pendingRequests.drop(1).collect collector with no sheet check. Sequence: user taps Pay on R, it doesn't resolve, goes to backoff; user then pastes an unrelated invoice and is in Sheet.Send(Confirm); the retry fires and is dropped as blocked, but requestedPaymentRequestId/shouldRestorePaymentRequestSheet stay set; R's expiresAt passes, the repo prunes, and this path reaches showSheet(Sheet.PaymentRequests) — which nulls _currentSheet and tears down the user's Send flow. No fund impact (a swipe already in proceedWithPayment continues in viewModelScope), they just lose the result screen.
One line: if (restorePaymentRequestSheet && currentSheet.value == null) showSheet(Sheet.PaymentRequests). The toast still fires, and if the open sheet was R's own Send sheet the :808-815 collector already hides it. The tests at AppViewModelSendFlowTest.kt:866/906 only cover the no-other-sheet case.
| hideSheet() | ||
| val hasIncomingPaymentRequest = clearIncomingPaymentRequestTarget() | ||
| Logger.warn( | ||
| if (scan == null) "Failed to decode scan data" else "Received unhandled scan data '$scan'", |
There was a problem hiding this comment.
Non-blocking, and it's the adjacent branch to the one already fixed. The decode-failure log at :2612 is now redacted, but this unhandled-scan branch still interpolates the whole Scanner value.
A counterparty can publish, under a supported MethodId, a string that decodes successfully to a variant this when doesn't handle — a lightning address landing as Scanner.LnurlAddress(address=…), say. beginContactPayment → openContactPayment → handleScan → coreService.decode succeeds, falls through to else, and the peer-supplied payload gets written to the log on each of the 15 retries. Same class as the thread you closed on docs/payment-requests.md:11, so the doc's claim is slightly ahead of the code here.
if (hasIncomingPaymentRequest) "Received unhandled incoming Paykit payment request target" else … covers it. Separately and pre-existing: :2618's Logger.info("Handling decoded scan data: $it") logs the full decoded target on the success path too, which is outside that doc's rejection claim — noting only.
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, target-log redaction, localized terminal feedback, expiration during backoff or resolution, automatic-batch continuation, and request-sheet restoration.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.just compile,just test, andjust lintpassed before the latest feedback batch.AppViewModelSendFlowTestpasses 199/199 andPaykitPaymentRequestRepoTestpasses 30/30 after rebasing ontomaster; Kotlin compilation completed as part of the focused run.The full two-wallet journey passed on API 37: 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 full connected suite remains blocked by the unrelated existingOnchainServiceTests.testDeriveRegtestDescriptorsForSupportedAccountTypesfailure; the focused payment-request UI class passes on API 37 and API 36.