Fix funding-payment reclassification downgrade and deep-reorg duplication - #962
Fix funding-payment reclassification downgrade and deep-reorg duplication#962jkczyz wants to merge 13 commits into
Conversation
|
👋 Thanks for assigning @joostjager as a reviewer! |
| // below (gated on `Pending`) that graduation removed; without it a graduated payment would | ||
| // be left `Succeeded` with an `Unconfirmed` kind and no way to re-graduate. | ||
| if matches!(confirmation_status, ConfirmationStatus::Unconfirmed) { | ||
| payment.status = PaymentStatus::Pending; |
There was a problem hiding this comment.
As mentioned over at #888 (comment) I'm not sure we do this, as our base assumption is that anything beyond ANTI_REORG_DELAY can't be reorged anyways, hence why we have the entries only graduate after ANTI_REORG_DELAY? As mentioned in that comment, maybe it would be easier to fail early if the user tries to bump a confirmed splice?
There was a problem hiding this comment.
As mentioned over at #888 (comment) I'm not sure we do this, as our base assumption is that anything beyond
ANTI_REORG_DELAYcan't be reorged anyways, hence why we have the entries only graduate afterANTI_REORG_DELAY?
Sorry, you're right. This commit isn't needed. Dropping it will address the codex issues, too.
As mentioned in that comment, maybe it would be easier to fail early if the user tries to bump a confirmed splice?
Yeah, that should be covered as we check the tx_type in bump_fee_rbf. Or did you mean when using bump_channel_funding_fee we should error when RBF is possible according to LDK (i.e., the splice isn't locked yet), but we've already reached ANTI_REORG_DELAY confirmations?
There was a problem hiding this comment.
Right, IIUC we could avoid the race if we just don't proceed when we previously had reached ANTI_REORG_DELAY already?
There was a problem hiding this comment.
Yeah, though we should use ChannelDetails::splice_details` from https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/pulls/4687 rather than looking at the pending payment store.
There was a problem hiding this comment.
Yeah, though we should use ChannelDetails::splice_details` from https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/pulls/4687 rather than looking at the pending payment store.
Alright, so that probably means we want to wait for the backport of that PR to land and the API to become available?
There was a problem hiding this comment.
Yeah, and it looks like there are other splicing backports that need to happen first.
| // `find_payment_by_txid`'s payment-store fallback. Revert it like the | ||
| // `TxUnconfirmed`/`TxDropped` arms instead of mirroring a non-`Pending` record | ||
| // into the pending store, which graduation's pending-only scan would reject. | ||
| if payment.status != PaymentStatus::Pending |
There was a problem hiding this comment.
Codex:
- P1 /home/tnull/workspace/ldk-node-pr-962/src/wallet/mod.rs:410: TxReplaced uses BDK’s replaced txid as if it were the active unconfirmed tx. For a graduated funding payment, this stamps the old replaced txid and continues without recording conflicts; the later replacement TxUnconfirmed/
TxConfirmed will not map back and can create the duplicate this PR is trying to prevent.
There was a problem hiding this comment.
Codex:
- P1 /home/tnull/workspace/ldk-node-pr-962/src/wallet/mod.rs:265: TxConfirmed computes the depth-aware payment_status, but the funding short-circuit ignores it. A graduated funding payment re-confirmed in a new shallow block after a reorg can stay Succeeded, skip pending-store recreation,
and bypass ANTI_REORG_DELAY.
| self.runtime.block_on(self.pending_payment_store.insert_or_update(pending))?; | ||
| } | ||
| Ok(true) | ||
| } |
There was a problem hiding this comment.
Codex:
- P2 /home/tnull/workspace/ldk-node-pr-962/src/wallet/mod.rs:1551: re-created pending entries after graduation use an empty candidate list. If a different RBF candidate confirms after a deep reorg, pending.candidate(event_txid) cannot restore the correct amount/fee, so the payment can report
stale figures.
There was a problem hiding this comment.
Hmm, it seems the wallet sync/broadcaster race will also be a problem for #448, as there we'd then emit OnchainPayment{Successful,Received} events for transactions that then will be reclassified as channel-related (for which we'd usually not emit these events).
@jkczyz Any idea how we could avoid this class of error entirely? Or maybe it won't be an issue in practice if we stick to emitting the event only after ANTI_REORG_DELAY conf I guess?
This won't work for the restart case if the node is offline during confirmation for more than There are three realistic options, which can be combined: 1. Just wait for 2. Check channel state directly when about to emit. Instead of trusting the 3. Label more transaction types at broadcast. Today only funding txs get labeled; Weaker ideas I'd rule out: forcing the wallet sync to wait for the broadcast queue to My take: 3 is needed regardless, 2 is what actually eliminates the class, and 1 is a |
Hmm, so 3 is already done in #791 (so this comment seems somewhat stale), in the current version of #448 we already do 1. But, it seems 2 / the restart issue might be a good reason to move forward with #946 after all? |
Yeah, though for splicing most data can still live in the pending payment store. The channel store would only need funding outpoints to check. When we introduce batching, we wouldn't want to duplicate all that data across each channel in the storage. |
tnull
left a comment
There was a problem hiding this comment.
This needs a rebase by now.
Also, some additional claude comments:
Conflicts and candidates are dropped when reverting a graduated payment. The revert path re-creates the pending entry via create_pending_payment_from_tx(payment, Vec::new()) — empty conflicting_txids and empty candidates (the graduation removal already discarded the originals).
Consequence: if, after the deep reorg, a different RBF candidate confirms than the stamped one, an earlier/middle candidate's txid no longer maps to the payment (duplicate record — the very bug class this PR fixes), and even for the first candidate the confirmed-candidate figures can't be
re-stamped since pending.candidate(event_txid) finds nothing. In practice LDK's re-broadcast of the candidate re-runs classify_interactive_funding, whose merge path (commit 1) restores the full candidate history, so this likely self-heals — but that's an implicit dependency worth a comment
or an upstream question. Similarly, the graduated-funding TxReplaced path continues without recording the event's conflict txids.Residual TOCTOU in persist_funding_payment (src/wallet/mod.rs:1418). contains_key and the subsequent insert aren't atomic; a wallet sync landing in between would make the fresh-insert path do a full insert_or_update merge that could still clobber confirmation state. The window is tiny and
strictly better than before (the old code clobbered unconditionally), so a nit only.Documented invariant worth upstream confirmation. The funding_reclassification doc comment leans on "LDK only re-broadcasts the active/confirmed funding candidate" to justify unconditionally overwriting txid/amount/fee. If LDK ever rebroadcasts a non-confirmed candidate for an
already-confirmed record, the stamped figures would be silently replaced. Fine to rely on, but it's the kind of cross-crate invariant a reviewer on the LDK side should ack.
| // merges into it), so the first match is unambiguous. | ||
| if let Some(funding) = self | ||
| .payment_store | ||
| .list_filter(|p| { |
There was a problem hiding this comment.
I really don't think we can do this - this would scan all payment store entries constantly, which is a no-go even if all of them live in memory. And going forward we'll also want to only keep a cache of payments in memory while most of them live just in the KVStore.
To make this more efficient we probably need a secondary index, similar to what we'll do in #948.
There was a problem hiding this comment.
I believe this is no longer a problem since the commit adding this is now dropped.
f01e83e to
46096b0
Compare
Funding broadcasts are classified into payment records off the broadcaster's queue, which can run after wallet sync has already recorded the transaction -- for instance when the counterparty's broadcast of the funding transaction is observed by wallet sync first. In that case the classification overwrote a record wallet sync had already advanced, downgrading a confirmed or graduated funding payment back to unconfirmed/pending. Merge only the classification and our contribution figures into an existing record, leaving the confirmation state that the wallet-sync events own in place. Raised by Codex in the review of lightningdevkit#888. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Push is just a rebase plus dropping a commit as per #962 (comment). I have some of the other issues addressed locally but need to verify the work still. |
Alright, please re-request review when it's ready! |
…ce check Co-Authored-By: Claude <noreply@anthropic.com>
…fication Co-Authored-By: Claude <noreply@anthropic.com>
46096b0 to
0e44736
Compare
|
PTAL
Dropping the earlier commit fixes this.
Added fixups for these two. |
tnull
left a comment
There was a problem hiding this comment.
Still have to take a closer look.
In general, I do wonder if with the current design we'll really be able to whack-a-mole all edge cases here :(
| let pending = PendingPaymentDetails::new(details, Vec::new(), candidates); | ||
| self.pending_payment_store.update_or_insert(pending_update, pending).await?; | ||
| }, | ||
| DataStoreUpdateOrInsertResult::Updated | DataStoreUpdateOrInsertResult::Unchanged => { |
There was a problem hiding this comment.
Codex:
- P2 — /home/tnull/worktrees/ldk-node/pr-962-latest-20260730/src/wallet/mod.rs:1428: an absent pending entry is treated as necessarily graduated. If the payment-store write succeeds but the pending-store write fails—or the node stops between them—a retry takes the Updated/Unchanged branch
and silently ignores NotFound. Main’s unconditional insert_or_update repaired this state. For an RBF splice, the missing pending index prevents find_payment_by_txid from mapping the replacement txid to the stable payment ID, potentially producing a duplicate generic payment. A missing
entry should be recreated when the authoritative payment is still Pending.
There was a problem hiding this comment.
Added a fixup addressing this for now.
There was a problem hiding this comment.
P2: The missing-index repair still depends on classification running again, which is not guaranteed. persist_funding_payment commits the payment-store record before writing the pending entry. If the second write fails or the process stops between them, the stores remain inconsistent.
In the failure case, classify_package returns an error and the broadcast-queue consumer continues after discarding the already-dequeued in-memory package. There is therefore no automatic retry. This is particularly relevant for shared funding transactions because the counterparty may still broadcast the transaction.
The in-process mutex cannot provide crash atomicity. Please either persist payment details and candidate/index metadata as one record, introduce durable retry or startup reconciliation for this invariant, or otherwise demonstrate how every partial write is repaired without relying on a future LDK rebroadcast.
Yeah, I seem to be running into similar problems in the dependent PR (#930). What approach were you thinking? Adding a secondary index (txid -> payment_id) and maybe consolidating the two stores? |
The keep-confirmed-figures guard overshot: when wallet sync recorded the confirmation first, the record carries the wallet's own view of amount/fee, which cannot represent our contribution to a shared funding output, and the guard then discarded the late classification's correct contribution-derived figures along with the losing-candidate updates it was meant to block. Let an update that names the confirmed txid move the figures — it describes the very candidate that confirmed — and have classification build its update from the candidate matching an already-confirmed record, mirroring what apply_funding_status_update reports when confirmation arrives after classification. The txid comparison happens under the store's mutation lock at apply time, so the unlocked snapshot read cannot misapply figures. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Classification treated an absent pending entry as proof the payment had graduated and only merged into existing entries. But a crash or failed write between the payment-store and pending-store writes leaves a Pending record with no index entry, and that state was never repaired: the payment could no longer graduate (graduation iterates the pending store) and its candidate txids could no longer be mapped back to the record, which for an RBF splice invites a duplicate generic payment. Decide by the post-write payment status instead: while the record is still Pending, insert the missing entry (embedding the post-write record, so a confirmation wallet sync already mirrored keeps driving graduation); once it advanced beyond Pending, keep treating absence as graduated. A graduated payment is never Pending, so the no-reindex rule is preserved by the status gate itself. The repaired state is not constructible in a test: it requires a failure injected between the two store writes, and no such seam exists. The store primitives the decision rests on are unit-tested. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // The inserted entry embeds the post-write record rather than the fresh details, so a | ||
| // confirmation wallet sync already recorded keeps driving graduation. | ||
| let pending = PendingPaymentDetails::new(recorded, Vec::new(), candidates); | ||
| self.pending_payment_store.update_or_insert(pending_update, pending).await?; |
There was a problem hiding this comment.
AI drive-by
P1: This still races with graduation. recorded is an unlocked snapshot taken at line 1429. After it observes Pending, ChainTipChanged can update the authoritative payment to Succeeded and remove the pending entry. This update_or_insert then sees the entry absent and recreates it from the stale Pending snapshot. If that snapshot was also Unconfirmed, later chain-tip processing can repeatedly rebroadcast an already-graduated transaction. The two stores have independent mutation locks, so the status decision and index insertion need one cross-store critical section or transactional record.
There was a problem hiding this comment.
Moved the mutate method from #930 here to address this, but updated it to clone. Still worth considering options for more robust handling as mentioned here: #962 (comment)
The check that only Pending payments enter the pending index read the payment store before taking the pending store's lock. Graduation could write Succeeded and remove the index entry between the check and the write, and the stale check would then re-create an entry for the graduated payment. The next chain tip re-graduates it from the entry's stale embedded copy — or, if that copy was still unconfirmed, keeps rebroadcasting an already-confirmed transaction on every tip. Move the decision into the pending store's critical section via a new DataStore::mutate that reads, transforms, and persists an entry under one hold of the mutation lock. Re-reading the payment's status there is race-free because graduation writes Succeeded before removing the entry: a read that still observes Pending precedes the removal, which then also deletes anything inserted here. The closure runs with the in-memory map lock released, so it may read other stores without ordering the stores' map locks against each other. The race spans a few instructions between two store writes and no seam exists to schedule a graduation inside it, so no test exercises it; the new unit tests cover the mutate primitive itself. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Well, it seems to me that the fundamental issue is the chosen approach of classifying at time of broadcast which can happen before or after the wallet sync, i.e., after we actually see the transaction. So fixing it would be reconsidering that model, i.e., offer an LDK interface along the lines of |
| // is ordered before the removal, which then also deletes anything inserted here. A | ||
| // status read taken before this write goes stale when graduation lands in between, and | ||
| // would re-index the graduated payment. | ||
| self.pending_payment_store |
There was a problem hiding this comment.
Codex:
[P1] Reconcile confirmations during pending-index persistence — /home/tnull/worktrees/ldk-node/pr-962-latest-20260805/src/wallet/mod.rs:1431
After the payment-store write, candidate history is not visible until pending_payment_store.mutate() finishes persisting. A concurrent confirmation can therefore see no candidates, stamp confirmed candidate A with active candidate B’s figures, and create the pending entry. The
classification path subsequently adds the candidates but never repairs the authoritative payment record, leaving the wrong amount/fee permanently. Candidate history must be visible before confirmation handling, or the payment record must be reconciled afterward.
Hmm, maybe for now we need to introduce a funding_payment_update_lock: tokio::sync::Mutex<()> that we can hold across both payment store and pending payment store updates to ensure they are always in-sync?
There was a problem hiding this comment.
P1: The mutex still does not cover the complete decision and write sequence. TxConfirmed resolves payment_id before calling apply_funding_status_update, while that helper releases the mutex before the caller performs its generic fallback writes.
The remaining interleaving is:
- Wallet sync looks up the payment and enters
apply_funding_status_update. - The helper observes no classified funding record, returns
false, and releases the mutex. - Classification acquires the mutex and commits the classified payment plus candidate history.
- Wallet sync continues through the generic fallback outside the mutex, potentially replacing contribution-derived figures with wallet-derived figures or creating a second payment under the event txid.
The same boundary issue applies to TxUnconfirmed and TxDropped. Please acquire the shared lock before find_payment_by_txid and hold it until either the classified update or the complete generic payment-plus-pending update has finished. The helper will need a variant that assumes the caller already holds the lock. A deterministic barrier test should exercise both orderings.
There was a problem hiding this comment.
Ah, yeah, seems our LLMs agree: #962 (comment)
There was a problem hiding this comment.
🤖 You're right — the lock only covered the helper, while the arm's decision starts at id resolution and ends at the generic fallback. Each sync arm now holds the lock from find_payment_by_txid through its last write (including TxReplaced, which had the same hole: its pending write embeds a read of the payment record). Since all callers now hold the lock, apply_funding_status_update became apply_funding_status_update_locked, taking a guard reference as a reminder rather than keeping a self-locking twin nobody uses. Two barrier tests pin both orderings by parking one writer inside its critical section before dispatching the other.
persist_funding_payment chose which candidate's figures to merge by reading the payment record before taking the store's mutation lock. A wallet sync confirming a candidate between that read and the write left the choice stale: the update still named the actively-broadcast candidate, so the confirmed-figures guard rightly refused it, and the record kept figures no classification derived — wrong for a shared funding output, and frozen permanently if the payment graduated before another event for the confirmed candidate arrived. The whole decision — insert or merge, and which candidate's figures the record's state makes authoritative — now runs inside the store's critical section, where a concurrent confirmation is either fully visible and substituted, or lands after this write and reads the candidate history itself. The race has no test seam (nothing can interpose between the read and the write), so it is not exercised by a test; the decision's single-threaded behavior is unchanged and remains covered by the existing tests. update_or_insert loses its only caller and is removed. Fixes the first finding in lightningdevkit#962 (comment) Implemented with the assistance of AI tooling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apply_funding_status_update fetched the payment record, checked that it was a classified funding payment, merged in the new confirmation status, and wrote the result back as separate store operations. A classification racing in between -- merging tx_type and the contribution-derived figures into the record -- would be overwritten by the stale snapshot. Perform the check and the merge under the payment store's mutation lock so no write can interleave, and skip persisting when the merge changes nothing. Implemented with the assistance of AI tooling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Classification writes the payment record and the pending entry carrying the candidate history as two store operations. A funding confirmation processed between them saw the record already classified but the candidate history still absent, so it stamped the confirmed candidate's txid with a stale snapshot's figures -- and once the candidates landed, nothing revisited the payment record to repair them, leaving the wrong amount/fee to graduate with the payment. A wallet-level lock now serializes classification's two-store write pair against the funding-confirmation handling, so a confirmation either runs before the record is classified or sees the full candidate history. Writers touching a single store (e.g. graduation) are unaffected; the per-store gates continue to cover them. The race needs a confirmation interposed between two writes of one classification call, which no test can arrange; the lock is uncontended in the single-writer paths the existing tests exercise. Fixes the finding in lightningdevkit#962 (comment) Implemented with the assistance of AI tooling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Comments addressed. Please throw your agents at it again. |
Seems LLM ping-pong is how it's done by now.. |
| // The cross-store lock orders this against classification's two-store write pair: the | ||
| // candidate whose figures are reported below is only reliable once the classification | ||
| // that recorded the candidate history has fully landed. | ||
| let _guard = self.funding_payment_update_lock.lock().await; |
There was a problem hiding this comment.
Codex:
- P1 — Resolve the payment ID under the new lock. The event handler calls find_payment_by_txid before /home/tnull/workspace/ldk-node-pr-962/src/wallet/mod.rs:1590. If classification is between its payment-store and pending-store writes, a replacement txid misses the pending index and falls
back to its own txid. After waiting for classification, the update uses that stale ID, fails to find the stable funding record, and creates a duplicate generic payment. The lookup and funding update need one locked operation.
There was a problem hiding this comment.
One additional subcase from my resolved duplicate remains: moving this lookup under the lock fixes the partial-classification window, but a complete pending entry still does not map every candidate. find_payment_by_txid checks the stable ID, current txid, and conflicting_txids, but not p.candidates.
With three or more candidates, a middle candidate that has not reached conflicting_txids still falls back to its own payment ID even after classification has fully completed. Please also match p.candidate(target_txid).is_some() when resolving the stable ID.
There was a problem hiding this comment.
One additional subcase from my resolved duplicate remains: moving this lookup under the lock fixes the partial-classification window, but a complete pending entry still does not map every candidate.
find_payment_by_txidchecks the stable ID, current txid, andconflicting_txids, but notp.candidates.With three or more candidates, a middle candidate that has not reached
conflicting_txidsstill falls back to its own payment ID even after classification has fully completed. Please also matchp.candidate(target_txid).is_some()when resolving the stable ID.
🤖 Good catch — find_payment_by_txid now also matches the candidate history. A new test seeds a three-candidate entry and checks the middle candidate (not the record's id, not its current txid, never got its own TxReplaced) resolves to the stable id. A side effect worth noting: TxReplaced can now resolve these ids too, so its "payment already exists" comment was extended to cover classification-authored records.
Codex:
- P1 — Resolve the payment ID under the new lock. The event handler calls find_payment_by_txid before /home/tnull/workspace/ldk-node-pr-962/src/wallet/mod.rs:1590. If classification is between its payment-store and pending-store writes, a replacement txid misses the pending index and falls
back to its own txid. After waiting for classification, the update uses that stale ID, fails to find the stable funding record, and creates a duplicate generic payment. The lookup and funding update need one locked operation.
🤖 Fixed by the same fixup as above: the id lookup now happens under the lock, so sync can't resolve against a half-written candidate index. funding_confirmation_waits_for_classification reproduces this exact scenario — classification parked between its payment-store and pending-store writes, then the replacement candidate's confirmation dispatched. Before the fix it produced two records; now the confirmation waits and updates the classified record in place.
| // classification's payment-store write and its pending-store write sees the record classified | ||
| // but the candidate history absent, and stamps the confirmed candidate with another | ||
| // candidate's figures — which nothing afterwards repairs. Writers that touch only one store | ||
| // (e.g. graduation) stay safe through the per-store gates instead and need not take this. |
There was a problem hiding this comment.
Codex:
- P1 — Serialize graduation with classification. ChainTipChanged snapshots pending entries and later performs a full /home/tnull/workspace/ldk-node-pr-962/src/wallet/mod.rs:324 without the new cross-store lock. It can snapshot wallet-derived figures, classification can then write the correct
candidate figures, and graduation can overwrite them from its stale snapshot before removing the pending entry. Because that update carries a confirmation status, keep_confirmed_figures does not protect them. Graduation must either share the lock from snapshot through removal or atomically
update status only.
There was a problem hiding this comment.
🤖 Went with your second option: graduation now decides from the live record inside the payment store's mutate closure and writes a status-only update. Since the write carries no figures, txid, or confirmation status, there is nothing a concurrent classification could lose — which also keeps graduation off the cross-store lock (nothing extra in the every-block path). If the live record has diverged from the snapshot, graduation declines and leaves the entry for future events; that arm is hardening (no current writer produces such divergence) but falls out naturally from deciding on live state. Two tests: one pins figure preservation across graduation, one pins the decline.
joostjager
left a comment
There was a problem hiding this comment.
Wasn't reviewing this PR yet, but no problem to point my agent at it again. Left its feedback.
| /// the confirmed candidate's txid and figures from the candidate history, mirroring what | ||
| /// [`Wallet::apply_funding_status_update`] reports when confirmation arrives after classification. | ||
| /// | ||
| /// `current` is an unlocked snapshot; that is safe because [`PaymentDetails::update`] only lets |
There was a problem hiding this comment.
This documentation is stale after moving candidate selection into payment_store.mutate. current is now the state protected by the payment store's mutation lock, not an unlocked snapshot.
There was a problem hiding this comment.
🤖 Right, that doc predates moving the candidate choice into the mutate closure. Reworded: current is observed inside the payment store's critical section, so it can't go stale against a concurrent confirmation; the confirmed-figures merge rule remains as second-line arbitration rather than the safety argument.
Probably should have used buzz... |
The cross-store lock was drawn too narrowly: wallet sync's event arms resolved the payment id before acquiring it and ran their generic fallback after releasing it. A classification landing inside that window made sync resolve the id against a torn candidate index (minting a duplicate record keyed by the event txid) or overwrite the freshly classified record's contribution figures with wallet-derived ones. Each sync arm now holds the lock from payment-id resolution through its last write. That includes TxReplaced, which was not named in review but has the same boundary hole: the pending entry it writes embeds a read of the payment record that could go stale against classification. apply_funding_status_update no longer acquires the lock itself; it is renamed with a _locked suffix and takes a guard reference to remind callers of the contract, since all of its callers now hold the lock across a wider span than the call. Two barrier tests pin both orderings by parking one writer inside its critical section and dispatching the other: a confirmation arriving during classification's torn window must wait rather than duplicate, and a classification arriving during sync's fallback window must wait rather than be overwritten. Found by joostjager and Codex. This commit was authored with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
find_payment_by_txid resolved a txid through the record's id, its current txid, and its conflicting_txids, but not through the candidate history. A middle candidate from three or more RBF rounds matches none of those — it is not the first candidate (which keys the record), not the active one, and may never have received a TxReplaced event of its own — so wallet sync fell back to a txid-derived id and treated the round as an unrelated payment. Probe the candidate history as well. This also lets TxReplaced resolve such ids; its comment now covers why the payment record is present in that case too. Found by joostjager. This commit was authored with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Graduation wrote the pending-store snapshot back to the payment store with only its status flipped to Succeeded. That write carries the snapshot's figures and confirmation status, so a classification landing between the snapshot and the write had its contribution-derived figures rolled back to wallet-derived ones — the confirmed-figures merge rule does not protect a record against an update naming its own confirmed txid. Graduation now decides from the live record inside the payment store's mutation lock and writes a status-only update: it carries nothing a concurrent classification could lose, bumps the record's update timestamp through the regular update machinery, and no-ops when the record is already Succeeded (the leaked pending entry is still removed). This keeps graduation off the cross-store funding lock — the snapshot's depth check remains a cheap pre-filter, and the closure re-verifies against live state. Deciding from the live record also means a record that diverged from the snapshot declines instead of being force-graduated. That arm is hardening rather than a reachable-bug fix: no current production writer downgrades a record's confirmation behind the snapshot. One behavioral consequence: a record deleted via Node::remove_payment now leaves its pending entry in place (declining each tip change) instead of being resurrected from the snapshot — a zombie index entry over restoring user-deleted data. Found by Codex. This commit was authored with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The doc still described current as an unlocked snapshot whose staleness the confirmed-figures merge rule papers over. Since classification moved the candidate choice inside the payment store's mutate closure, current is observed within that critical section and cannot go stale against a concurrent confirmation; the merge rule is a second line of arbitration, not the safety argument. Doc-only change. Found by joostjager. This commit was authored with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jkczyz
left a comment
There was a problem hiding this comment.
Fixes planned and implemented with Claude.
| // is ordered before the removal, which then also deletes anything inserted here. A | ||
| // status read taken before this write goes stale when graduation lands in between, and | ||
| // would re-index the graduated payment. | ||
| self.pending_payment_store |
There was a problem hiding this comment.
🤖 You're right — the lock only covered the helper, while the arm's decision starts at id resolution and ends at the generic fallback. Each sync arm now holds the lock from find_payment_by_txid through its last write (including TxReplaced, which had the same hole: its pending write embeds a read of the payment record). Since all callers now hold the lock, apply_funding_status_update became apply_funding_status_update_locked, taking a guard reference as a reminder rather than keeping a self-locking twin nobody uses. Two barrier tests pin both orderings by parking one writer inside its critical section before dispatching the other.
| // The cross-store lock orders this against classification's two-store write pair: the | ||
| // candidate whose figures are reported below is only reliable once the classification | ||
| // that recorded the candidate history has fully landed. | ||
| let _guard = self.funding_payment_update_lock.lock().await; |
There was a problem hiding this comment.
One additional subcase from my resolved duplicate remains: moving this lookup under the lock fixes the partial-classification window, but a complete pending entry still does not map every candidate.
find_payment_by_txidchecks the stable ID, current txid, andconflicting_txids, but notp.candidates.With three or more candidates, a middle candidate that has not reached
conflicting_txidsstill falls back to its own payment ID even after classification has fully completed. Please also matchp.candidate(target_txid).is_some()when resolving the stable ID.
🤖 Good catch — find_payment_by_txid now also matches the candidate history. A new test seeds a three-candidate entry and checks the middle candidate (not the record's id, not its current txid, never got its own TxReplaced) resolves to the stable id. A side effect worth noting: TxReplaced can now resolve these ids too, so its "payment already exists" comment was extended to cover classification-authored records.
Codex:
- P1 — Resolve the payment ID under the new lock. The event handler calls find_payment_by_txid before /home/tnull/workspace/ldk-node-pr-962/src/wallet/mod.rs:1590. If classification is between its payment-store and pending-store writes, a replacement txid misses the pending index and falls
back to its own txid. After waiting for classification, the update uses that stale ID, fails to find the stable funding record, and creates a duplicate generic payment. The lookup and funding update need one locked operation.
🤖 Fixed by the same fixup as above: the id lookup now happens under the lock, so sync can't resolve against a half-written candidate index. funding_confirmation_waits_for_classification reproduces this exact scenario — classification parked between its payment-store and pending-store writes, then the replacement candidate's confirmation dispatched. Before the fix it produced two records; now the confirmation waits and updates the classified record in place.
| // classification's payment-store write and its pending-store write sees the record classified | ||
| // but the candidate history absent, and stamps the confirmed candidate with another | ||
| // candidate's figures — which nothing afterwards repairs. Writers that touch only one store | ||
| // (e.g. graduation) stay safe through the per-store gates instead and need not take this. |
There was a problem hiding this comment.
🤖 Went with your second option: graduation now decides from the live record inside the payment store's mutate closure and writes a status-only update. Since the write carries no figures, txid, or confirmation status, there is nothing a concurrent classification could lose — which also keeps graduation off the cross-store lock (nothing extra in the every-block path). If the live record has diverged from the snapshot, graduation declines and leaves the entry for future events; that arm is hardening (no current writer produces such divergence) but falls out naturally from deciding on live state. Two tests: one pins figure preservation across graduation, one pins the decline.
| /// the confirmed candidate's txid and figures from the candidate history, mirroring what | ||
| /// [`Wallet::apply_funding_status_update`] reports when confirmation arrives after classification. | ||
| /// | ||
| /// `current` is an unlocked snapshot; that is safe because [`PaymentDetails::update`] only lets |
There was a problem hiding this comment.
🤖 Right, that doc predates moving the candidate choice into the mutate closure. Reworded: current is observed inside the payment store's critical section, so it can't go stale against a concurrent confirmation; the confirmed-figures merge rule remains as second-line arbitration rather than the safety argument.
Two independent fixes Codex raised in the review of #888: