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