bolt12: inject feature-bit catalogues into Offer and InvoiceRequest validators
What changed, and why it matters
This change updates how the LND code checks feature bits in BOLT 12 offers and invoice requests. Previously, the read-side validator treated every even feature bit as unknown and would reject it, because it had no list of known features. Now the caller passes in a catalogue of known feature bits, so legitimate new features are accepted. The write-side validator is also relaxed: it no longer enforces feature rules when creating messages, because whether a feature is 'unknown' depends on the reader, not the writer. The change is a correctness fix for protocol feature negotiation, not a direct patch for an active exploit.
Review callers of ValidateOfferRead and ValidateInvoiceRequestRead to ensure they pass an accurate, up-to-date feature catalogue; otherwise the validators may still reject valid BOLT 12 messages or, conversely, accept unknown even bits if the catalogue is empty. No immediate emergency action is indicated by the diff alone.
Security signals we found
Feature-bit validation logic changed from hard-coded nil catalogue to caller-supplied catalogue
Write-side feature enforcement removed entirely
Read-side now accepts even feature bits that the local node knows, rejects only unknown even bits
Test coverage added for accepted known even feature bit and rejected unknown even feature bit
Evidence from the diff
The commit modifies bolt12/validate.go to inject a known-features map into ValidateOfferRead and ValidateInvoiceRequestRead, and passes nil on the write path (ValidateInvoiceRequestWrite). checkFeatures now uses the supplied catalogue instead of always using nil, enabling proper must-understand/even feature-bit validation per lnwire.NewFeatureVector semantics. Tests are updated to exercise both rejection of unknown even bits and acceptance of a known even bit. The change is architectural/correctness: it separates reader policy from writer enforcement and prepares for future BOLT 12 feature bits.
Changed components
bolt12/validate.gobolt12/validate_test.goValidateOfferReadValidateInvoiceRequestReadValidateInvoiceRequestWritecheckFeaturesInspect captured patch +61 / −18
diff --git a/bolt12/validate.go b/bolt12/validate.go
index 7b2e421..1900930 100644
--- a/bolt12/validate.go
+++ b/bolt12/validate.go
@@ -359,8 +359,10 @@ func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error {
// - if it supports bolt12 invoice request features:
// - MUST set invreq_features.features to the bitmap of features.
// We only reject unknown even bits here; advertising a feature is the
- // caller's decision.
- if err := checkFeatures(ir.InvreqFeatures); err != nil {
+ // caller's decision. Since the writer lacks a catalogue in scope, we
+ // pass nil for the catalogue, treating all even feature bits as
+ // unknown.
+ if err := checkFeatures(ir.InvreqFeatures, nil); err != nil {
return err
}
@@ -461,7 +463,8 @@ func getInvreqChain(ir *InvoiceRequest) [32]byte {
// Invoice message (see the TODO at the end of this function). Until then, a
// caller wiring this into a handler MUST verify the signature itself.
func ValidateInvoiceRequestRead(ir *InvoiceRequest,
- activeChain [32]byte) error {
+ activeChain [32]byte,
+ knownFeatures map[lnwire.FeatureBit]string) error {
// A present-but-nil pubkey passes IsSome but would panic the codec on
// encode, so reject both pubkey fields.
@@ -506,7 +509,7 @@ func ValidateInvoiceRequestRead(ir *InvoiceRequest,
// - if invreq_features contains unknown *even* bits that are non-zero:
// - MUST reject the invoice request.
- if err := checkFeatures(ir.InvreqFeatures); err != nil {
+ if err := checkFeatures(ir.InvreqFeatures, knownFeatures); err != nil {
return err
}
@@ -798,7 +801,9 @@ func isKnownOfferTLVType(typ tlv.Type) bool {
// defaults to Bitcoin mainnet, and the reader must reject offers that do not
// list a chain it operates on. Pass the genesis hash of the chain the receiver
// is willing to settle on.
-func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error {
+func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte,
+ knownFeatures map[lnwire.FeatureBit]string) error {
+
// A present-but-nil offer_issuer_id passes IsSome but would panic the
// codec on encode, so reject it here.
if err := checkPubKeyNotNil(
@@ -806,7 +811,6 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error {
); err != nil {
return err
}
-
// Check TLV types are in allowed range and that unknown even types are
// rejected (even = must-understand).
for _, t := range sortedTypes(o.decodedTLVs) {
@@ -820,7 +824,7 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error {
}
// Check for unknown even feature bits.
- if err := checkFeatures(o.OfferFeatures); err != nil {
+ if err := checkFeatures(o.OfferFeatures, knownFeatures); err != nil {
return err
}
@@ -1029,15 +1033,13 @@ func checkISO4217[T tlv.TlvType](opt tlv.OptionalRecordT[T, tlv.Blob]) error {
// checkFeatures rejects any unknown even (must-understand) feature bit.
func checkFeatures[T tlv.TlvType](
- opt tlv.OptionalRecordT[T, lnwire.RawFeatureVector]) error {
+ opt tlv.OptionalRecordT[T, lnwire.RawFeatureVector],
+ known map[lnwire.FeatureBit]string) error {
return fn.MapOptionZ(
opt.ValOpt(),
func(fv lnwire.RawFeatureVector) error {
- // nil catalogue: BOLT 12 defines no feature bits yet,
- // so every set even bit is "unknown". Swap in a
- // Bolt12Features map once the spec assigns bits.
- wrapped := lnwire.NewFeatureVector(&fv, nil)
+ wrapped := lnwire.NewFeatureVector(&fv, known)
unknown := wrapped.UnknownRequiredFeatures()
if len(unknown) == 0 {
return nil
diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go
index c31a060..e6240d6 100644
--- a/bolt12/validate_test.go
+++ b/bolt12/validate_test.go
@@ -242,6 +242,7 @@ func TestValidateOfferRead(t *testing.T) {
name string
mutate func(*Offer)
activeChain [32]byte
+ known map[lnwire.FeatureBit]string
wantErr error
}{
{
@@ -632,6 +633,21 @@ func TestValidateOfferRead(t *testing.T) {
activeChain: bitcoinMainnetGenesisHash,
wantErr: ErrUnknownEvenType,
},
+ {
+ name: "known even feature bit accepted",
+ mutate: func(o *Offer) {
+ o.OfferFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType12](
+ *lnwire.NewRawFeatureVector(0),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ known: map[lnwire.FeatureBit]string{
+ 0: "test_feature",
+ },
+ wantErr: nil,
+ },
}
for _, tc := range tests {
@@ -643,7 +659,9 @@ func TestValidateOfferRead(t *testing.T) {
o := validBobOffer(t)
tc.mutate(o)
- err := ValidateOfferRead(o, now, tc.activeChain)
+ err := ValidateOfferRead(
+ o, now, tc.activeChain, tc.known,
+ )
if tc.wantErr == nil {
require.NoError(t, err)
@@ -1198,6 +1216,7 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) {
tests := []struct {
name string
mutate func(*InvoiceRequest)
+ known map[lnwire.FeatureBit]string
wantErr error
}{
{
@@ -1413,6 +1432,20 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) {
},
wantErr: ErrUnknownEvenFeature,
},
+ {
+ name: "known even feature bit accepted",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType84](
+ *lnwire.NewRawFeatureVector(0),
+ ),
+ )
+ },
+ known: map[lnwire.FeatureBit]string{
+ 0: "test_feature",
+ },
+ wantErr: nil,
+ },
}
for _, tc := range tests {
@@ -1422,8 +1455,16 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) {
ir := validInvoiceRequest(t)
tc.mutate(ir)
+ if tc.name == "known even feature bit accepted" {
+ ir.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240](
+ [64]byte{0x01},
+ ),
+ )
+ }
+
err := ValidateInvoiceRequestRead(
- ir, bitcoinMainnetGenesisHash,
+ ir, bitcoinMainnetGenesisHash, tc.known,
)
require.ErrorIs(t, err, tc.wantErr)
})
@@ -1464,7 +1505,7 @@ func TestValidateInvoiceRequestReadAmountBelowExpected(t *testing.T) {
ir.InvreqAmount = tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType82](TUint64(1999)),
)
- err := ValidateInvoiceRequestRead(ir, bitcoinMainnetGenesisHash)
+ err := ValidateInvoiceRequestRead(ir, bitcoinMainnetGenesisHash, nil)
require.ErrorIs(t, err, ErrAmountBelowExpected)
}
@@ -1512,7 +1553,7 @@ func TestValidateInvoiceRequestAmountOverflow(t *testing.T) {
// The reader MUST reject the overflowing request.
readErr := ValidateInvoiceRequestRead(
- newRequest(), bitcoinMainnetGenesisHash,
+ newRequest(), bitcoinMainnetGenesisHash, nil,
)
require.ErrorIs(t, readErr, ErrAmountBelowExpected)
@@ -1538,7 +1579,7 @@ func TestValidateInvoiceRequestReadChain(t *testing.T) {
t.Parallel()
ir := validInvoiceRequest(t)
- err := ValidateInvoiceRequestRead(ir, altChain)
+ err := ValidateInvoiceRequestRead(ir, altChain, nil)
require.ErrorIs(t, err, ErrUnsupportedChain)
})
@@ -1552,7 +1593,7 @@ func TestValidateInvoiceRequestReadChain(t *testing.T) {
// The chain check runs before signature verification, so a
// mismatched chain is rejected regardless of the signature.
err := ValidateInvoiceRequestRead(
- ir, bitcoinMainnetGenesisHash,
+ ir, bitcoinMainnetGenesisHash, nil,
)
require.ErrorIs(t, err, ErrUnsupportedChain)
})
Why this scored 44/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.