Skip to content

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

Closed
ovitrif wants to merge 7 commits into
masterfrom
fix/expired-onchain-payment-lock
Closed

ovitrif wants to merge 7 commits into
masterfrom
fix/expired-onchain-payment-lock

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1551
Twin: synonymdev/bitkit-ios#971

Description

  • Lets the user cancel a Shop on-chain payment whose outcome is unknown once its request has ended, which otherwise blocked every new on-chain send for good: a retry needs a valid request, so the attempt never cleared. Payment Pending shows "Cancel expired payment" when the request was canceled, rejected or expired, or its payment deadline passed, and it has no proof.
  • On confirm, Bitkit runs a wallet sync and waits up to 30 s for an on-chain sync that finishes after the tap. It then cancels only if the wallet knows none of the payment's transactions while it still knows the transactions its coins came from: a candidate that reached the mempool or the chain spends wallet coins, so the sync would have added it.
  • The release runs under the attempt store's lock and removes the original Paykit proof first (releasing its private payment-list version, as an interrupted Shop preparation does), then the attempt, so the request stays unpaid and the coins become spendable again. A subscription payment counts once its own billing period has ended, and a payment restored from a backup is never cancelled, since the restored wallet may lack evidence that it was broadcast. Payment Pending re-checks every 30 s while it stays open. Ordinary sends, transfers and hardware-wallet payments are unchanged.

Out of Scope

  • OnchainSendAttemptStore: ordinary sends and transfers with an unknown outcome keep their existing Retry; only Paykit Shop payments whose request ended get the cancel.

Design

N/A — no design available; adds a secondary "Cancel expired payment" button and a confirmation dialog 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 refused: transaction already known

A Shop payment with an unknown outcome whose request has ended blocks a new bitcoin: payment. Its transaction is already known to the wallet, so the cancel is refused and the payment stays pending.

and-refused2x.mp4

Cancel released, next payment sent

The same blocked state, with a transaction that never reached the network: after a fresh sync the payment is cancelled, and a new on-chain payment is sent.

and-released2x.mp4

QA Notes

Journeys

N/A — not drivable; see Manual Tests.

Manual Tests

  • Cancel a Shop payment whose request has ended while its outcome is unknown → it is cancelled only if none of its transactions reached the network, and new on-chain sends work again — needs an Electrum server that swallows requests (synonymdev/bitkit-ios scripts/electrum-bridge.py).

    Steps
    1. Run the bridge on the host (python3 scripts/electrum-bridge.py --port 50099), 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, open its on-chain request in Bitkit and continue to Swipe To Pay. touch /tmp/electrum-bridge/blackhole, swipe, and wait for Payment Pending.
    3. Keep the blackhole until the Shop checkout ends or its request expires (about 30 min), then rm /tmp/electrum-bridge/blackhole.
    4. Open any new on-chain payment and swipe. Payment Pending for the earlier payment opens with "Cancel expired payment".
    5. Tap it, then Continue. Pass: the toast says "Payment cancelled. Its coins are available again.", logcat shows Released on-chain payment <txid> after its request ended without it reaching the network, and the next on-chain payment is sent. The Shop order stays unpaid.
    6. Variant with lose-reply instead of blackhole in step 2, so the transaction reaches the mempool: Payment Pending shows "This payment may still reach the network, so it cannot be cancelled.", or the next sync recognizes the transaction and no cancel is offered.

Automated Checks

  • added OnchainSendAttemptStoreTest.kt — an unresolved Shop payment is released only after its proof is removed and only if unchanged; an ordinary payment is never released
  • added PaykitPaymentProofRepoTest.kt — a canceled, rejected or expired request releases a payment that never reached the network and removes its proof; an open request, a submitted proof or a possibly broadcast payment keeps it
  • added SendPendingViewModelTest.kt — the cancel is offered only for an ended Shop request, reports a failed sync, and clears the pending state on success
  • ran the cancel path on an Android emulator at 059d256 with a seeded attempt: a candidate the wallet already knew was refused; a candidate that never reached the network was released, and the next bitcoin: payment 8cd9116c… was broadcast.

@ovitrif ovitrif self-assigned this Oct 16, 2026
@ovitrif
ovitrif requested review from a team, ben-kaufman and pwltr and removed request for a team October 16, 2026 19:27
Comment thread app/src/main/java/to/bitkit/repositories/LightningRepo.kt Fixed
Comment thread app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt Fixed
Comment thread app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt Fixed
@greptile-apps

