Skip to content

[Bug]: Payment request briefly reappears on Android after sending while recipient is backgrounded #1534

Description

@piotr-iohk

What happened?

During manual RC2 testing, a payment request was created on iOS and paid from Android while the iOS app was closed/backgrounded. After the initial send, Android briefly showed what looked like a second payment request, then it disappeared after a moment without another user action.

The reporter explicitly confirmed that the transient second request appeared on Android, not on iOS after reopening. The exact surface (list entry versus request sheet) and duration were not captured. Treat this as an observed transient request presentation; a second request creation or duplicate payment has not been established.

Expected behavior

A request that has been handled should not briefly look like a new actionable payment request. Payment completion, pending proof delivery and request reconciliation should produce a stable presentation. If an operation is still resolving, show an explicit pending state rather than briefly presenting an apparent second request.

Steps to Reproduce

Observed sequence; not yet a deterministic reproduction:

  1. On iOS Bitkit, create and send a payment request to the Android contact.
  2. Open the incoming request on Android.
  3. Close/background Bitkit on the receiving iPhone.
  4. Send the requested payment from Android.
  5. Observe Android immediately afterward: an apparent second payment request briefly appears, then disappears by itself.

The reporter performed the payment. The agent only collected logs and inspected source; no additional payment was initiated for this investigation.

Logs / Screenshots / Recordings

Sanitized excerpts from the connected devices, 16 October 2026. All times below are Europe/Warsaw (CEST, UTC+02:00); iOS and app-internal UTC timestamps have been converted. Wallet/contact identifiers, payment payloads, addresses and transaction identifiers are omitted.

17:03:52.200 iOS     Hiding sheet receive reason: Payment request created
17:04:39.158 Android Starting scan from 'scan result': 'incoming payment request target'
17:04:48.218 iOS     Scene phase changed: background
17:06:37.492 Android Rejected incoming Paykit payment request presentation:
                    category='resolution' reason='resolution_failed'
                    counterparty=[REDACTED]
17:06:59.066 Android Starting scan from 'scan result': 'incoming payment request target'
17:07:07.359 Android Queued a Paykit payment proof for private delivery

Evidence limits: the Android presentation lines do not contain request IDs, so the two openings cannot be proven to belong to the same request. There is no timestamped recording of the brief extra presentation. The collected iOS foreground log ends after the 17:04:48 background transition and cannot show subsequent receipt/reconciliation. Queuing a proof is not by itself evidence of final settlement or proof delivery. These lines are surrounding evidence, not a proven mapping of every UI action above.

No screenshot/recording captures this specific transient symptom. Raw device logs remain private because they contain wallet data; only the sanitized excerpt is included.

Bitkit Version

Android 3.0.0 RC2 (193), mainnet, release revision 19449fb0d729f51b9875eee2f66acfc0671e11da.

Request creator: iOS 3.0.0 RC2 (200), revision 59278ad871381f8ac4b9d4d0f70953643d59243e.

Device / OS

Samsung Galaxy S22 (SM-S901B), Android 16 (API 36).
Counterparty: iPhone 13; iOS version not captured in this report.

Reproducibility

Rarely (once or twice) — one reported occurrence in this sequence. No repeat rate or exact transient duration measured.

Additional context

Investigation, not a confirmed root cause: Android's finishIncomingPaymentRequestPreparation defers a request presentation when payment-detail resolution does not open a usable target. deferPaymentRequestPresentation schedules retries. The observed resolution failure makes presentation retry / stale request reconciliation worth investigating, but does not prove that retry created the apparent duplicate.

Correlate request IDs, lifecycle states, local pending/presented membership, in-flight/completed proof protection and refresh generations around send completion and sheet cleanup. Check whether a stale request is re-presented before a later refresh removes it. Do not assume that the backgrounded recipient created a second request or that funds were sent twice.

Related reports, not confirmed duplicates:

Activity

  1. added theissue type on Oct 16, 2026
  2. added this to the 3.0.5 milestone on Oct 16, 2026
  3. piotr-iohk commented on Oct 16, 2026

    @piotr-iohk
    CollaboratorAuthor

    Additional RC2 observation: stale request after initial USDT subscription payment

    During the same paired mainnet test, the reporter observed this recurring-payment sequence on both Android and iOS: accept subscription → complete initial USDT payment → payment-request notification remains briefly tappable → tapping it eventually removes it with a no-longer-available toast. No second payment was observed.

    This broadens the observed scope of #1534; it does not prove that the earlier ordinary-request reappearance and this subscription notification share a root cause. The on-chain subscription controls were one-step; stale-notification parity for on-chain was not separately confirmed.

    Sanitized logs, 2026-10-16 Europe/Warsaw:

    18:38:47.403 Android Queued a Paykit payment proof for private delivery
    18:39:21.674 Android Rejected incoming Paykit payment request presentation:
                        category=resolution reason=resolution_failed counterparty=[REDACTED]
    18:39:41.020 Android Opened private Paykit payment [contact redacted], list version 57 after 32
    
    18:30:25.021 iOS Showing sheet subscription
    18:30:55.435 iOS Opened private Paykit payment [contact redacted], list version 107
    18:31:21.161 iOS send(_:submissionDeadline:beforeSubmission:) took 3.99 seconds on core queue
    18:31:24.867 iOS Hiding sheet subscription: SendSheet.swift:862 viewForRoute(_:)
    18:31:26.978 iOS Queued a Paykit payment proof for private delivery
    18:31:35.201 iOS Hiding sheet paymentRequests: No pending payment requests
    

    Android proof queue precedes the resolution failure by 34.271 seconds. The Android messages omit request IDs, so the later presentation cannot be proven to concern the same request. iOS independently records no-pending-request cleanup 8.223 seconds after queuing the proof; that is not an exact measurement of notification visibility.

    Investigate recurring-period request visibility and completed/in-flight proof protection during refresh, alongside presentation retry and send-sheet cleanup. Do not claim duplicate payments or a confirmed requeue race.

    Related: historical iOS #737; Android #1407 latency; shared-runtime latency #1419. Current iOS stale-subscription counterpart: iOS #982. Paired two-step-USDT reports: Android #1540 / iOS #981. They are separate behaviors, not proven shared causes.

    Raw logs remain private; sanitized selected events only. No new agent payment or runtime reproduction was performed.

    Current related reports

    These reports track separate behaviors; a shared root cause is not established.

  4. ben-kaufman commented on Oct 18, 2026

    @ben-kaufman
    Contributor

    I checked the current request refresh, presentation retry and local proof-protection paths, including the matching iOS report. The existing Android refresh-race tests pass, but they do not reproduce the reported post-subscription notification window.

    Keeping this open separately from the UI fixes in #1564. We still need a deterministic reproduction correlating the same request ID/billing period with submission, proof queuing, pending membership and presentation generation. I have not established a duplicate payment or a shared cause with contact-method availability.

  5. piotr-iohk commented on Oct 19, 2026

    @piotr-iohk
    CollaboratorAuthor

    Still observed on 3.0.1 — 2026-10-19

    During today's manual testing with Android 3.0.1 (195) on Samsung Galaxy S22 and iOS 3.0.1 (202), Piotr observed a duplicate-looking payment request on Android for a request sent from iOS.

    The duplicate-looking request cannot be paid: attempting to act on it shows “payment request not available.” This is consistent with the stale request presentation described in this issue and remains observable in 3.0.1.

    This observation does not establish a second request being created or a duplicate payment; the attempted action on the stale entry is rejected. No new request-ID correlation or root-cause confirmation is available from this observation.

  6. ovitrif commented on Oct 20, 2026

    @ovitrif
    Collaborator

    Reproduced on mainnet with Bitkit Android 3.0.1 (195), and this time the request came back payable:

    • A fixed-amount Shop request (₿ 1,000) was paid. The transaction was broadcast at about 23:29:32 CEST and "Bitcoin Sent" showed.
    • About 30 seconds later the same Payment Request sheet for that order popped up again, with Swipe To Pay active. It was not paid a second time.

    That raises this from a transient display glitch to a double-payment risk: a buyer could pay a Shop order twice. It's a 3.0.x hotfix candidate once fixed.

    Likely related: iOS fixed the same class of bug in synonymdev/bitkit-ios#737 ("finishPayment can re-queue a just-paid request, enabling a second payment"). Check whether Android lacks the equivalent guard.

    Expected: a request that's paid, or broadcast and pending, never resurfaces as payable, and shows as paid in Payments.

  7. ovitrif commented on Oct 20, 2026

    @ovitrif
    Collaborator

    More detail on the mainnet case: on the "Bitcoin Sent" sheet, tapping Details opened the details screen, but the stale Payment Request sheet for the paid order sat on top of it, then disappeared on its own a few seconds later. So it looks like a stale request sheet left in the stack after payment, removed once the app syncs. It's still payable while it's visible.

  8. self-assigned this
    on Oct 20, 2026
  9. ovitrif commented on Oct 20, 2026

    @ovitrif
    Collaborator

    Root cause found for the mainnet case, fix in #1635 (still open on master; not fixed by anything merged since 3.0.1).

    What happens

    1. Swiping to pay accepts the request. PaykitPaymentRequestRepo.updateRequest removes it from the pending list, and discardExpiredRequestsLocked → prunePresentedRequestIds drops its "presented" mark, since it is no longer pending.
    2. The on-chain proof is only marked started right before broadcast (markOnchainPaymentStarted, in beforeSendAttempt). A request refresh that lands between acceptance and that point reads the SDK record as ACCEPTED with no started proof, so it puts the request back in the pending list with no presented mark (applyRequestSnapshotLocked, the oneTimeIncoming filter).
    3. Refreshes triggered by proof changes wait while the payment submission runs (observePaymentProofChanges is gated on isPaymentSubmissionActive), and the submission includes the Bitcoin Sent sheet and the wallet sync after it. On mainnet that sync takes tens of seconds, so the stale entry stays.
    4. Closing the success sheet (Details) runs clearActiveContactPaymentContext and presentNextIncomingPaykitPaymentRequest, which read automaticPendingRequests() and open the request again with Swipe To Pay. Once the submission ends, the queued refresh drops the request and the sheet closes on its own, which matches the "disappeared a few seconds later" observation.

    Android has no finishPayment re-queue like iOS had in synonymdev/bitkit-ios#737; the gap is this refresh and presentation path.

    Would a second swipe have paid? Probably not, but not verified on a device: PaykitPaymentProofRepo.prepare refuses a request with a started local proof or a proof already in the SDK record. The PR adds a guard in front of it anyway: isPending, which the swipe checks, now refuses any request with a started or completed local payment.

    Fix (#1635)

    • A one-time request this install accepted is never presented automatically again; it stays reachable from Payment Requests.
    • isPending and automatic presentation read the local proof store live, so a broadcast-pending or paid request is never payable even while the pending list predates it.
    • Two regression tests that fail on master and pass with the fix.

    The subscription stale-notification case from the RC2 comment above uses separate due-request logic and is not covered by this fix.

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions