Skip to content

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

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

jkczyz wants to merge 28 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: the record is written at signing (since #1057) or 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. A txid is no identity for a replaceable transaction, so a txid-derived key names whichever round a writer saw first, 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 transaction history.
  • Recording a splice's first round adopts the PaymentId of the channel's persisted splice intent instead of generating one, so the intent and the funding payment share a record. A round already on a record keeps that record, a failed record excepted; the intent decides the id only for a history no live record tracks yet, so a fee bump this node signs of a round wallet sync recorded first joins sync's record. Nothing persists an intent yet (Track in-flight splices for failure reporting and crash recovery #1080 does), so the intent path is dormant here.
  • A record wallet sync created for a round before it was recorded as a candidate — a counterparty round this node did not contribute to, which nothing records until this node signs a later round of the same splice — is folded back into the funding record once the round is a recorded candidate: the duplicate-record window discussed in #1057. The fold runs when this node signs a round, where a failure is only logged, and again when LDK reports the round negotiated, where a failure replays the event. Keep funding payment records accurate #1057 closes the window for rounds this node contributes to by recording them at signing time; the fold stays as the backstop for rounds it never signs.
  • Pending payments become an enum so a record can represent a splice with no funding transaction yet. Nothing outside the tests 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; a funding transaction was an untyped on-chain payment like any other).
  • 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.
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 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 10 commits October 9, 2026 09:32
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>
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>
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 are
recorded once the sweeper holds the outputs; anchor bumps and HTLC
claims once the bump handler has been handed the event, whose outcome
it does not report. In both cases 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.

Outputs the sweeper is told to leave alone are left out of the record
as well: LDK reports an output paying a script of this wallet's own,
such as the closing output of a cooperative close, as a static output,
and whatever spends it next is an ordinary wallet transaction, not a
sweep. Recording it would label that transaction a sweep and refuse to
fee-bump it.

Co-Authored-By: HAL 9000
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A funding transaction this node generates is withheld from LDK until
its facts are on record, so a failed write replays the event and the
channel becomes pending only once the write goes through. Every other
report accompanies a transaction already released, so its failure is
logged and the event proceeds. Cover both with a store whose writes to
the facts namespace fail while the test says so.

This change was made with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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
…ng output

What a transaction spends settles its type before what it creates, so
moving a channel's resolved output into a new funding output is the
closed channel's sweep. Pin that order with a test of its own.

This change was made with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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
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
a bound nor a 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. The classifier's helpers go with it: the per-candidate stake
aggregation, the confirmed-figures guard on payment updates, and
DataStore::mutate_async, which only its two-store write pair called.
The wallet-view derivation of a transaction's figures stays for the
test that checks a reported share outranks it.

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.
- classify_funding's own tests, including its rebroadcast handling.
- mutate_async_awaits_fallible_reads, with its subject.

Tests re-expressed rather than deleted: the funding-record fixture the
conflict and graduation tests build on now writes the payment record
and its pending entry the way wallet sync does instead of calling the
deleted classification path. The queue's arrival order and its
wake-on-push get tests of their own.

Co-Authored-By: HAL 9000
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The channel monitor's claims on a counterparty's commitment, resolving an
HTLC or punishing a revoked commitment, pay this wallet's destination
script directly rather than an output the sweeper takes charge of. Since
classification stopped reading the broadcast tag, the event handler never
learned which channel these transactions belonged to, so their payments
came out untyped where the broadcast-time classification had typed them.

LDK reports each such output as a static spendable output once the claim
matures. The event handler now records the transaction creating it as the
channel's payment straight to this wallet, and a transaction that creates
such an output without spending a recorded funding output is typed as a
claim. A cooperative close pays its shutdown output the same way: with its
funding on record it is a close, as before, and without one it is left
untyped rather than mistaken for a claim.

The report arrives at the depth the claim's payment record graduates at,
and the chain-tip pass names only records still pending. The event handler
therefore names the transaction's record as it records a channel's report,
instead of leaving a graduated record untyped for good.

This change was made with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkczyz and others added 18 commits October 9, 2026 09:33
LDK reports the output a claim paid this wallet at the very depth the
claim's payment record graduates at, and polling Bitcoin Core hands each
block to the wallet before the channel monitor. The record therefore
graduates unnamed and only the naming the event handler runs after
recording the report gives the claim its type. Cover that route through
the event itself: a held HTLC claimed on chain after the counterparty
force-closes.

This change was made with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 transaction that spends a funding,
anchor, HTLC or spendable output is refused by its inputs alone, since
the wallet holds none of those. A v1 funding transaction this node built
from its own coins spends only wallet outputs, so for it the refusal
rests on the Funding fact that FundingGenerationReady records before
the transaction is released, an event that is replayed if the write
fails. A fact that cannot be read is no answer about the transaction:
the bump is refused with the error rather than allowed for want of a
reason to refuse it. The confirmation, direction and payment-kind
checks are unchanged.

This change was made with the help of an AI tool.

Co-Authored-By: HAL 9000
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wallet sync can observe a splice transaction before this node has
recorded anything about it: once tx_signatures are exchanged, the
counterparty may broadcast first, and sync then files the round as a
plain on-chain payment of its own, with the wallet's view of a funding
output both parties own as its figures.

Record the round 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. What is recorded is what the round is:
an interactive funding of its channels, this node's share of it and the
funding payment it belongs to, all under the round's transaction id,
plus the round's place in the channel's splice history. No payment
record is written: wallet sync creates one when it observes the
transaction and resolves its identity through the recorded facts, so
sync stays the only creator of funding payment records. A pending-store
entry therefore tracks a splice before any payment record exists, and
names the channels of the funding it tracks, since with no record
nothing else says which channel's splice a signed round belongs to. An
entry left tracking nothing is removed.

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. Both writes are
idempotent, and a replay adopts the figures already on record rather
than deriving a second answer the facts would refuse.

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 round would sit in the channel's record forever. 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. LDK's view is
consulted when it reports the failed negotiation of a channel it still
lists, when the channel closes, 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.

