Skip to content

fix: recover on-chain sends after the app returns from the background - #971

Closed
ovitrif wants to merge 24 commits into
masterfrom
fix/ldk-node-electrum-reconnect
Closed

ovitrif wants to merge 24 commits into
masterfrom
fix/ldk-node-electrum-reconnect

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #976, closes #986
Twin:

Description

  • Bumps ldk-node to 0.7.0-rc.76. After Bitkit returned from the background, the Electrum server had dropped the idle connection and ldk-node never rebuilt it, so wallet sync, fee updates and every broadcast failed with Broken pipe until the app was relaunched, and a Pubky Shop payment never reached the network. ldk-node now replaces the connection once a call finds it broken and retries a failed broadcast once on the new connection, within each call's original deadline. 0.7.0-rc.76 is 0.7.0-rc.74 plus this fix, with no API change.
  • Resends an on-chain payment whose broadcast outcome is unknown once an on-chain wallet sync completes, so a send cut off by a dropped connection is not silently dropped. It spends the same coins at no higher fee than the user approved, and only while the original is the only candidate, so it can never create a second payment and resends each payment at most once. A Paykit request is resent only while still valid, through the same recovery authorization as the pending screen's Retry, and the Paykit proof and activity are completed as that screen does. A try that stops before reaching the network repeats on a later sync, at most once a minute. Android stops the node in the background, so there an unknown outcome is resolved only by the pending screen's Retry or a later sync that sees the transaction.
  • After Bitkit returns from at least 30 s in the background, an on-chain payment (including a Paykit/Shop request or a bitcoin: link) shows the send sheet's existing "Connecting to network" screen until a wallet sync succeeds, retrying every 3 s, then continues on its own. After 20 s it shows the existing "Connection issues" screen, which keeps retrying. The wait shares the foreground wallet sync and ends once an on-chain sync finishes after the resume. It covers only the steps before sending: the empty Send sheet and the Sent, Pending and Failure screens are not affected, and no new copy needs translation.
  • Stops a failed scheduled wallet backup from rescheduling itself, which kept retrying about every 5 s and turned master's BackupServiceTests.testAutomaticBackupDoesNotRepeatUnrelatedFailures red. The scheduler now writes the wallet backup without the re-mark, so it retries only by its own rules; the backup stays required, and other callers such as USDT payments keep the re-mark.
  • Lets the user cancel a Paykit 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). 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 waits for an on-chain wallet sync that finishes after the tap (up to 30 s), 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 started Paykit proof is removed before the attempt, so the request stays unpaid and a crash in between leaves an attempt a second tap releases. 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. Five new English strings.

Out of Scope

Design

N/A — no design available; reuses the existing send sheet connecting and connection-issues screens, and adds a secondary "Cancel expired payment" button to Payment Pending.

Preview

Regtest staging, iPhone 17 Pro simulator (iOS 26.5), 2x, through a local Electrum bridge that can drop or swallow requests.

Dropped connection with a call waiting

