Repository navigation
Conversation
|
👋 Thanks for assigning @tnull as a reviewer! |
aba7686 to
80a1b27
Compare
80a1b27 to
ca4e5fd
Compare
d457713 to
74e0acd
Compare
d520e2e to
8f2f58d
Compare
0767802 to
67315bb
Compare
67315bb to
f597be8
Compare
ce2f247 to
3194e0c
Compare
f5e5753 to
9f474a0
Compare
d1d9cc8 to
b347dc0
Compare
|
This needs a rebase now that #1079 landed. |
b347dc0 to
219c46f
Compare
|
To start, here are a few things Claude says need/should be addressed:
|
|
Also needs a rebase again. |
219c46f to
8e5fe8b
Compare
|
Rebased and had Claude address those: Thanks — went through all eight. Four led to fixups, the rest I've answered inline. 1. 2. 3. The signing event when the cancel fails. 4. 5. 6. The second removal. Fixed in d8d6fca: the removal is gone. It was left over from the read-then-remove it replaced. 7. Force-close vs cooperative close. Documented on the 8. |
946f2d3 to
957275d
Compare
The comment that Claude wrote for this wasn't really understandable. I had it rename a couple methods and re-write it. 🤖 Five more fixups on top. The comment I added for 5 (72ad320) leaned on words that aren't in the code, so 95523e0 rewrites it to name the call, the snapshot, and the check it talks about. The two helpers it refers to are renamed after what they answer rather than after their caller: |
tnull
left a comment
There was a problem hiding this comment.
Another round of Claude feedback:
- Medium: cancel_splice can cancel the wrong splice and leave the failed round stuck (src/event.rs:949, called from lines 2832, 2859 and 2871)
The handler assumes that cancelling aborts the round it just declined to sign. The pinned LDK's Channel::cancel_funding_contributed (channel.rs:13681) checks
for a queued contribution first, and if there is one it abandons that and returns.LDK explicitly allows a queued contribution alongside a counterparty-initiated negotiation (channel.rs:13570). The PR's own docs also describe joining a
counterparty's round.Failure scenario:
- This node has a contribution in a counterparty-initiated round A, which is awaiting signatures.
- The user submits splice B. LDK queues it, because already_contributing only looks at negotiations this node initiated.
- Handling FundingTransactionReadyForSigning for A fails, through a signing failure, Unmeasurable, or LDK refusing the witnesses.
- cancel_splice cancels B, so the user sees LocallyCanceled for a healthy splice.
- A stays unsigned and the handler returns Ok(()), so nothing replays it in-session. A keeps the channel's splice state stuck until a restart regenerates
the event.In the "LDK refused" path, A's record also isn't taken back, contrary to the comment at event.rs:2857.
Possible fix: call cancel again if the first call only removed a queued contribution, or have LDK expose which one it cancelled. A regression test should
queue B behind a counterparty round and force a signing failure.
- Low: restart recovery releases a lost fee bump's inputs but not its outputs (src/channel/mod.rs:193)
When a splice survives a restart, reconcile only unlocks unclaimed_inputs, whereas the other release paths use release_contribution, which also frees
outputs.If a fee bump that re-selected its inputs is lost, its new change address stays marked as used. Nothing else recovers it at that point: the kept intent
settles without a release, and LDK's DiscardFunding may not cover a bump it never persisted. The cost is a skipped address rather than lost funds.Possible fix: use unclaimed_parts here and release both parts.
- Low: two comments describe an event LDK doesn't emit (src/channel/mod.rs:72, :561)
Both justify the submit lock partly by the failure event of a synchronously rejected hand-off. LDK's funding_contributed docs and code say a rejection emits
no SpliceNegotiationFailed, only DiscardFunding. The lock is still justified by the other races listed there, but these comments send readers after a race
that can't happen.
|
I also asked Claude whether it sees a way to keep this PR less invasive, and it seems to think so:
Probably the items on the 'What you give up' section are the debatable parts here? |
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. 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. Written with the help of an AI tool. 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 splice would have done. Both matter for deciding what to do next — a splice lost to a disconnect can simply be re-issued, while a failed fee bump leaves the splice it meant to bump in flight, possibly to 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 splice's parameters — the amount a splice-in would have added or the outputs a splice-out would have paid — read from the failed contribution LDK reports with the failure. A fee bump rebuilds the contribution of the splice it bumps, so a failed bump describes that splice, less what the higher fee takes from a splice-in of all available funds. 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. 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 splice-in's amount -- 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 reaches the user through the failure LDK persisted at shutdown and replays at startup -- once, with what the splice would have done. The restart tests cover a dropped splice-out, reported with its output, after which a further restart stays silent; a dropped fee bump, reported with the amount of the splice-in it meant to replace; and a splice queued behind a pending one of this node, reported with its own amount rather than the pending splice's. In each, the application re-initiates and the splice completes. Two more cases guard the payment records around these: a second splice submitted right after a zero-conf lock gets a record of its own rather than the record of the splice that just locked, and a splice confirmed while its node was offline keeps exactly one payment record, under the id recorded at signing, across the restart. 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 the splice-in's amount. Written with the help of an AI tool. Co-Authored-By: Elias Rohrer <dev@tnull.de> 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, as is the funding transaction this node builds for a channel it opens. 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. Written with the help of an AI tool. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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. Written with the help of an AI tool. 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>
|
Discussed a bit offline. We'll simplify as noted above. For finding 1, we'll fix this upstream. See Claude summary below for details and issue number: 🤖 Restructured as we discussed. Dropped the splice intent and everything built on it: the persist-until-lock commit, the startup unlock, the take-back commit, and the
On the "what you give up" list, yes, those were the decisions. The Of your eight items from 2026-10-29, only 3 and 7 touch code that is still here. 3 stays as answered. 7 is resolved: Kept: the abort on signing failure, the two facts commits (admit a round this node signs however full the store is; let the funding output alone identify a funding), and the Your second and third findings are moot with the tracker gone. The first stands against the abort commit, and the cause is in LDK: |
957275d to
4d9d872
Compare
Persist every application-initiated splice until LDK is guaranteed to remember it, so that a startup pass can release wallet inputs held for splices lost to a crash, and failure events can say which operation failed and why.
LDK persists a splice only once negotiation reaches
AwaitingSignatures; rounds short of that are failed on reload through theSpliceNegotiationFailed/DiscardFundingevents aChannelManagerwrite records alongside itself. But a splice initiated after the last manager write leaves no trace at all — no event ever comes. Once #1037 lands and splice contributions lock wallet inputs, a splice lost in that window would leave its inputs reserved forever, with nothing left running that knows to release them.What changes
Intent record. The splice is written into its pending payment record (from #1079) before the contribution is handed to LDK, and settled once the splice locks, its failure surfaces, or its channel closes. Each splice gets a record of its own — a channel may carry several, a pending splice and the splices queued behind it. Only a fee bump joins an existing record, replacing the intent of the round it replaces; a submission is refused while the channel carries an intent anchored at another funding.
Signing-time record, tracked. #1057 records each negotiated round into the funding payment while handling
Event::FundingTransactionReadyForSigning, beforefunding_transaction_signedreleases ourtx_signatures. Here that write goes through the tracker, under the lock that serializes splice submissions, and the round is filed under the intent whose contribution it carries, so the payment moves from intent to funding under one id.Abort on signing failure. If signing fails or LDK refuses the signed transaction, the splice is aborted through
cancel_funding_contributed. LDK'sDiscardFundingandSpliceNegotiationFailedthen release the contribution, settle the intent, and (through #1057's handling) take back the signing-time record of the refused round. Previously the handler logged and left the negotiation dangling.Failure events.
Event::SpliceNegotiationFailedgains the failure'sreason(mirroring LDK's negotiation-failure reasons) and the initiating request'sparameters(In/Out/FeeBump).reasonis always set;parametersisNonewhen the failure matches no recorded intent. Both are new odd TLVs, so events written by v0.7.0 still read and v0.7.0 readers skip them.Startup reconciliation. Runs before syncing and event processing, checking each record against the reloaded channel:
AwaitingSignatures, resumed on reconnect, orNegotiatedwith our contribution — keeps its record. Only inputs no surviving candidate spends are released; a fee bump lost with the restart may have reserved extras.Recovery is silent — the initiating call already returned and the channel shows no pending splice, so no failure event is fabricated. The failures LDK replays on reload are consumed before the node is marked running.
Notes for reviewers
DiscardFundinghandler that releases a discarded contribution's inputs, and a companion commit proposed there makes coin selection stage its locks so they reach disk only with the intent record.funding_contributedLDK refuses as a failed splice also queues aSpliceNegotiationFailed, so the call both returns an error and surfaces a failure event. For a splice-in or splice-out the event matches no intent by then; for a fee bump built from the round's own inputs it matches the intent of the round being bumped and settles it early, while the round itself is unaffected.Last in the PR stack replacing #930 for this release, per the discussion there; stacked on #1057 → #1079 (funding payment model).
Developed with assistance from Claude Code.