Fix three funding payment record bugs - #1057
Conversation
Wallet sync resolves a funding payment's id for any transaction linked to the record through its conflicting txids, and then adopted that transaction's txid and confirmation outright. A cooperative close conflicts with a pending splice in exactly that way: the splice record would report the close's txid and confirmation under its InteractiveFunding type and contribution figures and graduate as if the splice had confirmed, while the close's own record never received its confirmation. Adopt a transaction only when it is part of the payment's funding history — the record's current txid or a classified candidate. Anything else is recorded under its own txid-keyed id, which also delivers the close's confirmation to the close's own record. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A queued broadcast whose payment-record classification failed was dropped outright, on the theory that broadcasting a transaction we failed to record would leave it on-chain without a payment. For interactive funding that theory doesn't hold: the counterparty broadcasts the same transaction once the signature exchange completes, so dropping the package keeps nothing off-chain — it only guarantees the round is never recorded as a candidate on our side. The funding-status ownership gate then treats the round's confirmation as foreign to the funding record and re-keys it to a stray duplicate record, which shadows the funding record's txid lookups permanently: the splice payment stays Pending forever while an untyped duplicate holds the confirmation. Keep the package alive instead: requeue it after a short delay and retry classification until it succeeds, holding the broadcast back the whole time. Classification failures are persistence failures, so the retry is unbounded — a store that never recovers keeps the node from functioning anyway — and every failed round is logged. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bump_fee_rbf read the payment record and rejected channel-funding records before taking any lock, then took the locks and wrote the replacement. A funding classification landing in between re-types the record as channel funding, after which the bump retargets that record to the wallet-built replacement it broadcasts -- a double spend of the channel funding transaction. Hold the locks from the record read through the replacement writes so the two serialize: a record classified first is caught by the funding-kind check, and one that passes the check cannot be re-typed until the replacement is recorded. A funding round that wallet sync observes before classification leaves an untyped record that passes the funding-kind check outright; that is a stale-record problem rather than a race in this function, narrowed by the preceding classification-retry commit and by duplicate absorption later in the series. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
I've assigned @tnull as a reviewer! |
Jolah1
left a comment
There was a problem hiding this comment.
Third commit: the funding-kind check only matches tx_type: Some(Funding | InteractiveFunding), so the stale untyped record the commit message calls out passes it. An on-chain RBF replacing channel funding stays reachable after this PR, narrower than main, but still a funding double-spend, and it now rides on the rest of the stack landing. Worth its own issue.
| /// elapses, the node is shutting down and the package is dropped with it. | ||
| pub(crate) fn requeue_failed_classify(&self, package: BroadcastPackage) { | ||
| let sender = self.queue_sender.clone(); | ||
| tokio::spawn(async move { |
There was a problem hiding this comment.
Only detached tokio::spawn left in non-test production code outside postgres_store. It's also what reorders the queue — the requeued package lands behind anything queued after it.
Holding the failed package in the loop and adding a sleep branch to the existing select! avoids both, and needs no runtime handle.
tnull
left a comment
There was a problem hiding this comment.
This needs a rebase unfortunately.
| /// the counterparty broadcasts it regardless — it would only leave the transaction | ||
| /// confirming without a recorded candidate. If the queue has closed by the time the delay | ||
| /// elapses, the node is shutting down and the package is dropped with it. | ||
| pub(crate) fn requeue_failed_classify(&self, package: BroadcastPackage) { |
There was a problem hiding this comment.
Codex:
- [P1] Delayed requeue leaves the duplicate-record race open. /home/tnull/worktrees/ldk-node/pr-1057-review-20260819/src/tx_broadcaster.rs:164 removes the failed package and waits two seconds before requeueing it. If persistence recovers and wallet sync observes an interactive-RBF candidate
during that interval, sync creates a generic record keyed by the active txid. Classification later creates the funding record keyed by the first candidate, while direct lookup continues to prefer the generic record. The funding record can therefore remain pending—the outcome this commit
intends to prevent. The test only exercises a single Funding transaction whose payment ID equals its txid, without concurrent wallet sync.
| /// elapses, the node is shutting down and the package is dropped with it. | ||
| pub(crate) fn requeue_failed_classify(&self, package: BroadcastPackage) { | ||
| let sender = self.queue_sender.clone(); | ||
| tokio::spawn(async move { |
There was a problem hiding this comment.
As noted above, this likely should be spawn_cancellable_background_task. Though given the codex comment above, not even sure if doing it in the background is the right approach?
| // funding history: its current txid or a classified candidate. A conflicting | ||
| // transaction that is neither — a close also spends the funding outpoint — must | ||
| // not overwrite the record. | ||
| let pending = self.pending_payment_store.get(&payment_id); |
There was a problem hiding this comment.
Codex:
- [P2] Legitimate older candidates are classified as foreign. The gate at /home/tnull/worktrees/ldk-node/pr-1057-review-20260819/src/wallet/mod.rs:1986 accepts only the current txid or a recorded candidate. However, the persisted format explicitly permits an empty candidate list for older
records at /home/tnull/worktrees/ldk-node/pr-1057-review-20260819/src/payment/pending_payment_store.rs:46. If an earlier RBF candidate exists only in conflicting_txids and confirms, it is treated as foreign, producing a duplicate and leaving the funding record pending.
| }, | ||
| )]); | ||
|
|
||
| // Let the loop fail at least one classification round; a failed classification must not |
There was a problem hiding this comment.
Codex:
- [P2] The retry regression test lacks a failure barrier. /home/tnull/worktrees/ldk-node/pr-1057-review-20260819/src/wallet/mod.rs:4265 sleeps for three seconds but never proves the queue attempted—and failed—classification. If the loop is delayed until writes are re-enabled, the test can
pass on the pre-fix implementation. The store should signal/count an observed failed write before recovery is enabled.
| // classification re-types records concurrently, and a classification landing after the | ||
| // funding-kind check below would let the RBF replace a funding transaction. Acquired | ||
| // after the persister, matching the lock order of the wallet sync paths. | ||
| let funding_guard = self.funding_payment_update_lock.lock().await; |
There was a problem hiding this comment.
Ngl, it's kind of odd that we now also mix in the funding lock here with the regular RBF flow.
Do we really need to fix this? IIUC, not only does it require the wallet sync racing the LDK classification, it also requires that the user calls bump_fee_rbf on the wrong (i.e., funding transaction) record at exactly the right time, no?
tnull
left a comment
There was a problem hiding this comment.
Btw, if we now retry classification/broadcast anyways as the counterparty might also broadcast, couldn't we unblock the broadcast queue again, i.e., don't have it block on the persistence succeeding?
Three bugfixes for funding payment records (channel opens and splices), all reachable on current
main. Found while building the splice-retry work stacked on top (#930's replacement) but independent of it.Only adopt a funding payment's own transactions from wallet sync. Sync adopted the txid and confirmation of any transaction linked to a funding record through its conflicting txids. A cooperative close conflicts with a pending splice in exactly that way, so the splice record could adopt the close's confirmation and graduate as if the splice had confirmed.
Retry funding-broadcast classification instead of dropping it. A broadcast whose payment-record classification failed was dropped. For interactive funding the counterparty broadcasts the same transaction anyway, so the drop keeps nothing off-chain — it just leaves the round unrecorded, permanently stranding its confirmation on a duplicate record. Classification is now retried, with the broadcast held back, until it succeeds or the node shuts down.
Serialize on-chain RBF bumps with funding classification.
bump_fee_rbfchecked that the record isn't channel funding before taking any lock. A classification landing between the check and the writes re-types the record, and the bump then replaces a funding transaction — a double spend of channel funding. The check now happens under the locks. (A sequential variant — a sync-minted record passing the check untyped — closes later in the stack.)Each fix has a test that fails without it; the commit messages have the details.
First of three stacked PRs replacing #930's restart persistence for this release, per the discussion there; #XXXX (payment-model groundwork) and #XXXX (in-session splice retry) follow.
Developed with assistance from Claude Code.