Skip to content

fix: let an expired payment that never reached the network be cancelled - #1559

Merged
ovitrif merged 13 commits into
masterfrom
fix/cancel-expired-onchain-payment
Oct 17, 2026
Merged

ovitrif merged 13 commits into
masterfrom
fix/cancel-expired-onchain-payment

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1551
Twin: synonymdev/bitkit-ios#998
Refs: #1553

Description

  • Adds "Cancel expired payment" to Payment Pending, the one way out of a Shop on-chain payment whose outcome is unknown and whose request has ended. Today that payment blocks every new on-chain send for good, and a wipe and restore brings the block back, because the backup restores the attempt.
  • The cancel is offered only when the request was canceled, rejected or expired, or its payment deadline or quote passed, with no proof for it (for a subscription, no proof for that billing period). It re-checks every 30 s while the screen stays open.
  • On Continue, Bitkit runs a wallet sync and waits, up to 30 s, for an on-chain sync that finishes after the tap. It goes on only if the wallet then knows none of the payment's transactions while it still knows the transactions its coins came from. A known candidate refuses the cancel.
  • The cancel then spends at least one of the original's coins back to the wallet (1,000 sats to a new wallet address, the rest as change), at a fee above every candidate's, after the user approves the network fee and, when payments need it, their PIN or biometrics. The original can therefore never confirm alongside a later payment. The release transaction is saved before it is broadcast. The send block and the original Shop proof stay until the release is accepted or a sync finds it in the wallet. A rejected or unknown release keeps both.
  • After a wipe and restore the payment can still be cancelled: the release transaction, not the restore, is what keeps it safe. If the fresh sync finds none of the payment's transactions but one of its coins already spent, an earlier release or another transaction made the original unconfirmable, so the cancel finishes without a new release, including when a restart or a restore lost the earlier release's ID.
  • The attempt is marked approved before anything changes, so an app stop mid-release finishes on the next cancel, and a wallet restore keeps that local state.

Out of Scope

  • Resending a payment whose outcome is unknown: not in 3.0.0.
  • Ordinary sends, transfers and hardware-wallet payments: unchanged.

Design

N/A — no design available; adds a secondary "Cancel expired payment" button and two confirmation dialogs to the existing Payment Pending screen.

Preview

Regtest staging, Android emulator, 2x. The blocked attempt was seeded by a local debug-only hook, not in this PR, because a real one needs a Shop request left to expire.

Cancel released, next payment sent

A Shop payment with an unknown outcome, whose request has ended and whose transaction never reached the network, blocks a new bitcoin: payment. Cancel asks for the network fee, sends the coins back to the wallet, and the next payment is sent.

and-cancel-released2x.mp4

Cancel refused: transaction already known

The same blocked state, with a transaction the wallet already knows: the cancel is refused and the payment stays pending.

and-cancel-refused2x.mp4

QA Notes

Journeys

N/A — not drivable; see Manual Tests.

