graphdb: fix backwards-compat for channel edge feature deserialization
What changed, and why it matters
This is a database backwards-compatibility bug fix, not a security vulnerability. A previous version of LND changed how channel feature data is stored on disk, but the new code could not read old-format data. When users upgraded from v0.19.x to v0.20.x, the node would fail to read its own graph database and log an 'unable to decode features: EOF' error. The patch adds format detection so both old and new on-disk formats are accepted. There is no attacker-controlled path or remote exploit here.
No security action required. Operators upgrading from LND v0.19.x to v0.20.x should apply this patch (or use v0.20.1) to avoid graph database read failures. The change is safe to deploy and does not introduce a vulnerability.
Security signals we found
No security-relevant signals present
Backwards-compatibility fix for on-disk serialization format
No input from untrusted network sources is parsed by the changed code
Failure mode is a local EOF error during node startup/graph load
Evidence from the diff
The commit fixes a deserialization regression in graph/db/kv_store.go. In v0.20.x, channel edge features switched from raw base-256 feature bits to a length-prefixed encoding (Features.Encode/Decode). deserializeChanEdgeInfo now calls a new helper, deserializeChanEdgeFeatures, which detects the legacy format and decodes it with DecodeBase256, while still accepting the new length-prefixed format. The detection uses a uint16 length check plus a canonical re-encode verification to avoid false positives. Extensive unit and property-based tests are added. The issue is purely an upgrade-time data-format compatibility failure.
Changed components
graph/db/kv_store.godeserializeChanEdgeInfodeserializeChanEdgeFeatureschannel edge feature storage/retrievalInspect captured patch +530 / −5
diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md
index fddd81e..c5bb192 100644
--- a/docs/release-notes/release-notes-0.20.1.md
+++ b/docs/release-notes/release-notes-0.20.1.md
@@ -62,6 +62,13 @@
due to validation checks in the graph Builder that were resurfaced after the
graph refactor work.
+* [Fix backwards compatibility for channel edge feature
+ deserialization](https://github.com/lightningnetwork/lnd/pull/10529). Nodes
+ upgrading from pre-v0.20 versions could fail to read channel edges from their
+ graph database due to a format change in how channel features are serialized.
+ The fix adds automatic format detection to handle both legacy (raw feature
+ bits) and new (length-prefixed) formats.
+
# New Features
## Functional Enhancements
diff --git a/graph/db/kv_store.go b/graph/db/kv_store.go
index 80e802e..34b21c9 100644
--- a/graph/db/kv_store.go
+++ b/graph/db/kv_store.go
@@ -4715,6 +4715,78 @@ func fetchChanEdgeInfo(edgeIndex kvdb.RBucket,
return deserializeChanEdgeInfo(edgeInfoReader)
}
+// deserializeChanEdgeFeatures deserializes channel edge features from bytes,
+// handling both the legacy format (raw feature bits) and the current format
+// (2-byte length prefix followed by feature bits).
+//
+// Legacy format (pre-v0.20): VarBytes containing raw feature bits directly.
+// Current format (v0.20+): VarBytes containing a 2-byte big-endian length
+// followed by the feature bits.
+//
+// The format is detected by checking if the first 2 bytes, interpreted as a
+// big-endian uint16 length, equals len(featureBytes)-2. Since this length
+// check alone could have false positives (e.g., a 258-byte legacy vector
+// starting with 0x01, 0x00), we additionally verify by decoding and
+// re-encoding the payload to confirm it produces the exact same bytes
+// (canonical encoding check).
+func deserializeChanEdgeFeatures(featureBytes []byte) (*lnwire.FeatureVector,
+ error) {
+
+ features := lnwire.NewRawFeatureVector()
+
+ // Empty features are valid in both formats.
+ if len(featureBytes) == 0 {
+ return lnwire.NewFeatureVector(features, lnwire.Features), nil
+ }
+
+ // Check if this looks like the new format with a 2-byte length prefix.
+ // In the new format, the first 2 bytes encode the length of the
+ // remaining feature bytes.
+ if len(featureBytes) >= 2 {
+ encodedLen := binary.BigEndian.Uint16(featureBytes[:2])
+ if int(encodedLen) == len(featureBytes)-2 {
+ // This looks like it could be the new format. To be
+ // certain, we decode and re-encode to verify canonical
+ // encoding, as a legacy feature vector could
+ // accidentally match the length check (e.g., a 258-byte
+ // legacy vector starting with 0x01, 0x00 would have
+ // 256 == 258 - 2).
+ payload := featureBytes[2:]
+ tempFeatures := lnwire.NewRawFeatureVector()
+ err := tempFeatures.DecodeBase256(
+ bytes.NewReader(payload), int(encodedLen),
+ )
+
+ var checkBuf bytes.Buffer
+ if err == nil {
+ err = tempFeatures.EncodeBase256(&checkBuf)
+ }
+
+ // If there were no errors and the re-encoded payload
+ // matches the original, we are confident it's the new
+ // format.
+ isCanonical := bytes.Equal(checkBuf.Bytes(), payload)
+ if err == nil && isCanonical {
+ return lnwire.NewFeatureVector(
+ tempFeatures, lnwire.Features,
+ ), nil
+ }
+ }
+ }
+
+ // Legacy format: the bytes are raw feature bits without a length
+ // prefix.
+ err := features.DecodeBase256(
+ bytes.NewReader(featureBytes), len(featureBytes),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("unable to decode features "+
+ "(legacy format): %w", err)
+ }
+
+ return lnwire.NewFeatureVector(features, lnwire.Features), nil
+}
+
func deserializeChanEdgeInfo(r io.Reader) (*models.ChannelEdgeInfo, error) {
var (
err error
@@ -4739,13 +4811,10 @@ func deserializeChanEdgeInfo(r io.Reader) (*models.ChannelEdgeInfo, error) {
return nil, err
}
- features := lnwire.NewRawFeatureVector()
- err = features.Decode(bytes.NewReader(featureBytes))
+ edgeInfo.Features, err = deserializeChanEdgeFeatures(featureBytes)
if err != nil {
- return nil, fmt.Errorf("unable to decode "+
- "features: %w", err)
+ return nil, err
}
- edgeInfo.Features = lnwire.NewFeatureVector(features, lnwire.Features)
proof := &models.ChannelAuthProof{}
diff --git a/graph/db/kv_store_features_test.go b/graph/db/kv_store_features_test.go
new file mode 100644
index 0000000..161ea59
--- /dev/null
+++ b/graph/db/kv_store_features_test.go
@@ -0,0 +1,449 @@
+package graphdb
+
+import (
+ "bytes"
+ "encoding/binary"
+ "testing"
+
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
+ "pgregory.net/rapid"
+)
+
+// TestDeserializeChanEdgeFeaturesEmpty tests that empty feature bytes are
+// handled correctly for both legacy and new formats.
+func TestDeserializeChanEdgeFeaturesEmpty(t *testing.T) {
+ t.Parallel()
+
+ // Empty bytes should result in empty features.
+ features, err := deserializeChanEdgeFeatures(nil)
+ require.NoError(t, err)
+ require.True(t, features.IsEmpty())
+
+ features, err = deserializeChanEdgeFeatures([]byte{})
+ require.NoError(t, err)
+ require.True(t, features.IsEmpty())
+
+ // New format with zero-length features: [0x00, 0x00].
+ features, err = deserializeChanEdgeFeatures([]byte{0x00, 0x00})
+ require.NoError(t, err)
+ require.True(t, features.IsEmpty())
+}
+
+// TestDeserializeChanEdgeFeaturesLegacyFormat tests deserialization of
+// feature bytes written in the legacy format (pre-v0.20), which contains
+// raw feature bits without a 2-byte length prefix.
+func TestDeserializeChanEdgeFeaturesLegacyFormat(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ legacyBytes []byte
+ expectedFeats []lnwire.FeatureBit
+ }{
+ {
+ name: "single byte - bit 0",
+ legacyBytes: []byte{0x01}, // bit 0 set
+ expectedFeats: []lnwire.FeatureBit{0},
+ },
+ {
+ name: "single byte - bit 7",
+ legacyBytes: []byte{0x80}, // bit 7 set
+ expectedFeats: []lnwire.FeatureBit{7},
+ },
+ {
+ name: "single byte - multiple bits",
+ legacyBytes: []byte{0x25}, // bits 0, 2, 5 set
+ expectedFeats: []lnwire.FeatureBit{0, 2, 5},
+ },
+ {
+ name: "two bytes - bit 8",
+ legacyBytes: []byte{0x01, 0x00}, // bit 8 set
+ expectedFeats: []lnwire.FeatureBit{8},
+ },
+ {
+ name: "two bytes - bits 0 and 15",
+ legacyBytes: []byte{0x80, 0x01}, // bits 0 and 15 set
+ expectedFeats: []lnwire.FeatureBit{0, 15},
+ },
+ {
+ // bit 1 (DataLossProtectOptional).
+ name: "common features - data loss protect",
+ legacyBytes: []byte{0x02},
+ expectedFeats: []lnwire.FeatureBit{
+ lnwire.DataLossProtectOptional,
+ },
+ },
+ {
+ // bits 1, 7, 9, 13, 15 = DataLossProtect,
+ // GossipQueries, TLVOnion, StaticRemoteKey,
+ // PaymentAddr.
+ name: "multiple common features",
+ legacyBytes: []byte{0xA2, 0x82},
+ expectedFeats: []lnwire.FeatureBit{1, 7, 9, 13, 15},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ features, err := deserializeChanEdgeFeatures(
+ tc.legacyBytes,
+ )
+ require.NoError(t, err)
+
+ for _, bit := range tc.expectedFeats {
+ require.True(t, features.IsSet(bit),
+ "expected bit %d to be set", bit)
+ }
+
+ // Verify no extra bits are set by creating expected
+ // feature vector and comparing.
+ expectedRaw := lnwire.NewRawFeatureVector(
+ tc.expectedFeats...,
+ )
+ require.True(t, expectedRaw.Equals(
+ features.RawFeatureVector),
+ "feature vectors don't match")
+ })
+ }
+}
+
+// TestDeserializeChanEdgeFeaturesNewFormat tests deserialization of
+// feature bytes written in the new format (v0.20+), which contains
+// a 2-byte big-endian length prefix followed by raw feature bits.
+func TestDeserializeChanEdgeFeaturesNewFormat(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ expectedFeats []lnwire.FeatureBit
+ }{
+ {
+ name: "empty features",
+ expectedFeats: nil,
+ },
+ {
+ name: "single feature bit 0",
+ expectedFeats: []lnwire.FeatureBit{0},
+ },
+ {
+ name: "single feature bit 15",
+ expectedFeats: []lnwire.FeatureBit{15},
+ },
+ {
+ name: "multiple features",
+ expectedFeats: []lnwire.FeatureBit{1, 5, 9, 13, 17},
+ },
+ {
+ name: "common lightning features",
+ expectedFeats: []lnwire.FeatureBit{
+ lnwire.DataLossProtectOptional,
+ lnwire.GossipQueriesOptional,
+ lnwire.TLVOnionPayloadOptional,
+ lnwire.StaticRemoteKeyOptional,
+ lnwire.PaymentAddrOptional,
+ },
+ },
+ {
+ name: "high bit features",
+ expectedFeats: []lnwire.FeatureBit{
+ lnwire.AMPOptional, // 31
+ lnwire.KeysendOptional, // 55
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Create feature vector and encode in new format.
+ rawFeatures := lnwire.NewRawFeatureVector(
+ tc.expectedFeats...,
+ )
+ fv := lnwire.NewFeatureVector(
+ rawFeatures, lnwire.Features,
+ )
+
+ // Encode using the new format (with length prefix).
+ var buf bytes.Buffer
+ err := fv.Encode(&buf)
+ require.NoError(t, err)
+
+ // Deserialize and verify.
+ features, err := deserializeChanEdgeFeatures(
+ buf.Bytes(),
+ )
+ require.NoError(t, err)
+
+ for _, bit := range tc.expectedFeats {
+ require.True(t, features.IsSet(bit),
+ "expected bit %d to be set", bit)
+ }
+
+ // Verify feature equality.
+ require.True(t, rawFeatures.Equals(
+ features.RawFeatureVector),
+ )
+ })
+ }
+}
+
+// TestDeserializeChanEdgeFeaturesFormatDetection tests that the format
+// detection correctly distinguishes between legacy and new formats.
+func TestDeserializeChanEdgeFeaturesFormatDetection(t *testing.T) {
+ t.Parallel()
+
+ // Test that legacy format bytes that could theoretically be confused
+ // with new format are handled correctly. This shouldn't happen in
+ // practice because in legacy format the first byte always has at least
+ // one bit set (the highest feature bit determines the byte length).
+
+ // Create a feature vector with bit 8 set (requires 2 bytes in legacy).
+ // Legacy format: [0x01, 0x00] (big-endian, high byte first).
+ // As a length, 0x0100 = 256, which != 0 (len-2), so correctly detected
+ // as legacy.
+ legacyBit8 := []byte{0x01, 0x00}
+ features, err := deserializeChanEdgeFeatures(legacyBit8)
+ require.NoError(t, err)
+ require.True(t, features.IsSet(8))
+ require.False(t, features.IsSet(0))
+
+ // New format with bit 8: [0x00, 0x02, 0x01, 0x00]
+ // Length prefix 0x0002 = 2, remaining 2 bytes = feature bits.
+ newFormatBit8 := []byte{0x00, 0x02, 0x01, 0x00}
+ features, err = deserializeChanEdgeFeatures(newFormatBit8)
+ require.NoError(t, err)
+ require.True(t, features.IsSet(8))
+ require.False(t, features.IsSet(0))
+
+ // Test single byte legacy format - cannot be confused with new format
+ // since new format minimum is 2 bytes (the length prefix).
+ legacyBit0 := []byte{0x01}
+ features, err = deserializeChanEdgeFeatures(legacyBit0)
+ require.NoError(t, err)
+ require.True(t, features.IsSet(0))
+}
+
+// TestDeserializeChanEdgeFeaturesRoundTrip tests that features can be
+// serialized and deserialized correctly using the new format.
+func TestDeserializeChanEdgeFeaturesRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ testFeatureSets := [][]lnwire.FeatureBit{
+ {},
+ {0},
+ {7},
+ {8},
+ {15},
+ {0, 1, 2, 3, 4, 5, 6, 7},
+ {8, 9, 10, 11, 12, 13, 14, 15},
+ {0, 8, 16, 24, 32},
+ {
+ lnwire.DataLossProtectOptional,
+ lnwire.GossipQueriesOptional,
+ lnwire.TLVOnionPayloadOptional,
+ lnwire.StaticRemoteKeyOptional,
+ lnwire.PaymentAddrOptional,
+ lnwire.MPPOptional,
+ lnwire.AnchorsZeroFeeHtlcTxOptional,
+ },
+ }
+
+ for _, featureBits := range testFeatureSets {
+ // Create and encode.
+ rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
+ fv := lnwire.NewFeatureVector(rawFeatures, lnwire.Features)
+
+ var buf bytes.Buffer
+ err := fv.Encode(&buf)
+ require.NoError(t, err)
+
+ // Deserialize.
+ decoded, err := deserializeChanEdgeFeatures(buf.Bytes())
+ require.NoError(t, err)
+
+ // Verify equality.
+ require.True(t, rawFeatures.Equals(decoded.RawFeatureVector),
+ "mismatch for features %v", featureBits)
+ }
+}
+
+// TestDeserializeChanEdgeFeaturesPropertyBased uses property-based testing
+// to verify that the deserialization works correctly for arbitrary feature
+// combinations in both legacy and new formats.
+func TestDeserializeChanEdgeFeaturesPropertyBased(t *testing.T) {
+ t.Parallel()
+
+ // Test legacy format: raw feature bytes without length prefix.
+ rapid.Check(t, func(t *rapid.T) {
+ // Generate random feature bits (max 256 to keep reasonable).
+ numFeatures := rapid.IntRange(0, 20).Draw(t, "numFeatures")
+ featureBits := make([]lnwire.FeatureBit, numFeatures)
+ for i := 0; i < numFeatures; i++ {
+ featureBits[i] = lnwire.FeatureBit(
+ rapid.IntRange(0, 255).Draw(t, "featureBit"),
+ )
+ }
+
+ // Create feature vector.
+ rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
+
+ // Encode without length prefix (legacy format).
+ var buf bytes.Buffer
+ err := rawFeatures.EncodeBase256(&buf)
+ require.NoError(t, err)
+
+ // Deserialize.
+ decoded, err := deserializeChanEdgeFeatures(buf.Bytes())
+ require.NoError(t, err)
+
+ // Verify equality.
+ require.True(t, rawFeatures.Equals(decoded.RawFeatureVector))
+ })
+
+ // Test new format: with length prefix.
+ rapid.Check(t, func(t *rapid.T) {
+ // Generate random feature bits.
+ numFeatures := rapid.IntRange(0, 20).Draw(t, "numFeatures")
+ featureBits := make([]lnwire.FeatureBit, numFeatures)
+ for i := 0; i < numFeatures; i++ {
+ featureBits[i] = lnwire.FeatureBit(
+ rapid.IntRange(0, 255).Draw(t, "featureBit"),
+ )
+ }
+
+ // Create feature vector.
+ rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
+ fv := lnwire.NewFeatureVector(rawFeatures, lnwire.Features)
+
+ // Encode with length prefix (new format).
+ var buf bytes.Buffer
+ err := fv.Encode(&buf)
+ require.NoError(t, err)
+
+ // Deserialize.
+ decoded, err := deserializeChanEdgeFeatures(buf.Bytes())
+ require.NoError(t, err)
+
+ // Verify equality.
+ require.True(t, rawFeatures.Equals(decoded.RawFeatureVector))
+ })
+}
+
+// TestDeserializeChanEdgeFeaturesLegacyFormatNoCollision verifies that
+// the format detection cannot have false positives where legacy format
+// bytes are incorrectly detected as new format.
+func TestDeserializeChanEdgeFeaturesLegacyFormatNoCollision(t *testing.T) {
+ t.Parallel()
+
+ // The detection works through canonical encoding verification.
+ // Even if a legacy vector accidentally matches the length check
+ // (e.g., a 258-byte vector starting with 0x01, 0x00), the decode/
+ // re-encode check will fail because the legacy encoding won't be
+ // canonical when interpreted as new format payload.
+
+ rapid.Check(t, func(t *rapid.T) {
+ // Generate feature bits with higher range to catch more edge
+ // cases, including vectors that could match the length check.
+ maxBit := rapid.IntRange(0, 2200).Draw(t, "maxBit")
+ numExtra := rapid.IntRange(0, 10).Draw(t, "numExtra")
+
+ featureBits := []lnwire.FeatureBit{lnwire.FeatureBit(maxBit)}
+ for i := 0; i < numExtra; i++ {
+ bit := rapid.IntRange(0, maxBit).Draw(t, "extraBit")
+ featureBits = append(featureBits,
+ lnwire.FeatureBit(bit))
+ }
+
+ rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
+
+ // Encode in legacy format.
+ var buf bytes.Buffer
+ err := rawFeatures.EncodeBase256(&buf)
+ require.NoError(t, err)
+
+ legacyBytes := buf.Bytes()
+ if len(legacyBytes) < 2 {
+ // Single byte can't be confused with new format.
+ return
+ }
+
+ // Verify deserialization still works correctly regardless of
+ // whether the length check happens to match.
+ decoded, err := deserializeChanEdgeFeatures(legacyBytes)
+ require.NoError(t, err)
+ require.True(t, rawFeatures.Equals(decoded.RawFeatureVector),
+ "mismatch for legacy bytes %x with maxBit %d",
+ legacyBytes, maxBit)
+ })
+}
+
+// TestDeserializeChanEdgeFeaturesLengthCheckCollision specifically tests the
+// edge case where a legacy feature vector accidentally satisfies the length
+// check condition (first 2 bytes as uint16 == len - 2). This can happen with
+// a 258-byte vector starting with 0x01, 0x00, where 256 == 258 - 2.
+// The canonical encoding verification should correctly identify this as legacy
+// format.
+func TestDeserializeChanEdgeFeaturesLengthCheckCollision(t *testing.T) {
+ t.Parallel()
+
+ // Create a legacy feature vector that will produce bytes where the
+ // first two bytes, interpreted as a length, equal len - 2.
+ //
+ // To get 258 bytes in legacy format, we need bit 2063 set (258*8-1).
+ // The first byte will be 0x01 (bit 2056 is in byte 0, and we need
+ // bit 2063 which is 0x80, but the bytes are big-endian so byte 0
+ // contains the high bits). Actually let's work this out:
+ //
+ // For 258 bytes, bits 2056-2063 are in byte 0.
+ // Setting bit 2056 gives byte[0] = 0x01.
+ // If byte[0] = 0x01 and byte[1] = 0x00, then as uint16 = 256 = 258-2.
+ //
+ // So we need: bit 2056 set (gives 0x01 in byte 0), and no bits in
+ // byte 1 set (bits 2048-2055), and at least one bit set below to
+ // ensure we have full 258 bytes (bit 0 to ensure byte 257 is non-zero
+ // won't work since it affects the last byte...).
+ //
+ // Actually the encoding is that the first byte contains the HIGHEST
+ // bits. So for 258 bytes:
+ // - byte[0] contains bits 2056-2063
+ // - byte[1] contains bits 2048-2055
+ // - ...
+ // - byte[257] contains bits 0-7
+ //
+ // To get byte[0] = 0x01 and byte[1] = 0x00:
+ // - Set bit 2056 (gives 0x01 in byte 0)
+ // - Don't set bits 2048-2055 (keeps byte 1 = 0x00)
+ //
+ // We also need to set some lower bit to have meaningful features.
+ featureBits := []lnwire.FeatureBit{
+ 2056, // This gives 0x01 in first byte (258 bytes total)
+ 0, // Set bit 0 for a meaningful feature
+ }
+
+ rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
+
+ // Encode in legacy format.
+ var buf bytes.Buffer
+ err := rawFeatures.EncodeBase256(&buf)
+ require.NoError(t, err)
+
+ legacyBytes := buf.Bytes()
+ require.Len(t, legacyBytes, 258, "expected 258 bytes for bit 2056")
+
+ // Verify the collision condition: first 2 bytes as uint16 == len - 2.
+ encodedLen := binary.BigEndian.Uint16(legacyBytes[:2])
+ require.Equal(t, uint16(256), encodedLen,
+ "expected first 2 bytes to encode 256")
+ require.Equal(t, 256, len(legacyBytes)-2,
+ "expected length check to match")
+
+ // Despite the length check matching, deserialization should still
+ // correctly identify this as legacy format (via canonical encoding
+ // verification) and decode it properly.
+ decoded, err := deserializeChanEdgeFeatures(legacyBytes)
+ require.NoError(t, err)
+ require.True(t, decoded.IsSet(2056), "bit 2056 should be set")
+ require.True(t, decoded.IsSet(0), "bit 0 should be set")
+ require.True(t, rawFeatures.Equals(decoded.RawFeatureVector),
+ "feature vectors should match")
+}
Why this scored 29/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.