greptile-apps Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change affects one wallet payment workflow for eligible Shop on-chain sends.

Fix the broken device-test constructor call before merging; refreshing the cancel option would also improve the waiting flow.

Findings

  1. P1 Device tests cannot compile ▶
  2. P2 Cancel option stays hidden ▶

Summary

Adds cancellation for unresolved Shop on-chain payments after their request ends.

  • Ended Shop payments can be canceled when their transactions never reached the network.

The new constructor argument breaks an existing device test. The cancel option also does not refresh when a request ends while the screen remains open. Tests were not run.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Open Payment Pending] --> B{Shop request ended?}
  B -->|Yes| C[Offer cancel]
  C --> D[User confirms]
  D --> E[Sync wallet]
  E --> F{No candidate known and input parents known?}
  F -->|No| G[Keep payment pending]
  F -->|Yes| H{Attempt unchanged under lock?}
  H -->|No| G
  H -->|Yes| I[Remove original proof]
  I --> J[Delete attempt]
  J --> K[Show success and close]
Loading

Reviews (1) · Last reviewed commit: "fix: let an expired payment that never r..." · Reviewed by Greptile

Comment thread app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.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: 059d2569e2

ℹ️ 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 on lines +433 to +437
PaymentRequestLifecycleState.ACCEPTED,
PaymentRequestLifecycleState.ACTIVE_RECURRING -> {
val now = Clock.System.now()
val request = runCatching { record.toPaykitPaymentRequestHistory(now) }.getOrNull()
?: return@withContext false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Materialize the recurring billing period before checking expiry

For an unresolved subscription payment, the record state is ACTIVE_RECURRING, but toPaykitPaymentRequestHistory() delegates to a parser that explicitly rejects that state as a recurring request. This branch therefore always returns false, so an expired recurring-period attempt remains guarded and continues blocking every subsequent on-chain send. Resolve the billing period identified by requestId.billingPeriodStartsAt from the subscription record before checking its payment or quote deadline.

Useful? React with 👍 / 👎.

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.

Fixed in e6cc735: a subscription payment is now checked against its own billing period. It counts as ended when the subscription is canceled, rejected or expired, or when that period's payment deadline (from the subscription's pricing and the started proof's period) has passed; only a proof for that period refuses it. Covered by recurring period counts as ended only without its own proof.

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.

@ben-kaufman fixed in 51ede25: an active subscription period also counts as ended once the stored proof's quote deadline has passed, through submissionDeadline on the materialized period request. expired quote ends an active subscription period covers a live and an expired quote.

Comment on lines +1869 to +1872
val requestedAt = (System.currentTimeMillis() / 1000).toULong()
withTimeoutOrNull(timeout) {
sync()
while ((lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL) <= requestedAt) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Accept a same-second wallet sync as fresh

When an up-to-date wallet sync completes within the same wall-clock second as the tap, latestOnchainWalletSyncTimestamp equals requestedAt; the <= condition then waits for an unrelated later sync and can hit the 30-second timeout even though sync() already succeeded. This makes cancellation repeatedly report that syncing is unavailable on fast syncs, leaving all new on-chain sends blocked. Track actual sync completion or a pre-sync baseline instead of requiring a later timestamp second, and use the repository's injectable time helper rather than the direct system clock.

AGENTS.md reference: AGENTS.md:L285-L285

Useful? React with 👍 / 👎.

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.

Fixed in e6cc735: a sync that finishes in the same second as the tap now counts, and the tap time comes from nowMillis().

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.

@ben-kaufman fixed in 51ede25: the check reads the last on-chain sync timestamp before calling sync() and waits, up to 30 s, for one strictly later. A sync that finished earlier in the same second no longer counts, and a failed or skipped sync never moves the timestamp, so cancellation reports that it could not sync instead. only a sync finished after the request counts as fresh covers the same-second case.

Comment thread app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt Outdated
Comment thread app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
Comment thread app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt Outdated
@chatgpt-codex-connector

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-16T19:34:34.974497Z 059d256 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.

@ovitrif
ovitrif enabled auto-merge October 16, 2026 19:44

@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 a "Cancel expired payment" path on Payment Pending for a Shop on-chain payment whose request ended and whose candidates the wallet never saw after a fresh sync, removing the original proof under the proof lock before deleting the attempt under the store lock. Reviewed 717e4f9, full tier, reasoned from the code and CI (Bitkit Android branches are not built here).

What I checked, and 5 candidates I ruled out

Read in full: OnchainSendAttemptStore.kt (abandonUnbroadcast, releaseInterruptedShopPreparation, broadcastPreparedCandidate), PaykitPaymentProofRepo.kt (onchainRequestHasEnded, quoteDeadlinePassed, abandonUnbroadcastOnchainPayment, releaseDeniedPrivateConsumption, reconcile), LightningRepo.sync and the new wait, LightningService.walletKnowsNoCandidate, SendPendingViewModel.kt, SendPendingScreen.kt
Call sites traced: abandonUnbroadcastOnchainSend, onchainCandidatesNeverReachedNetwork, SendPendingViewModel constructor (unit and device tests updated)
CI: lint, detekt, build-local green; build (unit tests) and the e2e jobs still running at review time

Ruled out

  • Attempt changes between the network check and the release (a retry broadcasts a new candidate): abandonUnbroadcast reloads under the store mutex and requires current == expected, and a retry rewrites txid/candidateTxids, so the release is refused; inFlightPreparations covers a preparation still holding the attempt.
  • Lock-order inversion: the store mutex is taken first and the proof operationMutex inside the callback; no path in PaykitPaymentProofRepo calls into the store while holding operationMutex (the one lightningRepo call under it is getPayments).
  • Proof removed but attempt left behind on a crash: proof removal runs first and the attempt still satisfies canBeAbandoned with no matching proof, so original is empty and a later call deletes it.
  • Stale sync accepted: the wait requires latestOnchainWalletSyncTimestamp strictly after the tap, and a timeout raises OnchainSendAbandonSyncError, shown as its own message rather than a refusal.
  • Pruned wallet history read as "never broadcast": walletKnowsNoCandidate also requires every original input's parent transaction to be known, so a wallet that lost history refuses instead of releasing.

Merge confidence: 4/5, walletKnowsNoCandidate and the post-tap sync wait, the checks that decide whether coins are released, have no unit test of their own; the repo and view-model tests stub them.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 5e40108 (run).

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

@ovitrif
ovitrif disabled auto-merge October 16, 2026 20:03

@ben-kaufman ben-kaufman left a comment

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.

Reviewed e6cc735. The 134 focused store, proof-repository, and pending-ViewModel tests pass. An additional regression against the unchanged repository fails after simulating proof removal with the attempt still present, confirming the new inline finding.

Two existing findings are still incomplete. In #1553 (comment), the recurring path now materializes the period but still ignores the original quote expiry, so retry can reject the quote while cancellation stays unavailable. In #1553 (comment), the comparison now accepts a cached sync from earlier in the same second. The sync result is ignored and can return before an ongoing sync completes, so fresh wallet evidence is not established. Please address those in the existing threads. No device or funded-payment tests were run in this review.

}
if (!safe) return@withLock false
for (proof in original) releaseDeniedPrivateConsumption(proof)
if (original.isNotEmpty()) persist(proofs - original.toSet())

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.

If the process stops after removing this proof and before deleting the attempt, an expired recurring payment cannot finish cancellation after restart. activeRecurringPeriodPassed gets its period only from the removed proof, so a subscription that is still active now returns false and the attempt keeps blocking new sends. Could we retain enough release evidence with the attempt to resume after this intermediate state, and cover it with a restart test?

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.

Fixed in 51ede25: once the request is found ended, the attempt is marked releaseApproved before the proof is removed, so after a restart onchainRequestHasEnded returns true from the attempt alone and the release finishes after a fresh network check. approved release finishes after its proof was removed covers an active subscription with no local proof left.

@ovitrif
ovitrif requested a review from ben-kaufman October 16, 2026 20:28

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

Code scan at e3688a6: one MEDIUM inline. This round was reviewed on Opus because Fable credits ran out.

Checked and clean:

  • approveRelease and abandonUnbroadcast re-load the attempt under the store mutex and require current == expected, so a retry that retains or broadcasts a candidate in between blocks the release. Lock order is store mutex, then the proof operationMutex.
  • Retry candidates join candidateTxids before dispatch. A candidate the wallet once saw stays in the BDK graph after eviction, and missing input parents refuse the release.
  • Restored attempts are excluded. releaseApproved is persisted before the proof is removed, so a kill mid-release resumes with the network check re-run.
  • removeOriginalShopProofs never strips a submitted, verified, non-candidate or hardware-wallet proof. The recurring path refuses when a proof exists for the same period.
  • #1548 (Back on the recovery PIN screen) is present but not worsened. A cancel against a stale attempt is refused rather than hanging.

The same mechanism is in the iOS twin, synonymdev/bitkit-ios#971 (deeec53d).

Performance-relevant: Payment Pending polls onchainRequestHasEnded every 30 s, reading all payment requests each time, and Cancel waits up to 30 s for a fresh sync.

The device gate waits for the MEDIUM fix.

return@withLock false
}
if (!removeOriginalProof(current)) return@withLock false
keychain.delete(KEY, current.walletIndex)

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.

