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

@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
jkczyz force-pushed the 2026-10-open-channel-intents branch 5 times, most recently from c8b9b1a to 7e24935 Compare October 27, 2026 17:18
jkczyz and others added 14 commits October 27, 2026 20:28
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>
Written with the help of an AI tool.

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>
Written with the help of an AI tool.

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>
It only forwarded to mutate_with, so that method is now pub(crate) and
the callers that await fallible reads use it directly.

Written with the help of an AI tool.

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>
Written with the help of an AI tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jkczyz
jkczyz force-pushed the 2026-10-open-channel-intents branch from 7e24935 to 951b524 Compare October 28, 2026 14:55
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