Manual Tests

  • Cancel a Shop payment whose request ended while its outcome is unknown → its coins return to the wallet and new on-chain sends work again — needs an Electrum server that swallows requests and a Shop request left to expire.

    Steps
    1. Run scripts/electrum-bridge.py (from fix: reconnect electrum after the server drops the connection #1532) on the host, adb reverse tcp:50099 tcp:50099, and set Settings › Advanced › Electrum Server to 127.0.0.1, port 50099, TCP.
    2. Start a Pubky Shop checkout and continue to Swipe To Pay. The bridge must pass traffic until the first blockchain.transaction.broadcast, swallow it, then swallow everything after; a plain blackhole set before the swipe fails the pre-send sync instead. Swipe and wait for the payment to show as pending.
    3. Keep requests swallowed until the checkout ends or the request expires (about 30 min), then let traffic through again.
    4. Open any new on-chain payment and swipe: Payment Pending for the earlier payment shows "Cancel expired payment".
    5. Tap it, Continue, then Yes, Send on the network fee. Pass: "Payment cancelled. Its coins are back in your wallet.", logcat shows Released on-chain payment '<txid>' through '<release txid>', and the next on-chain payment is sent. The Shop order stays unpaid.
    6. Variant where the bridge forwards the broadcast but drops its reply, so the transaction reaches the mempool: Payment Pending shows "This payment may still reach the network, so it cannot be cancelled."

Automated Checks

  • added PaykitPaymentProofRepoTest.kt — canceled, rejected and expired requests release a payment that never reached the network; an open request, a submitted proof or a known candidate keeps it; a rejected or unknown release keeps the guard and proof, and a later cancel finishes once the release is in the wallet; the release must spend an original coin and outbid every candidate; an approved release finishes after its proof was removed; subscription periods and expired quotes
  • added OnchainSendAttemptStoreTest.kt — only an unresolved Shop payment is released, only after its proof is removed and its release approved; ordinary and restored payments are never released; a restore keeps the local approval
  • added SendPendingViewModelTest.kt — the cancel is offered only for an ended Shop request, reappears once the request ends while the screen is open, and reports a failed sync
  • added LightningRepoTest.kt — only a sync finished after the request counts as fresh
  • ran the release and refused cases on an Android emulator at 82ad38d with a seeded attempt: release d9680501… accepted and the next payment d47ef3db… sent; a known candidate refused.

@ovitrif ovitrif self-assigned this Oct 17, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T00:40:07.757192Z 82ad38d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Critical impact] The change reaches the on-chain wallet spending path for a narrow class of Shop payments.

Do not merge until cancellation keeps the original payment protected while the release is unconfirmed.

Findings

  1. P1 Cancellation finishes before confirmation ▶
  2. P2 Cancel retry uses old state ▶
  3. P2 Successful sync looks failed ▶

Summary

Adds cancellation for unresolved Shop on-chain payments after their requests end.

  • Payment Pending offers cancellation when a Shop request has ended.
  • Cancelling an expired payment returns its coins before removing the send block.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Expired unresolved Shop payment] --> B[Sync and check original transactions]
  B -->|Original known| C[Keep payment pending]
  B -->|None known| D[Save release approval]
  D --> E[Prepare release and ask user]
  E --> F[Save release transaction ID]
  F --> G[Broadcast release]
  G -->|Rejected or unknown| H[Keep proof and guard]
  G -->|Accepted, not necessarily confirmed| I[Remove proof and guard]
  H --> J[Later cancel checks saved release]
  J -->|Wallet knows release| I
Loading

Reviews (1) · Last reviewed commit: "fix: cancel an expired payment by sendin..." · Reviewed by Greptile

