Skip to content

fix: expose rejected incoming payment requests - #721

Open
ovitrif wants to merge 9 commits into
masterfrom
codex/714-payment-request-errors
Open

fix: expose rejected incoming payment requests#721
ovitrif wants to merge 9 commits into
masterfrom
codex/714-payment-request-errors

Conversation

@ovitrif

@ovitrif ovitrif commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #714

Expose rejected incoming payment requests through privacy-safe diagnostics and terminal user feedback while preserving automatic recovery.

Description

  • Classify malformed incoming requests with stable parse reason codes and log only the redacted counterparty.
  • Log each permanent parse rejection once per record and reason.
  • Classify endpoint-resolution and payment-target failures with redaction-safe error categories and codes without logging request contents or raw errors.
  • Keep automatic presentation retries running every 120 seconds after the initial retry window.
  • End an explicit Pay attempt after fifteen failures with the localized Payment Request unavailable toast while leaving the request actionable.
  • Preserve requested expirations across suspended resolution and retry backoff, then show the localized expired toast exactly once.
  • Document the failure contract and the ported Android resolution-failure journey.

Linked Issues/Tasks

Preview

pr721-payment-request-unavailable-2x.mp4

QA Notes

Manual Tests

  • 1. iPhone 17 simulator on iOS 26.5/regtest → pair with an iPhone Air Paykit peer: both apps exchange private Paykit data.
  • 2. Create profiles on both simulators → save each profile as a contact on the other → send a 1 sat incoming payment request: the request appears on the payer.
  • 3. Peer contact-payment settings → disable payment endpoints → payer Payment Requests → tap Pay: explicit resolution retries begin.
  • 4. Payment Requests → wait through fifteen explicit attempts: logs show category=resolution reason=no_supported_endpoint and redact the counterparty.
  • 5. Fifteenth failed attempt → inspect terminal feedback: PaymentRequestUnavailableToast shows Payment Request and The payment request is no longer available.
  • 6. Dismiss terminal feedback → inspect PaymentRequestRow-7abfa801-a3bd-4d74-b75a-18be91d2ddbf: PaymentRequestPay-7abfa801-a3bd-4d74-b75a-18be91d2ddbf remains enabled for another attempt.

Automated Checks

  • Focused simulator tests for the review fix: 72 passed across PaykitPaymentRequestServiceTests and PrivatePaykitServiceTests.
  • Prior focused simulator tests: 73 passed across PaykitPaymentRequestServiceTests and PublicPaykitServiceTests.
  • Simulator build-and-run: passed on iPhone 17.
  • SwiftFormat lint, translation validation, journey XML validation, and changed diff check: passed.
  • Android #1217 verification: matching requested-resolution-failure.xml journey confirmed at b79c43d.
  • Current head: 5e4bebd on master 215a9f1.
  • GitHub Actions: running for 5e4bebd.

@ovitrif
ovitrif requested a review from ben-kaufman September 2, 2026 00:17
@greptile-apps

This comment has been minimized.

Comment thread Bitkit/Services/PaykitPaymentRequestService.swift
@ovitrif
ovitrif marked this pull request as draft September 2, 2026 09:49
@ovitrif
ovitrif marked this pull request as ready for review September 2, 2026 10:06
@ovitrif
ovitrif force-pushed the codex/714-payment-request-errors branch from 70cd263 to ac81ad1 Compare September 2, 2026 12:05
@ovitrif

ovitrif commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

@greptileai

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The rewritten head preserves the earlier expiry fix. I found two non-blocking coverage and journey-documentation gaps.

Comment thread Bitkit/AppScene.swift Outdated
Comment thread journeys/payment-requests/README.md
Comment thread Bitkit/AppScene.swift Outdated
@ovitrif
ovitrif force-pushed the codex/714-payment-request-errors branch from ac81ad1 to 8172d6d Compare September 2, 2026 21:43
@ovitrif
ovitrif requested a review from ben-kaufman September 2, 2026 21:44
Comment thread Bitkit/AppScene.swift
@ovitrif ovitrif changed the title fix: expose payment request failures fix: expose rejected incoming payment requests Sep 3, 2026
@ovitrif
ovitrif force-pushed the codex/714-payment-request-errors branch from 8172d6d to 1f75bfd Compare September 3, 2026 21:50
@ovitrif
ovitrif requested a review from ben-kaufman September 3, 2026 21:51
ben-kaufman
ben-kaufman previously approved these changes Sep 3, 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.