Connection dropped with a call waiting on it, the state RC2 hit: ldk-node rebuilds the connection and the next send is accepted (recorded on rc.75; rc.76 only bounds it by each call's deadline).

a2x.mp4

Unknown outcome resent after reconnect

Broadcast outcome unknown, then the connection comes back: the same payment is resent at the first sync and accepted.

b2x.mp4

Return from background via a bitcoin: link

Return from 40 s in the background through a bitcoin: link: "Connecting to network" until a sync succeeds, then the payment is sent.

c2x.mp4

Broadcast reply lost

The broadcast reaches the server but its reply is lost: the outcome shows as unknown, the next sync recognizes the payment from the mempool, and nothing is resent.

e2x.mp4

Open confirmation backgrounded while offline

An open, already validated confirmation goes to the background for 40 s while the Electrum server swallows requests: on return it shows Connecting to network, then Connection Issues, and goes back to Confirm once the server answers again.

reval2x.mp4

Cancel refused: transaction already known

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

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

abandon-released2x.mp4

Bitcoin Sent kept after background

A payment is sent and Bitcoin Sent stays open while Bitkit spends 45 s in the background: on return the success screen stays as it was, with no connecting screen or balance re-check, and one wallet sync runs.

jv-success2x.mp4

QA Notes

Journeys

N/A

Setup for all three tests (staging regtest, about 10 min)
  1. Build this PR's Debug app (staging regtest is its default backend) and run it on an iOS Simulator; create a new wallet.
  2. Fund it: copy a Savings receive address (Receive › Savings › Copy, then xcrun simctl pbpaste <udid>), then
    curl -X POST https://api.stag0.blocktank.to/blocktank/api/v2/regtest/chain/deposit -H 'content-type: application/json' -d '{"address":"<bcrt1…>","amountSat":50000}'
    curl -X POST https://api.stag0.blocktank.to/blocktank/api/v2/regtest/chain/mine -H 'content-type: application/json' -d '{"count":1}'
  3. Start the bridge on the Mac running the simulator. It listens on 127.0.0.1:50099, forwards over TLS to the staging Electrum server and takes control files in /tmp/electrum-bridge:
    python3 scripts/electrum-bridge.py
  4. Point Bitkit at it: Settings › Advanced › Electrum Server, host 127.0.0.1, port 50099, TCP. On a simulator you can instead stop the app and run
    xcrun simctl spawn <udid> defaults write to.bitkit electrumServer -data $(printf '{"host":"127.0.0.1","port":50099,"protocolType":"tcp"}' | xxd -p | tr -d '\n')
    then relaunch; the bridge prints two open lines.
  5. Follow the app log:
    tail -f "$(ls -t ~/Library/Developer/CoreSimulator/Devices/<udid>/data/Containers/Shared/AppGroup/*/logs/*.log | head -1)"

Manual Tests

  • Return from the background into an on-chain payment → "Connecting to network" shows until a sync succeeds, then the payment opens and is sent — needs an Electrum connection dropped while a call waits on it.

    Steps
    1. With Bitkit in front, send it to the background (xcrun simctl launch <udid> com.apple.Preferences) and arm a drop: touch /tmp/electrum-bridge/drop.
    2. Wait 40 s, then open a payment: xcrun simctl openurl <udid> "bitcoin:<address>?amount=0.00002" (or a Pubky Shop checkout), and tap Open.
    3. Pass: the bridge prints drop: … -> closing; the log shows Incremental sync of on-chain wallet failed (all EOF while parsing) followed within a second by Reconnected to Electrum server after a failed call.; shouldShowSyncOverlay: true, then false once a sync succeeds (up to ~20 s, see bug: on-chain payment waits up to 20 s on the connecting screen after resume #978); continue and swipe → "Bitcoin Sent" and Successfully broadcast transaction <txid>.
    4. Fail (the 3.0.0 RC2 bug): every later sync and broadcast logs Broken pipe with no Reconnected line, and the payment ends as "outcome unknown".
  • Leave an open on-chain confirmation in the background while the server is unreachable → back in front, "Connecting to network" falls back to Connection Issues within 20 s and the confirmation returns once a sync succeeds — needs an Electrum server that swallows requests.

    Steps
    1. Open a payment and continue to the Confirm screen.
    2. touch /tmp/electrum-bridge/blackhole, send Bitkit to the background (xcrun simctl launch <udid> com.apple.Preferences) and wait 40 s.
    3. Bring Bitkit back (xcrun simctl launch <udid> to.bitkit).
    4. Pass: "Connecting to network" shows, then Connection Issues within 20 s; after rm /tmp/electrum-bridge/blackhole the Confirm screen returns.
    5. Fail: the sheet stays on "Connecting to network" with no Connection Issues fallback.
  • Lose the broadcast reply, then bring the connection back → the same payment is resent once and no second transaction appears — needs an Electrum server that swallows requests.

    Steps
    1. Open a payment and continue to the Swipe To Pay screen.
    2. touch /tmp/electrum-bridge/blackhole, then swipe. After ~15 s the sheet shows Payment Pending, "Its outcome is unknown", and the log shows Failed to broadcast transaction due to timeout and On-chain broadcast outcome unknown for <txid>.
    3. Wait 100 s so the lost call's own retries end, then rm /tmp/electrum-bridge/blackhole.
    4. Pass: within about 10 s the log shows Rebroadcasting on-chain payment <txid> with an unknown outcome and Rebroadcast of on-chain payment <txid> ended as accepted(txid: "<new txid>"); the pending sheet shows the new transaction ID; Activity has one new Sent row and the balance drops once.
  • Let the broadcast reach the server but lose its reply, then bring the connection back → the payment is recognized once and no second transaction is built — needs an Electrum server that drops only the broadcast reply.

    Steps
    1. Note the balance, open a payment and continue to the Swipe To Pay screen.
    2. touch /tmp/electrum-bridge/lose-reply, then swipe. The bridge prints lose-reply: forwarding broadcast … and dropped reply … with the txid; after ~15 s the sheet shows Payment Pending, "Its outcome is unknown".
    3. Wait 90 s, then rm /tmp/electrum-bridge/lose-reply.
    4. Pass: at the next sync the log shows New unconfirmed transaction <txid> detected in mempool and Created sent onchain activity for txid <txid>; the pending sheet shows "The earlier payment's local details were restored. No new payment was sent."; the log has no further Created new transaction line; the balance dropped by the payment and fee exactly once.
  • Let a Paykit request's deadline pass while its payment's outcome is unknown → no resend and the request is not marked paid — needs a Pubky Shop request and the bridge.

    Steps (not run for this PR)
    1. Start a Pubky Shop checkout, open its on-chain payment request in Bitkit and continue to Swipe To Pay.
    2. touch /tmp/electrum-bridge/blackhole, swipe, and wait for "Its outcome is unknown".
    3. Keep the blackhole until the request's deadline passes (the Shop's requests expire after about 30 min), then rm /tmp/electrum-bridge/blackhole.
    4. Pass: the log shows Could not rebroadcast on-chain payment <txid> with an expired or unavailable request, no Rebroadcast … ended as line, and the Shop order stays unpaid.
  • Cancel a Paykit 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 a Pubky Shop request and the bridge.

    Steps
    1. Start a Pubky Shop checkout, open its on-chain payment request in Bitkit and continue to Swipe To Pay.
    2. touch /tmp/electrum-bridge/blackhole, swipe, and wait for "Its outcome is unknown". Keep the blackhole until the Shop checkout ends or its request expires (about 30 min), then rm /tmp/electrum-bridge/blackhole.
    3. Open any new on-chain payment, for example xcrun simctl openurl <udid> "bitcoin:<address>?amount=0.00001". Payment Pending opens with "Cancel expired payment".
    4. Tap it, then Continue. Pass: within a few seconds the toast says "Payment cancelled. Its coins are available again.", the log shows Released on-chain payment <txid> after its request ended without it reaching the network, and opening the payment again reaches Swipe To Pay and sends it. The Shop order stays unpaid.
    5. Variant with lose-reply instead of blackhole in step 2, so the transaction reaches the mempool: the toast says "This payment may still reach the network, so it cannot be cancelled." and the log shows mayStillConfirm; or, once the next sync recognizes the transaction, the payment resolves as sent and no cancel is offered.
  • Open Send from the tab bar right after returning from the background → the options screen opens without waiting.

    Steps
    1. Repeat step 1 of the first test, wait 40 s, bring Bitkit back and tap Send in the tab bar.
    2. Pass: the Send options (scan, paste, contacts) show at once, with no "Connecting to network" screen.

Automated Checks

  • added OnchainSendAttemptServiceTests.swift — only an unknown original with one candidate and no order is resent, at its approved fee; a try that stopped before dispatch is retried after the throttle.
  • added OnchainConnectionMonitorTests.swift — a short background stay never waits; a long one waits until a sync succeeds; an unfinished wait resumes after a short background stay.
  • ran electrum.rs in fix: reconnect electrum after the server drops the connection ldk-node#127 — a dropped connection with a queued call no longer leaves broadcasts failing; a stalled reconnect keeps calls within their deadlines.
  • added scripts/electrum-bridge.py — the bridge used for the manual tests and recordings.
  • ran the lost-reply manual test on an iOS 26.5 simulator at 0.7.0-rc.76: the server accepted 589e9c20… while its replies were dropped, the next sync recognized it from the mempool, no resend was built, and the balance dropped once (39,285 → 37,142).
  • ran the sent-screen resume on an iOS 26.5 simulator at 4cd8f43: payment f7d56c6d… sent, 45 s in the background on Bitcoin Sent, back in front with no connecting screen, toast or sheet dismissal, and one Syncing LDK… / LDK synced pair after the resume.
  • ran the cancel path on an iOS 26.5 simulator at deeec53, with the blocked attempt seeded by a local debug-only hook (not in this PR) because a real one needs a Shop request left to expire: a candidate the wallet already knew was refused (mayStillConfirm), attempt kept; a candidate that never reached the network was released about 4 s after the tap, and the next bitcoin: payment ef107777… was broadcast.
  • ran the open-confirmation manual test on an iOS 26.5 simulator at aedeceb: Connecting to network on return, Connection Issues 15 s later, Confirm back once the blackhole was removed.
  • ran the first manual test on an iOS 26.5 simulator from the steps above at 0.7.0-rc.76: dropped with a fee estimate queued, reconnected 1 ms after the failed sync, connecting screen for 19 s, payment 1f1f68d9… sent.

Once the wallet syncs again, rebroadcast a payment whose broadcast outcome is unknown with the same coins at no higher fee, while its Paykit request is still valid, so a send cut off by a dropped connection is not silently dropped.
Bump ldk-node to 0.7.0-rc.75, which rebuilds the shared Electrum connection once a call finds it broken and retries a failed broadcast on the new connection.
@ovitrif ovitrif self-assigned this Oct 16, 2026
@ovitrif ovitrif added this to the 3.0.0 milestone Oct 16, 2026
@ovitrif
ovitrif marked this pull request as ready for review October 16, 2026 14:37
@ovitrif
ovitrif requested review from a team, ben-kaufman and pwltr and removed request for a team October 16, 2026 14:37
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 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-16T16:08:37.090244Z 1277827 Draft marked ready
ℹ️ 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 16, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] No blocking issue was identified in the reviewed changes.

