lnwallet: add support for local nonces map in revoke_and_ack
What changed, and why it matters
This commit updates the LND Lightning wallet so that RevokeAndAck messages can carry a new map of nonces keyed by funding transaction ID, while still accepting the older single-nonce field. It is a backwards-compatibility and protocol-extension change for Taproot channels, not a fix for an active bug or exploit. The code now validates that at least one nonce field is present and that any map entry matches the channel's funding transaction.
Treat as a routine protocol-extension/backwards-compatibility patch. Review the related BOLT specification change (bolts#995) for completeness, and ensure the new LocalNonces wire type is validated consistently across all consumers. No urgent security action is indicated by this commit alone.
Security signals we found
Input validation added: requires at least one nonce field in RevokeAndAck
Map-key validation added: LocalNonces entry must match channel funding txid
Backwards-compatible handling of legacy LocalNonce field retained
Taproot/MuSig2 session refresh now depends on validated nonce extraction
No memory-safety, cryptographic, or authorization flaw evident in diff
Evidence from the diff
The patch adds extractRevokeAndAckNonce, which prefers the new LocalNonces map over the legacy LocalNonce field when refreshing the remote MuSig2 session. It populates both fields when generating a revocation, keys the map by the channel funding txid, and rejects messages where neither field is set or where the map lacks an entry for the funding txid. A new test file exercises both fields populated, map keying, legacy-only reception, empty-map failure, and missing-both failure.
Changed components
lnwallet/channel.golnwallet/channel_revoke_nonces_test.goRevokeCurrentCommitmentReceiveRevocationgenerateRevocationTaproot channel MuSig2 nonce handlingInspect captured patch +312 / −4
diff --git a/lnwallet/channel.go b/lnwallet/channel.go
index 2f0cfae..eef75be 100644
--- a/lnwallet/channel.go
+++ b/lnwallet/channel.go
@@ -5804,6 +5804,47 @@ func (lc *LightningChannel) RevokeCurrentCommitment() (*lnwire.RevokeAndAck,
return revocationMsg, newCommitment.Htlcs, finalHtlcs, nil
}
+// extractRevokeAndAckNonce extracts the next verification nonce from a
+// RevokeAndAck message. It prioritizes the new LocalNonces field over the
+// legacy LocalNonce field for backwards compatibility. The fundingTxid is used
+// to validate the nonce map key per the spec (bolts#995). If neither field is
+// present, an error is returned.
+func extractRevokeAndAckNonce(revMsg *lnwire.RevokeAndAck,
+ fundingTxid chainhash.Hash) (lnwire.Musig2Nonce, error) {
+
+ switch {
+ case revMsg.LocalNonces.IsSome():
+ noncesData, err := revMsg.LocalNonces.UnwrapOrErr(
+ fmt.Errorf("invalid LocalNonces"),
+ )
+ if err != nil {
+ return lnwire.Musig2Nonce{}, err
+ }
+
+ // Per the spec, the nonce map key must match the channel's
+ // funding txid. Validate this before using the nonce.
+ nonce, ok := noncesData.NoncesMap[fundingTxid]
+ if ok {
+ return nonce, nil
+ }
+
+ return lnwire.Musig2Nonce{}, fmt.Errorf("no nonce for "+
+ "funding txid %v in revoke_and_ack", fundingTxid)
+
+ case revMsg.LocalNonce.IsSome():
+ localNonce, err := revMsg.LocalNonce.UnwrapOrErrV(errNoNonce)
+ if err != nil {
+ return lnwire.Musig2Nonce{}, err
+ }
+
+ return localNonce, nil
+
+ default:
+ return lnwire.Musig2Nonce{}, fmt.Errorf("remote " +
+ "verification nonce not sent")
+ }
+}
+
// ReceiveRevocation processes a revocation sent by the remote party for the
// lowest unrevoked commitment within their commitment chain. We receive a
// revocation either during the initial session negotiation wherein revocation
@@ -5989,15 +6030,16 @@ func (lc *LightningChannel) ReceiveRevocation(revMsg *lnwire.RevokeAndAck) (
// Now that we have a new verification nonce from them, we can refresh
// our remote musig2 session which allows us to create another state.
if lc.channelState.ChanType.IsTaproot() {
- localNonce, err := revMsg.LocalNonce.UnwrapOrErrV(errNoNonce)
+ fundingTxid := lc.channelState.FundingOutpoint.Hash
+ localNonce, err := extractRevokeAndAckNonce(
+ revMsg, fundingTxid,
+ )
if err != nil {
return nil, nil, err
}
session, err := lc.musigSessions.RemoteSession.Refresh(
- &musig2.Nonces{
- PubNonce: localNonce,
- },
+ &musig2.Nonces{PubNonce: localNonce},
)
if err != nil {
return nil, nil, err
@@ -9492,9 +9534,21 @@ func (lc *LightningChannel) generateRevocation(height uint64) (*lnwire.RevokeAnd
if err != nil {
return nil, err
}
+
+ // Populate the legacy LocalNonce field for backwards
+ // compatibility.
revocationMsg.LocalNonce = lnwire.SomeMusig2Nonce(
nextVerificationNonce.PubNonce,
)
+
+ // Also populate the new LocalNonces field. For revoke and ack,
+ // we'll key our nonce by the funding txid.
+ fundingTxid := lc.channelState.FundingOutpoint.Hash
+ noncesMap := make(map[chainhash.Hash]lnwire.Musig2Nonce)
+ noncesMap[fundingTxid] = nextVerificationNonce.PubNonce
+ revocationMsg.LocalNonces = lnwire.SomeLocalNonces(
+ lnwire.LocalNoncesData{NoncesMap: noncesMap},
+ )
}
return revocationMsg, nil
diff --git a/lnwallet/channel_revoke_nonces_test.go b/lnwallet/channel_revoke_nonces_test.go
new file mode 100644
index 0000000..a203b4d
--- /dev/null
+++ b/lnwallet/channel_revoke_nonces_test.go
@@ -0,0 +1,254 @@
+package lnwallet
+
+import (
+ "testing"
+
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
+ "github.com/lightningnetwork/lnd/channeldb"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
+)
+
+// extractRevocationNonce is a helper function to extract the nonce from a
+// RevokeAndAck message, preferring LocalNonces over LocalNonce.
+func extractRevocationNonce(t *testing.T,
+ msg *lnwire.RevokeAndAck) lnwire.Musig2Nonce {
+
+ if msg.LocalNonces.IsSome() {
+ noncesData := msg.LocalNonces.UnwrapOrFail(t)
+
+ for _, nonce := range noncesData.NoncesMap {
+ return nonce
+ }
+
+ // If map is empty, fall back to LocalNonce.
+ }
+
+ return msg.LocalNonce.UnwrapOrFailV(t)
+}
+
+// revokeModifier is a functional option to modify a RevokeAndAck message.
+type revokeModifier func(*lnwire.RevokeAndAck)
+
+// generateAndProcessRevocation creates fresh channels, performs a state
+// transition to generate a RevokeAndAck message, optionally modifies it, and
+// processes it. Returns the revocation message and channels for further
+// testing.
+func generateAndProcessRevocation(t *testing.T, chanType channeldb.ChannelType,
+ modifier revokeModifier) (
+ *lnwire.RevokeAndAck, *LightningChannel, *LightningChannel, error) {
+
+ aliceChannel, bobChannel, err := CreateTestChannels(t, chanType)
+ require.NoError(t, err)
+
+ aliceNewCommit, err := aliceChannel.SignNextCommitment(ctxb)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ err = bobChannel.ReceiveNewCommitment(aliceNewCommit.CommitSigs)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ bobRevocation, _, _, err := bobChannel.RevokeCurrentCommitment()
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ // Apply the modifier if provided, we'll use this to mutate things to
+ // test our logic.
+ if modifier != nil {
+ modifier(bobRevocation)
+ }
+
+ _, _, err = aliceChannel.ReceiveRevocation(bobRevocation)
+
+ return bobRevocation, aliceChannel, bobChannel, err
+}
+
+// TestRevokeAndAckTaprootLocalNonces tests that the RevokeAndAck message
+// properly populates and parses both the legacy LocalNonce field and the new
+// LocalNonces field for taproot channels. This ensures backwards compatibility
+// while supporting future splice operations that may require multiple nonces.
+func TestRevokeAndAckTaprootLocalNonces(t *testing.T) {
+ t.Parallel()
+
+ chanType := channeldb.SimpleTaprootFeatureBit
+
+ t.Run("both fields populated", func(t *testing.T) {
+ t.Parallel()
+
+ revMsg, _, _, err := generateAndProcessRevocation(
+ t, chanType, nil,
+ )
+ require.NoError(t, err)
+
+ // Verify both fields are populated.
+ require.True(
+ t, revMsg.LocalNonce.IsSome(),
+ "LocalNonce should be populated",
+ )
+ require.True(
+ t, revMsg.LocalNonces.IsSome(),
+ "LocalNonces should be populated",
+ )
+ })
+
+ t.Run("nonces match between fields", func(t *testing.T) {
+ t.Parallel()
+
+ revMsg, _, bobChannel, err := generateAndProcessRevocation(
+ t, chanType, nil,
+ )
+ require.NoError(t, err)
+
+ // Verify that the noncee map field is populated and is keyed
+ // properly.
+ noncesData := revMsg.LocalNonces.UnwrapOrFail(t)
+ require.Len(
+ t, noncesData.NoncesMap, 1,
+ "LocalNonces map should contain exactly one entry",
+ )
+ var mapNonce lnwire.Musig2Nonce
+ for txid, nonce := range noncesData.NoncesMap {
+ mapNonce = nonce
+
+ // Verify it's keyed by funding txid.
+ //
+ //nolint:ll
+ fundingTxid := bobChannel.channelState.FundingOutpoint.Hash
+ require.Equal(
+ t, fundingTxid, txid,
+ "Nonce should be keyed by funding txid",
+ )
+ break
+ }
+
+ legacyNonce := revMsg.LocalNonce.UnwrapOrFailV(t)
+
+ // Both nonces should match.
+ require.Equal(
+ t, legacyNonce, mapNonce,
+ "Nonces in LocalNonce and LocalNonces should match",
+ )
+ extractedNonce := extractRevocationNonce(t, revMsg)
+ require.Equal(
+ t, legacyNonce, extractedNonce,
+ "Extracted nonce should match legacy nonce",
+ )
+ })
+
+ t.Run("receive with only LocalNonces field", func(t *testing.T) {
+ t.Parallel()
+
+ // We need to know the funding txid to use as the map key,
+ // so we first create channels to get it, then use a
+ // modifier that moves the nonce to the correct map key.
+ aliceChannel, bobChannel, err := CreateTestChannels(
+ t, chanType,
+ )
+ require.NoError(t, err)
+
+ fundingTxid := aliceChannel.channelState.FundingOutpoint.Hash
+
+ aliceNewCommit, err := aliceChannel.SignNextCommitment(ctxb)
+ require.NoError(t, err)
+
+ err = bobChannel.ReceiveNewCommitment(
+ aliceNewCommit.CommitSigs,
+ )
+ require.NoError(t, err)
+
+ bobRevocation, _, _, err := bobChannel.RevokeCurrentCommitment()
+ require.NoError(t, err)
+
+ // Move the nonce from LocalNonce to LocalNonces map,
+ // keyed by the actual funding txid.
+ legacyNonce := bobRevocation.LocalNonce.UnwrapOrFailV(t)
+ noncesMap := make(
+ map[chainhash.Hash]lnwire.Musig2Nonce,
+ )
+ noncesMap[fundingTxid] = legacyNonce
+ bobRevocation.LocalNonces = lnwire.SomeLocalNonces(
+ lnwire.LocalNoncesData{NoncesMap: noncesMap},
+ )
+ bobRevocation.LocalNonce = lnwire.OptMusig2NonceTLV{}
+
+ _, _, err = aliceChannel.ReceiveRevocation(bobRevocation)
+ require.NoError(
+ t, err,
+ "should successfully process revocation "+
+ "with only LocalNonces",
+ )
+ })
+
+ t.Run("receive with only LocalNonce field (legacy peer)", func(t *testing.T) {
+ t.Parallel()
+
+ // Modify the message to clear the LocalNonces field.
+ clearLocalNonces := func(rev *lnwire.RevokeAndAck) {
+ rev.LocalNonces = lnwire.OptLocalNonces{}
+ }
+
+ // This should should successfully process with only LocalNonce
+ // (backwards compat).
+ _, _, _, err := generateAndProcessRevocation(
+ t, chanType, clearLocalNonces,
+ )
+ require.NoError(
+ t, err,
+ "should successfully process "+
+ "revocation with only LocalNonce for "+
+ "backwards compatibility",
+ )
+
+ })
+
+ t.Run("error when LocalNonces map is empty", func(t *testing.T) {
+ t.Parallel()
+
+ // Modify the message to have empty LocalNonces map and no
+ // LocalNonce.
+ emptyMap := func(rev *lnwire.RevokeAndAck) {
+ rev.LocalNonce = lnwire.OptMusig2NonceTLV{}
+ rev.LocalNonces = lnwire.SomeLocalNonces(
+ lnwire.LocalNoncesData{
+ NoncesMap: make(
+ map[chainhash.Hash]lnwire.Musig2Nonce,
+ ),
+ },
+ )
+ }
+
+ // We should get an error when the LocalNonces map is empty.
+ _, _, _, err := generateAndProcessRevocation(
+ t, chanType, emptyMap,
+ )
+ require.Error(
+ t, err, "Should error when LocalNonces map is empty",
+ )
+ require.Contains(
+ t, err.Error(), "no nonce for funding txid",
+ )
+ })
+
+ t.Run("error when both fields missing", func(t *testing.T) {
+ t.Parallel()
+
+ clearBoth := func(rev *lnwire.RevokeAndAck) {
+ rev.LocalNonce = lnwire.OptMusig2NonceTLV{}
+ rev.LocalNonces = lnwire.OptLocalNonces{}
+ }
+
+ // If both fields are missing, we should get an error.
+ _, _, _, err := generateAndProcessRevocation(
+ t, chanType, clearBoth,
+ )
+ require.Error(
+ t, err, "Should error when both fields are missing",
+ )
+ require.Contains(
+ t, err.Error(), "remote verification nonce not sent",
+ )
+ })
+}
Why this scored 34/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.