channeldb: add V2 (taproot) waiting proof codec support
What changed, and why it matters
This commit adds database support for a new kind of channel-announcement proof used by upcoming taproot channels. It does not change any live network behavior; it only prepares the code so future taproot features can store their proofs safely. Existing V1 proofs keep working exactly as before, and the gossiper is explicitly restricted to V1 proofs only.
No immediate action required. Treat as routine feature groundwork. When follow-up commits wire V2 gossiper handling, review that path carefully for signature verification, nonce handling, and proof reconstruction.
Security signals we found
Codec/schema change for future taproot channel proofs
Gossiper explicitly rejects non-V1 waiting proofs, preventing accidental processing of unimplemented V2 proofs
Key isolation between V1 and V2 proofs prevents same-key collisions
No live code path creates V2 proofs yet, per commit message
Evidence from the diff
The patch refactors channeldb.WaitingProof from embedding lnwire.AnnounceSignatures1 directly to holding a new WaitingProofInner interface. It adds V1WaitingProof and V2WaitingProof implementations, plus a type-prefix byte in Encode/Decode so either variant can be persisted. A new NewV2WaitingProof constructor stores an AnnounceSignatures2 plus an optional combined MuSig2 nonce. The gossiper adds a type assertion to V1WaitingProof and returns an error for anything else, so no V2 proof is processed yet. Tests cover round-trip encoding, store add/get/remove, and key isolation between V1 and V2 for the same short channel ID.
Changed components
channeldb/waitingproof.gochanneldb/waitingproof_test.godiscovery/gossiper.godocs/release-notes/release-notes-0.21.0.mdInspect captured patch +375 / −25
diff --git a/channeldb/waitingproof.go b/channeldb/waitingproof.go
index 63fe054..95d10cb 100644
--- a/channeldb/waitingproof.go
+++ b/channeldb/waitingproof.go
@@ -8,6 +8,7 @@ import (
"io"
"sync"
+ "github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/lnwire"
)
@@ -194,32 +195,190 @@ const (
// WaitingProofTypeV1 represents a waiting proof containing an
// AnnounceSignatures1 message (gossip v1, P2WSH channels).
WaitingProofTypeV1 WaitingProofType = 0
+
+ // WaitingProofTypeV2 represents a waiting proof containing an
+ // AnnounceSignatures2 message (gossip v2, taproot channels).
+ WaitingProofTypeV2 WaitingProofType = 1
)
+// typeToWaitingProof returns an empty instance of the WaitingProofInner
+// implementation corresponding to the given proof type.
+func typeToWaitingProof(pt WaitingProofType) (WaitingProofInner, bool) {
+ switch pt {
+ case WaitingProofTypeV1:
+ return &V1WaitingProof{}, true
+ case WaitingProofTypeV2:
+ return &V2WaitingProof{}, true
+ default:
+ return nil, false
+ }
+}
+
+// WaitingProofInner is an interface that must be implemented by any waiting
+// proof payload to be stored in the waiting proof store.
+type WaitingProofInner interface {
+ // SCID returns the short channel ID of the channel that the waiting
+ // proof is for.
+ SCID() lnwire.ShortChannelID
+
+ // Encode encodes the waiting proof to the given buffer.
+ Encode(w *bytes.Buffer, pver uint32) error
+
+ // Decode parses the bytes from the given reader to reconstruct the
+ // waiting proof.
+ Decode(r io.Reader, pver uint32) error
+
+ // Type returns the waiting proof type.
+ Type() WaitingProofType
+}
+
+// V1WaitingProof wraps an AnnounceSignatures1 message for storage as a
+// waiting proof.
+type V1WaitingProof struct {
+ lnwire.AnnounceSignatures1
+}
+
+// SCID returns the short channel ID of the channel.
+//
+// NOTE: this is part of the WaitingProofInner interface.
+func (p *V1WaitingProof) SCID() lnwire.ShortChannelID {
+ return p.ShortChannelID
+}
+
+// Type returns the waiting proof type.
+//
+// NOTE: this is part of the WaitingProofInner interface.
+func (p *V1WaitingProof) Type() WaitingProofType {
+ return WaitingProofTypeV1
+}
+
+// A compile time check to ensure V1WaitingProof implements the
+// WaitingProofInner interface.
+var _ WaitingProofInner = (*V1WaitingProof)(nil)
+
+// V2WaitingProof wraps an AnnounceSignatures2 message for storage as a
+// waiting proof. It also stores the combined MuSig2 signing nonce needed to
+// reconstruct the final signature.
+type V2WaitingProof struct {
+ lnwire.AnnounceSignatures2
+
+ // CombinedNonce is the final combined signing nonce (R = R_1 + b*R_2)
+ // derived from the aggregate of all signers' public nonces. It is used
+ // as the R value in the final Schnorr signature.
+ CombinedNonce *btcec.PublicKey
+}
+
+// SCID returns the short channel ID of the channel.
+//
+// NOTE: this is part of the WaitingProofInner interface.
+func (p *V2WaitingProof) SCID() lnwire.ShortChannelID {
+ return p.ShortChannelID.Val
+}
+
+// Decode parses the bytes from the given reader to reconstruct the waiting
+// proof.
+//
+// NOTE: this is part of the WaitingProofInner interface.
+func (p *V2WaitingProof) Decode(r io.Reader, pver uint32) error {
+ // Read the nonce-presence marker first.
+ var noncePresent bool
+ if err := binary.Read(r, byteOrder, &noncePresent); err != nil {
+ return err
+ }
+
+ // If present, parse and store the combined signing nonce.
+ if noncePresent {
+ var nonceBytes [btcec.PubKeyBytesLenCompressed]byte
+ if err := binary.Read(r, byteOrder, &nonceBytes); err != nil {
+ return err
+ }
+
+ nonce, err := btcec.ParsePubKey(nonceBytes[:])
+ if err != nil {
+ return err
+ }
+
+ p.CombinedNonce = nonce
+ }
+
+ // Decode the underlying AnnounceSignatures2 payload.
+ return p.AnnounceSignatures2.Decode(r, pver)
+}
+
+// Encode encodes the waiting proof to the given buffer.
+//
+// NOTE: this is part of the WaitingProofInner interface.
+func (p *V2WaitingProof) Encode(w *bytes.Buffer, pver uint32) error {
+ // Write whether a combined nonce follows.
+ noncePresent := p.CombinedNonce != nil
+ if err := binary.Write(w, byteOrder, noncePresent); err != nil {
+ return err
+ }
+
+ // If present, serialize and write the combined signing nonce.
+ if noncePresent {
+ err := binary.Write(
+ w, byteOrder, p.CombinedNonce.SerializeCompressed(),
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ // Encode the underlying AnnounceSignatures2 payload.
+ return p.AnnounceSignatures2.Encode(w, pver)
+}
+
+// Type returns the waiting proof type.
+//
+// NOTE: this is part of the WaitingProofInner interface.
+func (p *V2WaitingProof) Type() WaitingProofType {
+ return WaitingProofTypeV2
+}
+
+// A compile time check to ensure V2WaitingProof implements the
+// WaitingProofInner interface.
+var _ WaitingProofInner = (*V2WaitingProof)(nil)
+
// WaitingProof is the storable object, which encapsulate the half proof and
// the information about from which side this proof came. This structure is
// needed to make channel proof exchange persistent, so that after client
// restart we may receive remote/local half proof and process it.
type WaitingProof struct {
- *lnwire.AnnounceSignatures1
+ WaitingProofInner
isRemote bool
}
-// NewWaitingProof constructs a new waiting prof instance.
+// NewWaitingProof constructs a new waiting proof instance for an
+// AnnounceSignatures1 message.
func NewWaitingProof(isRemote bool,
proof *lnwire.AnnounceSignatures1) *WaitingProof {
return &WaitingProof{
- AnnounceSignatures1: proof,
- isRemote: isRemote,
+ WaitingProofInner: &V1WaitingProof{*proof},
+ isRemote: isRemote,
+ }
+}
+
+// NewV2WaitingProof constructs a new waiting proof instance for an
+// AnnounceSignatures2 message.
+func NewV2WaitingProof(isRemote bool, proof *lnwire.AnnounceSignatures2,
+ combinedNonce *btcec.PublicKey) *WaitingProof {
+
+ return &WaitingProof{
+ WaitingProofInner: &V2WaitingProof{
+ AnnounceSignatures2: *proof,
+ CombinedNonce: combinedNonce,
+ },
+ isRemote: isRemote,
}
}
// OppositeKey returns the key which uniquely identifies opposite waiting proof.
func (p *WaitingProof) OppositeKey() WaitingProofKey {
var key WaitingProofKey
- key[0] = byte(WaitingProofTypeV1)
- binary.BigEndian.PutUint64(key[1:9], p.ShortChannelID.ToUint64())
+ key[0] = byte(p.Type())
+ binary.BigEndian.PutUint64(key[1:9], p.SCID().ToUint64())
if !p.isRemote {
key[9] = 1
@@ -230,8 +389,8 @@ func (p *WaitingProof) OppositeKey() WaitingProofKey {
// Key returns the key which uniquely identifies waiting proof.
func (p *WaitingProof) Key() WaitingProofKey {
var key WaitingProofKey
- key[0] = byte(WaitingProofTypeV1)
- binary.BigEndian.PutUint64(key[1:9], p.ShortChannelID.ToUint64())
+ key[0] = byte(p.Type())
+ binary.BigEndian.PutUint64(key[1:9], p.SCID().ToUint64())
if p.isRemote {
key[9] = 1
@@ -241,7 +400,7 @@ func (p *WaitingProof) Key() WaitingProofKey {
// Encode writes the internal representation of waiting proof in byte stream.
func (p *WaitingProof) Encode(w io.Writer) error {
- if err := binary.Write(w, byteOrder, WaitingProofTypeV1); err != nil {
+ if err := binary.Write(w, byteOrder, p.Type()); err != nil {
return err
}
@@ -256,11 +415,7 @@ func (p *WaitingProof) Encode(w io.Writer) error {
return fmt.Errorf("expect io.Writer to be *bytes.Buffer")
}
- if err := p.AnnounceSignatures1.Encode(buf, 0); err != nil {
- return err
- }
-
- return nil
+ return p.WaitingProofInner.Encode(buf, 0)
}
// Decode reads the data from the byte stream and initializes the
@@ -271,20 +426,20 @@ func (p *WaitingProof) Decode(r io.Reader) error {
return err
}
- if proofType != WaitingProofTypeV1 {
- return fmt.Errorf("unknown waiting proof type: %v", proofType)
- }
-
if err := binary.Read(r, byteOrder, &p.isRemote); err != nil {
return err
}
- msg := &lnwire.AnnounceSignatures1{}
- if err := msg.Decode(r, 0); err != nil {
+ proof, ok := typeToWaitingProof(proofType)
+ if !ok {
+ return fmt.Errorf("unknown waiting proof type: %v", proofType)
+ }
+
+ if err := proof.Decode(r, 0); err != nil {
return err
}
- p.AnnounceSignatures1 = msg
+ p.WaitingProofInner = proof
return nil
}
diff --git a/channeldb/waitingproof_test.go b/channeldb/waitingproof_test.go
index a3916fa..4b0e444 100644
--- a/channeldb/waitingproof_test.go
+++ b/channeldb/waitingproof_test.go
@@ -7,6 +7,7 @@ import (
"reflect"
"testing"
+ "github.com/btcsuite/btcd/btcec/v2"
"github.com/davecgh/go-spew/spew"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
@@ -96,3 +97,179 @@ func TestWaitingProofDecodeUnknownType(t *testing.T) {
err := proof.Decode(&encoded)
require.ErrorContains(t, err, "unknown waiting proof type")
}
+
+// TestWaitingProofV2RoundTrip asserts that a V2 waiting proof can be encoded
+// and decoded correctly, both with and without an aggregate nonce.
+func TestWaitingProofV2RoundTrip(t *testing.T) {
+ t.Parallel()
+
+ partialSig := lnwire.NewPartialSig(*testRScalar)
+
+ annSig2 := lnwire.NewAnnSigs2(
+ lnwire.ChannelID{1, 2, 3},
+ lnwire.NewShortChanIDFromInt(42),
+ partialSig,
+ )
+
+ // Generate a deterministic public key for the combined nonce.
+ combinedNonce := pubKey
+
+ testCases := []struct {
+ name string
+ combinedNonce *btcec.PublicKey
+ }{
+ {
+ name: "with combined nonce",
+ combinedNonce: combinedNonce,
+ },
+ {
+ name: "without combined nonce",
+ combinedNonce: nil,
+ },
+ }
+
+ for _, tc := range testCases {
+ tc := tc
+
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ proof := NewV2WaitingProof(
+ true, annSig2, tc.combinedNonce,
+ )
+
+ var buf bytes.Buffer
+ require.NoError(t, proof.Encode(&buf))
+
+ // Verify the type prefix is V2.
+ var proofType WaitingProofType
+ r := bytes.NewReader(buf.Bytes())
+ require.NoError(t, binary.Read(
+ r, byteOrder, &proofType,
+ ))
+ require.Equal(t, WaitingProofTypeV2, proofType)
+
+ // Decode and compare.
+ var decoded WaitingProof
+ require.NoError(t, decoded.Decode(
+ bytes.NewReader(buf.Bytes()),
+ ))
+
+ require.Equal(t, proof.isRemote, decoded.isRemote)
+ require.Equal(t, proof.Key(), decoded.Key())
+
+ inner := decoded.WaitingProofInner
+ decodedV2, ok := inner.(*V2WaitingProof)
+ require.True(t, ok)
+
+ origInner := proof.WaitingProofInner
+ origV2, ok := origInner.(*V2WaitingProof)
+ require.True(t, ok)
+
+ require.Equal(
+ t,
+ origV2.ShortChannelID.Val,
+ decodedV2.ShortChannelID.Val,
+ )
+ require.Equal(
+ t,
+ origV2.ChannelID.Val,
+ decodedV2.ChannelID.Val,
+ )
+
+ if tc.combinedNonce != nil {
+ require.NotNil(t, decodedV2.CombinedNonce)
+ require.True(
+ t,
+ tc.combinedNonce.IsEqual(
+ decodedV2.CombinedNonce,
+ ),
+ )
+ } else {
+ require.Nil(t, decodedV2.CombinedNonce)
+ }
+ })
+ }
+}
+
+// TestWaitingProofV2Store tests add/get/remove of V2 waiting proofs through
+// the store.
+func TestWaitingProofV2Store(t *testing.T) {
+ t.Parallel()
+
+ db, err := MakeTestDB(t)
+ require.NoError(t, err)
+
+ store, err := NewWaitingProofStore(db)
+ require.NoError(t, err)
+
+ partialSig := lnwire.NewPartialSig(*testRScalar)
+ annSig2 := lnwire.NewAnnSigs2(
+ lnwire.ChannelID{5, 6, 7},
+ lnwire.NewShortChanIDFromInt(100),
+ partialSig,
+ )
+
+ proof := NewV2WaitingProof(true, annSig2, pubKey)
+
+ require.NoError(t, store.Add(proof))
+
+ got, err := store.Get(proof.Key())
+ require.NoError(t, err)
+ require.Equal(t, proof.Key(), got.Key())
+ require.Equal(t, proof.isRemote, got.isRemote)
+
+ gotV2, ok := got.WaitingProofInner.(*V2WaitingProof)
+ require.True(t, ok)
+ require.True(t, pubKey.IsEqual(gotV2.CombinedNonce))
+
+ require.NoError(t, store.Remove(proof.Key()))
+
+ _, err = store.Get(proof.Key())
+ require.ErrorIs(t, err, ErrWaitingProofNotFound)
+}
+
+// TestWaitingProofCrossVersionKeyIsolation asserts that V1 and V2 waiting
+// proofs for the same channel side are keyed independently.
+func TestWaitingProofCrossVersionKeyIsolation(t *testing.T) {
+ t.Parallel()
+
+ db, err := MakeTestDB(t)
+ require.NoError(t, err)
+
+ store, err := NewWaitingProofStore(db)
+ require.NoError(t, err)
+
+ scid := lnwire.NewShortChanIDFromInt(777)
+
+ v1Proof := NewWaitingProof(true, &lnwire.AnnounceSignatures1{
+ ShortChannelID: scid,
+ NodeSignature: wireSig,
+ BitcoinSignature: wireSig,
+ ExtraOpaqueData: []byte{1},
+ })
+
+ partialSig := lnwire.NewPartialSig(*testRScalar)
+ v2AnnSig := lnwire.NewAnnSigs2(
+ lnwire.ChannelID{9, 9, 9},
+ scid,
+ partialSig,
+ )
+ v2Proof := NewV2WaitingProof(true, v2AnnSig, pubKey)
+
+ require.NotEqual(t, v1Proof.Key(), v2Proof.Key())
+
+ require.NoError(t, store.Add(v1Proof))
+ require.NoError(t, store.Add(v2Proof))
+
+ gotV1, err := store.Get(v1Proof.Key())
+ require.NoError(t, err)
+ _, ok := gotV1.WaitingProofInner.(*V1WaitingProof)
+ require.True(t, ok)
+
+ gotV2, err := store.Get(v2Proof.Key())
+ require.NoError(t, err)
+ gotV2Inner, ok := gotV2.WaitingProofInner.(*V2WaitingProof)
+ require.True(t, ok)
+ require.True(t, pubKey.IsEqual(gotV2Inner.CombinedNonce))
+}
diff --git a/discovery/gossiper.go b/discovery/gossiper.go
index 49481a1..ac3a6d8 100644
--- a/discovery/gossiper.go
+++ b/discovery/gossiper.go
@@ -3732,19 +3732,33 @@ func (d *AuthenticatedGossiper) handleAnnSig(ctx context.Context,
// We now have both halves of the channel announcement proof, then
// we'll reconstruct the initial announcement so we can validate it
// shortly below.
+ //
+ // NOTE: For now only V1 proofs are supported in the gossiper. V2
+ // support will be added when taproot channel announcements are wired
+ // up.
+ oppV1, ok := oppProof.WaitingProofInner.(*channeldb.V1WaitingProof)
+ if !ok {
+ err := fmt.Errorf("expected V1 waiting proof, got %T",
+ oppProof.WaitingProofInner)
+ log.Error(err)
+ nMsg.err <- err
+
+ return nil, false
+ }
+
var dbProof *models.ChannelAuthProof
if isFirstNode {
dbProof = models.NewV1ChannelAuthProof(
ann.NodeSignature.ToSignatureBytes(),
- oppProof.NodeSignature.ToSignatureBytes(),
+ oppV1.NodeSignature.ToSignatureBytes(),
ann.BitcoinSignature.ToSignatureBytes(),
- oppProof.BitcoinSignature.ToSignatureBytes(),
+ oppV1.BitcoinSignature.ToSignatureBytes(),
)
} else {
dbProof = models.NewV1ChannelAuthProof(
- oppProof.NodeSignature.ToSignatureBytes(),
+ oppV1.NodeSignature.ToSignatureBytes(),
ann.NodeSignature.ToSignatureBytes(),
- oppProof.BitcoinSignature.ToSignatureBytes(),
+ oppV1.BitcoinSignature.ToSignatureBytes(),
ann.BitcoinSignature.ToSignatureBytes(),
)
}
diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md
index 914de7e..e7cf03f 100644
--- a/docs/release-notes/release-notes-0.21.0.md
+++ b/docs/release-notes/release-notes-0.21.0.md
@@ -255,6 +255,10 @@
[4](https://github.com/lightningnetwork/lnd/pull/10542),
[5](https://github.com/lightningnetwork/lnd/pull/10572),
[6](https://github.com/lightningnetwork/lnd/pull/10582).
+* Updated waiting proof persistence for gossip upgrades by introducing typed
+ waiting proof keys and payloads, with a DB migration to rewrite legacy
+ waiting proof records to the new key/value format
+ ([#10633](https://github.com/lightningnetwork/lnd/pull/10633)).
* Payment Store SQL implementation and migration project:
* Introduce an [abstract payment
Why this scored 18/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.