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())