electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit 54e1520ee4cce041d46a011cdef3ba9d2d4ec043
parent 12283d625b49c3a7d70f5fa7e9246098b6caf6bc
Author: SomberNight <somber.night@protonmail.com>
Date:   Mon, 13 Apr 2020 17:04:27 +0200

ln: check if chain tip is stale when receiving HTLC

if so, don't release preimage / don't forward HTLC

Diffstat:
Melectrum/blockchain.py | 15+++++++++++++++
Melectrum/lnpeer.py | 40++++++++++++++++++++++++++--------------
Melectrum/lnutil.py | 3++-
Melectrum/tests/test_lnpeer.py | 14++++++++++++++
Melectrum/wallet.py | 6+-----
5 files changed, 58 insertions(+), 20 deletions(-)

diff --git a/electrum/blockchain.py b/electrum/blockchain.py @@ -22,6 +22,7 @@ # SOFTWARE. import os import threading +import time from typing import Optional, Dict, Mapping, Sequence from . import util @@ -484,6 +485,20 @@ class Blockchain(Logger): height = self.height() return self.read_header(height) + def is_tip_stale(self) -> bool: + STALE_DELAY = 8 * 60 * 60 # in seconds + header = self.header_at_tip() + if not header: + return True + # note: We check the timestamp only in the latest header. + # The Bitcoin consensus has a lot of leeway here: + # - needs to be greater than the median of the timestamps of the past 11 blocks, and + # - up to at most 2 hours into the future compared to local clock + # so there is ~2 hours of leeway in either direction + if header['timestamp'] + STALE_DELAY < time.time(): + return True + return False + def get_hash(self, height: int) -> str: def is_height_checkpoint(): within_cp_range = height <= constants.net.max_checkpoint() diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py @@ -1131,19 +1131,23 @@ class Peer(Logger): chan.receive_htlc(htlc, onion_packet) def maybe_forward_htlc(self, chan: Channel, htlc: UpdateAddHtlc, *, - onion_packet: OnionPacket, processed_onion: ProcessedOnionPacket): + onion_packet: OnionPacket, processed_onion: ProcessedOnionPacket + ) -> Optional[OnionRoutingFailureMessage]: # Forward HTLC # FIXME: there are critical safety checks MISSING here forwarding_enabled = self.network.config.get('lightning_forward_payments', False) if not forwarding_enabled: self.logger.info(f"forwarding is disabled. failing htlc.") return OnionRoutingFailureMessage(code=OnionFailureCode.PERMANENT_CHANNEL_FAILURE, data=b'') + chain = self.network.blockchain() + if chain.is_tip_stale(): + return OnionRoutingFailureMessage(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') try: next_chan_scid = processed_onion.hop_data.payload["short_channel_id"]["short_channel_id"] except: return OnionRoutingFailureMessage(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') next_chan = self.lnworker.get_channel_by_short_id(next_chan_scid) - local_height = self.network.get_local_height() + local_height = chain.height() if next_chan is None: self.logger.info(f"cannot forward htlc. cannot find next_chan {next_chan_scid}") return OnionRoutingFailureMessage(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'') @@ -1161,7 +1165,7 @@ class Peer(Logger): if htlc.cltv_expiry - next_cltv_expiry < NBLOCK_OUR_CLTV_EXPIRY_DELTA: data = htlc.cltv_expiry.to_bytes(4, byteorder="big") + outgoing_chan_upd_len + outgoing_chan_upd return OnionRoutingFailureMessage(code=OnionFailureCode.INCORRECT_CLTV_EXPIRY, data=data) - if htlc.cltv_expiry - lnutil.NBLOCK_DEADLINE_BEFORE_EXPIRY_FOR_RECEIVED_HTLCS <= local_height \ + if htlc.cltv_expiry - lnutil.MIN_FINAL_CLTV_EXPIRY_ACCEPTED <= local_height \ or next_cltv_expiry <= local_height: data = outgoing_chan_upd_len + outgoing_chan_upd return OnionRoutingFailureMessage(code=OnionFailureCode.EXPIRY_TOO_SOON, data=data) @@ -1202,14 +1206,15 @@ class Peer(Logger): return OnionRoutingFailureMessage(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=data) return None - def maybe_fulfill_htlc(self, chan: Channel, htlc: UpdateAddHtlc, *, - onion_packet: OnionPacket, processed_onion: ProcessedOnionPacket): + def maybe_fulfill_htlc(self, *, chan: Channel, htlc: UpdateAddHtlc, + onion_packet: OnionPacket, processed_onion: ProcessedOnionPacket, + ) -> Tuple[Optional[bytes], Optional[OnionRoutingFailureMessage]]: try: info = self.lnworker.get_payment_info(htlc.payment_hash) preimage = self.lnworker.get_preimage(htlc.payment_hash) except UnknownPaymentHash: reason = OnionRoutingFailureMessage(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'') - return False, reason + return None, reason try: payment_secret_from_onion = processed_onion.hop_data.payload["payment_data"]["payment_secret"] except: @@ -1217,30 +1222,37 @@ class Peer(Logger): else: if payment_secret_from_onion != derive_payment_secret_from_payment_preimage(preimage): reason = OnionRoutingFailureMessage(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'') - return False, reason + return None, reason expected_received_msat = int(info.amount * 1000) if info.amount is not None else None if expected_received_msat is not None and \ not (expected_received_msat <= htlc.amount_msat <= 2 * expected_received_msat): reason = OnionRoutingFailureMessage(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'') - return False, reason - local_height = self.network.get_local_height() + return None, reason + # Check that our blockchain tip is sufficiently recent so that we have an approx idea of the height. + # We should not release the preimage for an HTLC that its sender could already time out as + # then they might try to force-close and it becomes a race. + chain = self.network.blockchain() + if chain.is_tip_stale(): + reason = OnionRoutingFailureMessage(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') + return None, reason + local_height = chain.height() if local_height + MIN_FINAL_CLTV_EXPIRY_ACCEPTED > htlc.cltv_expiry: reason = OnionRoutingFailureMessage(code=OnionFailureCode.FINAL_EXPIRY_TOO_SOON, data=b'') - return False, reason + return None, reason try: cltv_from_onion = processed_onion.hop_data.payload["outgoing_cltv_value"]["outgoing_cltv_value"] except: reason = OnionRoutingFailureMessage(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - return False, reason + return None, reason if cltv_from_onion != htlc.cltv_expiry: reason = OnionRoutingFailureMessage(code=OnionFailureCode.FINAL_INCORRECT_CLTV_EXPIRY, data=htlc.cltv_expiry.to_bytes(4, byteorder="big")) - return False, reason + return None, reason try: amount_from_onion = processed_onion.hop_data.payload["amt_to_forward"]["amt_to_forward"] except: reason = OnionRoutingFailureMessage(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - return False, reason + return None, reason try: amount_from_onion = processed_onion.hop_data.payload["payment_data"]["total_msat"] except: @@ -1248,7 +1260,7 @@ class Peer(Logger): if amount_from_onion > htlc.amount_msat: reason = OnionRoutingFailureMessage(code=OnionFailureCode.FINAL_INCORRECT_HTLC_AMOUNT, data=htlc.amount_msat.to_bytes(8, byteorder="big")) - return False, reason + return None, reason # all good return preimage, None diff --git a/electrum/lnutil.py b/electrum/lnutil.py @@ -262,7 +262,8 @@ CHANNEL_OPENING_TIMEOUT = 24*60*60 ##### CLTV-expiry-delta-related values # see https://github.com/lightningnetwork/lightning-rfc/blob/master/02-peer-protocol.md#cltv_expiry_delta-selection -# the minimum cltv_expiry accepted for terminal payments +# the minimum cltv_expiry accepted for newly received HTLCs +# note: when changing, consider Blockchain.is_tip_stale() MIN_FINAL_CLTV_EXPIRY_ACCEPTED = 144 # set it a tiny bit higher for invoices as blocks could get mined # during forward path of payment diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py @@ -58,6 +58,7 @@ class MockNetwork: self.channel_db.data_loaded.set() self.path_finder = LNPathFinder(self.channel_db) self.tx_queue = tx_queue + self._blockchain = MockBlockchain() @property def callback_lock(self): @@ -70,6 +71,9 @@ class MockNetwork: def get_local_height(self): return 0 + def blockchain(self): + return self._blockchain + async def broadcast_transaction(self, tx): if self.tx_queue: await self.tx_queue.put(tx) @@ -77,6 +81,16 @@ class MockNetwork: async def try_broadcasting(self, tx, name): await self.broadcast_transaction(tx) + +class MockBlockchain: + + def height(self): + return 0 + + def is_tip_stale(self): + return False + + class MockWallet: def set_label(self, x, y): pass diff --git a/electrum/wallet.py b/electrum/wallet.py @@ -174,11 +174,7 @@ def get_locktime_for_new_transaction(network: 'Network') -> int: if not network: return 0 chain = network.blockchain() - header = chain.header_at_tip() - if not header: - return 0 - STALE_DELAY = 8 * 60 * 60 # in seconds - if header['timestamp'] + STALE_DELAY < time.time(): + if chain.is_tip_stale(): return 0 # discourage "fee sniping" locktime = chain.height()