lnwire: add LocalNonces TLV structure and tests
What changed, and why it matters
This commit adds a new data structure and wire format for exchanging groups of cryptographic nonces used in multi-signature Lightning transactions. It is purely preparatory code: it defines how to pack and unpack the data and includes unit tests. There is no actual use of this structure in message handling yet, and nothing in the commit suggests a security bug or fix.
No security action required. Treat as normal feature/refactoring code. Monitor follow-up commits that wire this TLV record into channel reestablish or splice messages for potential parsing or nonce-reuse concerns.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces lnwire.LocalNoncesData, an optional TLV record (type 22) that maps transaction IDs to MuSig2 public nonces. It provides deterministic encoding (sorted by TXID), decoding with length validation, a 16-entry cap, duplicate-TXID rejection, and comprehensive unit tests. The commit message explicitly states this is preparation for future BOLT spec changes around splicing. No integration into wire messages or consensus-critical paths is present in the diff.
Changed components
lnwire/local_nonces.golnwire/local_nonces_test.goInspect captured patch +384 / −0
diff --git a/lnwire/local_nonces.go b/lnwire/local_nonces.go
new file mode 100644
index 0000000..c401da2
--- /dev/null
+++ b/lnwire/local_nonces.go
@@ -0,0 +1,187 @@
+package lnwire
+
+import (
+ "bytes"
+ "io"
+ "sort"
+
+ "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+// LocalNoncesRecordTypeDef is the concrete TLV record type for LocalNoncesData.
+// This is type 22 as defined in the BOLT specification for channel
+// reestablish.
+type LocalNoncesRecordTypeDef = tlv.TlvType22
+
+// localNonceEntry holds a single TXID -> Musig2Nonce mapping.
+type localNonceEntry struct {
+ txid chainhash.Hash
+
+ nonce Musig2Nonce
+}
+
+// LocalNoncesData is the core data structure holding the map of nonces.
+type LocalNoncesData struct {
+ NoncesMap map[chainhash.Hash]Musig2Nonce
+}
+
+// NewLocalNoncesData creates a new LocalNoncesData with an initialized map.
+func NewLocalNoncesData() *LocalNoncesData {
+ return &LocalNoncesData{
+ NoncesMap: make(map[chainhash.Hash]Musig2Nonce),
+ }
+}
+
+// Record implements the tlv.RecordProducer interface.
+func (l *LocalNoncesData) Record() tlv.Record {
+ return tlv.MakeDynamicRecord(
+ (LocalNoncesRecordTypeDef)(nil).TypeVal(),
+ l,
+ func() uint64 {
+ if len(l.NoncesMap) == 0 {
+ return 0
+ }
+
+ numEntries := len(l.NoncesMap)
+ entrySize := chainhash.HashSize + musig2.PubNonceSize
+
+ return uint64(numEntries * entrySize)
+ },
+ encodeLocalNoncesData,
+ decodeLocalNoncesData,
+ )
+}
+
+// encodeLocalNoncesData implements the tlv.Encoder for LocalNoncesData.
+func encodeLocalNoncesData(w io.Writer, val any, _ *[8]byte) error {
+ data, ok := val.(*LocalNoncesData)
+ if !ok {
+ return tlv.NewTypeForEncodingErr(val, "*lnwire.LocalNoncesData")
+ }
+
+ var sortedEntries []localNonceEntry
+
+ if len(data.NoncesMap) > 0 {
+ sortedEntries = make([]localNonceEntry, 0, len(data.NoncesMap))
+ for txid, nonce := range data.NoncesMap {
+ sortedEntries = append(
+ sortedEntries, localNonceEntry{
+ txid: txid, nonce: nonce,
+ },
+ )
+ }
+
+ sort.Slice(sortedEntries, func(i, j int) bool {
+ return bytes.Compare(
+ sortedEntries[i].txid[:],
+ sortedEntries[j].txid[:],
+ ) < 0
+ })
+ }
+
+ for _, entry := range sortedEntries {
+ if _, err := w.Write(entry.txid[:]); err != nil {
+ return err
+ }
+ if _, err := w.Write(entry.nonce[:]); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// decodeLocalNoncesData implements the tlv.Decoder for LocalNoncesData.
+func decodeLocalNoncesData(r io.Reader, val any, _ *[8]byte,
+ recordLen uint64) error {
+
+ l, ok := val.(*LocalNoncesData)
+ if !ok {
+ return tlv.NewTypeForDecodingErr(
+ val, "*lnwire.LocalNoncesData", recordLen, 0,
+ )
+ }
+
+ if l.NoncesMap == nil {
+ l.NoncesMap = make(map[chainhash.Hash]Musig2Nonce)
+ }
+
+ // If recordLen is 0, it means an empty TLV value, which is valid for
+ // 0 entries. Ensure the map is empty in this case.
+ if recordLen == 0 {
+ // Clear if it had previous entries.
+ if len(l.NoncesMap) > 0 {
+ l.NoncesMap = make(map[chainhash.Hash]Musig2Nonce)
+ }
+
+ return nil
+ }
+
+ // Each entry is a fixed size: TXID (32 bytes) + Nonce (66 bytes). We
+ // can use this to compute the number of expected entries and perform a
+ // sanity check while we're at it.
+ const entrySize = chainhash.HashSize + musig2.PubNonceSize
+ if recordLen%entrySize != 0 {
+ return tlv.NewTypeForDecodingErr(
+ l, "lnwire.LocalNoncesData (record length not "+
+ "evenly divisible by entry size)",
+ recordLen, 0,
+ )
+ }
+
+ numEntries := recordLen / entrySize
+
+ // Cap the number of entries to a reasonable limit. In practice,
+ // this is the number of active splices which is directly limited
+ // by implementations.
+ const maxEntries = 16
+ if numEntries > maxEntries {
+ return tlv.NewTypeForDecodingErr(
+ l, "lnwire.LocalNoncesData (too many entries)",
+ recordLen, 0,
+ )
+ }
+
+ // Prepare the map for new entries. Using 'make' here also clears any
+ // existing entries if the LocalNoncesData instance is being reused.
+ l.NoncesMap = make(map[chainhash.Hash]Musig2Nonce, numEntries)
+
+ for i := uint64(0); i < numEntries; i++ {
+ var (
+ txid chainhash.Hash
+ nonce Musig2Nonce
+ )
+
+ if _, err := io.ReadFull(r, txid[:]); err != nil {
+ return err
+ }
+ if _, err := io.ReadFull(r, nonce[:]); err != nil {
+ return err
+ }
+
+ if _, exists := l.NoncesMap[txid]; exists {
+ return tlv.NewTypeForDecodingErr(
+ l, "lnwire.LocalNoncesData (duplicate txid)",
+ recordLen, 0,
+ )
+ }
+
+ l.NoncesMap[txid] = nonce
+ }
+
+ return nil
+}
+
+var _ tlv.RecordProducer = (*LocalNoncesData)(nil)
+
+// OptLocalNonces is a type alias for the optional TLV structure.
+type OptLocalNonces = fn.Option[LocalNoncesData]
+
+// SomeLocalNonces is a helper function to create an fn.Option[LocalNoncesData]
+// with the given data.
+func SomeLocalNonces(data LocalNoncesData) OptLocalNonces {
+ return fn.Some(data)
+}
diff --git a/lnwire/local_nonces_test.go b/lnwire/local_nonces_test.go
new file mode 100644
index 0000000..8eae436
--- /dev/null
+++ b/lnwire/local_nonces_test.go
@@ -0,0 +1,197 @@
+package lnwire
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
+ "github.com/stretchr/testify/require"
+)
+
+// makeTestNonce creates a Musig2Nonce for testing.
+func makeTestNonce(val byte) Musig2Nonce {
+ var nonce Musig2Nonce
+ for i := range nonce {
+ nonce[i] = val
+ }
+
+ return nonce
+}
+
+// makeTestTxId creates a chainhash.Hash for testing.
+func makeTestTxId(val byte) chainhash.Hash {
+ var txid chainhash.Hash
+ for i := range txid {
+ txid[i] = val
+ }
+
+ return txid
+}
+
+// makeEncodedEntry encodes a single txid/nonce pair for testing.
+func makeEncodedEntry(txidVal, nonceVal byte) []byte {
+ entry := make([]byte, chainhash.HashSize+len(Musig2Nonce{}))
+ txid := makeTestTxId(txidVal)
+ nonce := makeTestNonce(nonceVal)
+
+ copy(entry[:chainhash.HashSize], txid[:])
+ copy(entry[chainhash.HashSize:], nonce[:])
+
+ return entry
+}
+
+// TestLocalNoncesDataEncodeDecodeValue tests that LocalNoncesData can be
+// properly encoded and decoded for various map configurations.
+func TestLocalNoncesDataEncodeDecodeValue(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ inputData *LocalNoncesData
+ }{
+ {
+ name: "nil map",
+ inputData: &LocalNoncesData{NoncesMap: nil},
+ },
+ {
+ name: "empty map",
+ inputData: NewLocalNoncesData(),
+ },
+ {
+ name: "one entry",
+ inputData: &LocalNoncesData{
+ NoncesMap: map[chainhash.Hash]Musig2Nonce{
+ makeTestTxId(1): makeTestNonce(1),
+ },
+ },
+ },
+ {
+ name: "multiple entries unsorted",
+ inputData: &LocalNoncesData{
+ NoncesMap: map[chainhash.Hash]Musig2Nonce{
+ makeTestTxId(3): makeTestNonce(3),
+ makeTestTxId(1): makeTestNonce(1),
+ makeTestTxId(2): makeTestNonce(2),
+ },
+ },
+ },
+ {
+ name: "multiple entries already sorted by key",
+ inputData: &LocalNoncesData{
+ NoncesMap: map[chainhash.Hash]Musig2Nonce{
+ makeTestTxId(1): makeTestNonce(1),
+ makeTestTxId(2): makeTestNonce(2),
+ makeTestTxId(3): makeTestNonce(3),
+ },
+ },
+ },
+ }
+
+ for _, test := range tests {
+ test := test
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ b bytes.Buffer
+ buf [8]byte
+ )
+
+ err := encodeLocalNoncesData(&b, test.inputData, &buf)
+ require.NoError(t, err)
+
+ decodedData := NewLocalNoncesData()
+ err = decodeLocalNoncesData(
+ bytes.NewReader(b.Bytes()), decodedData, &buf,
+ uint64(b.Len()),
+ )
+ require.NoError(t, err)
+
+ if len(test.inputData.NoncesMap) == 0 &&
+ len(decodedData.NoncesMap) == 0 {
+
+ return
+ }
+
+ require.Equal(
+ t, test.inputData.NoncesMap,
+ decodedData.NoncesMap,
+ )
+ })
+ }
+}
+
+// TestLocalNoncesDataDecodeFailuresValue tests that decoding fails
+// appropriately for various invalid input scenarios.
+func TestLocalNoncesDataDecodeFailuresValue(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ valueBytes []byte
+ length uint64
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "partial entry (1 byte value)",
+ valueBytes: []byte{0x01},
+ length: 1,
+ expectError: true,
+ errorContains: "not evenly divisible",
+ },
+ {
+ name: "partial entry (99 bytes)",
+ valueBytes: make([]byte, 99),
+ length: 99,
+ expectError: true,
+ errorContains: "not evenly divisible",
+ },
+ {
+ name: "one complete entry",
+ valueBytes: make([]byte, 98),
+ length: 98,
+ expectError: false,
+ },
+ {
+ name: "empty value",
+ valueBytes: []byte{},
+ length: 0,
+ expectError: false,
+ },
+ {
+ name: "duplicate txid",
+ valueBytes: append(
+ makeEncodedEntry(1, 2),
+ makeEncodedEntry(1, 3)...,
+ ),
+ length: uint64(
+ 2 * (chainhash.HashSize + len(Musig2Nonce{})),
+ ),
+ expectError: true,
+ errorContains: "duplicate txid",
+ },
+ }
+
+ for _, test := range tests {
+ test := test
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+
+ var buf [8]byte
+
+ decodedData := NewLocalNoncesData()
+ err := decodeLocalNoncesData(
+ bytes.NewReader(test.valueBytes), decodedData,
+ &buf, test.length,
+ )
+
+ if test.expectError {
+ require.Error(t, err)
+ require.Contains(t, err.Error(), test.errorContains)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
Why this scored 15/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.