tests: lnchannel: rewrite create_test_channels to use LNWallet
What changed, and why it matters
This commit is a test-code refactor that rewrites how fake Lightning channels are created for unit tests so they go through the real wallet code path. It also re-enables a safety assertion in production code that checks a channel's remote payout address belongs to the wallet. There is no direct security fix for end users; it is infrastructure work to make tests more realistic and catch configuration mistakes during development.
No immediate action required for users. Developers should verify the re-enabled assertion does not break legitimate channel configurations and that the refactored tests still cover static-remotekey and anchor channel variants.
Security signals we found
Re-enabled assertion in production code that verifies a Lightning commitment output address is owned by the local wallet
Test refactor moves channel setup to use real wallet local-config generation, improving test fidelity
No CVE, advisory, or vendor security disclosure present in supplied materials
Evidence from the diff
The diff rewrites tests/test_lnchannel.py::create_test_channels to construct LocalConfig via LNWallet.make_local_config_for_new_channel instead of hand-crafting keypairs, and adds a helper to derive the counterpart RemoteConfig. In electrum/lnworker.py, make_local_config_for_new_channel now accepts an optional channel_seed parameter to allow deterministic test seeds. In electrum/lnchannel.py, a previously commented-out assertion assert self.lnworker.wallet.is_mine(addr) is re-enabled in Channel.get_next_feerate_commitment_output_address_remotely. Test methods are converted from sync to async and minor formatting changes are made. No vulnerability in deployed code is patched.
Changed components
electrum/lnchannel.pyelectrum/lnworker.pytests/test_lnchannel.pytests/test_lnpeer.pyInspect captured patch +137 / −124
diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py
index f4d19dd..e1cb43d 100644
--- a/electrum/lnchannel.py
+++ b/electrum/lnchannel.py
@@ -1022,8 +1022,8 @@ class Channel(AbstractChannel):
elif self.is_static_remotekey_enabled():
our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
addr = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
- #if self.lnworker:
- # assert self.lnworker.wallet.is_mine(addr) # FIXME xxxxx chan should be deterministic. NEEDS to be fixed before merge
+ if self.lnworker:
+ assert self.lnworker.wallet.is_mine(addr)
return addr
def has_anchors(self) -> bool:
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 6f17ebc..259b4e8 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -1611,8 +1611,10 @@ class LNWallet(Logger):
channel_type: ChannelType,
multisig_funding_keypair: Optional[Keypair], # if None, will get derived from channel_seed
peer_features: LnFeatures,
+ channel_seed: bytes = None,
) -> LocalConfig:
- channel_seed = os.urandom(32)
+ if channel_seed is None:
+ channel_seed = os.urandom(32)
initial_msat = funding_sat * 1000 - push_msat if initiator == LOCAL else push_msat
# sending empty bytes as the upfront_shutdown_script will give us the
diff --git a/tests/test_lnchannel.py b/tests/test_lnchannel.py
index 0b36833..7a81dcd 100644
--- a/tests/test_lnchannel.py
+++ b/tests/test_lnchannel.py
@@ -37,10 +37,11 @@ from electrum import bitcoin
from electrum import lnpeer
from electrum import lnchannel
from electrum import lnutil
-from electrum import bip32 as bip32_utils
from electrum.crypto import privkey_to_pubkey
-from electrum.lnutil import SENT, LOCAL, REMOTE, RECEIVED, UpdateAddHtlc
-from electrum.lnutil import effective_htlc_tx_weight
+from electrum.lnutil import (
+ SENT, LOCAL, REMOTE, RECEIVED, UpdateAddHtlc, LnFeatures, secret_to_pubkey, ChannelType,
+ effective_htlc_tx_weight, LocalConfig, RemoteConfig, OnlyPubkeyKeypair,
+)
from electrum.logging import console_stderr_handler
from electrum.lnchannel import ChannelState, Channel
from electrum.json_db import StoredDict
@@ -55,63 +56,53 @@ if TYPE_CHECKING:
one_bitcoin_in_msat = bitcoin.COIN * 1000
-def create_channel_state(funding_txid, funding_index, funding_sat, is_initiator,
- local_amount, remote_amount, privkeys, other_pubkeys,
- seed, cur, nex, other_node_id, l_dust, r_dust, l_csv,
- r_csv, anchor_outputs, local_max_inflight, remote_max_inflight,
- max_accepted_htlcs):
- #assert local_amount > 0
- #assert remote_amount > 0
+def _convert_to_rconfig_from_lconfig(lconfig: LocalConfig) -> RemoteConfig:
+ """converts Alice's local config to Bob's remote config (neutering private keys, etc)"""
+ ctn = 0
+ pcp_secret = lnutil.get_per_commitment_secret_from_seed(
+ lconfig.per_commitment_secret_seed,
+ lnutil.RevocationStore.START_INDEX - ctn)
+ pcp_point = secret_to_pubkey(int.from_bytes(pcp_secret, 'big'))
+ rconfig = RemoteConfig(
+ payment_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.payment_basepoint.pubkey),
+ multisig_key=OnlyPubkeyKeypair(pubkey=lconfig.multisig_key.pubkey),
+ htlc_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.htlc_basepoint.pubkey),
+ delayed_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.delayed_basepoint.pubkey),
+ revocation_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.revocation_basepoint.pubkey),
+ to_self_delay=lconfig.to_self_delay,
+ dust_limit_sat=lconfig.dust_limit_sat,
+ max_htlc_value_in_flight_msat=lconfig.max_htlc_value_in_flight_msat,
+ max_accepted_htlcs=lconfig.max_accepted_htlcs,
+ initial_msat=lconfig.initial_msat,
+ reserve_sat=lconfig.reserve_sat,
+ htlc_minimum_msat=lconfig.htlc_minimum_msat,
+ 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=pcp_point,
+ current_per_commitment_point=None,
+ )
+ return rconfig
+
+def create_channel_state(
+ *,
+ funding_txid: str,
+ funding_index: int,
+ funding_sat: int,
+ is_initiator: bool,
+ other_node_id: bytes,
+ channel_type: ChannelType,
+ local_config: LocalConfig,
+ remote_config: RemoteConfig,
+):
channel_id, _ = lnpeer.channel_id_from_funding_tx(funding_txid, funding_index)
- channel_type = lnutil.ChannelType.OPTION_STATIC_REMOTEKEY
- if anchor_outputs:
- channel_type |= lnutil.ChannelType.OPTION_ANCHORS_ZERO_FEE_HTLC_TX
state = {
"channel_id":channel_id.hex(),
"short_channel_id":channel_id[:8],
"funding_outpoint":lnpeer.Outpoint(funding_txid, funding_index),
- "remote_config":lnpeer.RemoteConfig(
- payment_basepoint=other_pubkeys[0],
- multisig_key=other_pubkeys[1],
- htlc_basepoint=other_pubkeys[2],
- delayed_basepoint=other_pubkeys[3],
- revocation_basepoint=other_pubkeys[4],
- to_self_delay=r_csv,
- dust_limit_sat=r_dust,
- max_htlc_value_in_flight_msat=remote_max_inflight,
- max_accepted_htlcs=max_accepted_htlcs,
- initial_msat=remote_amount,
- reserve_sat=0,
- htlc_minimum_msat=1,
- next_per_commitment_point=nex,
- current_per_commitment_point=cur,
- upfront_shutdown_script=b'',
- announcement_node_sig=b'',
- announcement_bitcoin_sig=b'',
- ),
- "local_config":lnpeer.LocalConfig(
- channel_seed = None,
- payment_basepoint=privkeys[0],
- multisig_key=privkeys[1],
- htlc_basepoint=privkeys[2],
- delayed_basepoint=privkeys[3],
- revocation_basepoint=privkeys[4],
- to_self_delay=l_csv,
- dust_limit_sat=l_dust,
- max_htlc_value_in_flight_msat=local_max_inflight,
- max_accepted_htlcs=max_accepted_htlcs,
- initial_msat=local_amount,
- reserve_sat=0,
- per_commitment_secret_seed=seed,
- funding_locked_received=True,
- current_commitment_signature=None,
- current_htlc_signatures=None,
- htlc_minimum_msat=1,
- upfront_shutdown_script=b'',
- announcement_node_sig=b'',
- announcement_bitcoin_sig=b'',
- ),
+ "remote_config": remote_config,
+ "local_config": local_config,
"constraints":lnpeer.ChannelConstraints(
flags=0,
capacity=funding_sat,
@@ -130,15 +121,6 @@ def create_channel_state(funding_txid, funding_index, funding_sat, is_initiator,
return StoredDict(state, None)
-@lru_cache()
-def bip32(sequence):
- node = bip32_utils.BIP32Node.from_rootseed(b"9dk", xtype='standard').subkey_at_private_derivation(sequence)
- k = node.eckey.get_secret_bytes()
- assert len(k) == 32
- assert type(k) is bytes
- return k
-
-
def create_test_channels(
*,
alice_lnwallet: 'MockLNWallet' = None,
@@ -147,7 +129,7 @@ def create_test_channels(
local_msat=None,
remote_msat=None,
random_seed=None,
- anchor_outputs=False,
+ anchor_outputs: bool = False,
local_max_inflight=None,
remote_max_inflight=None,
max_accepted_htlcs=5,
@@ -155,50 +137,75 @@ def create_test_channels(
if random_seed is None: # needed for deterministic randomness
random_seed = os.urandom(32)
random_gen = PRNG(random_seed)
- if alice_lnwallet or bob_lnwallet:
- assert alice_lnwallet and bob_lnwallet, "either both or neither lnwallet must be set"
- alice_name = alice_lnwallet.name
- bob_name = bob_lnwallet.name
- alice_pubkey = alice_lnwallet.node_keypair.pubkey
- bob_pubkey = bob_lnwallet.node_keypair.pubkey
- else:
- alice_name = "alice"
- bob_name = "bob",
- alice_pubkey = b"\x01" * 33
- bob_pubkey = b"\x02" * 33
- funding_txid = binascii.hexlify(random_gen.get_bytes(32)).decode("ascii")
+ if alice_lnwallet is None:
+ from .test_lnpeer import create_mock_lnwallet
+ alice_lnwallet = create_mock_lnwallet(name="alice", has_anchors=anchor_outputs)
+ if bob_lnwallet is None:
+ from .test_lnpeer import create_mock_lnwallet
+ bob_lnwallet = create_mock_lnwallet(name="bob", has_anchors=anchor_outputs)
+ alice_name = alice_lnwallet.name
+ bob_name = bob_lnwallet.name
+ alice_pubkey = alice_lnwallet.node_keypair.pubkey
+ bob_pubkey = bob_lnwallet.node_keypair.pubkey
+ funding_txid = random_gen.get_bytes(32).hex()
funding_index = 0
funding_sat = ((local_msat + remote_msat) // 1000) if local_msat is not None and remote_msat is not None else (bitcoin.COIN * 10)
- local_amount = local_msat if local_msat is not None else (funding_sat * 1000 // 2)
- remote_amount = remote_msat if remote_msat is not None else (funding_sat * 1000 // 2)
+ local_msat = local_msat if local_msat is not None else (funding_sat * 1000 // 2)
+ remote_msat = remote_msat if remote_msat is not None else (funding_sat * 1000 // 2)
local_max_inflight = funding_sat * 1000 if local_max_inflight is None else local_max_inflight
remote_max_inflight = funding_sat * 1000 if remote_max_inflight is None else remote_max_inflight
- alice_raw = [bip32("m/" + str(i)) for i in range(5)]
- bob_raw = [bip32("m/" + str(i)) for i in range(5,11)]
- alice_privkeys = [lnutil.Keypair(privkey_to_pubkey(x), x) for x in alice_raw] # TODO make it depend on alice_lnwallet
- bob_privkeys = [lnutil.Keypair(privkey_to_pubkey(x), x) for x in bob_raw]
- alice_pubkeys = [lnutil.OnlyPubkeyKeypair(x.pubkey) for x in alice_privkeys]
- bob_pubkeys = [lnutil.OnlyPubkeyKeypair(x.pubkey) for x in bob_privkeys]
-
- alice_seed = random_gen.get_bytes(32) # TODO make it depend on alice_lnwallet
- bob_seed = random_gen.get_bytes(32)
-
- alice_first = lnutil.secret_to_pubkey(
- int.from_bytes(lnutil.get_per_commitment_secret_from_seed(
- alice_seed, lnutil.RevocationStore.START_INDEX), "big"))
- bob_first = lnutil.secret_to_pubkey(
- int.from_bytes(lnutil.get_per_commitment_secret_from_seed(
- bob_seed, lnutil.RevocationStore.START_INDEX), "big"))
+
+ for config in [alice_lnwallet.config, bob_lnwallet.config]:
+ config.LIGHTNING_MAX_FUNDING_SAT = max(config.LIGHTNING_MAX_FUNDING_SAT, funding_sat)
+
+ peer_features = alice_lnwallet.features | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT
+ channel_type = ChannelType.OPTION_STATIC_REMOTEKEY
+ if anchor_outputs:
+ channel_type |= ChannelType.OPTION_ANCHORS_ZERO_FEE_HTLC_TX
+ # create alice's local config
+ alice_lconfig = alice_lnwallet.make_local_config_for_new_channel(
+ funding_sat=funding_sat,
+ push_msat=remote_msat,
+ initiator=LOCAL,
+ channel_type=channel_type,
+ multisig_funding_keypair=None,
+ peer_features=peer_features,
+ channel_seed=random_gen.get_bytes(32),
+ )
+ alice_lconfig.funding_locked_received = True
+ alice_lconfig.dust_limit_sat = 200
+ alice_lconfig.to_self_delay = 5
+ alice_lconfig.reserve_sat = 0
+ alice_lconfig.max_accepted_htlcs = max_accepted_htlcs
+ alice_lconfig.max_htlc_value_in_flight_msat = local_max_inflight
+ # create bob's local config
+ bob_lconfig = bob_lnwallet.make_local_config_for_new_channel(
+ funding_sat=funding_sat,
+ push_msat=remote_msat,
+ initiator=REMOTE,
+ channel_type=channel_type,
+ multisig_funding_keypair=None,
+ peer_features=peer_features,
+ channel_seed=random_gen.get_bytes(32),
+ )
+ bob_lconfig.funding_locked_received = True
+ bob_lconfig.dust_limit_sat = 1300
+ bob_lconfig.to_self_delay = 4
+ bob_lconfig.reserve_sat = 0
+ bob_lconfig.max_accepted_htlcs = max_accepted_htlcs
+ bob_lconfig.max_htlc_value_in_flight_msat = remote_max_inflight
alice, bob = (
lnchannel.Channel(
create_channel_state(
- funding_txid, funding_index, funding_sat, True, local_amount,
- remote_amount, alice_privkeys, bob_pubkeys, alice_seed, None,
- bob_first, other_node_id=bob_pubkey, l_dust=200, r_dust=1300,
- l_csv=5, r_csv=4, anchor_outputs=anchor_outputs,
- local_max_inflight=local_max_inflight, remote_max_inflight=remote_max_inflight,
- max_accepted_htlcs=max_accepted_htlcs,
+ funding_txid=funding_txid,
+ funding_index=funding_index,
+ funding_sat=funding_sat,
+ is_initiator=True,
+ other_node_id=bob_pubkey,
+ channel_type=channel_type,
+ local_config=alice_lconfig,
+ remote_config=_convert_to_rconfig_from_lconfig(bob_lconfig),
),
name=f"{alice_name}->{bob_name}",
initial_feerate=feerate,
@@ -206,12 +213,14 @@ def create_test_channels(
),
lnchannel.Channel(
create_channel_state(
- funding_txid, funding_index, funding_sat, False, remote_amount,
- local_amount, bob_privkeys, alice_pubkeys, bob_seed, None,
- alice_first, other_node_id=alice_pubkey, l_dust=1300, r_dust=200,
- l_csv=4, r_csv=5, anchor_outputs=anchor_outputs,
- local_max_inflight=remote_max_inflight, remote_max_inflight=local_max_inflight,
- max_accepted_htlcs=max_accepted_htlcs,
+ funding_txid=funding_txid,
+ funding_index=funding_index,
+ funding_sat=funding_sat,
+ is_initiator=False,
+ other_node_id=alice_pubkey,
+ channel_type=channel_type,
+ local_config=bob_lconfig,
+ remote_config=_convert_to_rconfig_from_lconfig(alice_lconfig),
),
name=f"{bob_name}->{alice_name}",
initial_feerate=feerate,
@@ -235,11 +244,13 @@ def create_test_channels(
assert len(a_htlc_sigs) == 0
assert len(b_htlc_sigs) == 0
- alice.open_with_first_pcp(bob_first, sig_from_bob)
- bob.open_with_first_pcp(alice_first, sig_from_alice)
+ alice.open_with_first_pcp(alice.config[REMOTE].next_per_commitment_point, sig_from_bob)
+ bob.open_with_first_pcp(bob.config[REMOTE].next_per_commitment_point, sig_from_alice)
- alice_second = lnutil.secret_to_pubkey(int.from_bytes(lnutil.get_per_commitment_secret_from_seed(alice_seed, lnutil.RevocationStore.START_INDEX - 1), "big"))
- bob_second = lnutil.secret_to_pubkey(int.from_bytes(lnutil.get_per_commitment_secret_from_seed(bob_seed, lnutil.RevocationStore.START_INDEX - 1), "big"))
+ alice_second = lnutil.secret_to_pubkey(int.from_bytes(
+ lnutil.get_per_commitment_secret_from_seed(alice.config[LOCAL].per_commitment_secret_seed, lnutil.RevocationStore.START_INDEX - 1), "big"))
+ bob_second = lnutil.secret_to_pubkey(int.from_bytes(
+ lnutil.get_per_commitment_secret_from_seed(bob.config[LOCAL].per_commitment_secret_seed, lnutil.RevocationStore.START_INDEX - 1), "big"))
# from funding_locked:
alice.config[REMOTE].next_per_commitment_point = bob_second
@@ -256,13 +267,13 @@ class TestFee(ElectrumTestCase):
test
https://github.com/lightningnetwork/lightning-rfc/blob/e0c436bd7a3ed6a028e1cb472908224658a14eca/03-transactions.md#requirements-2
"""
- def test_fee(self):
+ async def test_fee(self):
alice_channel, bob_channel = create_test_channels(
feerate=253,
- local_msat=10000000000,
- remote_msat=5000000000,
+ local_msat=10_000_000_000,
+ remote_msat=5_000_000_000,
anchor_outputs=self.TEST_ANCHOR_CHANNELS)
- expected_value = 9999056 if self.TEST_ANCHOR_CHANNELS else 9999817
+ expected_value = 9_999_056 if self.TEST_ANCHOR_CHANNELS else 9_999_817
self.assertIn(expected_value, [x.value for x in alice_channel.get_latest_commitment(LOCAL).outputs()])
@@ -284,8 +295,8 @@ class TestChannel(ElectrumTestCase):
super().setUpClass()
console_stderr_handler.setLevel(logging.DEBUG)
- def setUp(self):
- super().setUp()
+ async def asyncSetUp(self):
+ await super().asyncSetUp()
# Create a test channel which will be used for the duration of this
# unittest. The channel will be funded evenly with Alice having 5 BTC,
# and Bob having 5 BTC.
@@ -341,7 +352,7 @@ class TestChannel(ElectrumTestCase):
self.assertNumberNonAnchorOutputs(2, self.alice_channel.get_latest_commitment(REMOTE))
self.assertNumberNonAnchorOutputs(4, self.alice_channel.get_next_commitment(REMOTE))
- def test_SimpleAddSettleWorkflow(self):
+ async def test_SimpleAddSettleWorkflow(self):
alice_channel, bob_channel = self.alice_channel, self.bob_channel
htlc = self.htlc
@@ -774,7 +785,7 @@ class TestChannelAnchors(TestChannel):
class TestAvailableToSpend(ElectrumTestCase):
- def test_DesyncHTLCs(self):
+ async def test_DesyncHTLCs(self):
alice_channel, bob_channel = create_test_channels(anchor_outputs=self.TEST_ANCHOR_CHANNELS)
self.assertEqual(499986152000 if not alice_channel.has_anchors() else 499980692000, alice_channel.available_to_spend(LOCAL))
self.assertEqual(500000000000, bob_channel.available_to_spend(LOCAL))
@@ -820,7 +831,7 @@ class TestAvailableToSpend(ElectrumTestCase):
self.assertEqual(500000000000, bob_channel.available_to_spend(LOCAL))
alice_channel.add_htlc(htlc)
- def test_single_payment(self):
+ async def test_single_payment(self):
alice_channel, bob_channel = create_test_channels(
anchor_outputs=self.TEST_ANCHOR_CHANNELS,
local_msat=4000000000,
@@ -1016,7 +1027,7 @@ class TestChanReserveAnchors(TestChanReserve):
class TestDust(ElectrumTestCase):
- def test_DustLimit(self):
+ async def test_DustLimit(self):
"""Test that addition of an HTLC below the dust limit changes the balances."""
alice_channel, bob_channel = create_test_channels(anchor_outputs=self.TEST_ANCHOR_CHANNELS)
dust_limit_alice = alice_channel.config[LOCAL].dust_limit_sat
diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py
index 134c6a8..0b14a6a 100644
--- a/tests/test_lnpeer.py
+++ b/tests/test_lnpeer.py
@@ -138,7 +138,7 @@ class MockStandardWallet(Standard_Wallet):
return passphrase # lol, super secure name
def create_mock_lnwallet(*, name, has_anchors) -> 'MockLNWallet':
- _user_dir = tempfile.mkdtemp(prefix="electrum-lnpeer-test-")
+ _user_dir = tempfile.mkdtemp(prefix="electrum-lnpeer-test-") # TODO clean-up after??
config = SimpleConfig({}, read_user_dir_function=lambda: _user_dir)
config.ENABLE_ANCHOR_CHANNELS = has_anchors
config.INITIAL_TRAMPOLINE_FEE_LEVEL = 0
Why this scored 12/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.