lnwallet/chancloser: record the remote close output only when accepted
What changed, and why it matters
This change fixes a bookkeeping bug in how LND records the other party's preferred closing address during a channel close. Previously, a late or unexpected Shutdown message could overwrite the correct address with one from a message that was rejected. The fix only saves the address when the message is actually accepted. The bug currently has no downstream effect because the recorded value is not used until after negotiation finishes, but it makes the close record more accurate.
Apply the patch. It is a low-risk correctness fix. No immediate incident response is required, but operators should keep LND up to date. Review whether any monitoring or reporting that consumes remoteCloseOutput could have shown inconsistent close addresses prior to this fix.
Security signals we found
State variable written before validation
Rejected message could overwrite accepted close output
Fix prevents stale/incorrect close metadata
No active exploitation path identified in diff
Evidence from the diff
In lnwallet/chancloser/chancloser.go, ReceiveShutdown previously assigned c.remoteCloseOutput before checking whether the incoming lnwire.Shutdown could be acted upon. If the message arrived in a state where it would be rejected (e.g., after negotiation already completed), the rejected message’s address/dust/balance data would overwrite the previously settled close output. The patch moves the assignment into the two branches that accept the Shutdown (the initial shutdown path and the already-initiated shutdown path), so c.remoteCloseOutput is only updated from accepted messages. The field is not consumed until ClosingTx reports the negotiation finished, so the practical effect today is limited to the reported close output.
Changed components
lnwallet/chancloser/chancloser.goReceiveShutdown methodChannel close negotiation stateInspect captured patch +7 / −2
### lnwallet/chancloser/chancloser.go
@@ -598,10 +598,13 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
noShutdown := fn.None[lnwire.Shutdown]()
// We'll track their remote close output, even if it's dust in BTC
- // terms, it might still carry value in custom channel terms.
+ // terms, it might still carry value in custom channel terms. We only
+ // commit it to our state in the branches below that go on to accept the
+ // message: a Shutdown that shows up at a point where we can't act on it
+ // has no business overwriting an output we already settled on.
_, dustAmt := c.cfg.Channel.RemoteBalanceDust()
_, remoteBalance := c.cfg.Channel.CommitBalances()
- c.remoteCloseOutput = fn.Some(types.CloseOutput{
+ remoteCloseOutput := fn.Some(types.CloseOutput{
Amt: remoteBalance,
DustLimit: dustAmt,
PkScript: msg.Address,
@@ -648,6 +651,7 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
// address. We'll use this when we craft the closure
// transaction.
c.remoteDeliveryScript = msg.Address
+ c.remoteCloseOutput = remoteCloseOutput
// We'll generate a shutdown message of our own to send across
// the wire.
@@ -697,6 +701,7 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
// address, we'll record their preferred delivery closing
// script.
c.remoteDeliveryScript = msg.Address
+ c.remoteCloseOutput = remoteCloseOutput
// At this point, we can now start the fee negotiation state, by
// constructing and sending our initial signature for what weWhy this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.