One nit, arising from reviewing the Android counterpart (#1217) rather than from this PR's own code: the iOS-only label on this suite stops being true once #1217 lands.

Comment thread journeys/README.md Outdated
@ovitrif
ovitrif force-pushed the codex/714-payment-request-errors branch from 1f75bfd to 2f3ef18 Compare September 5, 2026 02:18
@ovitrif ovitrif self-assigned this Sep 5, 2026
@ovitrif
ovitrif requested a review from jvsena42 September 5, 2026 02:19
@ovitrif
ovitrif requested a review from ben-kaufman September 5, 2026 02:46
ben-kaufman
ben-kaufman previously approved these changes Sep 6, 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.

Reviewed this against the Android twin (synonymdev/bitkit-android#1217), where I confirmed a real regression. iOS is structurally better and does not have it — worth recording why.

Android bumped its presentation generation unconditionally when a request turned out expired, including for automatic presentations, so an expired request A invalidated the batch and request B behind it was fully resolved over the network and then discarded. Here, presentRequests snapshots activePresentationGeneration for the closure (:888-901) and isCurrentPresentation (:905-912) is checked at AppScene.swift:950before the beginPaymentRequest network call at :952 — and again at :953-957, :983, :1012, :1027, :1038. So even when the generation does move, no resolution work is wasted. The new expired branch also returns at :946 before the bump, and discardExpiredRequests only bumps when requestedPresentationId itself is dropped (:1110-1113), which is nil for automatic presentations.

The other two Android bugs are absent too: .expired is effectively unreachable because the SDK derives ProposalExpired at query time and parse checks state != .proposed first (returning .nonActionableState, which is excluded from logging), and synchronizationDate is sampled before the await. And the toast queues can't both be non-empty in one transition — performRefresh guards on requestedId != handledRequestedExpirationId (:1019) and deferPresentation passes it through (:942-948).

Also verified: the expiry double-now() race is fixed (one presentationDate threaded into discardExpiredRequests(at:), :940-945); all five new toast strings exist in en.lproj/Localizable.strings:1477-1481; persistPresentedRequestIds() runs before every discardExpiredRequests return path.

Two non-blocking logging notes inline.

Comment thread Bitkit/Services/PrivatePaykitService+Payments.swift Outdated
Comment thread Bitkit/Services/PaykitPaymentRequestService.swift

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

Re-reviewed at 5e4bebd. No HIGH/MEDIUM. Nothing new to file — the two candidates I chased both died on verification (details below so they aren't re-chased).

Fund safety / authorization — clean. Amount and counterparty are pinned at approve time and this PR does not move that. SendSheet calls markPresentedIfPending(request) (clearing requestedPresentationId) and sets sendAmountSats from the request; confirmation goes prepareIncomingPaymentRequestprepareForPaymentperform, which re-checks expiry, pending membership, and single-flight via processingRequestIds. approvedPaymentRequestIds is set only after service.accept succeeds. No path added here re-enters payment: every deferPresentation return value only affects retry scheduling and toasts, and .requestedPresentationEnded marks the request presented and drops the id, so a new Pay tap must go through requestPresentation again. Because markPresentedIfPending nils the id on send-sheet open, the perform expiry path can't enqueue a second "expired" toast alongside the send flow's own error.

Trust boundary — clean, and this was the main thing I wanted to check given the PR surfaces counterparty-side failures. Toasts render only static localization keys; logs carry enum raw values, safeCode ([a-z0-9_-], ≤64 bytes), a metatype name, and the 12-char redacted pubkey prefix. PaykitError.context is dropped everywhere. No homeserver- or counterparty-supplied string reaches the UI or a log verbatim.

Also traced clean: no persisted schema change (PresentationStore.State and PaykitPaymentRequest.ID untouched; ParseFailure and IncomingPaykitPaymentRequestFailureReason are never persisted), so no migration risk from the previously shipped build. parse accepts exactly the set base accepted for both actionable and history records — only failure reporting changed. Both feedback queues drain with while let under a single trigger comparison, so SwiftUI coalescing several increments into one onChange can't drop a toast. Every deferIncomingPaykitPaymentRequestPresentation call site is preceded by isCurrentPresentation(request) with no await in between, so there's no TOCTOU on the requester across the suspension. No seed-derived material touched.

Android parity (#1217): the sheet-restore-cancelled-by-hideSheet bug is structurally absent here — the sheet hides itself before requesting presentation and never restores, terminal feedback lands as a toast on whatever is underneath. The peer-supplied-invoice-logged-on-decode-failure issue is absent too; this PR actually removed the base Logger.warn("...: \(error)") at that site in favour of reason=invalid_payment_target, which is the right direction. Expiry-during-backoff is present on both and handled here via recordRequestedPresentationExpiration + expirationTrigger.

Still dev/QA-facing today (isUIEnabled defaults false, Dev Settings only), so none of this is user-reachable until the flag flips.

Comment thread Bitkit/AppScene.swift
_ feedback: IncomingPaykitPaymentRequestPresentationFeedback,
for request: PaykitPaymentRequest
) {
Logger.warn(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every presentation retry reaches this logger, including .retryScheduled, because logging happens before the toast == nil guard. An automatic request without expiry can generate 15 warnings during the rapid retry phase, then another every 120 seconds indefinitely. Could we log only terminal outcomes, or deduplicate or rate-limit non-terminal retries by request ID and reason?

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.

fix: expose rejected incoming payment requests

3 participants