MEDIUM: cancelling an expired payment frees the original inputs without invalidating the signed original, so a later payment with other coins and the original can both confirm.

Cancel trusts a single Electrum server's history, which ldk-node documents it should not. walletKnowsNoCandidate passes when the configured Electrum server's history has no candidate txid but has every input's parent. That shows only that this server never reported the signed original, not that its bytes reached nobody. ldk-node rc.76 payment/onchain.rs:209 says: "neither Rejected nor Unknown authorizes clearing the original payment guard."

Nothing keeps the original inputs reserved after cancel. prepare_send_to_address does not add the signed tx to the BDK graph or lock its outpoints. Once the guard is gone, the next send's coin selection can pick disjoint UTXOs. On master, the only way out of an Unknown attempt was a retry, and retainPreparedReceipt required receipt.inputs.toSet() == originalInputs.toSet(), so every later spend conflicted with the original.

The button can show within seconds of an Unknown broadcast. The payee controls CANCELED, or the quote deadline may already have passed, and there is no minimum age.

Failure path:

  1. The broadcast outcome is Unknown.
  2. The request ends.
  3. The user taps Cancel and reads "Its coins are available again".
  4. The user checks out again and pays with other coins.
  5. The original surfaces later, from a withholding server, a plaintext-TCP custom server that is MITM'd, or a late delivery.
  6. The payee is paid twice, and Bitkit has already deleted the original's proof.

