URI: 
       tln: check if chain tip is stale when receiving HTLC - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
   DIR commit 54e1520ee4cce041d46a011cdef3ba9d2d4ec043
   DIR parent 12283d625b49c3a7d70f5fa7e9246098b6caf6bc
  HTML 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:
         M electrum/blockchain.py              |      15 +++++++++++++++
         M electrum/lnpeer.py                  |      40 ++++++++++++++++++++-----------
         M electrum/lnutil.py                  |       3 ++-
         M electrum/tests/test_lnpeer.py       |      14 ++++++++++++++
         M electrum/wallet.py                  |       6 +-----
       
       5 files changed, 58 insertions(+), 20 deletions(-)
       ---
   DIR diff --git a/electrum/blockchain.py b/electrum/blockchain.py
       t@@ -22,6 +22,7 @@
        # SOFTWARE.
        import os
        import threading
       +import time
        from typing import Optional, Dict, Mapping, Sequence
        
        from . import util
       t@@ -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()
   DIR diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py
       t@@ -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'')
       t@@ -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)
       t@@ -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:
       t@@ -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:
       t@@ -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
        
   DIR diff --git a/electrum/lnutil.py b/electrum/lnutil.py
       t@@ -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
   DIR diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py
       t@@ -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):
       t@@ -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)
       t@@ -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
   DIR diff --git a/electrum/wallet.py b/electrum/wallet.py
       t@@ -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()