A round this node contributed nothing to is not recorded here and is
left to wallet sync, as before.

An entry written before this change does not decode under the new
layout, and a stale entry fails node startup. The pending store has not
been in a release, so no released node holds one.

Developed with assistance from Claude Code.

Co-Authored-By: Elias Rohrer <dev@tnull.de>
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 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.

An entry of a round LDK released that wallet sync never observed holds
no payment record yet. It is resolved on the same terms: with no round
of ours held or locked, the attempt is failed under a record written
for it then, from the share of its newest round of ours that the
signing recorded, so that it shows in the payment list as one wallet
sync had observed would, and the entry is removed. Wallet sync
otherwise creates every funding record; here it never saw the
transaction, so the record is written from what the signing kept. That
newest round may have no share on record: the signing records nothing
for a round that moves no wallet funds, such as a splice-out to an
external address, while a later round signed with it in the history
lists it as ours by its contribution. Such a round was never a payment
of the wallet's, so once the later round is dropped the entry is
removed without a record. A share that cannot be read is no answer
about the round: the pass fails with the error, and the event is
replayed with the entry kept, still listing the round.

Developed with assistance from Claude Code.

Co-Authored-By: Elias Rohrer <dev@tnull.de>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

A round this node signed is named by the facts the signing recorded
before either entry is consulted, so the order decides for a round
without such facts, as one this node contributed nothing to.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
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 naming of a recorded
transaction once its channel's facts arrive, 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: Elias Rohrer <dev@tnull.de>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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
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 every spend the wallet holds of a channel funding it records is
confirmed to twice the depth that counts as safe from a reorg, with its
own payment settled. 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.
A funding the wallet holds no spend of does not keep it: a commitment
transaction paying none of the wallet's scripts never enters the
wallet's graph, so a channel closed that way would otherwise keep its
record for good, and whether the channel may still produce a transaction
is already answered by whether the node still holds it.

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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test's comment said the spender's record had yet to learn what the
transaction is, but the record it inserts is typed already. What keeps
the facts is that the pending store still refers to the spender.

This change was made with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 channel's facts are recorded by the event that creates its outputs:
the funding when the channel is opened, the outputs a channel resolved
to this node when LDK reports them. A node upgraded from a version
without the facts store holds channels none of those events will fire
for again, and a report that failed in an earlier session is not
repeated either. A close or a sweep of such a channel then goes
unclassified for good.

On start, before anything syncs, the node now records what LDK still
holds for its channels: the funding output of every channel the channel
manager lists or the chain monitor watches, and every output the
sweeper tracks for a channel. A node whose producers reported everything
finds each of those on record already and writes nothing. A failure to
record one costs that output's classification until the next start, not
the start itself.

The channel manager alone knows a channel's local identifier; a monitor
and the sweeper report without it. A fact reported without the
identifier therefore no longer contradicts one recorded with it: the
identifier fills in where absent and counts only where both reports
carry one.

This change was made with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The unreachable-state test asserted an empty namespace against a state
that held nothing, which an empty answer would satisfy as well as the
unreachable one. The state now holds an output while unreachable, and
the pass records it once the state can be consulted.

The held-outputs test asserted the reported funding's outputs unchanged,
which a same-content rewrite would satisfy too. The pass now runs at a
later tip and the whole record, its date included, is asserted equal.

This change was made with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Funding records were keyed by a PaymentId derived from a funding txid:
the first negotiated candidate's txid when this node signed a round,
the transaction's own txid when wallet sync recorded a round it could
not attribute. 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 a signed round is first
recorded, 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 the signing resolves the id under
the lock it writes under.

Resolution also reaches records that have graduated out of the pending
store. Without that, a wallet event naming a graduated record's
transaction -- LDK re-broadcasting a 0conf splice whose confirmation
landed while the node was offline, or a reorg after graduation -- would
miss the record and create a duplicate under a fresh id.

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.

Only a record wallet sync created for a round it could not attribute
still carries a txid-derived id, that of the transaction it saw, and
the settled-record collision the wallet-sync fallback guards against
arises for those records alone.

The funding-record surface (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.

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

Give pending-payment entries an optional splice intent, retained until
the splice locks, alongside the rounds signed under the entry and the
payment record wallet sync adds once it observes a transaction. Add the
SpliceIntent and SpliceKind types that record what was handed to LDK
and the API call that produced it. A round trip checks that a persisted
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 a plain round trip
could lose it unnoticed.

A payment-tracking merge leaves the intent alone: the writers that
persist and settle splices own it, and a record built by wallet sync
carries none, so letting the merge write it would clear a live intent.
An entry promoted to a record once a payment exists under its id keeps
its intent along with its signed rounds.

The pending-store write that follows a payment's status check now
re-reads the status inside the store's critical section: only Pending
payments belong in the pending store, and a status read taken outside
it can go stale against graduation.

This is groundwork; nothing persists an intent yet. The entry points
that do 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>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Extend the legacy-read test to the splice intent as well: an entry a node
wrote before the intent was kept on it must still read, with no intent.

This change was made with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <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
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, so
the intent's entry becomes the round's record and keeps 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.

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 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, and the round's
SpliceNegotiated event re-runs the merge and replays on failure. 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.1 <noreply@anthropic.com>
@jkczyz
jkczyz force-pushed the 2026-08-splice-payment-model branch from bbed3c7 to 4e6e2a1 Compare October 9, 2026 20:47
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.

3 participants