Against an honest server, the forced fresh sync would see a tx that reached a mempool, so the exposure needs a dishonest or interposed server, or a narrow overload race. But the cost is a second full payment in a common flow, and the cancel copy promises more than the check proves.

Fix: make cancel conflict with the original rather than forget it. Either spend the original inputs to a wallet-owned output and delete the guard only after that replacement is Accepted or Observed, or keep a persisted tombstone of the original inputs and force the next send to include one of them until they are seen spent. At minimum, word the dialog and toast as "may still confirm" until then. The retry path cannot be reused as-is, because retainPreparedReceipt(isRecovery=true) requires the same address and amount.

Reproduction

./gradlew :app:testDevDebugUnitTest --tests 'to.bitkit.repositories.OnchainSendAttemptStoreTest'

Fails on e3688a6 (32 tests, 1 failed):

java.lang.AssertionError: disjoint send admitted while abandoned original is still valid. Expected an exception of class java.lang.IllegalArgumentException to be thrown, but was completed successfully with the result: <OnchainSendAttempt(walletId=wallet0, ... requestId=PaykitPaymentRequestId(paymentRequestId=request-2 ...), evidence=Pending, txid=cdcd...

An illustrative store-side change that requires the next receipt to spend one of the abandoned inputs makes all 32 pass. It is in memory only, so it is not a production fix.

diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt
index d5ccfcc0b..fd4c17b6c 100644
--- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt
@@ -780,6 +780,50 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() {
         assertTrue(store.admitForTest(walletId = WalletScope.default).attemptId != attempt.attemptId)
     }
 
+    @Test
+    fun `payment after an abandon cannot spend coins disjoint from the abandoned original`() = test {
+        var saved: String? = null
+        val keychain = mock<Keychain>()
+        whenever(keychain.loadString(key, 0)).thenAnswer { saved }
+        whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) }
+        whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null }
+        val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System)
+        val attempt = store.admit(
+            walletId = WalletScope.default, requestId = PaykitPaymentRequestId("request", "payer"),
+            orderId = "order", address = "bcrt1qrecipient", amountSats = 1000uL,
+            isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false,
+            channelId = null, tags = emptyList(), beforeSendAttempt = {}, payerIdentity = "payer",
+        )
+        val original = OnchainPreparedReceipt(
+            txid = "ab".repeat(32),
+            inputs = listOf(OnchainSendInput("11".repeat(32), 0u)),
+            address = attempt.address,
+            amountSats = attempt.amountSats,
+        )
+        store.retainPreparedReceipt(attempt.attemptId, 0, original, false)
+        store.broadcastPreparedCandidate(attempt.attemptId, 0, original.txid) { beforeDispatch ->
+            beforeDispatch()
+            OnchainSendOutcome.Unknown(original.txid)
+        }.also { store.recordOutcome(attempt.attemptId, it, 0) }
+        val approved = requireNotNull(store.approveRelease(requireNotNull(store.current())))
+        assertTrue(store.abandonUnbroadcast(approved) { true })
+
+        // Same payee re-issues the request after the cancel; coin selection picks a different UTXO.
+        val next = store.admit(
+            walletId = WalletScope.default, requestId = PaykitPaymentRequestId("request-2", "payer"),
+            orderId = "order-2", address = "bcrt1qrecipient", amountSats = 1000uL,
+            isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false,
+            channelId = null, tags = emptyList(), beforeSendAttempt = {}, payerIdentity = "payer",
+        )
+        val disjoint = original.copy(txid = "cd".repeat(32), inputs = listOf(OnchainSendInput("22".repeat(32), 1u)))
+        // The signed original still spends 11..:0 and can surface later; a disjoint new payment lets both confirm.
+        assertFailsWith<IllegalArgumentException>("disjoint send admitted while abandoned original is still valid") {
+            store.retainPreparedReceipt(next.attemptId, 0, disjoint, false)
+        }
+        val conflicting = disjoint.copy(inputs = original.inputs + disjoint.inputs)
+        assertEquals(conflicting.txid, store.retainPreparedReceipt(next.attemptId, 0, conflicting, false).txid)
+    }
+
     @Test
     fun `ordinary payment is never abandoned`() = test {
         var saved: String? = null

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.

HIGH: A timed-out local broadcast can still arrive after cancellation

This overlaps @jvsena42's finding above. Adding the concrete timeout evidence from my independently saved source review.

The pinned native implementation can return Unknown while its spawn_blocking(transaction_broadcast) continues; electrum_timeout_returns_unknown_while_blocking_rpc_remains_in_flight explicitly demonstrates that lifetime. A newer sync can observe no candidate before that delayed broadcast is processed.

At app/src/main/java/to/bitkit/services/LightningService.kt:1041–1042, absence from wallet history still permits deleting the started proof and send guard without stopping the old broadcast or invalidating its signed transaction. A later payment using different UTXOs can therefore confirm alongside it.

Retain the guard until dispatch is definitively ruled out or the original candidate is invalidated. Cover a broadcast held past timeout and released after the cancellation check.

Evidence basis: source tracing and the existing native timeout regression test; I have not run a funded/device reproduction of this cancellation path.

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.

Agreed: cancelling must make the original unconfirmable rather than forget it. This PR moved to 3.0.5, and the RC3 part split out to synonymdev/bitkit-ios#990, so the fix lands here for 3.0.5. The planned approach: after the fresh-sync check, cancel spends at least one of the original's coins back to the wallet at a fee above every candidate's, behind the same fee approval and PIN as Retry, and keeps the guard and the started proof until that release is accepted or the wallet sees it. A Rejected or Unknown release keeps both. The dialog will say that cancelling sends the coins back to the wallet and costs a network fee.

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 release transaction planned in the reply to the double-pay finding covers this too: it spends one of the original's coins at a higher fee, so a broadcast still in flight can no longer confirm, and the guard stays until that release is accepted or the wallet sees it. It lands here for 3.0.5.

@ovitrif
ovitrif marked this pull request as draft October 16, 2026 21:25
@ovitrif

ovitrif commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: ldk-node rc.76 alone fixes the dropped-connection bug, shown on device in synonymdev/bitkit-ios#990 (synonymdev/bitkit-ios#990 (comment)) and on synonymdev/bitkit-android master. The recovery features here aren't needed for 3.0.0. The branch stays, so this can be reopened if a real case shows up.

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

6 participants