Summary

Updates ldk-node from 0.7.0-rc.74 to 0.7.0-rc.76 to restore Electrum connections after backgrounding.

  • On-chain payments recover after Bitkit returns from the background.

Reviews (2) · Last reviewed commit: "fix: keep electrum reconnects within eac..." · Reviewed by Greptile

@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. Pins ldk-node to 0.7.0-rc.75, which rebuilds the shared Electrum connection once a call finds it broken and retries a failed broadcast once on the new connection. Reviewed c56fb64, full tier (dependency manifest). The Swift side was reasoned from the code and the ldk-node diff; iOS does not build here.

What I checked, and 3 candidates I ruled out

Pin: tag v0.7.0-rc.75 points at dc3e53d, the revision in Package.resolved; pbxproj and Package.resolved agree on the version.
Binary: the Package.swift checksum at that tag (7075b2aa…05da) matches the sha256 GitHub reports for the release's LDKNodeFFI.xcframework.zip.
Scope of rc.74 to rc.75: rc.74 is an ancestor. Between them are the Electrum reconnect commit, the release commit, and two merges (#125, #126: sign-foreign-psbt-inputs, a force-close HTLC test). The only source change outside tests is src/chain/electrum.rs. bindings/ldk_node.udl and src/lib.rs are unchanged, so the Swift API does not change.
ldk-node change: read in synonymdev/ldk-node#127 at dc3e53d.

Ruled out

  • Duplicate broadcast from the retry: a rebroadcast of the same transaction has the same txid, so a second accept does not create a second payment.
  • API break in the generated bindings: no UDL or public API change between the two tags.
  • Unreviewed code in the pinned range: the extra merges are already in synonymdev/ldk-node master.

Context, not a finding: the pinned tag was cut from the synonymdev/ldk-node#127 branch. That PR is still open, with a change request from @ben-kaufman about reconnect time limits.

Merge confidence: 4/5, Run Tests and Run Integration Tests were still running at this head when I reviewed, and nothing here builds iOS.

Background wallet syncs report only their completion and never set isSyncingWallet, so the resend never ran outside a manual sync. Trigger it from the on-chain sync-completed event instead.
@ovitrif
ovitrif requested a review from jvsena42 October 16, 2026 15:03
@ovitrif
ovitrif marked this pull request as draft October 16, 2026 15:08
…sume

After a stay in the background long enough for the Electrum server to drop the connection, show the send sheet's connecting screen for on-chain payments until a wallet sync succeeds again, then continue on its own; after 20 s it shows the existing connection-issues screen.
Only a send with an on-chain target waits for the network after resume; the options screen stays reachable for scan, paste and Lightning.
Bump ldk-node to 0.7.0-rc.76, which adds the deadline bound to the Electrum reconnect and broadcast retry from 0.7.0-rc.75.
@ovitrif
ovitrif marked this pull request as ready for review October 16, 2026 16:05
@ovitrif
ovitrif requested a review from a team October 16, 2026 16:05
@ovitrif
ovitrif enabled auto-merge October 16, 2026 16:05

@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: 1277827b39

ℹ️ 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 Bitkit.xcodeproj/project.pbxproj
@ovitrif

ovitrif commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Recovery check for #986 on the build at deeec53, using the same stuck wallet from the earlier run on an iPhone 26.5 sim (regtest staging): the wallet sends on-chain again.

Cancel the expired payment, then send ✅

Before the install, payment 226500f2… was still stuck at "outcome unknown". The build was installed over it at 21:10 CEST, keeping the wallet (44,214 sats).

  • 21:11:44 CEST: a new on-chain send opened that payment's Payment Pending screen, now with "Cancel expired payment".
  • 21:12:37, cancel: "Cancel expired payment" then Continue showed "Payment cancelled. Its coins are available again." Log: Released on-chain payment 226500f2… after its request ended without it reaching the network.
  • 21:12:51, new send: the same bitcoin: URI opened the normal Amount screen.
  • 21:13:29, broadcast: the new send went out: Successfully broadcast transaction b794dafb….
  • 21:13:57, confirmed: it confirmed at height 232056, and the balance is 44,071 sats (143 sats fee).

226500f2… was never broadcast (staging Electrum still answers missing transaction).

recover-986-cancel-expired-2x.mp4

@ovitrif
ovitrif requested a review from ben-kaufman October 16, 2026 19:30
@ovitrif

ovitrif commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

@ben-kaufman this PR now also carries the fix for #988, in 33d7ca7 and 55445f9, which touch your #950 backup code.

The scheduled path now calls writeWalletBackup without the re-mark, so a failed scheduled wallet backup no longer schedules another attempt. That is what #950's description intended (no repeated retries of genuine export or upload failures), and what testAutomaticBackupDoesNotRepeatUnrelatedFailures checks; it passes again. The failed backup stays required, since runBackup keeps required above synced. persistWalletBackup callers such as USDT payments keep the re-mark.

@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 e690f4a. The earlier reconnect findings are addressed, but the three recovery issues below remain. Isolated Swift helper checks reproduce stale-sync acceptance and loss of recurring cancellation eligibility. These are helper tests, not device or funded-payment tests.

SwiftFormat also reports one introduced hoistTry finding in BitkitTests/PaykitPaymentProofServiceTests.swift:178. The other 16 diagnostics were verified against the merge base. Unit, integration, and build checks are still running.

let node = onchainDispatchNode as? Node, let inputs = attempt.recoveryContext?.inputs, !inputs.isEmpty
else { return false }
let requestedAt = UInt64(Date().timeIntervalSince1970)
Task { try? await self.sync() }

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.

A sync completed earlier in this same second passes this comparison before the new sync finishes, and the task discards sync errors. That lets cancellation remove the send guard using stale wallet state when the requested sync fails. Could we await a confirmed post-request sync completion before checking candidates, and cover an old same-second timestamp with a failed sync?

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 7ba0406: the check now reads the last on-chain sync timestamp before requesting the 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 sync never moves the timestamp, so cancellation reports that it could not sync instead. testOnlyASyncFinishedAfterTheRequestCountsAsFresh covers the same-second case.

(proof.onchainWalletId == nil || proof.onchainWalletId == WalletScope.default) &&
(proof.paymentIdentifier == nil || attempt.containsCandidate(proof.paymentIdentifier)))
}
if remaining != proofs { try await persist(remaining) }

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 app closes after this save and before the attempt is deleted, a recurring payment cannot finish cancellation after restart. recurringPeriodHasEnded needs the billing period from the proof we just removed, so it returns false even when the subscription is canceled and the attempt keeps blocking new sends. Could we retain enough release evidence with the attempt to resume this cleanup, and cover that restart state?

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 7ba0406: 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. A canceled or rejected subscription also counts without the local proof now, matching the record's proofs by the attempt's billing-period start. testApprovedReleaseFinishesAfterItsProofWasRemoved covers the restart state for an active subscription.

