lnrouter: use separate timestamp per liquidity hint value
What changed, and why it matters
This commit fixes a bug in Electrum's Lightning payment routing that could cause the wallet to avoid using a channel for large payments longer than it should. After a small payment succeeded, an old record of a failed large payment could incorrectly become active again, making Electrum think the channel still could not handle large amounts until the app was restarted. The fix gives each routing hint its own expiration timestamp instead of sharing one across all hints.
Reviewers should verify that HINT_DURATION boundaries are correctly handled and that the new per-value expiry does not introduce any path where a stale can_send could incorrectly override a fresh cannot_send. The regression test should be run. No immediate security advisory appears necessary, but the fix should be included in the next release.
Security signals we found
Logic bug in routing state expiration could cause denial of service for large Lightning payments
Shared timestamp allowed stale failure hints to be reactivated by unrelated success updates
Setter comparisons against raw internal values instead of getter-validated values allowed expired hints to block new data
Fix includes regression test demonstrating expiry resurrection scenario
Evidence from the diff
The patch refactors LiquidityHint in electrum/lnrouter.py so that each stored value (can_send_forward, cannot_send_forward, can_send_backward, cannot_send_backward) carries its own timestamp via a new LiquidityHintItem NamedTuple. Previously a single shared hint_timestamp was reset whenever any value was updated, which could resurrect an expired cannot_send record when a smaller can_send update occurred. Setters now compare against the getter’s currently valid value to prevent stale larger values from blocking new smaller ones. reset_liquidity_hints now clears stored amounts rather than just zeroing the shared timestamp. A regression test is added.
Changed components
electrum/lnrouter.pyLiquidityHint classLiquidityHintMgr.reset_liquidity_hintstests/test_lnrouter.pyInspect captured patch +75 / −33
diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py
index 023395a..bf25d93 100644
--- a/electrum/lnrouter.py
+++ b/electrum/lnrouter.py
@@ -25,7 +25,7 @@
import queue
from collections import defaultdict
-from typing import Sequence, Tuple, Optional, Dict, TYPE_CHECKING, Set, Callable
+from typing import Sequence, Tuple, Optional, Dict, TYPE_CHECKING, Set, Callable, NamedTuple
import time
import threading
from threading import RLock
@@ -33,7 +33,7 @@ from math import inf
import attr
-from .util import profiler, with_lock
+from .util import profiler, with_lock, now
from .logging import Logger
from .lnutil import (NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, LnFeatures,
NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, PaymentFeeBudget)
@@ -159,6 +159,21 @@ def is_route_within_budget(
return True
+class LiquidityHintItem(NamedTuple):
+ amount_msat: int
+ timestamp: int
+
+ @classmethod
+ def from_amount(cls, amount_msat: int) -> 'LiquidityHintItem':
+ return cls(amount_msat=amount_msat, timestamp=now())
+
+ def get_valid_amount(self) -> Optional[int]:
+ return None if self.is_invalid() else self.amount_msat
+
+ def is_invalid(self) -> bool:
+ return now() - self.timestamp > HINT_DURATION
+
+
class LiquidityHint:
"""Encodes the amounts that can and cannot be sent over the direction of a
channel.
@@ -168,57 +183,55 @@ class LiquidityHint:
"""
def __init__(self):
# use "can_send_forward + can_send_backward < cannot_send_forward + cannot_send_backward" as a sanity check?
- self._can_send_forward = None # type: Optional[int]
- self._cannot_send_forward = None # type: Optional[int]
- self._can_send_backward = None # type: Optional[int]
- self._cannot_send_backward = None # type: Optional[int]
- self.hint_timestamp = 0 # type: int
+ self._can_send_forward = None # type: Optional[LiquidityHintItem]
+ self._cannot_send_forward = None # type: Optional[LiquidityHintItem]
+ self._can_send_backward = None # type: Optional[LiquidityHintItem]
+ self._cannot_send_backward = None # type: Optional[LiquidityHintItem]
self._inflight_htlcs_forward = 0
self._inflight_htlcs_backward = 0
- def is_hint_invalid(self) -> bool:
- now = int(time.time())
- return now - self.hint_timestamp > HINT_DURATION
-
@property
def can_send_forward(self) -> Optional[int]:
- return None if self.is_hint_invalid() else self._can_send_forward
+ return self._can_send_forward.get_valid_amount() if self._can_send_forward else None
@can_send_forward.setter
def can_send_forward(self, amount_msat: int) -> None:
# we don't want to record less significant info
# (sendable amount is lower than known sendable amount):
- if self._can_send_forward and self._can_send_forward > amount_msat:
+ known = self.can_send_forward
+ if known is not None and known > amount_msat:
return
- self._can_send_forward = amount_msat
+ self._can_send_forward = LiquidityHintItem.from_amount(amount_msat)
# we make a sanity check that sendable amount is lower than not sendable amount
- if self._cannot_send_forward and self._can_send_forward > self._cannot_send_forward:
+ if self._cannot_send_forward and self._can_send_forward.amount_msat > self._cannot_send_forward.amount_msat:
self._cannot_send_forward = None
@property
def can_send_backward(self) -> Optional[int]:
- return None if self.is_hint_invalid() else self._can_send_backward
+ return self._can_send_backward.get_valid_amount() if self._can_send_backward else None
@can_send_backward.setter
def can_send_backward(self, amount_msat: int) -> None:
- if self._can_send_backward and self._can_send_backward > amount_msat:
+ known = self.can_send_backward
+ if known is not None and known > amount_msat:
return
- self._can_send_backward = amount_msat
- if self._cannot_send_backward and self._can_send_backward > self._cannot_send_backward:
+ self._can_send_backward = LiquidityHintItem.from_amount(amount_msat)
+ if self._cannot_send_backward and self._can_send_backward.amount_msat > self._cannot_send_backward.amount_msat:
self._cannot_send_backward = None
@property
def cannot_send_forward(self) -> Optional[int]:
- return None if self.is_hint_invalid() else self._cannot_send_forward
+ return self._cannot_send_forward.get_valid_amount() if self._cannot_send_forward else None
@cannot_send_forward.setter
def cannot_send_forward(self, amount_msat: int) -> None:
# we don't want to record less significant info
# (not sendable amount is higher than known not sendable amount):
- if self._cannot_send_forward and self._cannot_send_forward < amount_msat:
+ known = self.cannot_send_forward
+ if known is not None and known < amount_msat:
return
- self._cannot_send_forward = amount_msat
- if self._can_send_forward and self._can_send_forward > self._cannot_send_forward:
+ self._cannot_send_forward = LiquidityHintItem.from_amount(amount_msat)
+ if self._can_send_forward and self._can_send_forward.amount_msat > self._cannot_send_forward.amount_msat:
self._can_send_forward = None
# if we can't send over the channel, we should be able to send in the
# reverse direction
@@ -226,14 +239,15 @@ class LiquidityHint:
@property
def cannot_send_backward(self) -> Optional[int]:
- return None if self.is_hint_invalid() else self._cannot_send_backward
+ return self._cannot_send_backward.get_valid_amount() if self._cannot_send_backward else None
@cannot_send_backward.setter
def cannot_send_backward(self, amount_msat: int) -> None:
- if self._cannot_send_backward and self._cannot_send_backward < amount_msat:
+ known = self.cannot_send_backward
+ if known is not None and known < amount_msat:
return
- self._cannot_send_backward = amount_msat
- if self._can_send_backward and self._can_send_backward > self._cannot_send_backward:
+ self._cannot_send_backward = LiquidityHintItem.from_amount(amount_msat)
+ if self._can_send_backward and self._can_send_backward.amount_msat > self._cannot_send_backward.amount_msat:
self._can_send_backward = None
self.can_send_forward = amount_msat
@@ -252,14 +266,12 @@ class LiquidityHint:
return self.cannot_send_backward
def update_can_send(self, is_forward_direction: bool, *, amount_msat: int) -> None:
- self.hint_timestamp = int(time.time())
if is_forward_direction:
self.can_send_forward = amount_msat
else:
self.can_send_backward = amount_msat
def update_cannot_send(self, is_forward_direction: bool, *, amount_msat: int) -> None:
- self.hint_timestamp = int(time.time())
if is_forward_direction:
self.cannot_send_forward = amount_msat
else:
@@ -283,9 +295,15 @@ class LiquidityHint:
else:
self._inflight_htlcs_backward = max(0, self._inflight_htlcs_backward - 1)
+ def reset_amounts(self) -> None:
+ self._cannot_send_backward = None
+ self._cannot_send_forward = None
+ self._can_send_forward = None
+ self._can_send_backward = None
+
def __repr__(self):
- return f"forward: can send: {self._can_send_forward} msat, cannot send: {self._cannot_send_forward} msat, htlcs: {self._inflight_htlcs_forward}\n" \
- f"backward: can send: {self._can_send_backward} msat, cannot send: {self._cannot_send_backward} msat, htlcs: {self._inflight_htlcs_backward}\n"
+ return f"forward: can send: {self._can_send_forward}, cannot send: {self._cannot_send_forward}, htlcs: {self._inflight_htlcs_forward}\n" \
+ f"backward: can send: {self._can_send_backward}, cannot send: {self._cannot_send_backward}, htlcs: {self._inflight_htlcs_backward}\n"
class LiquidityHintMgr:
@@ -377,7 +395,7 @@ class LiquidityHintMgr:
@with_lock
def reset_liquidity_hints(self):
for k, v in self._liquidity_hints.items():
- v.hint_timestamp = 0
+ v.reset_amounts()
v._inflight_htlcs_forward = 0
v._inflight_htlcs_backward = 0
diff --git a/tests/test_lnrouter.py b/tests/test_lnrouter.py
index 673ee86..89726c3 100644
--- a/tests/test_lnrouter.py
+++ b/tests/test_lnrouter.py
@@ -1,6 +1,7 @@
import random
import unittest
from math import inf
+from unittest import mock
from typing import Optional
from os import urandom
@@ -18,7 +19,8 @@ from electrum import bitcoin, lnrouter
from electrum.constants import BitcoinTestnet
from electrum.simple_config import SimpleConfig
from electrum.lnrouter import (PathEdge, LiquidityHintMgr, DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH,
- DEFAULT_PENALTY_BASE_MSAT, fee_for_edge_msat, LNPaymentTRoute, TrampolineEdge)
+ DEFAULT_PENALTY_BASE_MSAT, fee_for_edge_msat, LNPaymentTRoute, TrampolineEdge,
+ HINT_DURATION)
from . import ElectrumTestCase
from .test_bitcoin import needs_test_with_all_chacha20_implementations
@@ -373,6 +375,28 @@ class Test_LNRouter(ElectrumTestCase):
# we have got 600 (attempt) + 600 (inflight) penalty
self.assertEqual(1200, liquidity_hints.penalty(node_from, node_to, channel_id, amount_msat=1_000_000))
+ def test_liquidity_hints_expiry(self):
+ liquidity_hints = LiquidityHintMgr()
+ node_from = bytes(0)
+ node_to = bytes(1)
+ channel_id = ShortChannelID.from_components(0, 0, 0)
+ mock_time = 1_000_000
+ with mock.patch.object(lnrouter, 'now', lambda: mock_time):
+ liquidity_hints.update_cannot_send(node_from, node_to, channel_id, amount_msat=1_000_000)
+ self.assertEqual(inf, liquidity_hints.penalty(node_from, node_to, channel_id, amount_msat=1_000_000))
+ # updating can_send after cannot_send expired must not resurrect the old cannot_send
+ mock_time += HINT_DURATION + 1
+ liquidity_hints.update_can_send(node_from, node_to, channel_id, amount_msat=10_000)
+ hint = liquidity_hints.get_hint(channel_id)
+ self.assertEqual(10_000, hint.can_send(node_from < node_to))
+ self.assertEqual(None, hint.cannot_send(node_from < node_to))
+ self.assertNotEqual(inf, liquidity_hints.penalty(node_from, node_to, channel_id, amount_msat=1_000_000))
+ # an expired higher can_send must not block recording a lower can_send
+ mock_time += HINT_DURATION + 1
+ liquidity_hints.update_can_send(node_from, node_to, channel_id, amount_msat=5_000)
+ hint = liquidity_hints.get_hint(channel_id)
+ self.assertEqual(5_000, hint.can_send(node_from < node_to))
+
def test_reset_liquidity_hints_clears_inflight_htlcs(self):
liquidity_hints = LiquidityHintMgr()
node_from, node_to = bytes(0), bytes(1)
Why this scored 51/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.