diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 0d17b896c..0920a6f58 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1850,17 +1850,22 @@ class Peer(Logger): max_fee = chan.get_latest_fee(LOCAL if is_local else REMOTE) our_fee = min(our_fee, max_fee) - drop_to_remote = False # does the peer drop its to_local output or not? - def send_closing_signed(): - MODERN_FEE = True - if MODERN_FEE: - nonlocal fee_range_sent # we change fee_range_sent in outer scope - fee_range_sent = fee_range - closing_signed_tlvs = {'fee_range': fee_range} + 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 + + def send_closing_signed(our_fee, our_fee_range, drop_remote): + if our_fee_range: + closing_signed_tlvs = {'fee_range': our_fee_range} else: closing_signed_tlvs = {} - - our_sig, closing_tx = chan.make_closing_tx(our_scriptpubkey, their_scriptpubkey, fee_sat=our_fee, drop_remote=drop_to_remote) + our_sig, closing_tx = chan.make_closing_tx(our_scriptpubkey, their_scriptpubkey, fee_sat=our_fee, drop_remote=drop_remote) self.logger.info(f"Sending fee range: {closing_signed_tlvs} and fee: {our_fee}") self.send_message( 'closing_signed', @@ -1869,49 +1874,30 @@ class Peer(Logger): signature=our_sig, closing_signed_tlvs=closing_signed_tlvs, ) + def verify_signature(tx, sig): their_pubkey = chan.config[REMOTE].multisig_key.pubkey preimage_hex = tx.serialize_preimage(0) pre_hash = sha256d(bfh(preimage_hex)) return ecc.verify_signature(their_pubkey, sig, pre_hash) - # this is the fee range we initially try to enforce - # we aim at a fee between next block inclusion and some lower value - fee_range = {'min_fee_satoshis': our_fee // 2, 'max_fee_satoshis': our_fee * 2} - their_fee = None - fee_range_sent = {} - is_initiator = chan.constraints.is_initiator - # the funder sends the first 'closing_signed' message - if is_initiator: - send_closing_signed() - - # negotiate fee - while True: + async def receive_closing_signed(): try: cs_payload = await self.wait_for_message('closing_signed', chan.channel_id) except asyncio.exceptions.TimeoutError: - if not is_initiator and not their_fee: # we only force close if a peer doesn't reply - self.lnworker.schedule_force_closing(chan.channel_id) - raise Exception("Peer didn't reply with closing signed, force closed.") - else: - # situation when we as an initiator send a fee and the recipient - # already agrees with that fee, but doens't tell us - raise Exception("Peer didn't reply, probably already closed.") - - their_previous_fee = their_fee + self.lnworker.schedule_force_closing(chan.channel_id) + raise Exception("closing_signed not received, force closing.") their_fee = cs_payload['fee_satoshis'] - - # 0. integrity checks - # determine their closing transaction + their_fee_range = cs_payload['closing_signed_tlvs'].get('fee_range') their_sig = cs_payload['signature'] - # verify their sig: they might have dropped their output + # perform checks our_sig, closing_tx = chan.make_closing_tx(our_scriptpubkey, their_scriptpubkey, fee_sat=their_fee, drop_remote=False) if verify_signature(closing_tx, their_sig): - drop_to_remote = False + drop_remote = False else: our_sig, closing_tx = chan.make_closing_tx(our_scriptpubkey, their_scriptpubkey, fee_sat=their_fee, drop_remote=True) if verify_signature(closing_tx, their_sig): - drop_to_remote = True + drop_remote = True else: # this can happen if we consider our output too valuable to drop, # but the remote drops it because it violates their dust limit @@ -1919,102 +1905,91 @@ class Peer(Logger): # at this point we know how the closing tx looks like # check that their output is above their scriptpubkey's network dust limit to_remote_set = closing_tx.get_output_idxs_from_scriptpubkey(their_scriptpubkey.hex()) - if not drop_to_remote and to_remote_set: + if not drop_remote and to_remote_set: to_remote_idx = to_remote_set.pop() to_remote_amount = closing_tx.outputs()[to_remote_idx].value transaction.check_scriptpubkey_template_and_dust(their_scriptpubkey, to_remote_amount) + return their_fee, their_fee_range, their_sig, drop_remote - # 1. check fees - # if fee_satoshis is equal to its previously sent fee_satoshis: - if our_fee == their_fee: - # SHOULD sign and broadcast the final closing transaction. - break # we publish - - # 2. at start, adapt our fee range if we are not the channel initiator - fee_range_received = cs_payload['closing_signed_tlvs'].get('fee_range') - self.logger.info(f"Received fee range: {fee_range_received} and fee: {their_fee}") - # The sending node: if it is not the funder: - if fee_range_received and not is_initiator and not fee_range_sent: + def choose_new_fee(our_fee, our_fee_range, their_fee, their_fee_range, their_previous_fee): + assert our_fee != their_fee + 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: # SHOULD set max_fee_satoshis to at least the max_fee_satoshis received - fee_range['max_fee_satoshis'] = max(fee_range_received['max_fee_satoshis'], fee_range['max_fee_satoshis']) + 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 - fee_range['min_fee_satoshis'] = fee_range['max_fee_satoshis'] // 2 + our_fee_range['min_fee_satoshis'] = our_fee_range['max_fee_satoshis'] // 2 - # 3. if fee_satoshis matches its previously sent fee_range: - if fee_range_sent and (fee_range_sent['min_fee_satoshis'] <= their_fee <= fee_range_sent['max_fee_satoshis']): + # 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']): # SHOULD reply with a closing_signed with the same fee_satoshis value if it is different from its previously sent fee_satoshis - if our_fee != their_fee: - our_fee = their_fee - send_closing_signed() # peer publishes - break - # SHOULD use `fee_satoshis` to sign and broadcast the final closing transaction - else: - our_fee = their_fee - break # we publish + our_fee = their_fee - # 4. if the message contains a fee_range - if fee_range_received: - overlap_min = max(fee_range['min_fee_satoshis'], fee_range_received['min_fee_satoshis']) - overlap_max = min(fee_range['max_fee_satoshis'], fee_range_received['max_fee_satoshis']) + # the receiving node, if the message contains a fee_range + elif our_fee_range and their_fee_range: + overlap_min = max(our_fee_range['min_fee_satoshis'], their_fee_range['min_fee_satoshis']) + 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: - raise Exception("There is no overlap between between their and our fee range.") # TODO: MUST fail the channel if it doesn't receive a satisfying fee_range after a reasonable amount of time - # otherwise: + raise Exception("There is no overlap between between their and our fee range.") + # otherwise, if it is the funder + if is_initiator: + # if fee_satoshis is not in the overlap between the sent and received fee_range: + if not (overlap_min <= their_fee <= overlap_max): + # MUST fail the channel + self.lnworker.schedule_force_closing(chan.channel_id) + raise Exception("Their fee is not in the overlap region, we force closed.") + # otherwise, MUST reply with the same fee_satoshis. + our_fee = their_fee + # otherwise (it is not the funder): else: - if is_initiator: - # if fee_satoshis is not in the overlap between the sent and received fee_range: - if not (overlap_min <= their_fee <= overlap_max): - # MUST fail the channel - self.lnworker.schedule_force_closing(chan.channel_id) - raise Exception("Their fee is not in the overlap region, we force closed.") - # otherwise: - else: - our_fee = their_fee - # MUST reply with the same fee_satoshis. - send_closing_signed() # peer publishes - break - # otherwise (it is not the funder): - else: - # if it has already sent a closing_signed: - if fee_range_sent: - # if fee_satoshis is not the same as the value it sent: - if their_fee != our_fee: - # MUST fail the channel - self.lnworker.schedule_force_closing(chan.channel_id) - raise Exception("Expected the same fee as ours, we force closed.") - # otherwise: - else: - # MUST propose a fee_satoshis in the overlap between received and (about-to-be) sent fee_range. - our_fee = (overlap_min + overlap_max) // 2 - send_closing_signed() - continue - # otherwise, if fee_satoshis is not strictly between its last-sent fee_satoshis - # and its previously-received fee_satoshis, UNLESS it has since reconnected: - elif their_previous_fee and not (min(our_fee, their_previous_fee) < their_fee < max(our_fee, their_previous_fee)): - # SHOULD fail the connection. - raise Exception('Their fee is not between our last sent and their last sent fee.') - # otherwise, if the receiver agrees with the fee: - elif abs(their_fee - our_fee) <= 1: # we cannot have another strictly in-between value - # SHOULD reply with a closing_signed with the same fee_satoshis value. - our_fee = their_fee - send_closing_signed() # peer publishes - break - # otherwise: + # if it has already sent a closing_signed: + if fee_range_sent: + # fee_satoshis is not the same as the value we sent, we MUST fail the channel + self.lnworker.schedule_force_closing(chan.channel_id) + raise Exception("Expected the same fee as ours, we force closed.") + # otherwise: + # MUST propose a fee_satoshis in the overlap between received and (about-to-be) sent fee_range. + our_fee = (overlap_min + overlap_max) // 2 else: - # MUST propose a value "strictly between" the received fee_satoshis and its previously-sent fee_satoshis. - our_fee_proposed = (our_fee + their_fee) // 2 - if not (min(our_fee, their_fee) < our_fee_proposed < max(our_fee, their_fee)): - our_fee_proposed += (their_fee - our_fee) // 2 + # otherwise, if fee_satoshis is not strictly between its last-sent fee_satoshis + # and its previously-received fee_satoshis, UNLESS it has since reconnected: + if their_previous_fee and not (min(our_fee, their_previous_fee) < their_fee < max(our_fee, their_previous_fee)): + # SHOULD fail the connection. + raise Exception('Their fee is not between our last sent and their last sent fee.') + # accept their fee if they are very close + if abs(their_fee - our_fee) < 2: + our_fee = their_fee else: - our_fee = our_fee_proposed - send_closing_signed() + # this will be "strictly between" (as in BOLT2) previous values because of the above + our_fee = (our_fee + their_fee) // 2 - # reaching this part of the code means that we have reached agreement; to make - # sure the peer doesn't force close, send a last closing_signed + return our_fee, our_fee_range + + # Fee negotiation: both parties exchange 'funding_signed' messages. + # The funder sends the first message, the non-funder sends the last message. + # In the 'modern' case, at most 3 messages are exchanged, because choose_new_fee of the funder either returns their_fee or fails + their_fee = None + drop_remote = False # does the peer drop its to_local output or not? + if is_initiator: + send_closing_signed(our_fee, our_fee_range, drop_remote) + while True: + their_previous_fee = their_fee + their_fee, their_fee_range, their_sig, drop_remote = await receive_closing_signed() + if our_fee == their_fee: + break + our_fee, our_fee_range = choose_new_fee(our_fee, our_fee_range, their_fee, their_fee_range, their_previous_fee) + if not is_initiator and our_fee == their_fee: + break + send_closing_signed(our_fee, our_fee_range, drop_remote) + if is_initiator and our_fee == their_fee: + break if not is_initiator: - send_closing_signed() + send_closing_signed(our_fee, our_fee_range, drop_remote) # add signatures closing_tx.add_signature_to_txin( diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py index 2d5bbb33a..d964ad350 100644 --- a/electrum/tests/test_lnpeer.py +++ b/electrum/tests/test_lnpeer.py @@ -1063,13 +1063,22 @@ class TestPeer(TestCaseForTestnet): run(f()) @needs_test_with_all_chacha20_implementations - def test_close(self): + def test_close_modern(self): + self._test_close(True, True) + + @needs_test_with_all_chacha20_implementations + def test_close_old_style(self): + self._test_close(False, False) + + def _test_close(self, modern_alice, modern_bob): 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', 1000) + w2.network.config.set_key('fee_per_kb', 2000) w2.enable_htlc_settle = False lnaddr, pay_req = run(self.prepare_invoice(w2)) async def pay():