return true
case .activeRecurring:
guard let subscription = PaykitSubscription(record: record) else { return false }
return subscription.isExpired(at: now) ||

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.

This recurring branch ignores the original quote's expiry. If that quote expires before the period payment deadline, recovery rejects it but this still returns false, so the unresolved payment cannot be retried or canceled. Could we check the stored proof's conversionQuoteId against the materialized period's submission deadline too, and cover an expired quote on an otherwise active subscription?

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 7ba0406: for an active subscription, the period also counts as ended once the stored proof's conversionQuoteId deadline (submissionDeadline(quoteId:) on the materialized period request) has passed. testExpiredQuoteEndsAnActiveSubscriptionPeriod covers a live and an expired quote. The hoistTry finding in the tests is fixed too.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Follow-up review of changes since 4cd8f432, at e690f4a3. Inherited baseline a0a05e2a-9bd8-4a9d-a26a-b311007889fa, with the same base and merge base eb3156ab. This pass reviewed the scheduled wallet-backup re-mark split, restoredFromBackup, subscription period release, the Payment Pending cancel recheck, and the new unit tests. Inherited coverage is the unchanged ldk-node 0.7.0-rc.76 pin, rebroadcaster rules, baseline predicate tests, electrum bridge, reconnect monitor and pre-send routes, and the bitkit-android#1532 reconnect contract at 0e13a52e.