Comment thread app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
Comment thread app/src/main/java/to/bitkit/repositories/LightningRepo.kt Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82ad38d816

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/src/main/java/to/bitkit/services/LightningService.kt Outdated
Comment thread app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
Comment thread app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt
Comment thread app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt
Comment thread app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt Outdated
Comment thread app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt Outdated
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from f22c596 (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@talosmachina talosmachina left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No findings. Adds "Cancel expired payment" to Payment Pending: once the Shop request has ended and a fresh sync shows no candidate in the wallet, it spends an original coin back to the wallet at a fee above every candidate's, saving the release txid before broadcast. Reviewed 0c7b844, full tier, reasoned from the code and CI. The Android branch was not built here, because a Gradle build runs the branch's code with a write token in its environment.

What I checked, and 5 candidates I ruled out

Read: the diff of LightningRepo.kt, OnchainSendAttemptStore.kt, PaykitPaymentProofRepo.kt, LightningService.kt, SendPendingScreen.kt, SendPendingViewModel.kt; PreparedOnchainSend in OnchainSendCoordinator.kt; ldk-node prepare_send, build_transaction_psbt, get_spendable_utxos, get_tx_details, sync_onchain_wallet (synonymdev/ldk-node at rc.74)
CI: build, build-local, lint, detekt green (unit tests run inside build); e2e-tests-local still running when reviewed

Ruled out

  • The freshness check accepts a sync that was already in flight: ldk-node's sync_onchain_wallet subscribes a second caller to the running sync's result, so lightningService.sync() succeeding can mean an older view. The fallback, though, is only reached when that call fails, and a candidate that hit the mempool before the request is in any sync that started after the original broadcast. The release also outbids every candidate, so a missed candidate is replaced rather than paid twice.
  • releaseOutbids compares absolute fee, not rate: replacement under BIP125 needs a higher absolute fee and a higher feerate. prepareRelease starts at the highest candidate rate + 2 and doubles, and with 1,000 sats out plus change from the same inputs the release is no larger than the candidate, so both rules hold.
  • originalCoinSpentElsewhere reads a coin as spent when it is only locked: get_spendable_utxos drops channel-funding outputs only, and an original input can't fund a channel while the attempt holds the send block. A coin missing from list_unspent was spent by a transaction the wallet knows.
  • Approval cancelled mid-flow leaves releaseApproved set: approveRelease only persists the flag, and onchainRequestHasEnded short-circuits to true on it. That is intended: the request had already been checked as ended, and the proof is removed only after the release is accepted or found in the wallet.
  • The release txid is lost on a restore: restoreActive keeps the local releaseApproved/releaseTxid, and when they are gone the originalCoinSpentElsewhere branch finishes the cancel without a second release.

Merge confidence: 4/5. The new wallet-spend path has unit tests but no device or regtest test in CI, and the branch was not built here.

@ovitrif

ovitrif commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

Device check on an emulator (regtest staging, build 0c7b844): a wallet stuck on an expired, never-broadcast Shop payment recovers with "Cancel expired payment" after a wipe and a restore from backup. The first Cancel tap gave no feedback.

Restore while stuck, then cancel the restored payment ✅

The Shop payment 99ba502d was stuck (order DCB555; the broadcast was swallowed). After a wipe and restore, the lock came back with Cancel offered. The release 288b0477 broadcast with Released … after its request ended and confirmed at height 233355. 99ba502d was never broadcast. A new send f1fe6032 went out and confirmed at height 233356.

rc3-b2-and-stick-6x.mp4
rc3-b2-and-wipe-restore-cancel-4x.mp4
First Cancel tap gives no feedback ⚠️

The first tap on Cancel, while staging Electrum syncs were timing out, showed no fee prompt and no message. A second tap resumed the saved release. A sync in progress, or a failure, should say so.

@ovitrif

ovitrif commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up to the restore run: the silent first tap is fixed in 03c7e73. While the cancel checks the network, Payment Pending shows "Checking the network. This can take up to 30 seconds." under the button, and a failure now shows as a toast as well. The 30 s limit now holds: the sync blocks on the LDK queue and ignored the timeout, so it runs on its own and the wait stops at its deadline. synonymdev/bitkit-ios#998 shows the same progress line since f0f5afcb; its wait already kept the limit.

@ovitrif

ovitrif commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

Why this PR is in 3.0.0 RC3

ldk-node rc.76 (synonymdev/bitkit-ios#990) fixes the dropped-connection bug, but a broadcast the server never answers still ends as "outcome unknown" after about 24 s (test A, swallowed-request case: synonymdev/bitkit-ios#990 (comment)). If that payment's Shop or Paykit request then expires, every later on-chain send opens its Payment Pending screen, and nothing in the app clears it.

Test B on RC2 showed that a wipe and restore doesn't help: the restore brings the stuck attempt back from the backup, and the next send is blocked again. Reproduced twice.

Recording: lock survives wipe and restore
rc3-b-ios-rc2-wipe-restore-lock-returns-3x.mp4

So RC3 takes only this narrow escape: "Cancel expired payment" for a payment the network never saw, done as a release transaction that makes the original unconfirmable. The broader recovery work (synonymdev/bitkit-ios#971, #1553) stays closed. This PR's test B re-run passes, including after a wipe and restore (see the results comment).

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

No HIGH or MEDIUM findings. Two LOWs are inline, each with a failing test.

I reviewed this as a funds change: it adds a new wallet spend (the release broadcast with fee approval and PIN), removes Paykit payment proofs, rolls back private payment-list consumption, and changes the persisted attempt and backup schema.

Checked and clean:

  • Consent: declining the fee dialog or the PIN/biometrics prompt throws CancellationException from requestRecoveryApproval, which runSuspendCatching rethrows, so nothing is broadcast. The dialog shows the exact mining fee, which is the whole cost because the release pays the wallet itself. The PIN rule matches Retry.
  • Conflict with every candidate: every candidate spends exactly originalInputs, and the release passes every spendable original as utxosToSpend with manually_selected_only.
  • The release txid is saved before broadcast with an equality check, and a stale attempt is rejected before broadcast.
  • A release that lost to a confirmed candidate does not finish the cancel: walletKnowsTransaction goes through BDK get_tx, which only searches canonical transactions in bdk_wallet 2.4.0, so the flow reaches the candidate check and refuses.
  • PreparedOnchainSend.broadcast does not insert the transaction into the wallet graph, so a known candidate really was seen by a sync.
  • Identity: the payer identity is checked in onchainRequestHasEnded, and proofs are filtered by payerIdentity.
  • Logs carry txids only. Retry and Cancel disable each other, and the lock order matches releaseInterruptedShopPreparation.

Parity with synonymdev/bitkit-ios#998: both LOWs have the same shape there. One difference that is not a finding: Android's removeOriginalShopProofs (:504) rolls back the consumed private payment-list version through releaseDeniedPrivateConsumption, and iOS does not. After a release that conflicts every candidate the rollback is correct; it only costs privacy (reuse of the payee's private address) in the residual case above where the original still confirms. Worth aligning the two either way.

CI is green at this head.

perf: relevant (a user-facing wait of up to 30 s for the sync on Continue, a paymentRequests() poll every 30 s while Payment Pending is open). To be measured in the device gate.

Device gate (partial): 03c7e73 — not run yet. Pending: the cancel and refused cases from Manual Tests through the Electrum bridge (blackhole and lose-reply), timing Continue to the fee dialog against master, the poll rate while Payment Pending stays open, and wallet__onchain_abandon_note in long locales. The device result will follow.

Comment thread app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt
Comment thread app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt Outdated
@ovitrif
ovitrif requested a review from jvsena42 October 17, 2026 14:06
@jvsena42

Copy link
Copy Markdown
Member

Device gate (partial): f22c596. The attempt was created on the 03c7e73 CI APK, and both emulators were then upgraded in place to the f22c596 CI APK with the attempt still pending. Setup is done and Payment Pending is reached. The cancel and refused cases are still running.

  • Payer on a Pixel 9 emulator: fresh wallet and Pubky profile, 600,000 sats, Electrum through scripts/electrum-bridge.py on 127.0.0.1:50099. Payee on a Pixel Tablet emulator: fresh wallet, linked as a contact. The payee sent a one-time 50,000-sat request (Savings, on-chain only) that expires in 1 hour.
  • With the broadcast swallowed, the payer shows Payment Pending with Transaction ID: f5614374…cb38, Retry original payment, Details and Close.

Two notes on the Manual Tests steps. Neither is a code finding.

  • Step 2 does not work as written. touch /tmp/electrum-bridge/blackhole before the swipe makes the send fail before dispatch with "Wallet sync failed before send", because every send syncs first:
    14:23:08.397 DEBUG [LightningRepo.kt:820] Ensuring wallet is synced before send - LightningRepo
    14:23:23.402 ERROR [LightningRepo.kt:299] Error executing 'sync' [AppError='Updating fee rate estimates timed out.'] - LightningRepo
    
    I used a local copy of the bridge that passes traffic until the first blockchain.transaction.broadcast, swallows it, then blackholes everything. It swallowed the broadcast at 11:24:36 and the automatic rebroadcast at 11:24:48.
  • scripts/electrum-bridge.py on master has no lose-reply control, which step 6 names. The local copy adds one: it forwards the broadcast, then drops every reply.
  • A one-time request has no cancel control on the payee side, and the payer's dismiss is gone once the payment is pending. This request is being left to expire (about 12:23 local). The refused case will use a subscription, which its creator can cancel.

Still running: the cancel after expiry, the refused case, Continue → fee dialog timings, the poll rate while Payment Pending stays open, and the new strings in long locales.

@ovitrif

ovitrif commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

@jvsena42 thanks, both step notes are right. The Manual Tests here and in synonymdev/bitkit-ios#998 now describe the bridge behaviour instead: pass traffic until the first blockchain.transaction.broadcast, swallow it and everything after (a plain blackhole before the swipe fails the pre-send sync), and for the variant, forward the broadcast but drop its reply.

@jvsena42

Copy link
Copy Markdown
Member

Device gate (partial): f22c596. The cancel case and the refused case pass on the CI APK, on Pixel 9 (payer) and Pixel Tablet (payee) emulators with staging Electrum behind the bridge.

Cancel case. I sent a one-time 50,000-sat request with a 1 h expiry at 11:23 and paid it at 11:24:20. The broadcast of f5614374…cb38 and its automatic rebroadcasts were all swallowed. After the request expired, a new 10,000-sat bitcoin: payment opened Payment Pending for the earlier one with "Retry original payment" and "Cancel expired payment". Cancel → note → Continue → "Checking the network…" → "Send this payment's coins back to your wallet? Network fee: 422 sats." Two runs were dismissed with Cancel, and each returned to the screen with the button. The third was approved and showed "Payment cancelled. Its coins are back in your wallet."

12:35:30.358 DEBUG [Keychain.kt:170] Deleted value for key 'PAYKIT_PENDING_PAYMENT_PROOFS' - Keychain
12:35:30.365 DEBUG [Keychain.kt:170] Deleted value for key 'ONCHAIN_SEND_ATTEMPT' - Keychain
12:35:30.367 INFO  [PaykitPaymentProofRepo.kt:566] Released on-chain payment 'f56143747e5f914246fb9af441dbb9489af3a3d15577ce6a86f284029762cb38' through '8ed070df96f65dd8296d82c23b5cdbb6ed053a503186887a546cbf7ab08313e1' after its request ended - PaykitPaymentProofRepo
12:35:31.793 INFO  [ldk_node::chain:533] New unconfirmed transaction 8ed070df…13e1 detected in mempool (amount: -422 sats)
12:35:41.806 INFO  [ldk_node::chain:481] Onchain transaction 8ed070df…13e1 confirmed at height 234564

Staging Electrum has the release and reports the original as a missing transaction. The next payment was sent at 12:37:28 (d8bad89e…3d50, confirmed at 234567). The payee never received the 50,000.

Refused case. A second request, 40,000 sats. The broadcast of f362b556…4799 was forwarded and its reply dropped, so it reached the staging mempool while the app saw no outcome. After expiry, Cancel → Continue showed "This payment may still reach the network, so it cannot be cancelled." No release was built: there is no new funding address and no created transaction in that window.

12:46:18.646 LDK event fired: {"type":"OnchainTransactionReceived","txid":"f362b556…"}
12:47:22.014 WARN [SendPendingViewModel.kt:229] Could not release on-chain payment 'f362b556f7849e8de0bf9dc96c661184eb97d393cee59b76b7f3cac6f4344799' [OnchainSendAbandonRefusedError='This on-chain payment may still reach the network or its request is still open.'] - SendPendingViewModel

The original then resolved through the existing proof path ("Bitcoin Sent 40 000") and confirmed at 234581. The payee shows +40,000 and the request as paid.

Observations, not findings:

  • Each Continue derives a new wallet address for the release output. My three runs used bcrt1qsqhuhkc4…, bcrt1qjlefmus0… and bcrt1q2quyq2eq…, so every dismissed fee dialog leaves an unused address behind.
  • With the Electrum blackhole on for an hour, LDK stopped logging, and a new send stayed on "Connecting to network" until an app restart. A bridge restart did not recover it. I am treating this as environment, since the bridge swallowed every request.

Still running: Continue → fee dialog timings (three runs are recorded and being measured), the poll rate while Payment Pending stays open, and the new strings in long locales.

@jvsena42

Copy link
Copy Markdown
Member

Device gate (partial): f22c596. Performance is measured. No regression against master 8328ec2 on the same Pixel 9 emulator and staging setup.

Tap Continue → result, timed from screen-recording frames:

Run Outcome Tap → result
cancel 1 fee dialog (422 sats) ~6.0 s (bounded by layout polling)
cancel 2 fee dialog 5.03 s
cancel 3 fee dialog, approved 4.83 s
refused 1 "may still reach the network" 3.43 s

Cancel run 2, from the log: about 2.5 s of Paykit homeserver resolution for the request-ended check (8 Resolving homeserver … via PKARR lines, 12:34:31.8 → 12:34:34.3), then Syncing LDK… 12:34:34.494 → LDK synced 12:34:36.555 (2.06 s), then the release is built at 12:34:36.784. The 30 s limit was never reached. Master has no cancel, so it has no baseline for this wait. While it runs, "Checking the network…" shows and both buttons are disabled.

Poll rates per minute (logcat):

Window Build Resolving homeserver on-chain syncs APP/LDK/paykit lines
Home idle 150 s, 48 s after launch master 160.0 6.0 582
Home idle 150 s, 48 s after launch PR 167.6 6.0 578
Home idle 150 s, 25 min after launch PR 120.8 6.0 504
Payment Pending with the cancel offered, 180 s PR 67.7 6.0 442

The 30 s re-check adds no visible load: Payment Pending runs below Home on both counters, and the explicit Syncing LDK… appears only on Continue. There were no ANRs or crashes. Skipped N frames appears only at cold starts, on master too, and not during the cancel or refused flows.

Still running: the new strings in long locales.

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

Approving at f22c59644.

f22c596 fixes both LOWs from my code-scan review. The backup carries candidateMiningFees, validated against candidateTxids, using the same key as synonymdev/bitkit-ios#998. The approval now runs before the spent-elsewhere branch. Both threads are resolved with the trace. CI is green at this head.

Device gate: f22c596 — CI APK, Pixel 9 payer and Pixel Tablet payee emulators linked as contacts, staging Electrum through the bridge. Details in the partial comments above.

  • Cancel: passed. A one-time Paykit request was paid with the broadcast swallowed, then expired. "Cancel expired payment" → note → Continue → fee dialog (422 sats) → Yes, Send. The release 8ed070df… confirmed, the original never reached the network, the next payment was sent, and the payee never received the original. Dismissing the fee dialog twice returned to the screen with the button each time.
  • Refused: passed. The original reached the mempool with its reply dropped. After expiry the cancel was refused with "may still reach the network", no release was built, and the original confirmed and was proven normally.
  • perf: Continue → fee dialog 4.8–6.0 s (2.5 s request-ended check, 2.1 s sync). The refused path took 3.4 s. Master has no cancel, so there is no baseline. Idle on Home: 167.6 homeserver resolutions/min (master 160.0) and 6.0 on-chain syncs/min (master 6.0). Payment Pending with the 30 s re-check runs at 67.7/min. No ANR, and skipped frames only at cold start, as on master.
  • l10n: 8 new keys, English only so far, so there is no translated text to overflow. In English the note dialog wraps to 7 lines and the checking and refusal lines wrap to 2, with no clipping. ⚠️ Long locales were not viewed: they show the same English text today. When translations land, check German and Greek on the button and the note.
  • Figma: N/A, no design.

Notes on the PR body's Manual Tests, not findings. Step 2 fails as written: the blackhole before the swipe makes the pre-send sync fail with "Wallet sync failed before send". scripts/electrum-bridge.py has no lose-reply control, which step 6 names. The gate used a local copy of the bridge that blackholes after the first broadcast or drops only its reply. Each Continue also derives a new wallet address for the release, so dismissed fee dialogs leave unused addresses behind.

@ovitrif
ovitrif merged commit bf64c29 into master Oct 17, 2026
21 checks passed
@ovitrif
ovitrif deleted the fix/cancel-expired-onchain-payment branch October 17, 2026 16:06
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.

bug: expired unknown-outcome on-chain payment blocks all new on-chain sends

3 participants