diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 0920a6f58..8d32372c5 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1825,6 +1825,31 @@ class Peer(Logger): # can fullfill or fail htlcs. cannot add htlcs, because state != OPEN chan.set_can_send_ctx_updates(True) + def get_shutdown_fee_range(self, chan, closing_tx, is_local): + """ return the closing fee and fee range we initially try to enforce """ + config = self.network.config + if config.get('test_shutdown_fee'): + our_fee = config.get('test_shutdown_fee') + else: + fee_rate_per_kb = config.eta_target_to_fee(FEE_LN_ETA_TARGET) + if not fee_rate_per_kb: # fallback + fee_rate_per_kb = self.network.config.fee_per_kb() + our_fee = fee_rate_per_kb * closing_tx.estimated_size() // 1000 + # TODO: anchors: remove this, as commitment fee rate can be below chain head fee rate? + # BOLT2: The sending node MUST set fee less than or equal to the base fee of the final ctx + max_fee = chan.get_latest_fee(LOCAL if is_local else REMOTE) + our_fee = min(our_fee, max_fee) + # config modern_fee_negotiation can be set in tests + if config.get('test_shutdown_legacy'): + our_fee_range = None + elif config.get('test_shutdown_fee_range'): + our_fee_range = config.get('test_shutdown_fee_range') + else: + # we aim at a fee between next block inclusion and some lower value + our_fee_range = {'min_fee_satoshis': our_fee // 2, 'max_fee_satoshis': our_fee * 2} + self.logger.info(f"Our fee range: {our_fee_range} and fee: {our_fee}") + return our_fee, our_fee_range + @log_exceptions async def _shutdown(self, chan: Channel, payload, *, is_local: bool): # wait until no HTLCs remain in either commitment transaction @@ -1841,24 +1866,9 @@ class Peer(Logger): assert our_scriptpubkey # estimate fee of closing tx our_sig, closing_tx = chan.make_closing_tx(our_scriptpubkey, their_scriptpubkey, fee_sat=0) - fee_rate_per_kb = self.network.config.eta_target_to_fee(FEE_LN_ETA_TARGET) - if not fee_rate_per_kb: # fallback - fee_rate_per_kb = self.network.config.fee_per_kb() - our_fee = fee_rate_per_kb * closing_tx.estimated_size() // 1000 - # TODO: anchors: remove this, as commitment fee rate can be below chain head fee rate? - # BOLT2: The sending node MUST set fee less than or equal to the base fee of the final ctx - max_fee = chan.get_latest_fee(LOCAL if is_local else REMOTE) - our_fee = min(our_fee, max_fee) is_initiator = chan.constraints.is_initiator - # config modern_fee_negotiation can be set in tests - if self.network.config.get('modern_fee_negotiation', True): - # this is the fee range we initially try to enforce - # we aim at a fee between next block inclusion and some lower value - our_fee_range = {'min_fee_satoshis': our_fee // 2, 'max_fee_satoshis': our_fee * 2} - self.logger.info(f"Our fee range: {our_fee_range} and fee: {our_fee}") - else: - our_fee_range = None + our_fee, our_fee_range = self.get_shutdown_fee_range(chan, closing_tx, is_local) def send_closing_signed(our_fee, our_fee_range, drop_remote): if our_fee_range: @@ -1916,12 +1926,14 @@ class Peer(Logger): fee_range_sent = our_fee_range and (is_initiator or (their_previous_fee is not None)) # The sending node, if it is not the funder: - if our_fee_range and their_fee_range and not is_initiator: + if our_fee_range and their_fee_range and not is_initiator and not self.network.config.get('test_shutdown_fee_range'): # SHOULD set max_fee_satoshis to at least the max_fee_satoshis received our_fee_range['max_fee_satoshis'] = max(their_fee_range['max_fee_satoshis'], our_fee_range['max_fee_satoshis']) # SHOULD set min_fee_satoshis to a fairly low value - # TODO: what's fairly low value? allows the initiator to go to low values - our_fee_range['min_fee_satoshis'] = our_fee_range['max_fee_satoshis'] // 2 + our_fee_range['min_fee_satoshis'] = min(their_fee_range['min_fee_satoshis'], our_fee_range['min_fee_satoshis']) + # Note: the BOLT describes what the sending node SHOULD do. + # However, this assumes that we have decided to send 'funding_signed' in response to their fee_range. + # In practice, we might prefer to fail the channel in some cases (TODO) # the receiving node, if fee_satoshis matches its previously sent fee_range, if fee_range_sent and (our_fee_range['min_fee_satoshis'] <= their_fee <= our_fee_range['max_fee_satoshis']): @@ -1934,7 +1946,9 @@ class Peer(Logger): overlap_max = min(our_fee_range['max_fee_satoshis'], their_fee_range['max_fee_satoshis']) # if there is no overlap between that and its own fee_range if overlap_min > overlap_max: - # TODO: MUST fail the channel if it doesn't receive a satisfying fee_range after a reasonable amount of time + # TODO: the receiving node should first send a warning, and fail the channel + # only if it doesn't receive a satisfying fee_range after a reasonable amount of time + self.lnworker.schedule_force_closing(chan.channel_id) raise Exception("There is no overlap between between their and our fee range.") # otherwise, if it is the funder if is_initiator: diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py index d964ad350..7cc0716d8 100644 --- a/electrum/tests/test_lnpeer.py +++ b/electrum/tests/test_lnpeer.py @@ -1063,22 +1063,46 @@ class TestPeer(TestCaseForTestnet): run(f()) @needs_test_with_all_chacha20_implementations - def test_close_modern(self): - self._test_close(True, True) + def test_legacy_shutdown_low(self): + self._test_shutdown(alice_fee=100, bob_fee=150) @needs_test_with_all_chacha20_implementations - def test_close_old_style(self): - self._test_close(False, False) + def test_legacy_shutdown_high(self): + self._test_shutdown(alice_fee=2000, bob_fee=100) - def _test_close(self, modern_alice, modern_bob): + @needs_test_with_all_chacha20_implementations + def test_modern_shutdown_with_overlap(self): + self._test_shutdown( + alice_fee=1, + bob_fee=200, + alice_fee_range={'min_fee_satoshis': 1, 'max_fee_satoshis': 10}, + bob_fee_range={'min_fee_satoshis': 10, 'max_fee_satoshis': 300}) + + ## This test works but it is too slow (LN_P2P_NETWORK_TIMEOUT) + ## because tests do not use a proper LNWorker object + #@needs_test_with_all_chacha20_implementations + #def test_modern_shutdown_no_overlap(self): + # self.assertRaises(Exception, lambda: asyncio.run( + # self._test_shutdown( + # alice_fee=1, + # bob_fee=200, + # alice_fee_range={'min_fee_satoshis': 1, 'max_fee_satoshis': 10}, + # bob_fee_range={'min_fee_satoshis': 50, 'max_fee_satoshis': 300}) + # )) + + def _test_shutdown(self, alice_fee, bob_fee, alice_fee_range=None, bob_fee_range=None): alice_channel, bob_channel = create_test_channels() p1, p2, w1, w2, _q1, _q2 = self.prepare_peers(alice_channel, bob_channel) - w1.network.config.set_key('modern_fee_negotiation', modern_alice) - w2.network.config.set_key('modern_fee_negotiation', modern_bob) - w1.network.config.set_key('dynamic_fees', False) - w2.network.config.set_key('dynamic_fees', False) - w1.network.config.set_key('fee_per_kb', 5000) - w2.network.config.set_key('fee_per_kb', 2000) + w1.network.config.set_key('test_shutdown_fee', alice_fee) + w2.network.config.set_key('test_shutdown_fee', bob_fee) + if alice_fee_range is not None: + w1.network.config.set_key('test_shutdown_fee_range', alice_fee_range) + else: + w1.network.config.set_key('test_shutdown_legacy', True) + if bob_fee_range is not None: + w2.network.config.set_key('test_shutdown_fee_range', bob_fee_range) + else: + w2.network.config.set_key('test_shutdown_legacy', True) w2.enable_htlc_settle = False lnaddr, pay_req = run(self.prepare_invoice(w2)) async def pay():