Skip to content

Track outbound channel opens from initiation - #1203

Draft
jkczyz wants to merge 14 commits into
lightningdevkit:mainfrom
jkczyz:2026-10-open-channel-intents
Draft

jkczyz wants to merge 14 commits into
lightningdevkit:mainfrom
jkczyz:2026-10-open-channel-intents

Conversation

@jkczyz

@jkczyz jkczyz commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1080, as discussed in #1079 (comment). Based on #1080; the last two commits are new. The first of them only swaps the user:password pair in the builder's PostgreSQL connection string examples for angle-bracket placeholders, which secret scanners otherwise read as credentials.

An open this node initiates had no record of its own until LDK reported the channel: the funding payment was recorded under an id derived from the funding txid once the transaction was seen, and an open the node stopped on before the channel was funded disappeared without a trace, as LDK persists a channel only once it is funded.

  • Every open_channel variant now returns a ChannelOpen holding the user_channel_id and the PaymentId the funding payment is recorded under. The open is recorded before the channel is created, so the id is known from the start and the payment lands under it whether the broadcaster or wallet sync records it first.
  • The record is settled once LDK reports the channel pending or closed. An open LDK no longer lists at startup is reported through Event::ChannelClosed with ClosureReason::DisconnectedPeer, through the same handler path as a close LDK persisted — unless LDK's own event replay failed, in which case the report waits for the next startup. The report may be emitted more than once if the node stops, or a store write fails, right after it is queued; applications should expect a ChannelClosed for a user_channel_id they hold no channel for. An open LDK still lists unfunded — the node was stopped and started without the process restarting — is left to the event LDK reports about it.
  • Node::remove_payment keeps the record of an open or splice still in flight under the removed id, back at the state it had before the payment was recorded.
  • A funding transaction whose facts cannot be read is no longer recorded under a txid-derived id; the sync pass fails instead, as it does on any other store failure.

Compatibility: channels opened before upgrading, and channels opened as an LSPS2 service, keep the txid-derived payment id. The pending-store entry gains an optional TLV field; older versions ignore it.

Tests: unit tests for the tracker, the pending-store entry, and the wallet paths; three integration tests covering the returned id across a restart and through confirmation, a refused and a counterparty-rejected open, and a lost open reported after a restart.

🤖 Generated with Claude Code

jkczyz and others added 14 commits October 22, 2026 17:19
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 for 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.

Every candidate of a signed history is attributed to the payment when
the round is signed, the rounds this node contributed nothing to
included: once the record graduates, nothing else ties such a round to
it, and one of them may be the round that confirms. A round attributed
already keeps its attribution.

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 tracks 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 it would never graduate 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; intents are not persisted 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>
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: a failed record is never revisited, 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 is not recorded 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. When no payment record exists under the id yet -- wallet sync has
seen none of the payment's rounds, or only rounds this node contributed
nothing to -- a confirmed duplicate becomes the payment's record
instead, with the figures recorded for its round. A duplicate is
recognized by any of the payment's rounds, not only the round it was
created for: wallet sync files a replacement under the record of the
round it replaced. 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>
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
reserves 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; when wallet sync has
removed that record in between, as it does once the round's payment
graduates, the bump gets a record of its own rather than going out
with none. 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. Dropping the signed round a failure abandoned from
its record leaves the intent the record carried 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 tracks 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 the submission check does not refuse a splice on its
account. An entry without a payment record yet, failed on those
occasions under a record written for it then, keeps its intent the same
way.

Wallet state staged on a splice's behalf is flushed only after the
intent record persists, so what the wallet reserves for a splice can
never outlive the record through which a later startup would release
it. A splice that fails before the hand-off immediately releases what
the wallet reserves for it, short of what another round uses as well --
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.

The round 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. Wallet sync later files the funding payment under that
id and indexes it in the pending store in the same write, so a payment
record found under a bare intent is the first half of a write that never
completed, which the pending store would never drive; settling the
intent removes it along with the entry rather than leaving a record that
could never be driven.

Developed with assistance from Claude Code.

Co-Authored-By: Elias Rohrer <dev@tnull.de>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 reserves 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 drops the round recorded at signing time from
its record: the round is gone from the channel's history and can never
be broadcast, so left in place the record would wait forever on a
payment that can never confirm.

Developed with assistance from Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
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 LDK never returns through an event
what the wallet reserved for it -- today the addresses its outputs pay;
once inputs are locked at coin selection, those inputs as well.

At startup, reconcile each persisted splice intent against live channel
state: release the reservations of a splice LDK no longer tracks 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 its rounds never 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 the stale reservations are gone before anything can act on them.
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>
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 tracks 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 across the restart.

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 tracks
that splice; and a funding record left half-written by a stop between
the two stores of the write that files it is dropped at the next startup
instead of lingering as a payment that is never indexed.

The existing test of a signed round the channel's monitor never watched
asserts the reason the node surfaces at the force-close, 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: Elias Rohrer <dev@tnull.de>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 without
this node's contribution on record, 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 that is never corrected.

The round's facts are now admitted however many records the store
contains. 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 describes 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 never
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 never released with the wallet's view of a funding output both
parties own standing in for this node's share.

Failing the signing for the handler to replay would not do: 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 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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 the store should not have an entry for it 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
A channel becoming pending reported both the output that funds it and
that the transaction is a funding of that channel. The second only
repeats the first, 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 stating outright what the
transaction is lists one channel, so the next channel's contradicts it
and is refused whole, taking its funding output with it. The payment
then listed 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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Secret scanners read the `user:password` pair in the example connection
strings as credentials and refuse to commit the file. Angle-bracket
placeholders read as placeholders and pass the scan.

Written with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An open this node initiated had no record of its own until LDK
reported the channel. The funding payment was recorded under an id
derived from the funding txid, so the caller could not look for it
before the transaction was seen, and had to derive the id. And an open
the node stopped on before the channel was funded disappeared without
a trace: LDK persists a channel only once it is funded, and drops an
outbound open still awaiting the counterparty's acceptance at a restart
without reporting it.

Every `open_channel` variant now records the open before creating the
channel and returns a `ChannelOpen` holding the `user_channel_id` and
the `PaymentId` the funding payment is recorded under, however the
funding transaction is first seen. The record is settled once LDK
reports the channel pending or closed. At startup, an open LDK lists no
channel for is reported through `Event::ChannelClosed` with
`ClosureReason::DisconnectedPeer`, the way a close LDK persisted is
reported, unless the replay of LDK's own events failed and may still
hold the open's fate.

Removing a payment no longer drops the record of an open or splice
still in flight under its id, and a funding transaction whose facts
cannot be read is no longer recorded under a txid-derived id, which
would have hidden it from the open that funds it.

Written with the help of an AI tool.

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

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