Skip to content

Track each channel funding in a single payment record - #1079

Draft
jkczyz wants to merge 15 commits into
lightningdevkit:mainfrom
jkczyz:2026-08-splice-payment-model
Draft

Track each channel funding in a single payment record#1079
jkczyz wants to merge 15 commits into
lightningdevkit:mainfrom
jkczyz:2026-08-splice-payment-model

Conversation

@jkczyz

@jkczyz jkczyz commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Track each channel funding — open or splice — in a single payment record that every observer resolves to instead of creating its own.

Several independent writers observe a funding: classification writes the record when the funding transaction comes off the broadcast queue, wallet sync sees the transaction in the mempool (possibly broadcast by the counterparty first), and RBF rounds replace the transaction outright. An id derived from a txid stops matching once a replacement lands, and a writer that can't find the record creates its own.

What changes

  • Funding records get a random PaymentId, generated at creation; txids resolve to the record through its candidate history.
  • Classification adopts the id a splice was assigned at initiation instead of creating a second record.
  • A record wallet sync created while a round's classification was still pending is folded back into the funding record once the round classifies — the duplicate-record window discussed in #1057. The splice-tracking PR (Track in-flight splices for failure reporting and crash recovery #1080) closes that window for rounds this node signs by recording them at signing time; the fold stays as the backstop for rounds we never sign.
  • Pending payments become an enum so a record can represent a splice with no funding transaction yet. Nothing constructs that variant here — the splice-tracking PR next in the stack persists splice intents through it.

Compatibility

  • The id scheme can still change: funding records first ship in the upcoming release (v0.7.0 shipped splice_in with no record machinery).
  • The enum changes the pending store's serialization format, which is safe because that store has never shipped in a release.

Second in the PR stack replacing #930 for this release, per the discussion there; stacked on #1057; a splice-recovery PR (#1080: persist intents, enrich failure events, release lost input reservations at startup) follows. Automatic retries are deferred to a post-release follow-up.

Developed with assistance from Claude Code.

jkczyz and others added 6 commits September 1, 2026 15:12
Wallet sync resolves a funding payment's id for any transaction linked
to the record through its conflicting txids, and then adopted that
transaction's txid and confirmation outright. A cooperative close
conflicts with a pending splice in exactly that way: the splice record
would report the close's txid and confirmation under its
InteractiveFunding type and contribution figures and graduate as if
the splice had confirmed, while the close's own record never received
its confirmation. Adopt a transaction only when it is part of the
payment's funding history — the record's current txid or a classified
candidate. Anything else is recorded under its own txid-keyed id,
which also delivers the close's confirmation to the close's own
record.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A queued broadcast whose payment-record classification failed was
dropped outright, on the theory that broadcasting a transaction we
failed to record would leave it on-chain without a payment. For
interactive funding that theory doesn't hold: the counterparty
broadcasts the same transaction once the signature exchange completes,
so dropping the package keeps nothing off-chain — it only guarantees
the round is never recorded as a candidate on our side. The
funding-status ownership gate then treats the round's confirmation as
foreign to the funding record and re-keys it to a stray duplicate
record, which shadows the funding record's txid lookups permanently:
the splice payment stays Pending forever while an untyped duplicate
holds the confirmation.

Keep the package alive instead: requeue it after a short delay and
retry classification until it succeeds, holding the broadcast back the
whole time. Classification failures are persistence failures, so the
retry is unbounded — a store that never recovers keeps the node from
functioning anyway — and every failed round is logged.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retry test slept a fixed three seconds and assumed classification had
failed by then; if writes were re-enabled before the first attempt, the
test would pass without any retry happening. Count failed writes in
FailSwitchStore and wait for one before re-enabling writes.

Also fix the test's store reads to use list_page: the payment store's
cache is bounded, so list_filter is unavailable, and this commit did not
compile its tests standalone (the conversion had landed in the following
commit).

Implemented with Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retry for a failed classification was a detached tokio::spawn that
outlived the node. Its comment claimed a re-send after shutdown would
fail because the queue had closed, but the queue receiver lives in the
broadcaster and is only dropped with the node, so the re-send succeeded
and a stale package would be classified and broadcast after a
stop()/start() cycle.

Queue failed packages inside the broadcast loop instead and retry them
from a timer branch of the same select. New packages keep flowing while
a retry waits, and pending retries are dropped when the loop stops.

Implemented with Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A queued classification can retry after a newer candidate of the same
funding already classified. The retry carries the candidate history as
of its own broadcast, so applying it rotated the record's txid back to
the older candidate and shrank the stored candidate history — after
which wallet sync could no longer map the newer transaction to the
record and would file it as a foreign duplicate.

A fresh interactive-funding classification always carries the record's
current txid in its history, so one that doesn't is stale: ignore it,
and never let a candidate-history update drop stored candidates.

Implemented with Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LDK re-broadcasts pending claims every 30 seconds (and sweeps once per
block) until they confirm, so while the payment store is unavailable,
the list of pending retries accumulated a copy per rebroadcast —
memory, retry load on the struggling store, and a duplicate broadcast
burst on recovery all growing with the outage's duration.

A package whose transactions already await a retry is not queued
again, and the rest are bounded: at the bound, the oldest waiting
non-funding package is dropped to make room — its transactions return
with LDK's next periodic rebroadcast — but never a funding package,
whose transaction would be left confirming without a recorded
candidate. Fee-bumped rebroadcast variants carry new txids, so the
bound, not the dedup, is what limits their accumulation.

Implemented with Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jkczyz jkczyz added this to the 0.8 milestone Sep 3, 2026
@ldk-reviews-bot

Copy link
Copy Markdown