1 actionable finding — resolve or provide an evidence-backed rebuttal.

An ended subscription payment cannot be released once its local proof is gone, so the crash window documented on abandonUnbroadcastOnchainPayment leaves that attempt blocking on-chain sends. bitkit-android#1553 at e6cc735 still releases a canceled or rejected subscription from the attempt's billing-period start. The scheduled-backup change matches testAutomaticBackupDoesNotRepeatUnrelatedFailures: a failed scheduled wallet backup stays required and does not queue itself again. Earlier reconnect-wait threads are unchanged since 4cd8f43.

Validation: source inspection only. unit-tests and integration-tests were still in progress on e690f4a. Device testing: not performed in this review.

Suggested additional test cases

  • iOS unit test. Setup: a canceled subscription on-chain attempt with an unknown outcome whose local proof was already removed. Action: release it through abandonUnbroadcastOnchainPayment. Expected: the attempt is removed and no longer blocks a new on-chain send.

Findings

  • [HIGH] Release an ended subscription payment after its proof is already gone — inline at Bitkit/Services/PaykitPaymentProofService.swift:573.

private func recurringPeriodHasEnded(
_ record: Paykit.PaymentRequestRecord, requestId: PaykitPaymentRequest.ID, payer: String, now: Date
) async throws -> Bool {
guard let period = try await loadProofs().last(where: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[HIGH] Release an ended subscription payment after its proof is already gone

Trigger: cancel an ended Paykit subscription payment whose on-chain broadcast never reached the network, and the app stops after the started proof is deleted and before the attempt is removed. A second tap sees that same state.

Mechanism: recurringPeriodHasEnded returns false unless a local on-chain proof still carries a billing period. abandonUnbroadcastOnchainPayment removes that proof before abandonUnbroadcast. The next call takes the billing-period branch and treats the request as still open, so loadAbandonableAttempt hides Cancel expired payment. The comment on abandonUnbroadcastOnchainPayment says a later call must still be able to release the attempt. One-time canceled, rejected, and expired requests still return true without a local proof. Android recurringPeriodHasEnded at e6cc735 matches remote proofs from the attempt's billingPeriodStartsAt and still returns true for a canceled or rejected subscription when the local proof is gone.

Consequence: the unresolved attempt keeps blocking every new on-chain send, and Payment Pending no longer offers a way to release it. The coins never reached the network, so a later sync does not clear the attempt.

Expected: a later cancel releases the attempt from the request's billing period, including a canceled or rejected subscription whose local proof is already gone.

Evidence basis: source analysis at e690f4a. This review did not run the unit tests or a device session. testReleaseFinishesWhenTheProofWasAlreadyRemoved covers this window only for a one-time request, and testEndedSubscriptionPeriodCountsOnlyWithoutItsOwnProof keeps the local proof seeded.

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 7ba0406: the attempt records releaseApproved before its proof is removed, so a later cancel finishes from the attempt alone. A canceled or rejected subscription now counts as ended without the local proof, matching the record's proofs by the attempt's billing-period start. testCanceledSubscriptionReleasesAfterItsProofWasRemoved is the suggested test; testApprovedReleaseFinishesAfterItsProofWasRemoved covers the restart state for an active subscription.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Follow-up review of changes since e690f4a3, at 7ba0406d. Inherited baseline review 5553204785.

1 actionable finding — resolve or provide an evidence-backed rebuttal.

This commit persists releaseApproved before the started proof is removed, treats a canceled or rejected subscription as ended without that local proof, and ends an active period once the stored quote's submissionDeadline(quoteId:) has passed. Cancel now waits for an on-chain sync timestamp strictly later than the one read before the request. Those paths match the open comments on same-second sync, restart after proof removal, quote expiry, and the baseline release finding. Wallet restore still compares the whole attempt after clearing only restoredFromBackup and candidate fees, so a release-approved attempt fails the restore and can leave the app on the restore-failed screen.

The new release, quote, and sync predicates were compared with bitkit-android#1553 at e3688a622719658be4c6ae43434c8253b473fb20. Unit tests were inspected and not executed. Run Tests was still pending. Device testing: not performed in this review.

Suggested additional test cases

  • iOS, staging regtest. Leave a Paykit on-chain attempt in the keychain with releaseApproved true and its started proof already removed, and keep a wallet backup of that same attempt from before the approval. Run a full wallet restore. The restore completes, the local attempt stays release-approved, and a later cancel on Payment Pending removes it after a fresh sync. The app does not remain on the restore-failed screen.

Findings

  • [MEDIUM] Wallet restore rejects a release-approved attempt — inline at Bitkit/Services/OnchainSendAttemptService.swift:196.

/// Set on an attempt restored from a backup, which may not hold evidence that its transaction was broadcast.
var restoredFromBackup: Bool? = nil
/// Set once its request was found ended, so a release cut short after its proof was removed can finish.
var releaseApproved: Bool? = nil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MEDIUM] Wallet restore rejects a release-approved attempt

Trigger: a wallet restore runs while the keychain still holds a Paykit on-chain attempt with releaseApproved true. approveRelease sets that flag before the proof is removed, and it stays until abandonUnbroadcast deletes the attempt, including when the app stops in between.

Mechanism: synthesized equality includes releaseApproved. The shared ActiveOnchainAttempt backup has no field for it, and restored() leaves it nil. restoreBackup clears restoredFromBackup and candidate fees, then requires the local attempt to equal the restored one. The flagged attempt fails that check and throws OnchainSendAttemptError.unresolved. WalletBackupRestoreGate.beginRestore has already stored a pending restore, and the throw returns before completeRestore.

Consequence: performFullRestoreFromLatestBackup stops. The app stays on the restore-failed screen, and Try Again repeats the same restore. Payment Pending is not reachable from that screen, so the release cannot finish and the retry cannot succeed.

Expected: a restore of the same attempt keeps the local release approval and continues, the same way restoredFromBackup is ignored in this comparison.

Evidence basis: source analysis at 7ba0406d. This review did not execute the restore. Android restoreActive at e3688a622719658be4c6ae43434c8253b473fb20 also leaves releaseApproved out of the fields it normalizes before equality.

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 6ff2cee: restoreBackup now leaves the local releaseApproved out of the comparison, as it already does restoredFromBackup, and keeps the local attempt with its approval. testRestoreKeepsALocalReleaseApproval restores the same attempt over an approved one. The Android twin is fixed in synonymdev/bitkit-android#1553 as well.

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

Delta review aedeceb..7ba0406: my two threads are fixed and resolved, and there is one new MEDIUM inline. This round was reviewed on Opus because Fable credits ran out.

  • My MEDIUM is fixed in 4cd8f43. waitsForReconnect (SendSheet.swift:226-235) skips the reconnect wait while submitting and on outcome routes. Confirm still revalidates, and testReconnectWaitCoversOnlyTheStepsBeforeSending covers it.
  • My LOW is fixed in 4cd8f43. The monitor calls wallet.sync(), which dedupes with the foreground sync. A waiter without a fresh on-chain sync throws OnchainSyncPending and retries.
  • Release flow (deeec53d, 65e5210, e690f4a, 7ba0406):
    • approveRelease and abandonUnbroadcast require no dispatch in progress and the same attempt.
    • Racing resends are refused by authorizeOnchainRecovery and the dispatch-time deadline.
    • Write order is release flag, then proof removal, then attempt deletion, with rollback.
    • Restored attempts are excluded, and recurring periods need no remote proof for the period.
    • Outside the MEDIUM, this is clean.
  • Backup (33d7ca7, 55445f9): a failed scheduled wallet backup no longer reschedules itself. The wallet stays isRequired and re-runs on the next data change, at wallet start or on the periodic failure notice, as in v2.5.1.

Scope: 33d7ca7 and 55445f9 fix #988 in the backup code and are unrelated to this PR's Electrum reconnect. The "Cancel expired payment" flow (#986) is a new Paykit funds feature landing under an "ldk-node electrum reconnect" title. Splitting it out, or saying so in the description, would help reviewers.

Performance-relevant: one sync per resume now. Payment Pending polls every 30 s, and Cancel waits up to 30 s for a fresh sync.

The device gate waits for the MEDIUM fix. It will also re-run the Max-send-and-background check and count Syncing LDK pairs per resume.

guard nativeDispatchInProgress == nil, let attempt = try currentAttempt(), attempt == expected,
attempt.canBeAbandoned
else { throw OnchainSendAttemptError.unresolved }
try store.save([])

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.

This is the same mechanism as synonymdev/bitkit-android#1553. abandonUnbroadcast runs try store.save([]) once the attempt is canBeAbandoned and onchainCandidatesNeverReachedNetwork passes (OnchainSendAttemptService.swift:64-81). Nothing keeps the original inputs reserved, and nothing forces a conflicting spend.

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

The Android twin's store test reproduces the gap. After an abandon, a new send's receipt that spends inputs disjoint from the abandoned original is admitted:

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

On iOS the same path is abandonUnbroadcast → store.save([]) (:994-998). The next prepare places no constraint on inputs. An iOS unit test was not run.

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 Bitkit/Services/OnchainSendAttemptService.swift:78–79, 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 #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 above 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 commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

This PR now holds the on-chain recovery features for 3.0.5: resending a payment whose outcome is unknown, waiting for the network after a long stay in the background, and cancelling an expired payment. The 3.0.0 RC3 part, the ldk-node 0.7.0-rc.76 pin and the wallet-backup fix, moved to #990, which now closes #967 and #988.

@ovitrif

ovitrif commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Note for 3.0.5: this PR adds changelog.d/next/967.fixed.md, and #990 adds a fragment at the same path. Once #990 merges, this PR's fragment moves to 986.fixed.md and its line covers only the recovery features.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Follow-up review of changes since 7ba0406d, at 6ff2cee0. Inherited baseline review 5553590165. This pass rechecked restoreBackup equality for a local release approval, the decoded ActiveOnchainAttempt, and testRestoreKeepsALocalReleaseApproval. The other surfaces from that baseline are unchanged and inherited.

1 actionable finding — resolve or provide an evidence-backed rebuttal.

Leaving releaseApproved out of the comparison does not let a production wallet restore finish. The decoded backup's follow-up still carries backupCreatedAtMillis, and the live attempt does not, so equality still throws. The earlier restore thread remains open at this revision.

Two cancel threads on this head are outside this delta and were not re-assessed: cancelling frees the original inputs and a timed-out broadcast can still arrive.

Validation: source inspection only. This environment has no Swift toolchain, so OnchainSendAttemptServiceTests was not executed. No device or simulator was started. Android restoreActive was read on bitkit-android#1553 at 5e40108887f75c2451db81f107acd4e00ef86b61.

Device testing: not performed in this review.

Suggested additional test cases

  • iOS simulator, staging regtest. Send a Paykit on-chain payment through Confirm so the attempt stores follow-up metadata, and leave the broadcast outcome unknown. End the request and approve release so the attempt remains in the keychain. Run a full wallet restore from the latest backup. Expected: restore completes, the stored attempt still has release approval, and Payment Pending can still cancel that payment after the wallet starts.

Findings

  • [MEDIUM] Wallet restore still rejects a release-approved Paykit payment — inline at Bitkit/Services/OnchainSendAttemptService.swift:439.

previous.restoredFromBackup = nil
// A local release approval is not part of the shared backup record.
previous.releaseApproved = nil
guard previous == incoming else { throw OnchainSendAttemptError.unresolved }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MEDIUM] Wallet restore still rejects a release-approved Paykit payment

