lightning: give LocalConfig and RemoteConfig the same fields.
What changed, and why it matters
This commit refactors how Electrum stores Lightning channel configuration data so that both sides of a channel (local and remote) use the same set of fields. It adds per-commitment points and signature fields to the shared base class, updates channel setup and state-transition code to populate them, and includes a database upgrade for existing wallets. The change appears to be a structural cleanup and preparation for future peer-backup functionality rather than a direct fix for an active security bug. There is no explicit vendor statement that this is a security patch, and no independent researcher is credited.
Treat as a normal code-quality and data-model refactor. Reviewers should verify that the new per-commitment point and signature fields are correctly populated at every state transition and that the database upgrade handles edge cases (e.g., channels with no revocation history) without corrupting channel state. No urgent security deployment is indicated by the commit itself.
Security signals we found
Structural refactor of Lightning channel state storage
Adds persistence of local per-commitment points and remote-bound signatures
Includes WalletDB schema upgrade (version 73→74) for existing channels
Changes are described by the author as groundwork for 'peerbackups' branch
No explicit security relevance or vulnerability disclosure in commit message
Evidence from the diff
The patch unifies LocalConfig and RemoteConfig by moving current_commitment_signature, current_htlc_signatures, current_per_commitment_point, and next_per_commitment_point into the common ChannelConfig base class. It updates lnchannel.py so that sign_next_commitment stores the signatures sent to the remote in REMOTE config, and revoke_current_commitment updates LOCAL per-commitment points. lnpeer.py now uses local_config.current_per_commitment_point for first_per_commitment_point during channel establishment and initializes the new remote fields. wallet_db.py’s version 74 upgrade derives and stores current/next per-commitment points for existing channels. Tests are adjusted accordingly and a new test verifies per-commitment points track the oldest unrevoked commitment number.
Changed components
electrum/lnutil.py: ChannelConfig, LocalConfig, RemoteConfig dataclasseselectrum/lnchannel.py: sign_next_commitment, revoke_current_commitmentelectrum/lnpeer.py: channel_establishment_flow, on_open_channelelectrum/lnworker.py: make_local_config_for_new_channelelectrum/wallet_db.py: _convert_version_74 database upgradetests/lnhelpers.pytests/test_lnchannel.pyInspect captured patch +78 / −43
### electrum/lnchannel.py
@@ -641,8 +641,6 @@ def init_config(self, cb: ImportedChannelBackupStorage):
max_accepted_htlcs=None,
initial_msat=None,
reserve_sat=None,
- current_commitment_signature=None,
- current_htlc_signatures=b'',
htlc_minimum_msat=1,
upfront_shutdown_script='',
announcement_node_sig=b'',
@@ -664,8 +662,10 @@ def init_config(self, cb: ImportedChannelBackupStorage):
initial_msat = None,
reserve_sat = None,
htlc_minimum_msat=None,
- next_per_commitment_point=None,
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
current_per_commitment_point=None,
+ next_per_commitment_point=None,
upfront_shutdown_script='',
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
@@ -1374,6 +1374,8 @@ def sign_next_commitment(self) -> Tuple[bytes, Sequence[bytes]]:
htlcsigs = [x[1] for x in htlcsigs]
with self.db_lock:
self.hm.send_ctx()
+ self.config[REMOTE].current_commitment_signature = sig_64
+ self.config[REMOTE].current_htlc_signatures = b''.join(htlcsigs)
return sig_64, htlcsigs
def receive_new_commitment(self, sig: bytes, htlc_sigs: Sequence[bytes]) -> None:
@@ -1470,10 +1472,14 @@ def revoke_current_commitment(self):
if not self.signature_fits(new_ctx):
# this should never fail; as receive_new_commitment already did this test
raise Exception("refusing to revoke as remote sig does not fit")
- with self.db_lock:
- self.hm.send_rev()
last_secret, last_point = self.get_secret_and_point(LOCAL, new_ctn - 1)
+ _cur_secret, cur_point = self.get_secret_and_point(LOCAL, new_ctn)
next_secret, next_point = self.get_secret_and_point(LOCAL, new_ctn + 1)
+ with self.db_lock:
+ self.hm.send_rev()
+ # new_ctn is now our oldest unrevoked ctn
+ self.config[LOCAL].current_per_commitment_point = cur_point
+ self.config[LOCAL].next_per_commitment_point = next_point
return RevokeAndAck(last_secret, next_point)
def receive_revocation(self, revocation: RevokeAndAck):
### electrum/lnpeer.py
@@ -1071,9 +1071,6 @@ async def channel_establishment_flow(
open_channel_tlvs['channel_opening_fee'] = {
'channel_opening_fee': opening_fee
}
- # for the first commitment transaction
- per_commitment_point_first = local_keys.per_commitment_point(0)
-
# store the temp id now, so that it is recognized for e.g. 'error' messages
self.temp_id_to_id[temp_channel_id] = None
self._cleanup_temp_channelids()
@@ -1091,7 +1088,7 @@ async def channel_establishment_flow(
htlc_basepoint=local_config.htlc_basepoint.pubkey,
payment_basepoint=local_config.payment_basepoint.pubkey,
delayed_payment_basepoint=local_config.delayed_basepoint.pubkey,
- first_per_commitment_point=per_commitment_point_first,
+ first_per_commitment_point=local_config.current_per_commitment_point,
to_self_delay=local_config.to_self_delay,
max_htlc_value_in_flight_msat=local_config.max_htlc_value_in_flight_msat,
channel_flags=channel_flags,
@@ -1138,8 +1135,10 @@ async def channel_establishment_flow(
initial_msat=push_msat,
reserve_sat=payload["channel_reserve_satoshis"],
htlc_minimum_msat=payload['htlc_minimum_msat'],
- next_per_commitment_point=remote_per_commitment_point,
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
current_per_commitment_point=None,
+ next_per_commitment_point=remote_per_commitment_point,
upfront_shutdown_script=upfront_shutdown_script,
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
@@ -1370,8 +1369,10 @@ async def on_open_channel(self, payload):
initial_msat=funding_sat * 1000 - push_msat,
reserve_sat=payload['channel_reserve_satoshis'],
htlc_minimum_msat=payload['htlc_minimum_msat'],
- next_per_commitment_point=payload['first_per_commitment_point'],
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
current_per_commitment_point=None,
+ next_per_commitment_point=payload['first_per_commitment_point'],
upfront_shutdown_script=upfront_shutdown_script,
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
@@ -1390,9 +1391,6 @@ async def on_open_channel(self, payload):
channel_flags = ord(payload['channel_flags'])
# -> accept channel
- # for the first commitment transaction
- per_commitment_point_first = local_keys.per_commitment_point(0)
-
min_depth = 0 if is_zeroconf else 3
accept_channel_tlvs = {
@@ -1419,7 +1417,7 @@ async def on_open_channel(self, payload):
payment_basepoint=local_config.payment_basepoint.pubkey,
delayed_payment_basepoint=local_config.delayed_basepoint.pubkey,
htlc_basepoint=local_config.htlc_basepoint.pubkey,
- first_per_commitment_point=per_commitment_point_first,
+ first_per_commitment_point=local_config.current_per_commitment_point,
accept_channel_tlvs=accept_channel_tlvs,
)
### electrum/lnutil.py
@@ -130,10 +130,11 @@ def __post_init__(self):
@dataclasses.dataclass(repr=False)
class ChannelConfig(StoredObject):
- """The channel parameters of one side of a channel.
+ """The channel parameters and state of one side of a channel.
- The config never holds a secret: our privkeys are all derived from the channel seed,
- which lives next to the two configs in the channel storage. (see ChannelKeys)
+ Both sides are described by the same fields, and neither holds any secret: our
+ privkeys are all derived from the channel seed, which lives next to the two configs
+ in the channel storage. (see ChannelKeys)
"""
payment_basepoint: OnlyPubkeyKeypair
multisig_key: OnlyPubkeyKeypair
@@ -150,6 +151,15 @@ class ChannelConfig(StoredObject):
upfront_shutdown_script: bytes
announcement_node_sig: bytes
announcement_bitcoin_sig: bytes
+ # signatures for this side's latest ctx and its htlc txs: for LOCAL, the ones the
+ # remote sent us; for REMOTE, the ones we sent them.
+ current_commitment_signature: bytes
+ current_htlc_signatures: bytes
+ # per-commitment points of this side's oldest unrevoked ctx, and of the next one.
+ # ours are derivable from the channel seed; we keep them so that both configs can be
+ # described, and sent to a peer that backs up our channel, in the same way.
+ current_per_commitment_point: bytes
+ next_per_commitment_point: bytes
def __post_init__(self):
# keypairs are stored as dicts, and bytes as hex
@@ -161,6 +171,10 @@ def __post_init__(self):
self.upfront_shutdown_script = hex_to_bytes(self.upfront_shutdown_script)
self.announcement_node_sig = hex_to_bytes(self.announcement_node_sig)
self.announcement_bitcoin_sig = hex_to_bytes(self.announcement_bitcoin_sig)
+ self.current_commitment_signature = hex_to_bytes(self.current_commitment_signature)
+ self.current_htlc_signatures = hex_to_bytes(self.current_htlc_signatures)
+ self.current_per_commitment_point = hex_to_bytes(self.current_per_commitment_point)
+ self.next_per_commitment_point = hex_to_bytes(self.next_per_commitment_point)
def __repr__(self):
return repr_dataclass(self, {bytes: bytes_to_hex})
@@ -209,6 +223,10 @@ def for_us(
htlc_basepoint=OnlyPubkeyKeypair(keys.htlc_basepoint.pubkey),
delayed_basepoint=OnlyPubkeyKeypair(keys.delayed_basepoint.pubkey),
revocation_basepoint=OnlyPubkeyKeypair(keys.revocation_basepoint.pubkey),
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
+ current_per_commitment_point=keys.per_commitment_point(0),
+ next_per_commitment_point=keys.per_commitment_point(1),
**kwargs,
)
@@ -315,14 +333,6 @@ def cross_validate_params(
@stored_at('/channels/*/local_config')
@dataclasses.dataclass(repr=False)
class LocalConfig(ChannelConfig):
- current_commitment_signature: bytes
- current_htlc_signatures: bytes
-
- def __post_init__(self):
- super().__post_init__()
- self.current_commitment_signature = hex_to_bytes(self.current_commitment_signature)
- self.current_htlc_signatures = hex_to_bytes(self.current_htlc_signatures)
-
def validate_params(self, *, funding_sat: int, config: 'SimpleConfig', peer_features: 'LnFeatures') -> None:
conf_name = type(self).__name__
# run base checks regardless whether LOCAL/REMOTE config
@@ -337,13 +347,7 @@ def validate_params(self, *, funding_sat: int, config: 'SimpleConfig', peer_feat
@stored_at('/channels/*/remote_config')
@dataclasses.dataclass(repr=False)
class RemoteConfig(ChannelConfig):
- next_per_commitment_point: bytes
- current_per_commitment_point: Optional[bytes] = None
-
- def __post_init__(self):
- super().__post_init__()
- self.next_per_commitment_point = hex_to_bytes(self.next_per_commitment_point)
- self.current_per_commitment_point = hex_to_bytes(self.current_per_commitment_point)
+ pass
@dataclasses.dataclass(repr=False)
### electrum/lnworker.py
@@ -1820,8 +1820,6 @@ def make_local_config_for_new_channel(
max_accepted_htlcs=30,
initial_msat=initial_msat,
reserve_sat=reserve_sat,
- current_commitment_signature=None,
- current_htlc_signatures=b'',
htlc_minimum_msat=1,
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
### electrum/wallet_db.py
@@ -1514,16 +1514,17 @@ def _convert_version_73(self):
self.data['seed_version'] = 73
def _convert_version_74(self):
- """Stop storing the local channel keys that are derived from the channel seed,
- and keep the ones we cannot derive outside of the config.
+ """Stop storing the local channel keys that are derived from the channel seed, and
+ describe both sides of a channel with the same fields.
The channel seed and the multisig privkey (the one key we cannot re-derive) move
out of the config and into the channel itself, together with
- funding_locked_received. What is left is a config that holds public data only.
+ funding_locked_received. What is left is a config that holds public data only,
+ and that has the same fields for LOCAL and for REMOTE.
"""
from .bip32 import BIP32Node
from .lnutil import (DERIVED_LOCAL_BASEPOINTS, LnKeyFamily, generate_keypair,
- derive_payment_basepoint)
+ derive_payment_basepoint, get_per_commitment_point_from_seed)
if not self._is_upgrade_method_needed(73, 73):
return
@@ -1539,6 +1540,7 @@ def check(channel_id: str, name: str, derived: bytes, stored: str) -> None:
for channel_id, c in self.data.get('channels', {}).items():
local_config = c['local_config']
+ remote_config = c['remote_config']
channel_seed = local_config.pop('channel_seed')
node = BIP32Node.from_rootseed(bfh(channel_seed), xtype='standard')
for name, key_family in DERIVED_LOCAL_BASEPOINTS.items():
@@ -1560,6 +1562,13 @@ def check(channel_id: str, name: str, derived: bytes, stored: str) -> None:
c['channel_seed'] = channel_seed
c['multisig_privkey'] = local_config['multisig_key'].pop('privkey')
c['funding_locked_received'] = local_config.pop('funding_locked_received')
+ # the remaining fields exist for both sides now. The signatures we sent for
+ # their ctx were not kept, so the remote config starts without them.
+ remote_config['current_commitment_signature'] = None
+ remote_config['current_htlc_signatures'] = ''
+ ctn = max(c['log']['1']['ctn'], 0) # our oldest unrevoked ctn
+ local_config['current_per_commitment_point'] = get_per_commitment_point_from_seed(secret_seed, ctn).hex()
+ local_config['next_per_commitment_point'] = get_per_commitment_point_from_seed(secret_seed, ctn + 1).hex()
self.data['seed_version'] = 74
def _convert_imported(self):
### tests/lnhelpers.py
@@ -512,7 +512,7 @@ def prepare_chans_and_peers_in_graph(
return graph
-def _convert_to_rconfig_from_lconfig(lconfig: LocalConfig, lkeys: ChannelKeys) -> RemoteConfig:
+def _convert_to_rconfig_from_lconfig(lconfig: LocalConfig) -> RemoteConfig:
"""converts Alice's local config to Bob's remote config"""
rconfig = RemoteConfig(
payment_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.payment_basepoint.pubkey),
@@ -530,8 +530,10 @@ def _convert_to_rconfig_from_lconfig(lconfig: LocalConfig, lkeys: ChannelKeys) -
upfront_shutdown_script=lconfig.upfront_shutdown_script,
announcement_node_sig=lconfig.announcement_node_sig,
announcement_bitcoin_sig=lconfig.announcement_bitcoin_sig,
- next_per_commitment_point=lkeys.per_commitment_point(0),
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
current_per_commitment_point=None,
+ next_per_commitment_point=lconfig.current_per_commitment_point,
)
return rconfig
@@ -654,7 +656,7 @@ def create_test_channels(
channel_type=channel_type,
local_config=alice_lconfig,
local_keys=alice_lkeys,
- remote_config=_convert_to_rconfig_from_lconfig(bob_lconfig, bob_lkeys),
+ remote_config=_convert_to_rconfig_from_lconfig(bob_lconfig),
),
name=f"{alice_name}->{bob_name}",
initial_feerate=feerate,
@@ -670,7 +672,7 @@ def create_test_channels(
channel_type=channel_type,
local_config=bob_lconfig,
local_keys=bob_lkeys,
- remote_config=_convert_to_rconfig_from_lconfig(alice_lconfig, alice_lkeys),
+ remote_config=_convert_to_rconfig_from_lconfig(alice_lconfig),
),
name=f"{bob_name}->{alice_name}",
initial_feerate=feerate,
### tests/test_lnchannel.py
@@ -937,6 +937,24 @@ def test_check_against_config(self):
self.assertEqual(chan.config[LOCAL].payment_basepoint.pubkey,
ecc.ECPrivkey(privkey).get_public_key_bytes())
+ async def test_local_per_commitment_points_follow_our_ctn(self):
+ """We keep our own per-commitment points in the config, like the remote's, so
+ that both sides are described in the same way. They must follow our ctn.
+ """
+ def check_both_channels():
+ for chan in (self.alice_channel, self.bob_channel):
+ ctn = chan.get_oldest_unrevoked_ctn(LOCAL)
+ config = chan.config[LOCAL]
+ self.assertEqual(chan.keys.per_commitment_point(ctn),
+ config.current_per_commitment_point)
+ self.assertEqual(chan.keys.per_commitment_point(ctn + 1),
+ config.next_per_commitment_point)
+
+ check_both_channels()
+ for _ in range(3):
+ force_state_transition(self.alice_channel, self.bob_channel)
+ check_both_channels()
+
class TestChannelNoAnchors(TestChannel):
assert TestChannel.TEST_ANCHOR_CHANNELS is TrueWhy this scored 27/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.