👋 Hi! I see this is a draft PR.
I'll wait to assign reviewers until you mark it as ready for review.
Just convert it out of draft status when you're ready for review!

jkczyz and others added 2 commits September 4, 2026 10:51
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>
@jkczyz
jkczyz force-pushed the 2026-08-splice-payment-model branch from 50bbfe1 to 041e39b Compare September 4, 2026 17:09
jkczyz and others added 7 commits September 5, 2026 03:18
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 from the channel's
pending splice history -- the same history LDK later hands the
broadcaster, under the same id -- so the round's broadcast-time
classification has nothing left to write but the fact of the broadcast.
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 its classification clears the mark, and 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 classification has run
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
its classification has necessarily run. 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 gets no failure report
then, and a failure reported once the channel is gone is left to this
report, which carries the channel's last funding -- 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. 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 left
to broadcast-time classification, as before.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Keep, at ChannelClosed, the splice rounds the channel's monitor still
watches. The channel manager forgets a pending round with the channel
and reports no failed negotiation for one awaiting the counterparty's
signatures, so the handler took back every recorded round but the last
funding. The monitor, however, watches every round from the
counterparty's commitment_signed on, and our signatures cannot have
left the node before that message: the counterparty may hold a fully
signed transaction and broadcast it, in which case the dropped record
resurfaced as an untyped payment under the transaction's id, or a
dropped bump marked the splice Failed when it confirmed. Reachable
when this node's contribution is the smaller one -- a queued
contribution merged into a counterparty-initiated round -- and on
LDK's own force-close after a tx_abort that follows its commitment, as
well as at reload when the monitor is ahead of the channel manager.

The kept set is every round the monitor watches, not only those our
signatures left for: the monitor cannot tell them apart, and a watched
round still marked at close is in either case one whose counterparty
signatures never arrived, since with both signature sets held LDK
broadcasts the round and its classification clears the mark. Such a
round stays a Pending record until the close spend matures and the
monitor's DiscardFunding arrives; that handler only reclaims addresses
today, so nothing terminates the record yet (pre-existing; the
following commit adds that). A round is marked at signing, which LDK
triggers at tx_complete, before the counterparty's commitment_signed,
so a marked round that message never reached is still dropped; our
signatures cannot have left for it. After a zero-conf lock the monitor
stops watching the rounds the lock superseded -- an RBF sibling of the
locked round and the previous funding scope alike -- so a later close
still drops those, which matters only while their classification is
queued.

Two integration tests drive the handler into each case by holding
back the peers' store writes, which precede their signing: one lets
this node sign only once the counterparty's commitment_signed has
arrived and keeps the counterparty's tx_signatures from ever arriving,
and expects the record kept; the other keeps the counterparty from
signing at all and expects the record dropped.

When squashing, the base message's "which carries the channel's last
funding" should read "which carries the channel's last funding, joined
by the rounds its monitor still watches".

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 funding payment the event names. A round nothing ever
broadcast is dropped first, as at `ChannelClosed`, and with it a record
no broadcast round of ours remains under. The 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, and it learns a round only after the manager lists it.

The event names a round by its transaction only when this node did not
contribute to it; otherwise it describes what LDK returns of the
contribution: the inputs and output scripts the round that replaced it
does not reuse. Record each candidate's contributed inputs and output
scripts so the event can be matched to the round, exactly or as the one
recorded contribution with more parts. A round recorded before this
carries no parts, and an event describing its contribution changes
nothing while the channel is listed, as before. So does an event
describing the channel's current funding: LDK also returns a
contribution it refused before building a round from it, whole when the
channel had no pending splice to check it against -- a fee bump adjusted
from a round that locked as the bump was built, queued until the channel
goes quiescent for it and returned once the node restarts, the channel
force-closes or begins a cooperative close while no stfu is outstanding
on it, the user cancels it, or the negotiation begun from it is refused,
fails or is cut off by a disconnect -- and such a bump describes the
locked round, while no round LDK discards can be the funding.

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: when LDK discards
a sibling round, 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 with every round held and leaves the payment. So
`ChannelClosed` now 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 by the rounds the monitor holds alone, without
matching the event to a round: the close has settled what remains, and
the held rounds decide for a record written without its parts or for
records signed under different first-candidate ids that share one
contribution, which a match cannot tell apart.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 classification acquires the
cross-store lock itself and the write helper now takes the guard.

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

Wallet writes to the pending store go through DataStore::mutate, 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>
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
classifier to find a pre-broadcast splice intent by its channel and reuse
that id for a splice no 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. The intent identifies the channel, not a round:
after a zero-conf lock, the channel may carry the intent of a newer
splice while the locked round's classification is still queued, and
consulting the intent first would file that round under the newer splice
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 a round of ours is always on record by the time it is
classified, and the intent only ever decides the id of a splice's first
signed round. Splices we did not originate (counterparty-initiated or V2
dual-funded opens) have no intent, and a round wallet sync recorded
first converges on the record sync created. An intent submitted for a
channel whose history is already on record under another id 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
classification already 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 classification 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>
Wallet sync can observe a funding round before it is recorded as a
candidate: the counterparty broadcasts an interactively funded
transaction on its own, so a classification failure being retried here
— or a sync poll racing the broadcast queue — leaves the round
unrecorded while its events arrive. 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's classification lands, 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 whenever a round is recorded — at its broadcast-time
classification, or when this node signs a later round and records the
channel's history with it — under the writer's cross-store lock
acquisition, so sync cannot interleave, and is idempotent, so the
broadcast queue's classification retry 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, its own classification re-runs the merge with
the retry behind it, 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 retry 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>
@jkczyz
jkczyz force-pushed the 2026-08-splice-payment-model branch from 041e39b to 019bfcb Compare September 8, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants