From 6103db9fae02912b2253e9002f4baf30bbbf384f Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 16 Apr 2026 21:54:02 +0200 Subject: [PATCH] htlcswitch+lnwallet: log signer-side commit tx on invalid commit sig When a peer rejects our CommitSig with an Error message, the InvalidCommitSigError it sends back contains the commitment transaction the peer built on their side. We never logged the commitment transaction we signed, making it impossible to determine whether the failure was caused by a state divergence (the two peers derived different transactions) or a genuine signing bug. --- htlcswitch/link.go | 20 ++++++++++++++++++++ lnwallet/channel.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 4dbfa522373..4e5f60ea614 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -4553,6 +4553,26 @@ func (l *channelLink) processRemoteError(msg *lnwire.Error) { // Error received from remote, MUST fail channel, but should only print // the contents of the error message if all characters are printable // ASCII. + + // Before tearing the link down, attempt to re-derive the commitment + // transaction and sighash we last computed for the remote party. If + // the peer sent a rejected-commitment error, logging our view alongside + // theirs makes it possible to compare the two transactions + // byte-for-byte and immediately identify any discrepancy. + commitTx, err := l.channel.BuildLastSignedRemoteCommitTx() + switch { + case err != nil: + l.log.Errorf("ChannelPoint(%v): unable to re-derive last "+ + "signed remote commit tx: %v", + l.channel.ChannelPoint(), err) + + case commitTx != nil: + l.log.Errorf("ChannelPoint(%v): signer-side commit_tx=%x "+ + "(compare with commit_tx in the peer error if present "+ + "to identify any state divergence)", + l.channel.ChannelPoint(), commitTx) + } + l.failf( // TODO(halseth): we currently don't fail the channel // permanently, as there are some sync issues with other diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 484a019da5b..e77ba95b286 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -4303,6 +4303,40 @@ func (lc *LightningChannel) SignNextCommitment( }, nil } +// BuildLastSignedRemoteCommitTx returns the serialized commitment transaction +// most recently signed for the remote party. The remote commit chain holds +// all commitments we have signed but the remote has not yet revoked. Its tip +// is the newest entry — added by the last SignNextCommitment call — and is +// only removed once the remote sends a RevokeAndAck for the preceding +// commitment. On the error path the remote sends an Error instead of a +// RevokeAndAck, so the tip is guaranteed to be the commitment the remote +// rejected. +// +// This method is intended exclusively for error-path diagnostics. When the +// remote rejects our CommitSig, logging our commit TX alongside the one +// embedded in the peer's error message makes it possible to determine +// immediately whether the two sides derived different transactions (state +// divergence) or agreed on the same transaction but the signature still +// failed (signing bug). +func (lc *LightningChannel) BuildLastSignedRemoteCommitTx() ([]byte, error) { + lc.RLock() + defer lc.RUnlock() + + // tip() is the back of the list — the most recently signed + // commitment. + lastCommit := lc.commitChains.Remote.tip() + if lastCommit == nil { + return nil, nil + } + + var buf bytes.Buffer + if err := lastCommit.txn.Serialize(&buf); err != nil { + return nil, fmt.Errorf("serializing remote commit tx: %w", err) + } + + return buf.Bytes(), nil +} + // resignMusigCommit is used to resign a commitment transaction for taproot // channels when we need to retransmit a signature after a channel reestablish // message. Taproot channels use musig2, which means we must use fresh nonces