Repository navigation
fix: let an expired payment that never reached the network be cancelled - #1559
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
[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
SummaryAdds cancellation for unresolved Shop on-chain payments after their requests end.
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
Reviews (1) · Last reviewed commit: "fix: cancel an expired payment by sendin..." · Reviewed by Greptile |
There was a problem hiding this comment.
💡 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".
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
talosmachina
left a comment
There was a problem hiding this comment.
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_walletsubscribes a second caller to the running sync's result, solightningService.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. releaseOutbidscompares absolute fee, not rate: replacement under BIP125 needs a higher absolute fee and a higher feerate.prepareReleasestarts 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.originalCoinSpentElsewherereads a coin as spent when it is only locked:get_spendable_utxosdrops channel-funding outputs only, and an original input can't fund a channel while the attempt holds the send block. A coin missing fromlist_unspentwas spent by a transaction the wallet knows.- Approval cancelled mid-flow leaves
releaseApprovedset:approveReleaseonly persists the flag, andonchainRequestHasEndedshort-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:
restoreActivekeeps the localreleaseApproved/releaseTxid, and when they are gone theoriginalCoinSpentElsewherebranch 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.
|
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
First Cancel tap gives no feedback
|
|
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. |
|
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 restorerc3-b-ios-rc2-wipe-restore-lock-returns-3x.mp4So 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
left a comment
There was a problem hiding this comment.
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
CancellationExceptionfromrequestRecoveryApproval, whichrunSuspendCatchingrethrows, 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 asutxosToSpendwithmanually_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:
walletKnowsTransactiongoes through BDKget_tx, which only searches canonical transactions in bdk_wallet 2.4.0, so the flow reaches the candidate check and refuses. PreparedOnchainSend.broadcastdoes 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 bypayerIdentity. - 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.
|
Device gate (partial):
Two notes on the Manual Tests steps. Neither is a code finding.
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. |
|
@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 |
|
Device gate (partial): 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 Staging Electrum has the release and reports the original as a missing transaction. The next payment was sent at 12:37:28 ( Refused case. A second request, 40,000 sats. The broadcast of 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:
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. |
|
Device gate (partial): Tap Continue → result, timed from screen-recording frames:
Cancel run 2, from the log: about 2.5 s of Paykit homeserver resolution for the request-ended check (8 Poll rates per minute (logcat):
The 30 s re-check adds no visible load: Payment Pending runs below Home on both counters, and the explicit Still running: the new strings in long locales. |
jvsena42
left a comment
There was a problem hiding this comment.
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.
Closes #1551
Twin: synonymdev/bitkit-ios#998
Refs: #1553
Description
Out of Scope
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
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 to127.0.0.1, port50099, TCP.blockchain.transaction.broadcast, swallow it, then swallow everything after; a plainblackholeset before the swipe fails the pre-send sync instead. Swipe and wait for the payment to show as pending.Released on-chain payment '<txid>' through '<release txid>', and the next on-chain payment is sent. The Shop order stays unpaid.Automated Checks
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 quotesOnchainSendAttemptStoreTest.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 approvalSendPendingViewModelTest.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 syncLightningRepoTest.kt— only a sync finished after the request counts as freshd9680501…accepted and the next paymentd47ef3db…sent; a known candidate refused.