What changed, and why it matters
This commit refines how Electrum checks Lightning network feature flags so that the rules for what one feature requires can differ depending on where it appears (e.g., an old BOLT11 invoice versus a newer BOLT12 invoice). The main user-visible change is that BOLT12 invoices can advertise multi-part payment support (BASIC_MPP) without also advertising payment_secret, which was previously required. This is a protocol-correctness change rather than a clear-cut security fix, but it removes a validation rule that could have caused BOLT12 invoices to be wrongly rejected.
Treat as a normal correctness/maintenance patch. Review whether the relaxed dependency matches the BOLT12 specification and that no other context accidentally loses a required dependency. No urgent security action is indicated from this commit alone.
Security signals we found
Validation logic change: a previously enforced dependency (BASIC_MPP requires PAYMENT_SECRET) is relaxed in specific contexts
Context-aware feature validation reduces risk of rejecting valid BOLT12 invoices, which could affect payment reliability
No memory safety, cryptographic, or authentication changes observed
No explicit security bug or CVE referenced in commit message or diff
Evidence from the diff
The patch makes Lightning feature transitive dependencies context-dependent. It introduces LnFeatureContexts and registers direct dependencies per context. The key behavioral change is _register_transitive_deps(BASIC_MPP_OPT, {PAYMENT_SECRET_OPT}, contexts=~LNFC_BOLT12), meaning BASIC_MPP no longer requires PAYMENT_SECRET in BOLT12 invoice/offer/invoice_request contexts. validate_features now takes a required context argument, and callers in bolt11.py, channel_db.py, and lnpeer.py pass the appropriate context. New helper methods filter features for BOLT12 contexts. Tests are updated to assert BASIC_MPP_OPT validates standalone under BOLT12_INVOICE and that filtering works.
Changed components
electrum/lnutil.pyelectrum/bolt11.pyelectrum/channel_db.pyelectrum/lnpeer.pytests/test_lnutil.pyInspect captured patch +68 / −45
diff --git a/electrum/bolt11.py b/electrum/bolt11.py
index f020463..ab3a357 100644
--- a/electrum/bolt11.py
+++ b/electrum/bolt11.py
@@ -336,9 +336,9 @@ class BOLT11Addr:
as then when we started requiring a new feature,
old saved already paid invoices could no longer be parsed.
"""
- from .lnutil import validate_features, ln_compare_features
+ from .lnutil import validate_features, ln_compare_features, LnFeatureContexts
invoice_features = self.get_features()
- validate_features(invoice_features)
+ validate_features(invoice_features, context=LnFeatureContexts.BOLT11_INVOICE)
ln_compare_features(myfeatures.for_bolt11_invoice(), invoice_features)
def __str__(self):
diff --git a/electrum/channel_db.py b/electrum/channel_db.py
index bba7e96..2159a4b 100644
--- a/electrum/channel_db.py
+++ b/electrum/channel_db.py
@@ -43,7 +43,7 @@ from .sql_db import SqlDB, sql
from . import constants, util
from .util import profiler, get_headers_dir, is_ip_address, json_normalize, UserFacingException, is_private_netaddress
from .lntransport import LNPeerAddr
-from .lnutil import (ShortChannelID, validate_features, IncompatibleOrInsaneFeatures,
+from .lnutil import (ShortChannelID, validate_features, IncompatibleOrInsaneFeatures, LnFeatureContexts,
InvalidGossipMsg, GossipForwardingMessage, GossipTimestampFilter)
from .lnverifier import LNChannelVerifier, verify_sig_for_channel_update
from .lnmsg import decode_msg
@@ -74,7 +74,7 @@ class ChannelInfo(NamedTuple):
@staticmethod
def from_msg(payload: dict) -> 'ChannelInfo':
features = int.from_bytes(payload['features'], 'big')
- features = validate_features(features)
+ features = validate_features(features, context=LnFeatureContexts.CHAN_ANN_AS_IS)
channel_id = payload['short_channel_id']
node_id_1 = payload['node_id_1']
node_id_2 = payload['node_id_2']
@@ -176,7 +176,7 @@ class NodeInfo(NamedTuple):
def from_msg(payload) -> Tuple['NodeInfo', Sequence['LNPeerAddr']]:
node_id = payload['node_id']
features = int.from_bytes(payload['features'], "big")
- features = validate_features(features)
+ features = validate_features(features, context=LnFeatureContexts.NODE_ANN)
addresses = NodeInfo.parse_addresses_field(payload['addresses'])
peer_addrs = []
for host, port in addresses:
diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py
index 8acabb8..d4551cc 100644
--- a/electrum/lnpeer.py
+++ b/electrum/lnpeer.py
@@ -38,7 +38,7 @@ from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error,
OnionParsingError)
from .lnchannel import Channel, RevokeAndAck, ChannelState, PeerState, ChanCloseOption, CF_ANNOUNCE_CHANNEL
from . import lnutil
-from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig,
+from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig, LnFeatureContexts,
RemoteConfig, OnlyPubkeyKeypair, ChannelConstraints, RevocationStore,
funding_output_script, get_per_commitment_secret_from_seed,
secret_to_pubkey, PaymentFailure, LnFeatures,
@@ -397,7 +397,7 @@ class Peer(Logger, EventListener):
_their_features = int.from_bytes(payload['features'], byteorder="big")
_their_features |= int.from_bytes(payload['globalfeatures'], byteorder="big")
try:
- self.their_features = validate_features(_their_features)
+ self.their_features = validate_features(_their_features, context=LnFeatureContexts.INIT)
except IncompatibleOrInsaneFeatures as e:
raise GracefulDisconnect(f"remote sent insane features: {repr(e)}")
# check if features are compatible, and set self.features to what we negotiated
diff --git a/electrum/lnutil.py b/electrum/lnutil.py
index e79074a..9ce985c 100644
--- a/electrum/lnutil.py
+++ b/electrum/lnutil.py
@@ -3,8 +3,7 @@
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
from enum import IntFlag, IntEnum
import enum
-from collections import defaultdict
-from typing import NamedTuple, List, Tuple, Mapping, Optional, TYPE_CHECKING, Union, Dict, Set, Sequence
+from typing import NamedTuple, List, Tuple, Mapping, Optional, TYPE_CHECKING, Union, Dict, Set, Sequence, FrozenSet
import sys
import time
from functools import lru_cache
@@ -1417,10 +1416,17 @@ class LnFeatureContexts(enum.Flag):
LNFC = LnFeatureContexts
+LNFC_ALL = ~LnFeatureContexts(0)
+LNFC_BOLT12 = LNFC.BOLT12_OFFER | LNFC.BOLT12_INVREQ | LNFC.BOLT12_INVOICE
-_ln_feature_direct_dependencies = defaultdict(set) # type: Dict[LnFeatures, Set[LnFeatures]]
+_ln_feature_direct_dependencies = {} # type: Dict[LnFeatureContexts, Dict[LnFeatures, FrozenSet[LnFeatures]]]
_ln_feature_contexts = {} # type: Dict[LnFeatures, LnFeatureContexts]
+def _register_transitive_deps(dependant: 'LnFeatures', direct_deps: Set['LnFeatures'], *, contexts: LnFeatureContexts):
+ for context in LnFeatureContexts:
+ if context & contexts:
+ _ln_feature_direct_dependencies.setdefault(context, {})[dependant] = frozenset(direct_deps)
+
class LnFeatures(IntFlag):
OPTION_DATA_LOSS_PROTECT_REQ = 1 << 0
@@ -1448,7 +1454,7 @@ class LnFeatures(IntFlag):
GOSSIP_QUERIES_EX_REQ = 1 << 10
GOSSIP_QUERIES_EX_OPT = 1 << 11
- _ln_feature_direct_dependencies[GOSSIP_QUERIES_EX_OPT] = {GOSSIP_QUERIES_OPT}
+ _register_transitive_deps(GOSSIP_QUERIES_EX_OPT, {GOSSIP_QUERIES_OPT}, contexts=LNFC_ALL)
_ln_feature_contexts[GOSSIP_QUERIES_EX_OPT] = (LNFC.INIT | LNFC.NODE_ANN)
_ln_feature_contexts[GOSSIP_QUERIES_EX_REQ] = (LNFC.INIT | LNFC.NODE_ANN)
@@ -1459,13 +1465,13 @@ class LnFeatures(IntFlag):
PAYMENT_SECRET_REQ = 1 << 14
PAYMENT_SECRET_OPT = 1 << 15
- _ln_feature_direct_dependencies[PAYMENT_SECRET_OPT] = {VAR_ONION_OPT}
+ _register_transitive_deps(PAYMENT_SECRET_OPT, {VAR_ONION_OPT}, contexts=LNFC_ALL)
_ln_feature_contexts[PAYMENT_SECRET_OPT] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.BOLT11_INVOICE)
_ln_feature_contexts[PAYMENT_SECRET_REQ] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.BOLT11_INVOICE)
BASIC_MPP_REQ = 1 << 16
BASIC_MPP_OPT = 1 << 17
- _ln_feature_direct_dependencies[BASIC_MPP_OPT] = {PAYMENT_SECRET_OPT}
+ _register_transitive_deps(BASIC_MPP_OPT, {PAYMENT_SECRET_OPT}, contexts=~LNFC_BOLT12)
_ln_feature_contexts[BASIC_MPP_OPT] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.BOLT11_INVOICE | LNFC.BOLT12_INVOICE)
_ln_feature_contexts[BASIC_MPP_REQ] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.BOLT11_INVOICE | LNFC.BOLT12_INVOICE)
@@ -1476,7 +1482,7 @@ class LnFeatures(IntFlag):
OPTION_ANCHORS_REQ = 1 << 22
OPTION_ANCHORS_OPT = 1 << 23
- _ln_feature_direct_dependencies[OPTION_ANCHORS_OPT] = {OPTION_STATIC_REMOTEKEY_OPT}
+ _register_transitive_deps(OPTION_ANCHORS_OPT, {OPTION_STATIC_REMOTEKEY_OPT}, contexts=LNFC_ALL)
_ln_feature_contexts[OPTION_ANCHORS_REQ] = (LNFC.INIT | LNFC.NODE_ANN)
_ln_feature_contexts[OPTION_ANCHORS_OPT] = (LNFC.INIT | LNFC.NODE_ANN)
@@ -1494,7 +1500,7 @@ class LnFeatures(IntFlag):
OPTION_ROUTE_BLINDING_REQ = 1 << 24
OPTION_ROUTE_BLINDING_OPT = 1 << 25
- _ln_feature_direct_dependencies[OPTION_ROUTE_BLINDING_OPT] = {VAR_ONION_OPT}
+ _register_transitive_deps(OPTION_ROUTE_BLINDING_OPT, {VAR_ONION_OPT}, contexts=LNFC_ALL)
_ln_feature_contexts[OPTION_ROUTE_BLINDING_REQ] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.BOLT11_INVOICE)
_ln_feature_contexts[OPTION_ROUTE_BLINDING_OPT] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.BOLT11_INVOICE)
@@ -1520,46 +1526,50 @@ class LnFeatures(IntFlag):
OPTION_ZEROCONF_REQ = 1 << 50
OPTION_ZEROCONF_OPT = 1 << 51
- _ln_feature_direct_dependencies[OPTION_ZEROCONF_OPT] = {OPTION_SCID_ALIAS_OPT}
+ _register_transitive_deps(OPTION_ZEROCONF_OPT, {OPTION_SCID_ALIAS_OPT}, contexts=LNFC_ALL)
_ln_feature_contexts[OPTION_ZEROCONF_REQ] = (LNFC.INIT | LNFC.NODE_ANN)
_ln_feature_contexts[OPTION_ZEROCONF_OPT] = (LNFC.INIT | LNFC.NODE_ANN)
- def validate_transitive_dependencies(self) -> bool:
+ def validate_transitive_dependencies(self, *, context: LnFeatureContexts) -> bool:
# for all even bit set, set corresponding odd bit:
features = self # copy
flags = list_enabled_bits(features)
for flag in flags:
if flag % 2 == 0:
features |= 1 << get_ln_flag_pair_of_bit(flag)
+ # use a single context for CHAN_ANN_*
+ if context in (LnFeatureContexts.CHAN_ANN_ALWAYS_EVEN, LnFeatureContexts.CHAN_ANN_ALWAYS_ODD):
+ context = LnFeatureContexts.CHAN_ANN_AS_IS
# Check dependencies. We only check that the direct dependencies of each flag set
# are satisfied: this implies that transitive dependencies are also satisfied.
+ direct_deps = _ln_feature_direct_dependencies.get(context, {}) # type: dict[LnFeatures, FrozenSet[LnFeatures]]
flags = list_enabled_bits(features)
for flag in flags:
- for dependency in _ln_feature_direct_dependencies[1 << flag]:
+ for dependency in direct_deps.get(LnFeatures(1 << flag), frozenset()):
if not (dependency & features):
return False
return True
def for_init_message(self) -> 'LnFeatures':
- features = LnFeatures(0)
- for flag in list_enabled_ln_feature_bits(self):
- if LnFeatureContexts.INIT & _ln_feature_contexts[1 << flag]:
- features |= (1 << flag)
- return features
+ return self._for_context(LnFeatureContexts.INIT)
def for_node_announcement(self) -> 'LnFeatures':
- features = LnFeatures(0)
- for flag in list_enabled_ln_feature_bits(self):
- if LnFeatureContexts.NODE_ANN & _ln_feature_contexts[1 << flag]:
- features |= (1 << flag)
- return features
+ return self._for_context(LnFeatureContexts.NODE_ANN)
def for_bolt11_invoice(self) -> 'LnFeatures':
- features = LnFeatures(0)
- for flag in list_enabled_ln_feature_bits(self):
- if LnFeatureContexts.BOLT11_INVOICE & _ln_feature_contexts[1 << flag]:
- features |= (1 << flag)
- return features
+ return self._for_context(LnFeatureContexts.BOLT11_INVOICE)
+
+ def for_bolt12_offer(self) -> 'LnFeatures':
+ return self._for_context(LnFeatureContexts.BOLT12_OFFER)
+
+ def for_bolt12_invoice_request(self) -> 'LnFeatures':
+ return self._for_context(LnFeatureContexts.BOLT12_INVREQ)
+
+ def for_bolt12_invoice(self) -> 'LnFeatures':
+ return self._for_context(LnFeatureContexts.BOLT12_INVOICE)
+
+ def for_blinded_path(self) -> 'LnFeatures':
+ return self._for_context(LnFeatureContexts.BLINDED_PATH)
def for_channel_announcement(self) -> 'LnFeatures':
features = LnFeatures(0)
@@ -1604,6 +1614,13 @@ class LnFeatures(IntFlag):
r.append(feature_name or f"bit_{flag}")
return r
+ def _for_context(self, context: 'LnFeatureContexts') -> 'LnFeatures':
+ features = LnFeatures(0)
+ for flag in list_enabled_ln_feature_bits(self):
+ if context & _ln_feature_contexts[1 << flag]:
+ features |= (1 << flag)
+ return features
+
if hasattr(IntFlag, "_numeric_repr_"): # python 3.11+
# performance improvement (avoid base2<->base10), see #8403
_numeric_repr_ = hex
@@ -1666,7 +1683,7 @@ class ChannelType(IntFlag):
return str(self)
-del LNFC # name is ambiguous without context
+del LNFC, LNFC_ALL, LNFC_BOLT12 # name is ambiguous without context
# features that are actually implemented and understood in our codebase:
# (note: this is not what we send in e.g. init!)
@@ -1803,7 +1820,7 @@ if hasattr(sys, "get_int_max_str_digits"):
@lru_cache(maxsize=1000) # massive speedup for the hot path of channel_db.load_data()
-def validate_features(features: int) -> LnFeatures:
+def validate_features(features: int, *, context: LnFeatureContexts) -> LnFeatures:
"""Raises IncompatibleOrInsaneFeatures if
- a mandatory feature is listed that we don't recognize, or
- the features are inconsistent
@@ -1819,7 +1836,7 @@ def validate_features(features: int) -> LnFeatures:
for fbit in enabled_features:
if (1 << fbit) & LN_FEATURES_IMPLEMENTED == 0 and fbit % 2 == 0:
raise UnknownEvenFeatureBits(fbit)
- if not features.validate_transitive_dependencies():
+ if not features.validate_transitive_dependencies(context=context):
raise IncompatibleOrInsaneFeatures(f"not all transitive dependencies are set. "
f"features={features}")
return features
diff --git a/tests/test_lnutil.py b/tests/test_lnutil.py
index 23ecb73..8ff9142 100644
--- a/tests/test_lnutil.py
+++ b/tests/test_lnutil.py
@@ -10,7 +10,7 @@ from electrum.lnutil import (
derive_privkey, derive_pubkey, make_htlc_tx, extract_ctn_from_tx, get_compressed_pubkey_from_bech32,
ScriptHtlc, calc_fees_for_commitment_tx, UpdateAddHtlc, LnFeatures, ln_compare_features,
IncompatibleLightningFeatures, ChannelType, offered_htlc_trim_threshold_sat, received_htlc_trim_threshold_sat,
- ImportedChannelBackupStorage, list_enabled_ln_feature_bits, PaymentFeeBudget,
+ ImportedChannelBackupStorage, list_enabled_ln_feature_bits, PaymentFeeBudget, LnFeatureContexts
)
from electrum.util import bfh, MyEncoder
from electrum.transaction import Transaction, PartialTransaction, Sighash
@@ -919,19 +919,21 @@ class TestLNUtil(ElectrumTestCase):
def test_ln_features_validate_transitive_dependencies(self):
features = LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ
- self.assertTrue(features.validate_transitive_dependencies())
+ self.assertTrue(features.validate_transitive_dependencies(context=LnFeatureContexts.INIT))
features = LnFeatures.PAYMENT_SECRET_OPT
- self.assertFalse(features.validate_transitive_dependencies())
+ self.assertFalse(features.validate_transitive_dependencies(context=LnFeatureContexts.BOLT11_INVOICE))
features = LnFeatures.PAYMENT_SECRET_REQ
- self.assertFalse(features.validate_transitive_dependencies())
+ self.assertFalse(features.validate_transitive_dependencies(context=LnFeatureContexts.BOLT11_INVOICE))
features = LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.VAR_ONION_REQ
- self.assertTrue(features.validate_transitive_dependencies())
+ self.assertTrue(features.validate_transitive_dependencies(context=LnFeatureContexts.BOLT11_INVOICE))
features = LnFeatures.BASIC_MPP_OPT | LnFeatures.PAYMENT_SECRET_REQ
- self.assertFalse(features.validate_transitive_dependencies())
+ self.assertFalse(features.validate_transitive_dependencies(context=LnFeatureContexts.BOLT11_INVOICE))
features = LnFeatures.BASIC_MPP_OPT | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.VAR_ONION_OPT
- self.assertTrue(features.validate_transitive_dependencies())
+ self.assertTrue(features.validate_transitive_dependencies(context=LnFeatureContexts.BOLT11_INVOICE))
features = LnFeatures.BASIC_MPP_OPT | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.VAR_ONION_REQ
- self.assertTrue(features.validate_transitive_dependencies())
+ self.assertTrue(features.validate_transitive_dependencies(context=LnFeatureContexts.BOLT11_INVOICE))
+ features = LnFeatures.BASIC_MPP_OPT
+ self.assertTrue(features.validate_transitive_dependencies(context=LnFeatureContexts.BOLT12_INVOICE))
def test_ln_features_for_init_message(self):
features = LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ
@@ -949,7 +951,7 @@ class TestLNUtil(ElectrumTestCase):
features = LnFeatures.BASIC_MPP_OPT | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.VAR_ONION_REQ
self.assertEqual(features, features.for_init_message())
- def test_ln_features_for_invoice(self):
+ def test_ln_features_for_bolt11_invoice(self):
features = LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ
self.assertEqual(LnFeatures(0), features.for_bolt11_invoice())
features = LnFeatures.PAYMENT_SECRET_OPT
@@ -967,6 +969,10 @@ class TestLNUtil(ElectrumTestCase):
features = LnFeatures.BASIC_MPP_OPT | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.VAR_ONION_REQ
self.assertEqual(features, features.for_bolt11_invoice())
+ def test_ln_features_for_bolt12_invoice(self):
+ features = LnFeatures.BASIC_MPP_OPT | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.VAR_ONION_OPT | LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ
+ self.assertEqual(LnFeatures.BASIC_MPP_OPT, features.for_bolt12_invoice())
+
def test_ln_compare_features(self):
f1 = LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ | LnFeatures.OPTION_DATA_LOSS_PROTECT_OPT
f2 = LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ
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.