Trigger: a wallet restore runs while the keychain still holds the original Paykit on-chain attempt with releaseApproved true. Confirm stores follow-up metadata on that attempt, and approveRelease sets the flag before the proof is removed.

Mechanism: restoreBackup clears releaseApproved, restoredFromBackup, and candidate fees on the local copy, then requires the rest of the attempt to equal ActiveOnchainAttempt.restored(). That decoder sets followupContext.backupCreatedAtMillis from the wire timestamp. The live attempt leaves that field nil. Synthesized equality fails and throws OnchainSendAttemptError.unresolved. testRestoreKeepsALocalReleaseApproval replays the in-memory pre-approval attempt, which has no follow-up timestamp provenance, so it never decodes a backup.

Consequence: WalletBackupRestoreGate has already stored a pending restore, and the throw returns before completeRestore. The app stays on the restore-failed screen, and Try Again repeats the same comparison. Payment Pending is not reachable there, so the release cannot finish and the retry cannot succeed.

Expected: a backup of the same attempt restores and keeps the local release approval, including the follow-up timestamp the wire format adds. Android restoreActive at 5e40108887f75c2451db81f107acd4e00ef86b61 treats release approval as local-only and keeps the stored approval.

Evidence basis: source analysis at 6ff2cee0ca97991fb3256b2e7fb1cf766b9bc27e. This review did not execute the restore or the unit 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.

Confirmed from the source: the restored copy's followupContext.backupCreatedAtMillis comes from the wire, while the live attempt leaves it nil, so the comparison still fails, and testRestoreKeepsALocalReleaseApproval never decodes a backup. This PR's cancel and resend paths are paused while the RC3 part ships in #990. The fix lands here with the 3.0.5 cancel rework: compare the attempt without the local-only and backup-only follow-up fields, and test it with a real ActiveOnchainAttempt round trip.

@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 #990 (#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

6 participants