Conversation
…sync 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> (cherry picked from commit 129005a)
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: retry classification after a short delay, holding the broadcast back until it succeeds. Other packages keep flowing while a retry waits, and fresh packages are classified ahead of due retries, so packages arriving during a store outage are not held behind the outage's retries. Classification failures are persistence failures, so there is no limit on attempts -- a store that never recovers keeps the node from functioning anyway -- and every failed round is logged. The queue belongs to the broadcaster and outlives the task draining it: a package still waiting when the node stops, fresh or awaiting a retry, is classified and broadcast after the next start, as a package not yet attempted always was; a funding package has no other way back. The queue is deduplicated and bounded. LDK re-broadcasts pending claims every 30 seconds and regenerates sweeps once per block until they confirm, so over a long store outage a copy per rebroadcast would otherwise pile up and replay as a burst on recovery. A package whose transactions already await a retry is recognized as it is queued and not queued again. At the bound, an incoming package that LDK would re-broadcast anyway makes room by dropping the oldest such queued package, whose transactions return with the next rebroadcast; if every queued package is one nothing re-broadcasts, the incoming package is dropped instead. Fundings and cooperative closes are never dropped to make room and never refused at the bound, since nothing re-broadcasts them: a dropped funding would leave its transaction confirming without a recorded candidate, and a dropped cooperative close might lose the only copy of the signed closing transaction. Fee-bumped rebroadcasts carry new txids, so the bound, not the deduplication, is what limits their accumulation. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 6dfa047)
…ng it Log a re-broadcast dropped for an identical queued package at trace level: it repeats for every LDK re-broadcast while the store is down. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit c412658)
Since declining to adopt a conflicting close's confirmation, a funding payment whose transaction was double-spent stayed Pending forever -- nothing wrote a terminal status for an on-chain record -- and the sync loop kept re-queueing the dead transaction for rebroadcast on every tip change. Mark such a record Failed once a conflict from outside its candidate history has confirmed through ANTI_REORG_DELAY while neither its own transaction nor any RBF candidate can still confirm, mirroring the anti-reorg finality the Succeeded transition already assumes. Removing the payment's pending entry then stops the re-queueing. Settling also removes the entry that maps candidate txids to the record, so a later wallet event for a dead candidate falls back to keying by that candidate's txid -- which, for the first candidate, is the record's own id. Skip such events rather than let the generic handling resurrect the settled record, and let a replayed replacement event finish an entry removal a crash interrupted instead of stamping the terminal status into the leftover entry. Implemented with Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 767855c)
Wallet sync can learn of a splice transaction before broadcast-time classification records it: once tx_signatures are exchanged, the counterparty may broadcast first, and sync then files the round under a duplicate record keyed by its txid, which shadows the funding record's txid lookups from then on. Retrying a failed classification only narrows that window: a round the counterparty broadcasts is still observed before our record exists. Record the funding payment while handling FundingTransactionReadyForSigning, before funding_transaction_signed hands our signatures to LDK. The counterparty cannot broadcast without them, so the record precedes anything wallet sync can observe, and every later observer resolves to it. The record is written in full from the channel's pending splice history, so the round's broadcast has nothing left to record and records nothing. If the record cannot be written, the event is replayed rather than proceeding unrecorded: LDK re-offers it in-session and regenerates it across restarts while the transaction remains unsigned. A failed write leaves no half-written record behind for the replayed event to build on. Should undoing it fail as well, the replayed event removes what was left of a first round once the round is gone from the channel's history; the leftovers of a bump live under an earlier round's record, which wallet sync moves on as that round confirms or fails. Recording before the round is negotiated means a recorded round can still be abandoned: the counterparty may abort after we sign but before its commitment_signed, or the channel may close, and until LDK has released our signatures nothing can ever broadcast the transaction. Left in place, the record would wait forever on a payment nothing can confirm. The signed round is therefore marked as awaiting broadcast until LDK reports the splice negotiated, which it does once our tx_signatures were ready to send, normally as it hands the fully signed round to the broadcaster: from then on the counterparty may hold our signatures and broadcast on its own. If the mark cannot be cleared, that event is replayed as well. A marked round is dropped once LDK no longer holds it, unless the wallet has seen its transaction: the counterparty may broadcast a round it received our signatures for while LDK still waits on its own. A round whose negotiation LDK has reported keeps its place whether or not wallet sync has seen it yet, and so does the channel's current funding: a zero-conf splice becomes the funding as soon as splice_locked is exchanged, before its transaction confirms or LDK's report of its negotiation has necessarily been handled. Dropping a round leaves the record on the last remaining round this node contributed to, moving it there if it still names the dropped round, or removes the record when none remains. LDK's view is consulted when it reports the failed negotiation of a channel it still lists, when the channel closes -- a round awaiting the counterparty's signatures is reported failed only after ChannelClosed, and that report is resolved by what it carries, the channel's last funding, and by the rounds its monitor still watches -- and at startup, before any background task runs: LDK reports the loss of a negotiation its last channel manager write carried mid-way, but a round committed, negotiated and signed since that write gets no report if the node stops before the next one. The channel manager forgets a closed channel's pending rounds, but its monitor keeps watching every round the counterparty's commitment_signed reached, and our signatures cannot have left the node before that message: the counterparty may hold the fully signed transaction and broadcast it, as when this node's contributed input value is the smaller and its tx_signatures therefore go first, so such a round is kept for wallet sync to resolve should it confirm, while a marked round the monitor never watched is dropped, as nothing can broadcast it. A round already missing from the channel's history when the signing event is handled is not recorded at all. Rounds without a local contribution emit no signing event and are not recorded at broadcast either, as before; they are left to wallet sync. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 161a933)
A splice round this node signed is kept at `ChannelClosed` when the channel's monitor watches it: the counterparty committed to it, so our signatures may have left the node, and the counterparty may broadcast the round and see it confirm. A close the wallet sees as a conflict -- a cooperative close spending an input the round shares -- fails the payment once it confirms beyond the reorg depth, but nothing resolved such a record when a commitment transaction, which pays no wallet script, won instead. Once the close matures -- after the reorg delay for a counterparty's commitment transaction, and once the to_self_delay on our balance has passed for one of our own -- the monitor stops watching the rounds it kept and queues a `DiscardFunding` event for each, and the handler only reclaimed the contribution's addresses: the funding payment stayed `Pending` forever. Likewise for a round of ours that a sibling round this node did not contribute to replaced on an open channel: LDK discards our round as the sibling locks, and the payment stayed `Pending` for a transaction that can no longer confirm. Resolve the channel's funding payments by the rounds LDK holds. A round nothing ever broadcast is dropped first, as `ChannelClosed` already did, and with it a record no broadcast round of ours remains under. A payment is then left alone if a round of ours that LDK still holds remains in its record -- the round that locked, or one still pending -- or one LDK promoted to the funding before, and failed otherwise: no round of ours can confirm anymore, whether the channel closed on a commitment transaction or a round we did not contribute to locked. The rounds LDK holds are the channel's pending rounds and funding while the manager lists the channel, and once it does not, the funding its monitor settled on plus whatever the monitor still watches. The monitor is left out for a listed channel: its updates land after the manager's, deferred to the background processor's flush, so it may still watch a round the manager let go. The event names this node's contribution, not the round: the inputs and output scripts LDK returns of it. Matching that to a recorded round would take the parts of every contribution on record. LDK discards the round's siblings as it promotes the round and reports the promotion through `ChannelReady`, so that event resolves the payments of a listed channel instead: it records the promotion and resolves the channel's other payments by the rounds the manager holds once updated -- the promoted round, and whatever was negotiated behind it. For a channel the manager no longer lists it records the promotion alone and leaves the payments to the close. A `DiscardFunding` for a listed channel then only drops a round nothing broadcast that the manager no longer holds and reclaims the contribution's addresses. A zero-conf splice is promoted to the funding as `splice_locked` is exchanged, before its transaction confirms, and a later splice moves the funding on again: at the close neither the manager nor the monitor holds the earlier round, although it can still confirm, the later round descending from it. So the funding payment records each promotion LDK reports through `ChannelReady`, and a round promoted once counts as one that can confirm wherever the rounds LDK holds decide: as a sibling round is promoted, and when the channel closes. The monitor's events can reach the handler ahead of the channel's `ChannelClosed` when one sync delivers the close and its maturity: the channel manager polls the monitor's report of the close at the start of each event pass and on peer traffic, and the monitor's own events are handled right after the manager's. Each event then finds the channel still listed and leaves the payments, there being no promotion to resolve them. So `ChannelClosed` fails every payment of the channel left with no round of ours the monitor watches and none promoted before, and a `DiscardFunding` event for a channel the manager no longer lists resolves each record the same way, by the funding its monitor settled on and whatever it still watches. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit b69ea69)
Drop the log line for a discard on an open channel. It took a sentence to say what the branch does not do, and the wallet logs what it drops right after. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 8e0741e)
Take every transaction the integration tests need from bitcoind's mempool instead of decoding the bytes a node logs at trace level, which tied the tests to a log line. Where bitcoind refused the transaction before, a commitment while a splice round spending the same funding sits in the mempool or a fee bump paying little more than the round it joins, the tests first deprioritise the mempool round so that the replacement passes bitcoind's replacement checks, which compare modified fees. The fee bump joining a counterparty's round is now accepted every run instead of sometimes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 605aae4)
…listing it as a conflict A pending-store entry lists the transactions that replaced its own, so a cooperative close (or any other wallet transaction) that a splice round replaces lists the round among its conflicting txids. The round's events then matched two entries, its own record's and the close's, and the pending cache's iteration order decided which one won. About one time in five the round's confirmation landed on the close's record, which took the round's txid, figures and confirmation and graduated, while the splice's payment never learned of the confirmation and stayed pending for good. Prefer the entry that records the transaction as its own, whether as its current transaction or as a negotiated candidate, and fall back to an entry that only lists it as a conflict when no entry owns it. The conflict listing stays: it is how a replaced round of a record without candidates, an ordinary payment's RBF history or the replacement of an inbound transaction, maps back to its record. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 8315d74)
Classifying a funding-typed broadcast read the payment store only to log that a re-broadcast of a promoted splice had met its interactive-funding record, then read it again inside the write. The write hands back what it found, so the log comes from that read and a channel-open funding costs one read fewer. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 5d4a518)
…ck issues The writers of funding payment records take a lock guard so that a caller has to hold the funding lock to reach them. The parameter took a guard of any `Mutex<()>`, though, and the wallet has another one, for refilling the address pool, so a caller holding the wrong lock compiled. Wrap the lock in a type whose guard only it can produce and have the writers take that guard, so holding this lock is the only way to call them. Whether the caller's reads before the write happened under the same acquisition is still up to the caller. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit c6cda2d)
…ir writers The writers of funding payment records take the funding lock's guard, but the stores are fields of the wallet and a writer can still call them directly. Three did, with no lock: on-chain payment graduation, the classification of non-funding broadcasts, and the fee bump. Move both stores and the lock into one type. Writes are methods of the guard the lock hands out, so a write compiles only for a holder of the lock; reads take no lock. The three writers take the lock too. Graduation was kept off it on purpose, since its status-only write could clobber nothing a concurrent writer wrote; it locks now so that the API needs no unlocked write, per payment, because the conflict check in the same loop takes the lock itself. The fee bump locks after the wallet persister, the order wallet sync takes the two locks in. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit b9d64f0)
Funding records were keyed by a PaymentId derived from a funding txid: the broadcast txid in the generic classification path, the first negotiated candidate's txid in the interactive path. A txid is no identity for a replaceable transaction -- the record deliberately outlives RBF rounds of its funding, so its key carried the txid of whichever round happened to come first, and code could be tempted to re-derive the id from a txid instead of resolving it. Generate the id from the OS entropy source when the record is created, and resolve existing records through their transaction history (find_payment_by_txid) everywhere. RBF stability now comes from resolution instead of derivation. Resolution must share one lock acquisition with the record writes: resolved outside it, the id could go stale against a record wallet sync creates for the same transaction, producing a divergent record -- so both the classification path and the interactive path resolve the id under the lock they write under. Resolution also reaches records that have graduated out of the pending store. Without that, a funding classified again after graduation -- LDK re-broadcasting a 0conf splice whose confirmation landed while the node was offline -- would get a duplicate record under a fresh id, and a reorg after graduation would never reach the record. A record already failed is passed over when a newly signed round resolves its id. Wallet sync fails a funding payment whose round lost to a conflicting spend confirmed while the channel stays open, but LDK still holds the round, so a fee bump of it is signed with the failed round among its candidates. Filed under the failed record, the bump would stay failed and untracked, so nothing would graduate it once it confirmed. The bump gets a record of its own instead. The funding-record surface (classification, candidates, stable ids) debuts in the upcoming release -- v0.7.0 shipped splice_in with no record machinery -- so changing the scheme now costs nothing, while one release later it would break payment(&PaymentId(funding_txid)) lookups for new records. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit ece6070)
Find the record of each classified transaction instead of deriving its payment-store key from the txid, which no longer names a funding record's id. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit f587672)
Say where a funding record still carries a txid-derived id now that classification generates ids: only a record wallet sync created before classification keeps the id of that transaction, and the settled-record collision the wallet-sync fallback guards against arises for those records alone. Three docs still described the derived id as the rule. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit f9b6f8f)
A user-initiated splice dropped before LDK persists it leaves no trace in LDK. Recovering whatever the splice reserved and describing later events about it in terms of the original request both require persisting the splice intent before handing it to LDK, which happens before negotiation and therefore before any funding transaction exists. The pending-payment record was built around an on-chain PaymentDetails carrying a txid, which cannot represent a splice that has not been broadcast yet. Reshape PendingPaymentDetails into an enum: a PendingSplice variant that holds only the generated PaymentId and the splice intent, and a Tracked variant that is the previous record plus an optional intent retained until the splice locks. Add the SpliceIntent and SpliceKind types that record what was handed to LDK and the API call that produced it. The wallet's pending-store writes that depend on a payment's status now make that check and the write atomically, replacing racy read-then-write pairs. They share one helper whose closure re-reads the payment's status inside the critical section -- only Pending payments belong in the pending store, and a status read taken outside it can go stale against graduation -- and promotes a bare PendingSplice to a Tracked record once a payment exists under its id: a plain payment-tracking merge would silently no-op against the variant, leaving the splice invisible to txid lookups. This is groundwork; nothing constructs a PendingSplice yet. A later commit adds the classification that reads the variant; the entry points that persist splice intents land with the splice tracking built on this. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 8a6bb71)
Check that a persisted splice intent keeps the parts its contribution inherited from the round it replaces, which the pinned LDK records in the contribution so that `reserved_inputs` and `reserved_outputs` leave them out. The contribution's equality ignores that record, so the existing round trip could lose it unnoticed. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 68d9822)
A user-initiated splice will be keyed by a PaymentId generated at splice time rather than derived from a candidate's txid, so its splice intent, funding payment, and candidate history all share one record. Teach the signing-time recording to find a pre-broadcast splice intent by its channel and reuse that id for a splice no live record tracks yet, promoting the intent record to a tracked funding payment while preserving the intent until the splice locks. A round already on record keeps its record, whatever id it is under: the id of the first round of the history any record tracks is adopted before the channel's intent is consulted, and a fresh id is generated only when neither yields one. A record wallet sync has already failed does not count: nothing revisits a failed record, so a fee bump signed with its lost round in the history adopts the channel's intent instead, and its entry carries the intent. The intent identifies the channel, not a round, and must not decide the id of a round already on record: a splice this node joins as a fee bump of a round wallet sync recorded first converges on the record sync created, and consulting the intent first would file the bump under the intent as a second record, with wallet sync then graduating whichever of the two it finds first. Every splice round this node contributes to that the wallet records is recorded when it is signed, before our signatures are released, so the intent only ever decides the id of a splice's first signed round, or of a bump signed after wallet sync has failed every round on record before it. Splices we did not originate (counterparty-initiated or V2 dual-funded opens) have no intent. An intent submitted for a channel whose history is already on a record under another id that has not failed is never promoted and stays bare until the splice locks or fails. A splice under a generated id is no longer found by the txid-derived lookup, so it leans on find_payment_by_txid's candidate probe to map its txids back to the record. The generic funding classification already resolves an existing record the same way before generating a fresh id: LDK re-broadcasts a promoted-but-unconfirmed 0conf funding transaction through that path, and a test added here covers the rebroadcast merging into the record the signing created rather than creating a duplicate. Promotion of a pre-broadcast intent in persist_funding_payment_locked is gated on the payment still being Pending, read inside the pending store's critical section like the rest of the write's decision: a payment that confirmed through ANTI_REORG_DELAY before the write must not re-enter the pending store, which graduation and rebroadcast assume holds only Pending payments. No splice intents are created yet; the splice entry points that persist them land in a follow-up -- on this branch the intent probe stays dormant. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 1c45324)
Wallet sync can observe a funding round before it is recorded as a candidate: the counterparty broadcasts a round this node did not contribute to, which nothing records until this node signs a later round of the same splice and records the channel's history with it. The funding-status gate rightly reports such a round foreign, and sync re-keys the event to the round's txid-derived id, creating an untyped duplicate record whose pending entry from then on shadows the funding record in txid resolution: even after the round is recorded as a candidate, every later event routes to the duplicate, the confirmation strands there, and the funding record never confirms or graduates. Fold the duplicate back in when its round becomes a recorded candidate: adopt its confirmation onto the funding record -- through the same status-update path wallet sync uses, so the confirmed candidate's figures land -- and remove the duplicate along with its pending entry. A duplicate for a round that never confirmed is dropped without adopting anything; the actively-broadcast candidate stays the record's current txid. The merge runs when this node signs a round and records the channel's history with it, and again when LDK reports the round negotiated, under the writer's cross-store lock acquisition, so sync cannot interleave, and is idempotent, so a replayed SpliceNegotiated event can re-run it after a partial failure. At signing time the merge is a courtesy and a failure is only logged: the signed round can have no duplicate yet, as our signatures have not left the node, the round's SpliceNegotiated event re-runs the merge and replays on failure, and failing the signing would replay it against a record whose two-store write already completed, which the write's rollback does not cover. The pending entry is removed before the payment record: a replay rediscovers the duplicate through the record, so a failure between the two removals can still be cleaned up, instead of orphaning a pending entry that would shadow txid resolution all over again. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 85fee29)
LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK. Persist each user-initiated splice as an intent record before its contribution is handed to LDK, so such a splice can be recognized at the next startup -- releasing whatever the wallet still holds for it, which a later commit adds -- and so events about the splice can be described in terms of the original request. Each splice gets a record of its own, so that its failure is described from its own intent and a restart recognizes it whatever became of the channel's other splices: a splice queued behind a pending one negotiates as a splice of its own once the pending one locks, and its rounds must not be filed under the pending splice's payment. Only a fee bump joins an existing record, that of the round it replaces. A splice is refused while the channel carries an intent anchored at another funding -- one the lock that superseded it failed to settle or to re-anchor -- rather than recorded beside it. A submission reads the channel's funding under the lock that serializes splice submissions and anchors its intent there, not at the funding the caller read before building the contribution: a splice locking in between moves the funding, and an intent anchored at the old one would never be settled by the lock that superseded it. A funding that moved refuses a fee bump, whose round has locked, and a splice-in, whose inputs the locked round may have spent; a splice-out carries no wallet inputs and proceeds. A splice submitted after the previous one locked with zero confirmations settles that splice's intent first, as the lock's event would have: LDK promotes the funding as soon as splice_locked is exchanged but only queues the event. The lock and close event handlers settle intents under the same lock, so a lock handled mid-submission cannot settle the new intent before its contribution reaches LDK. The record is undone when LDK rejects the hand-off synchronously and settled once the splice locks, its failure is surfaced, or its channel closes. A failure event settles the intent only after the event is durably queued -- a crash in between leaves the intent for the replayed event to settle, erring toward a duplicate report over a lost one -- and only when the event's contribution identifies the recorded splice: a mismatch means the failure concerns an older, superseded attempt with no record of its own. Taking back the funding record of a signed round the failure abandoned leaves its intent behind as a bare intent, so the report can still describe the splice. A splice queued behind another pending splice survives the pending splice's lock, so its intent is re-anchored to the new funding rather than settled. Failing a funding payment -- when a round other than its own locks, when its channel closes, or when wallet sync finds its round lost to a confirmed conflicting spend -- likewise keeps the intent its entry carried, as a bare intent under an id of its own. LDK carries a fee bump queued behind a round it does not overlap across that round's lock and begins a fresh splice from it, so the intent is still needed: to re-anchor it at the new funding, to file the fresh round under it when signed, and to describe the failure LDK reports if the fresh negotiation fails instead. Under the failed record's id, the fresh round would take that record and go untracked. The lock and close handlers settle the kept intent right after it is kept, unless LDK still holds its splice and the lock re-anchors it instead; one kept from wallet sync stays anchored at the channel's unchanged funding, where a later fee bump joins it and no submission is refused on its account. Wallet state staged on a splice's behalf is flushed only after the intent record persists, so nothing the wallet reserves for a splice can outlive the record through which a later startup would release it. A splice that fails before the hand-off immediately releases what the wallet holds for it and no other round uses -- a fee bump built by adjusting the fee of the round it replaces shares that round's inputs and change address, which stay reserved while the round can confirm; one LDK rejects has it returned through the DiscardFunding event instead. A lock settles an intent without releasing anything: what the locked round did not spend, LDK returns through the DiscardFunding events it queues at the promotion. Once a splice funding payment is classified, the intent is carried on the payment's record until the splice locks or the payment fails; a payment that already graduated instead removes the leftover intent record. The funding payment recorded when this node signs a splice round is filed under the record of the intent carrying the round's contribution, written while holding the lock that serializes splice submissions, so neither a fee bump replacing the intent nor a failure settling it can interleave with the write. A signing write cut short after the payment store leaves that payment under a bare intent; it records a round whose signatures never left the node, so it is dropped -- when the replayed signing finds the round gone, or with the intent once the splice settles -- rather than promoted into a record nothing could ever drive. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit b9b0d24)
Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(lightningdevkit#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 7ab378d)
The pinned LDK now reports through `DiscardFunding` only the parts a refused or failed contribution reserved for itself, also for a channel or peer it no longer knows (rust-lightning issue 4986), and reports the `DiscardFunding` for a signed round the monitor never watched after `ChannelClosed` at a force-close (issue 4967), so the comments no longer hedge on either, and the note about the inputs such a round would leave locked before that fix is dropped. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 203af6e)
Decide a removal inside the payment store's own critical section as well, now that remove_if exists: the drop pass no longer reads a record before writing it, and the note saying a removal had no critical section to decide in goes with the read. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 62a73cd)
The signing handler previously logged and dropped both failure paths (with TODOs to abort once LDK supported it), leaving the negotiation dangling until a peer disconnect abandons it. Cancel the contributed funding instead. LDK then emits DiscardFunding, releasing whatever the wallet holds for the contribution, and SpliceNegotiationFailed, which surfaces the failure and settles the persisted intent. Cancel errors are only logged: every error case means the splice is already beyond canceling. When LDK refuses the already-signed transaction, the failure report that cancelling produces also takes back the payment recorded at signing time: the round is gone from the channel's history and nothing can ever broadcast it, so left in place the record would wait forever on a payment nothing can confirm. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit e10d1b0)
An application handling SpliceNegotiationFailed had nothing to act on: the event did not say why the splice failed, nor what the failed call had attempted. Both matter for deciding what to do next — a fee bump lost to a disconnect can simply be re-issued, while the splice it meant to bump may still confirm at the prior feerate. Attach a reason, mapped from LDK's NegotiationFailureReason onto an ldk-node-owned enum so the event's serialization and bindings do not change with LDK's, and the parameters of the originating API call, taken from the persisted splice intent when the failure identifies it. Both fields are optional and serialized as odd TLVs: events written by LDK Node v0.7 read back as None, and v0.7 readers ignore the new fields. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 6fb80a8)
LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK's channel state, and no event of LDK's ever returns what the wallet reserved for it — today the addresses its outputs pay; once At startup, reconcile each persisted splice intent against live channel state: release the reservations of a splice LDK no longer holds and drop its record, re-anchor a queued splice whose predecessor locked while the node was down, and keep — minus any inputs no surviving round still claims — those LDK resumes on its own. A splice whose channel closed meanwhile is released only if no round of it reached signing: a signed round is one the channel's monitor watches until the close matures, and what it reserved is spent by it or returned through DiscardFunding then. Reconciliation holds the lock that serializes splice submissions, as the event handlers settling intents do. Recovery fabricates no failure event for a splice lost this way: the initiating call already returned, and the channel simply no longer shows a pending splice. LDK itself reports the loss of a contribution it was still queueing or negotiating when it was last persisted — it fails the contribution as it is written and replays the failure at startup. The replay runs after reconciliation, so that report carries the splice's parameters only where reconciliation kept the intent: for a splice queued behind a pending one of ours, or a fee bump of one, but not for a channel's only splice, whose intent reconciliation settled. Reconciliation runs before background syncing and broadcasting start, so nothing can act on the stale reservations first. Events LDK replays from its last persisted state (e.g. a DiscardFunding for a splice that died before the node stopped) are likewise consumed before the node is running, so they cannot act on state a new user operation set up since. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 3581203)
Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(lightningdevkit#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 3247cba)
The pinned LDK now reports the `DiscardFunding` for a signed round the monitor never watched at the force-close (rust-lightning issue 4967), so the comments on a gone channel's intent no longer hedge on it, and the TODO on releasing that round's inputs keeps only the case LDK never learns of: a hand-off it never wrote. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 9bd7f9a)
A disconnect during the interactive negotiation fails the splice with PeerDisconnected. The first test asserts that exactly one SpliceNegotiationFailed reaches the user — carrying the reason and the originating request's parameters — and that a new splice initiated afterwards completes with a single funding payment. The window only exists mid-negotiation: a contribution still queued at disconnect is resumed by LDK itself on reconnect, and one awaiting signatures survives re-establishment. The test therefore synchronizes on the counterparty's splice_ack — logged by LDK's peer handler — and stretches the negotiation by funding the splice from many small UTXOs, each of which adds an interactive-tx round trip. A splice dropped by a restart is recovered silently: startup reconciliation releases what the wallet reserved and drops the record without fabricating a failure event. What does reach the user is the failure LDK persisted at shutdown and replays at startup — once, with parameters only when it still matches a kept record. The restart tests cover both cases: a dropped splice-out surfaces without parameters and a further restart stays silent, while a dropped fee bump — whose record reconciliation keeps, since LDK still holds the negotiated splice — surfaces with the bump's parameters. In both, the application re-initiates and the splice completes. A splice confirmed while its node was offline keeps exactly one payment record under its splice-time id regardless of whether wallet sync or classification sees the confirmation first. Three more cases: a second splice submitted right after a zero-conf lock gets a record of its own rather than being folded into the record of the splice that just locked; a queued splice the node stopped on, which LDK fails as it shuts down, is reported at startup with its parameters — its record, an intent that never became a payment, outlives the pending splice's graduation, and reconciliation keeps it while LDK still holds that splice; and a funding record left half-written by a stop between the signing write's two stores is dropped at the next startup instead of lingering as a payment nothing indexes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 095ba80)
The pinned LDK now reports `SpliceNegotiationFailed` after `ChannelClosed` for a signed round the monitor never watched at a force-close (rust-lightning issue 4967), so the test of that round asserts the reason the node surfaces, `ChannelClosing`, and that no parameters come with it, the intent having been cleared at the close. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 67315bb)
What a transaction is, is known only to the channel that produced it, and only while the event announcing it is being handled. Record that knowledge durably, keyed by transaction id, so it is still available whenever the transaction is looked at later. Facts are immutable and merged rather than replaced, because several channel events describe the same transaction from different angles: re-recording what is already known writes nothing, so an event handler may replay freely, while a report contradicting a recorded fact is rejected and logged rather than overwriting it. Co-Authored-By: HAL 9000
The channel events that hand a transaction over are the only place this node learns what that transaction is; record it there, so the knowledge outlives the handler. A funding transaction this node builds is recorded before LDK is allowed to release it, because the event is regenerated rather than persisted: recording afterwards could lose the outpoint to a crash. Sweeps, anchor bumps and HTLC claims are recorded only once the protective action has succeeded, and a failed write is logged rather than reported, so that bookkeeping can never withhold a claim. The remaining channel events record the same funding outpoints a second time as a backstop, which the merge absorbs. Co-Authored-By: HAL 9000
What a transaction is follows from what this node's channels said about it and about the transactions it spends from, so derive it there rather than from the tag its broadcast carried: a tag describes one broadcast, while the facts describe the transaction and survive re-broadcasts and replacements unchanged. A funding output spent in a shape no channel produces stays unnamed. Guessing would put a classification on a payment record that nothing later corrects, and an unnamed record is the honest answer. Co-Authored-By: HAL 9000
Wallet sync recorded every on-chain transaction as unclassified, leaving what the transaction is to the classification the broadcast queue wrote separately. Name it from the recorded facts instead, so the record wallet sync creates already says what its transaction is. The facts live behind an async store while the record is built under the wallet lock, so each caller reads them first and passes them in, looking up only the transactions the inputs actually reference. A transaction the wallet sees before its channel reports it is named on a later chain tip, from the same scan that graduates confirmed payments. The retry only names a record that is still unnamed, read inside the payment store's critical section, so it can add a name but never replace one. Where a producer reported this node's share of an interactively negotiated funding, that share describes the payment better than the wallet's view does, which reads a shared funding input as wholly this node's. Co-Authored-By: HAL 9000
Signing a splice round wrote a payment record so that a counterparty broadcasting the round first would find one rather than mint a duplicate. Record what the round is instead: an interactive funding of its channels, this node's share of it, and the funding payment it belongs to, all keyed by the round's transaction id. Wallet sync then creates the record when it observes the transaction and resolves its identity through those facts, which leaves sync as the only creator of funding payment records while keeping the guarantee that bought the signing-time write. A pending-store entry therefore has to track a splice before any payment record for it exists, so its two shapes become one: the record, the conflicts wallet sync listed, the channels of the funding it tracks, the signed rounds, the splice intent and the rounds LDK promoted are each present on their own schedule, and an entry left tracking nothing is removed. The channels are recorded on the entry because, with no record to name them, nothing else says which channel's splice a signed round belongs to. With no payment record written at signing there is no half-written record either, so the rollback of the write pair and the cleanup of what a failed rollback left behind both go. The two writes that remain are idempotent, and a replay adopts the figures already on record rather than deriving a second answer the facts would refuse. Co-Authored-By: HAL 9000
Moving the funding payment record from signing to wallet sync regressed four of the splice integration tests, two of them through production behaviour. Settling a bare splice intent stopped taking the payment record under its id along. What can leave one there changed — wallet sync now files the record and its pending entry in one write, under the id the round's facts name — but a record found under a bare intent is still the first half of a write that never completed, and still one no entry would ever drive, so restore its removal. LDK re-offers a promoted but unconfirmed zero-conf splice through its generic funding path, re-typed as a plain funding of the channel and carrying the wallet's view of an output both parties own. That re-offer used to find the round's record in place and be declined; with no record until sync has seen the transaction it created one itself, naming a splice a plain funding. Leave a transaction already on record as an interactive funding to sync. Two tests read the transaction of the round just signed out of a payment record that no longer exists at that point, and one of them held back the payment-store writes that used to precede signing to sequence the two nodes. Both now take the round from what the node logs as it records it, and the sequencing holds back the provenance write that precedes signing instead. The kept-at-close test also asserted a record that is only written once a wallet has seen the transaction, which nothing broadcasts there; it now asserts that the close resolves the channel's rounds and leaves this one in the record, against its sibling where the same resolution drops the round the monitor never watched. Two further tests are timing-fragile rather than wrong. The disconnect-mid-negotiation test loses its own race on a loaded machine — the negotiation completes before the disconnect lands, so no failure is ever due — and is widened as its comment prescribes. The zero-conf queued-splice test now needs a chain source that reports an unconfirmed round promptly, the record no longer existing before one has, and is pinned to Esplora as its sibling already is. Co-Authored-By: HAL 9000
Wallet sync classifies on-chain payments from recorded provenance and
owns the payment record. The second classifier, which ran on the
broadcaster's queue and had to hold a broadcast back until its record
was persisted, is now redundant: it wrote records sync would write
anyway, under merge rules that existed only to keep the two writers from
clobbering each other.
Broadcasting no longer waits on persistence, so the queue needs neither
retries nor a bound nor deduplication, and the broadcaster needs no
handle on the wallet: it is a plain FIFO that the chain source drains
and sends. The LDK-supplied transaction type is ignored on arrival.
Tests deleted with their subjects:
- zero_conf_splice_{out,in}_funding_rebroadcast_canary, together with
the rust-lightning#4878 TODO they pin. They assert log lines emitted
by the funding-over-interactive-funding guards, which are gone; with
no tag to re-type, the upstream behaviour they watch is unobservable.
- funding_reclassification_* and funding_classification_*, plus
transaction_type_from_ldk_variants: their subjects are
funding_reclassification_update, PaymentDetailsUpdate::
funding_reclassification, the confirmed-figures guard and the
LdkTransactionType conversion.
- funding_confirmation_waits_for_classification and
funding_classification_waits_for_wallet_sync: race tests between
classification's two-store write pair and a sync arm. There is no
second writer left to race.
- The broadcast-queue tests for retries, deduplication and the package
bound, and the wallet-level tests driving them. Arrival order and the
wake-on-push remain covered.
- classify_funding's own tests, including its generated-id and
rebroadcast handling.
- recording_a_round_removes_the_intent_record_of_an_advanced_payment:
the leftover-intent removal it pins lived only in the deleted
persist_funding_payment_locked. The writer that creates a funding
record now declines to write when the record has advanced past
Pending, and leaves a bare intent entry for the splice lifecycle to
clean up.
Tests re-expressed rather than deleted: the funding-record fixture the
conflict, graduation and duplicate-merge tests build on now composes the
surviving writers -- the payment store, upsert_pending_payment and
merge_duplicate_candidate_records -- instead of calling the deleted
classification path.
Co-Authored-By: HAL 9000
The RBF gate refused a payment whose recorded type named a funding transaction and allowed everything else, so a record carrying no type at all -- one wallet sync wrote before it could name the transaction, or one from a node version that predates classification -- passed as an ordinary payment and could have its replacement broadcast behind LDK's back. Decide it the other way round: allow the bump only when the recorded facts make nothing of the transaction and every input is an output this wallet owns and can re-sign. A channel transaction reaches for a funding, anchor, HTLC or spendable output the wallet does not hold, so it is refused whether or not anything named it. The confirmation, direction and payment-kind checks are unchanged. Co-Authored-By: HAL 9000
On-chain payments are classified from recorded provenance now, so nothing reads the transaction type LDK reports at broadcast, and the pinned revision can go back to handing the broadcaster transactions alone. Point every rust-lightning crate at a local checkout of the branch that restores that signature. The patch section is temporary and must not reach a proposed branch: the paths it names exist only on the machine this was developed on, so the tree builds nowhere else. It has to be replaced by an accessible, reviewed revision, moved into the `rev` of each crate, before this work is proposed. Co-Authored-By: HAL 9000
The broadcaster takes the transactions of a `broadcast_transactions` call and nothing else: the type that accompanied each of them no longer exists, and nothing has read it since on-chain payments began to be classified from recorded provenance. `FundingCandidate` and `ChannelFunding` are gone from LDK with it, so the splice rounds handed to the signing-time recording are described by a pair of types of our own. They carry no funding purpose: every round listed here is a splice, and only a test ever read it back. Co-Authored-By: HAL 9000
The store of what this node's channels reported about the transactions they produced grew for the lifetime of the node: nothing ever removed a record, so a node kept evidence about channels it had settled years ago. A transaction's record now goes once every use this node has for it is over: nothing has been learned about the transaction for about a year, none of the channels it names is still held by the channel manager, the chain monitor or the output sweeper, no pending payment still refers to it, and whatever channel funding it records has been spent by a transaction buried twice over. Any one of those keeps the record, and the wallet keeps everything while it cannot reach the node's channel state at all, so the loss of that view is never mistaken for a node with no channels. The check shares the chain tip pass that graduates payments and resumes where the previous tip left it, so it costs one page of records a block however large the store is, and it runs after the pass has named what it could. A record is dropped only while it still is the one the check looked at, since a producer may have reported something about the transaction in between. Because a payment is classified when its transaction is observed, expiring a record never takes a classification back. It means a transaction of a long-resolved channel, met for the first time after its evidence expired, is reported without one -- which the public API now says. Co-Authored-By: HAL 9000
Records of what a channel reported about its transactions are dropped only once that channel has resolved, so between two of those passes a counterparty decides how much this node stores: how many HTLCs it puts on a commitment transaction, and how many channels and negotiated fundings it drives. A record is now refused once it would outgrow what one record may take up, and a transaction this node holds no record of at all is refused once the store holds as many records as it may. What this node already took on is still kept up to date however full the store is, so an obligation is never half-kept; refusing is only ever about taking on a new one. The store's size comes from the walk the dropping pass already makes: it visits every record over consecutive chain tips, so the count it arrives at is the store's own, without a second pass over it and without holding an index of every transaction in memory. A refusal is reported as an incomplete record rather than as a failure. There is nothing to retry -- a replay would meet the same full store -- and the cost is a transaction reported without a classification, which is bounded loss of detail rather than a lost write. Co-Authored-By: HAL 9000
A splice is persisted with nothing but its intent before anything about it has been observed. Wallet sync promotes that entry once it sees the transaction -- unless the payment has already advanced past pending, in which case no entry belongs in the store and the write is declined. Declining left the bare intent where it was, so a splice that was over went on looking like one still in flight, and the next restart acted on it. The intent is now taken back instead, and only while the entry still is the bare one: a round signed or a fee bump submitted since then is live state of its own, which the splice lifecycle has to be the one to resolve. Co-Authored-By: HAL 9000
What a transaction's facts record names its payment from the moment a round is signed, which is before wallet sync creates the record and for as long as the facts are kept after `remove_payment` has taken it away. Those facts describe a transaction that happened and still classify later ones, so a bookkeeping removal leaves them where they are. Resolving a replaced transaction can therefore name a payment nothing holds a record of. That now skips the event, as a transaction resolving to no payment at all already did, rather than failing: the failure abandoned every remaining event of the batch, and the wallet's own view of the chain went unpersisted with it, discarding an ordinary sync. Co-Authored-By: HAL 9000
Recording what a signed interactive funding round is, and what this node's share of it comes to, can be refused for want of room, and the signing went ahead regardless. The transaction was then released with nothing on record about this node's contribution, so whoever first observed it recorded the wallet's view of a funding output both parties own: the whole of it read as this node's spend, a figure nothing later corrects. The refusal now fails the signing. LDK re-offers the event while the transaction is unsigned, so the cost is a splice that does not complete until the dropping pass frees room, rather than a payment reporting what it is not. The commit introducing the refusal said its cost was a transaction reported without a classification. That holds for a producer reporting on something it has already released, which still only logs, and the places restating it now distinguish the two. Co-Authored-By: HAL 9000
Refusing the round's facts for want of room failed the signing, and the handler turned that failure into a replay. LDK stops handling events at the first failure and leaves the failing one at the head of its queue, so everything behind it waits, claims on inbound HTLCs among them, while the same pass flags the channel manager for persistence and notifies a waiter that re-enters at once. A full store is not a condition a replay clears: room comes only from the dropping pass, which wants a record a year old whose channels have all resolved. The round's facts are now admitted however many records the store holds. The cap bounds what a counterparty drives by opening and closing channels or replacing a negotiated funding; a round this node chose to sign is not that, and its record names the round and this node's share of it while carrying no outputs. What can still refuse the round is a record already as large as one record may be, which nothing shrinks, so the round is reported unmeasurable and the handler cancels the splice, as it already does where LDK refuses the signed transaction. The failure is then the splice's rather than the node's, and the round is still never released with the wallet's view of a funding output both parties own standing in for this node's share. Co-Authored-By: HAL 9000
A channel becoming pending reported both the output that funds it and that the transaction is a funding of that channel. The second says nothing the first does not, since a funding output is what a funding transaction is recognised by. It also cost something. One transaction can open several channels, and each reports only its own output; a report naming the transaction outright names one channel, so the next channel's contradicts it and is refused whole, taking its funding output with it. The payment then named one of the channels opened instead of all of them. A channel becoming ready already reports only its funding output, so the two backstops now agree. Co-Authored-By: HAL 9000
The tests that needed a store parking one namespace's writes, the order the gated store's keys were written in, and a plain on-chain payment fixture went with the broadcast-time classification path and the writes it made. What they used is dead, and the compiler says so. Co-Authored-By: HAL 9000
A note on where the pending store's status check happens pointed at the function it sits in, having been re-pointed there when the function it named went away. It says the same thing without the cross-reference. Two retention tests, one over the check itself and one over the pass that applies it, shared a name; the second now reads like its neighbours. Co-Authored-By: HAL 9000
|
👋 Hi! This PR is now in draft status. |
|
I was able to fold the commits into the PR stack instead of having them based upon it. Branch is here: https://github.com/jkczyz/ldk-node/tree/2026-09-1080-fold-1121. Though it looks like it didn't include the signing time payment entry for a splice, which #1057 added. (edit: ah, looks like that was a deliberate choice in this PR) |
|
I prompt Claude a bit to compare the two approaches. 🤖 Following up on the fold: here is how the folded branch compares with the three PRs as they stand. Links go to the fold tip, 98bf0be3; names without links are on #1057/#1079/#1080. Verdict. The facts-store approach is the better structure, and I'd keep it. It gives up two things the three PRs had, and I'd close both before merging: the broadcaster ignores the What it gets right
What it gives up or adds
RecommendationKeep the structure, and close the two gaps:
With both, the remaining case where a record is wrong when final is a counterparty-broadcast close of a channel whose funding facts failed at every report, and the One more thing the fold carries that this PR's base predates: the fixup to Analysis assisted by Claude Code. |
Draft, based on #1057, #1079, #1080.