Files
purple-electrumwallet/tests/test_lnwallet.py
T
f321x 85356e5544 lnwallet: make jit fees configurable, add mining fees
Make the just in time channel fees and channel size
configvars, as in practice not every provider would
use the same hardcoded fees or channel sizes.
Add the mining fees required for the funding transaction on top of the
opening fees to prevent opening channels at a loss in a higher
fee environment.
2026-03-26 17:39:03 +01:00

418 lines
19 KiB
Python

import logging
import os
import asyncio
from unittest import mock
from decimal import Decimal
from electrum.address_synchronizer import TX_HEIGHT_LOCAL
import electrum.trampoline
from . import ElectrumTestCase
from .test_lnchannel import create_test_channels
from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures
from electrum.logging import console_stderr_handler
from electrum.lntransport import LNPeerAddr
from electrum.invoices import LN_EXPIRY_NEVER, PR_UNPAID
from electrum.lnpeer import Peer
from electrum.lnchannel import Channel, ChannelState
from electrum.lnonion import OnionPacket, OnionRoutingFailure
from electrum.crypto import sha256
class TestLNWallet(ElectrumTestCase):
TESTNET = True
@classmethod
def setUpClass(cls):
super().setUpClass()
console_stderr_handler.setLevel(logging.DEBUG)
async def asyncSetUp(self):
self.lnwallet_anchors = self.create_mock_lnwallet(name='mock_lnwallet_anchors', has_anchors=True)
await super().asyncSetUp()
def test_create_payment_info(self):
wallet = self.lnwallet_anchors
tests = (
(100_000, 200, 100),
(None, 200, 100),
(None, None, LN_EXPIRY_NEVER),
(100_000, None, 0),
)
for amount_msat, min_final_cltv_delta, exp_delay in tests:
payment_hash = wallet.create_payment_info(
amount_msat=amount_msat,
min_final_cltv_delta=min_final_cltv_delta,
exp_delay=exp_delay,
)
self.assertIsNotNone(wallet.get_preimage(payment_hash))
pi = wallet.get_payment_info(payment_hash, direction=RECEIVED)
self.assertEqual(pi.amount_msat, amount_msat)
self.assertEqual(pi.min_final_cltv_delta, min_final_cltv_delta or MIN_FINAL_CLTV_DELTA_ACCEPTED)
self.assertEqual(pi.expiry_delay, exp_delay or LN_EXPIRY_NEVER)
self.assertEqual(pi.db_key, f"{payment_hash.hex()}:{int(pi.direction)}")
self.assertEqual(pi.status, PR_UNPAID)
self.assertIsNone(wallet.get_payment_info(os.urandom(32), direction=RECEIVED))
def test_create_payment_info__amount_must_not_be_zero(self):
wallet = self.lnwallet_anchors
amount_msat, min_final_cltv_delta, exp_delay = (0, 200, 100)
with self.assertRaises(ValueError):
wallet.create_payment_info(
amount_msat=amount_msat,
min_final_cltv_delta=min_final_cltv_delta,
exp_delay=exp_delay,
)
async def test_trampoline_invoice_features_and_routing_hints(self):
"""
When the invoice_features signal trampoline support, routing hints must only
contain trampoline nodes. When it does not, all channel can be added as r_tags.
We only signal trampoline support in the invoice if all open channels do support trampoline.
"""
wallet = self.lnwallet_anchors
self.assertFalse(wallet.uses_trampoline())
trampoline_peer = self.create_mock_lnwallet(name='trampoline_peer', has_anchors=True)
trampoline_pubkey = trampoline_peer.node_keypair.pubkey
regular_peer = self.create_mock_lnwallet(name='regular_peer', has_anchors=True)
regular_pubkey = regular_peer.node_keypair.pubkey
chan_t, _ = create_test_channels(alice_lnwallet=wallet, bob_lnwallet=trampoline_peer, anchor_outputs=True)
chan_r, _ = create_test_channels(alice_lnwallet=wallet, bob_lnwallet=regular_peer, anchor_outputs=True)
wallet._add_channel(chan_t)
wallet._add_channel(chan_r)
# only trampoline_peer is a known trampoline forwarder
electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = {
'trampoline_peer': LNPeerAddr(
host="127.0.0.1",
port=9735,
pubkey=trampoline_pubkey,
),
}
self.addCleanup(lambda: electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS.clear())
amount_msat = 100_000
# mixed peers: trampoline feature must be stripped, all peers in hints
payment_hash = wallet.create_payment_info(amount_msat=amount_msat)
pi = wallet.get_payment_info(payment_hash, direction=RECEIVED)
self.assertFalse(
pi.invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM),
"trampoline bit should be stripped when not all peers are trampoline",
)
lnaddr, _ = wallet.get_bolt11_invoice(payment_info=pi, message='test', fallback_address=None)
hint_node_ids = {route[0][0] for route in lnaddr.get_routing_info('r')}
self.assertEqual(hint_node_ids, {trampoline_pubkey, regular_pubkey})
# trampoline feature should not be set if we use trampoline but one peer is not a trampoline
old_check, wallet.uses_trampoline = wallet.uses_trampoline, lambda: True
self.assertTrue(wallet.uses_trampoline())
payment_hash = wallet.create_payment_info(amount_msat=amount_msat)
pi = wallet.get_payment_info(payment_hash, direction=RECEIVED)
self.assertFalse(
pi.invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM),
"trampoline feature should not be set if we use trampoline but one peer is not a trampoline",
)
wallet.clear_invoices_cache()
lnaddr, _ = wallet.get_bolt11_invoice(payment_info=pi, message='test', fallback_address=None)
hint_node_ids = {route[0][0] for route in lnaddr.get_routing_info('r')}
self.assertEqual(hint_node_ids, {trampoline_pubkey, regular_pubkey})
wallet.uses_trampoline = old_check
self.assertFalse(wallet.uses_trampoline())
# all peers trampoline: we signal trampoline support, even with trampoline disabled
electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS['regular_peer'] = LNPeerAddr(
host="127.0.0.1",
port=9735,
pubkey=regular_pubkey,
)
payment_hash2 = wallet.create_payment_info(amount_msat=amount_msat)
pi2 = wallet.get_payment_info(payment_hash2, direction=RECEIVED)
self.assertTrue(
pi2.invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM),
"trampoline bit should be present when all peers are trampoline",
)
wallet.clear_invoices_cache()
lnaddr2, _ = wallet.get_bolt11_invoice(payment_info=pi2, message='test', fallback_address=None)
hint_node_ids2 = {route[0][0] for route in lnaddr2.get_routing_info('r')}
self.assertEqual(hint_node_ids2, {trampoline_pubkey, regular_pubkey})
# assert only trampoline peers are included in r_tags if the invoice_features signal trampoline
del electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS['regular_peer']
wallet.clear_invoices_cache()
lnaddr3, _ = wallet.get_bolt11_invoice(payment_info=pi2, message='test', fallback_address=None)
hint_node_ids3 = {route[0][0] for route in lnaddr3.get_routing_info('r')}
self.assertEqual(hint_node_ids3, {trampoline_pubkey})
async def test_open_channel_just_in_time_success(self):
wallet = self.lnwallet_anchors
wallet.config.ZEROCONF_MIN_OPENING_FEE = 0
wallet.config.OPEN_ZEROCONF_CHANNELS = True
next_peer = mock.Mock(spec=Peer)
next_chan = mock.Mock(spec=Channel)
next_chan.get_scid_or_local_alias.return_value = bytes(8)
funding_tx = mock.Mock()
funding_tx.txid.return_value = os.urandom(32).hex()
funding_tx.get_fee = lambda: 250
wallet.open_channel_with_peer = mock.AsyncMock(return_value=(next_chan, funding_tx))
wallet.network.try_broadcasting = mock.AsyncMock(return_value=True)
preimage = os.urandom(32)
payment_hash = sha256(preimage)
htlc = mock.Mock()
htlc.htlc_id = 0
next_peer.send_htlc.return_value = htlc
task = asyncio.create_task(wallet.open_channel_just_in_time(
next_peer=next_peer,
next_amount_msat_htlc=1000000,
next_cltv_abs=500,
payment_hash=payment_hash,
next_onion=mock.Mock(spec=OnionPacket)
))
await asyncio.sleep(0.1)
wallet.save_preimage(payment_hash, preimage)
htlc_key = await task
htlc_key_correct = serialize_htlc_key(next_chan.get_scid_or_local_alias(), htlc.htlc_id)
self.assertEqual(htlc_key, htlc_key_correct)
wallet.open_channel_with_peer.assert_called_once()
next_peer.send_htlc.assert_called_once()
wallet.network.try_broadcasting.assert_called()
async def test_open_channel_just_in_time_failure_channel_open(self):
"""The channel opening failed on the LSP side because the client rejected the incoming channel"""
wallet = self.lnwallet_anchors
wallet.config.ZEROCONF_MIN_OPENING_FEE = 0
wallet.config.OPEN_ZEROCONF_CHANNELS = True
next_peer = mock.Mock(spec=Peer)
wallet.open_channel_with_peer = mock.AsyncMock(side_effect=Exception("peer rejected incoming channel"))
preimage = os.urandom(32)
wallet.save_preimage(sha256(preimage), preimage)
wallet._cleanup_failed_jit_channel = mock.AsyncMock()
with self.assertRaises(OnionRoutingFailure):
await wallet.open_channel_just_in_time(
next_peer=next_peer,
next_amount_msat_htlc=1000000,
next_cltv_abs=500,
payment_hash=sha256(preimage),
next_onion=mock.Mock(spec=OnionPacket)
)
self.assertIsNone(wallet.get_preimage(sha256(preimage)))
wallet._cleanup_failed_jit_channel.assert_not_called()
async def test_open_channel_just_in_time_failure_send_htlc(self):
"""The LSP fails to forward the htlc to the client"""
wallet = self.lnwallet_anchors
wallet.config.ZEROCONF_MIN_OPENING_FEE = 0
wallet.config.OPEN_ZEROCONF_CHANNELS = True
next_peer = mock.Mock(spec=Peer)
chan = mock.Mock(spec=Channel)
funding_tx = mock.Mock()
wallet.open_channel_with_peer = mock.AsyncMock(return_value=(chan, funding_tx))
next_peer.send_htlc.side_effect = Exception("couldn't send htlc, peer disconnected")
preimage = os.urandom(32)
wallet.save_preimage(sha256(preimage), preimage)
wallet._cleanup_failed_jit_channel = mock.AsyncMock()
with self.assertRaises(OnionRoutingFailure):
await wallet.open_channel_just_in_time(
next_peer=next_peer,
next_amount_msat_htlc=1000000,
next_cltv_abs=500,
payment_hash=sha256(preimage),
next_onion=mock.Mock(spec=OnionPacket)
)
self.assertIsNone(wallet.get_preimage(sha256(preimage)))
wallet._cleanup_failed_jit_channel.assert_called_once_with(chan)
async def test_open_channel_just_in_time_failure_preimage_timeout(self):
"""The client never releases the preimage"""
wallet = self.lnwallet_anchors
wallet.config.ZEROCONF_MIN_OPENING_FEE = 0
wallet.config.OPEN_ZEROCONF_CHANNELS = True
next_peer = mock.Mock(spec=Peer)
chan = mock.Mock(spec=Channel)
funding_tx = mock.Mock()
wallet.open_channel_with_peer = mock.AsyncMock(return_value=(chan, funding_tx))
htlc = mock.Mock()
next_peer.send_htlc.return_value = htlc
wallet._cleanup_failed_jit_channel = mock.AsyncMock()
with mock.patch('electrum.lnworker.LN_P2P_NETWORK_TIMEOUT', 0.01):
with self.assertRaises(OnionRoutingFailure):
await wallet.open_channel_just_in_time(
next_peer=next_peer,
next_amount_msat_htlc=1000000,
next_cltv_abs=500,
payment_hash=os.urandom(32),
next_onion=mock.Mock(spec=OnionPacket)
)
wallet._cleanup_failed_jit_channel.assert_called_once_with(chan)
async def test_open_channel_just_in_time_failure_broadcast(self):
wallet = self.lnwallet_anchors
wallet.config.ZEROCONF_MIN_OPENING_FEE = 0
wallet.config.OPEN_ZEROCONF_CHANNELS = True
next_peer = mock.Mock(spec=Peer)
chan = mock.Mock(spec=Channel)
funding_tx = mock.Mock()
wallet.open_channel_with_peer = mock.AsyncMock(return_value=(chan, funding_tx))
preimage = os.urandom(32)
wallet.save_preimage(sha256(preimage), preimage)
wallet.network.try_broadcasting = mock.AsyncMock(return_value=False)
wallet.wallet.adb.get_tx_height = mock.Mock(return_value=mock.Mock(height=lambda: TX_HEIGHT_LOCAL))
wallet._cleanup_failed_jit_channel = mock.AsyncMock()
with mock.patch('electrum.lnworker.ZEROCONF_TIMEOUT', 0.01), \
mock.patch('electrum.lnworker.asyncio.sleep', new_callable=mock.AsyncMock):
with self.assertRaises(OnionRoutingFailure):
await wallet.open_channel_just_in_time(
next_peer=next_peer,
next_amount_msat_htlc=1000000,
next_cltv_abs=500,
payment_hash=sha256(preimage),
next_onion=mock.Mock(spec=OnionPacket)
)
self.assertIsNone(wallet.get_preimage(sha256(preimage)))
wallet._cleanup_failed_jit_channel.assert_called_once_with(chan)
async def test_open_channel_just_in_time_config_disabled(self):
"""open_channel_just_in_time rejects to open a channel if the config is disabled"""
wallet = self.lnwallet_anchors
wallet.config.ZEROCONF_MIN_OPENING_FEE = 0
wallet.config.OPEN_ZEROCONF_CHANNELS = False
with self.assertRaises(AssertionError):
await wallet.open_channel_just_in_time(
next_peer=mock.Mock(spec=Peer),
next_amount_msat_htlc=1000000,
next_cltv_abs=500,
payment_hash=os.urandom(32),
next_onion=mock.Mock(spec=OnionPacket)
)
async def test_cleanup_failed_jit_channel(self):
wallet = self.lnwallet_anchors
chan = mock.Mock(spec=Channel)
chan_id = os.urandom(32).hex()
chan.channel_id = chan_id
funding_txid = os.urandom(32).hex()
chan.funding_outpoint = mock.Mock()
chan.funding_outpoint.txid = funding_txid
chan.get_funding_height.return_value = None
# close_channel fails with exception
wallet.close_channel = mock.AsyncMock(side_effect=Exception("peer disconnected"))
wallet.remove_channel = mock.Mock()
wallet.lnwatcher = mock.Mock()
wallet.lnwatcher.adb = mock.Mock()
wallet.lnwatcher.adb.remove_transaction = mock.Mock()
await wallet._cleanup_failed_jit_channel(chan)
wallet.close_channel.assert_called_once_with(chan_id)
chan.set_state.assert_called_once_with(ChannelState.REDEEMED, force=True)
wallet.lnwatcher.adb.remove_transaction.assert_called_once_with(funding_txid)
wallet.remove_channel.assert_called_once_with(chan_id)
async def test_receive_requires_jit_channel(self):
wallet = self.lnwallet_anchors
with self.subTest(msg="cannot get jit channel"):
wallet.can_get_zeroconf_channel = mock.Mock(return_value=False)
wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(0))
self.assertFalse(wallet.receive_requires_jit_channel(1_000_000))
with self.subTest(msg="could get zeroconf channel but doesn't need one"):
wallet.can_get_zeroconf_channel = mock.Mock(return_value=True)
wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(2000))
self.assertFalse(wallet.receive_requires_jit_channel(1_000_000))
with self.subTest(msg="could get zeroconf channel and needs one"):
wallet.can_get_zeroconf_channel = mock.Mock(return_value=True)
wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(500))
self.assertTrue(wallet.receive_requires_jit_channel(1_000_000))
with self.subTest(msg="could get one but can receive exactly the requested amount"):
wallet.can_get_zeroconf_channel = mock.Mock(return_value=True)
wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(1000))
self.assertFalse(wallet.receive_requires_jit_channel(1_000_000))
with self.subTest(msg="0 amount invoice, could get channel but can receive something"):
wallet.can_get_zeroconf_channel = mock.Mock(return_value=True)
wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(1))
self.assertFalse(wallet.receive_requires_jit_channel(None))
with self.subTest(msg="0 amount invoice (None amount), cannot receive anything and can get channel"):
wallet.can_get_zeroconf_channel = mock.Mock(return_value=True)
wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(0))
self.assertTrue(wallet.receive_requires_jit_channel(None))
with self.subTest(msg="0 amount invoice (0 msat), cannot receive anything, could get channel"):
wallet.can_get_zeroconf_channel = mock.Mock(return_value=True)
wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(0))
self.assertTrue(wallet.receive_requires_jit_channel(0))
async def test_can_get_zeroconf_channel(self):
wallet = self.lnwallet_anchors
valid_peer = "02" * 33 + "@localhost:9735"
with self.subTest(msg="disabled in config"):
wallet.config.OPEN_ZEROCONF_CHANNELS = False
wallet.config.ZEROCONF_TRUSTED_NODE = valid_peer
self.assertFalse(wallet.can_get_zeroconf_channel())
with self.subTest(msg="enabled, but no trusted node configured"):
wallet.config.OPEN_ZEROCONF_CHANNELS = True
wallet.config.ZEROCONF_TRUSTED_NODE = ''
self.assertFalse(wallet.can_get_zeroconf_channel())
with self.subTest(msg="enabled, invalid trusted node string"):
wallet.config.OPEN_ZEROCONF_CHANNELS = True
wallet.config.ZEROCONF_TRUSTED_NODE = "invalid_node_string"
self.assertFalse(wallet.can_get_zeroconf_channel())
with self.subTest(msg="enabled, valid trusted node, but not connected"):
wallet.config.OPEN_ZEROCONF_CHANNELS = True
wallet.config.ZEROCONF_TRUSTED_NODE = valid_peer
self.assertFalse(wallet.can_get_zeroconf_channel())
with self.subTest(msg="enabled, valid trusted node, and connected"):
wallet.lnpeermgr.get_peer_by_pubkey = mock.Mock(return_value=mock.Mock(spec=Peer))
wallet.config.OPEN_ZEROCONF_CHANNELS = True
wallet.config.ZEROCONF_TRUSTED_NODE = valid_peer
self.assertTrue(wallet.can_get_zeroconf_channel())