onion_message: pathfinding: ignore amount contraints
What changed, and why it matters
This commit fixes a design bug in Electrum's Lightning onion-message routing. Previously, the app used a fake payment amount (10,000 millisatoshis) when finding a route for an onion message, which could cause it to reject valid message paths because real channels have minimum/maximum amount limits. The change lets onion-message pathfinding ignore those amount limits, since no actual money is being moved. It is a correctness/availability fix for a messaging feature, not a fix for theft of funds or remote code execution.
Treat as a routine bug-fix commit. Reviewers should verify that `ignore_amount_constraints` is only set when `invoice_amount_msat is None` and that callers cannot accidentally pass `None` for real payments. No urgent security response is indicated by the diff alone.
Security signals we found
Correctness fix in Lightning pathfinding logic
Removes hard-coded dummy payment amount used for onion messages
Adds regression test for amount-constraint bypass behavior
No evidence of memory corruption, cryptographic flaw, or remote exploit in the diff
Evidence from the diff
The patch introduces an ignore_amount_constraints flag in LNPathFinder and a new find_path_for_onion_message() helper. When invoice_amount_msat is None, Dijkstra pathfinding skips htlc_minimum_msat, htlc_maximum_msat, capacity, and local can_pay() checks, and also ignores fee/CLTV penalties. onion_message.py now calls this helper instead of find_path_for_payment with a hard-coded 10,000 msat dummy amount. A regression test confirms that an onion message can still route through a channel whose htlc_minimum_msat would block a real payment of the same nominal size.
Changed components
electrum/lnrouter.pyelectrum/onion_message.pytests/test_lnrouter.pyInspect captured patch +67 / −34
diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py
index 4322dc6..790303e 100644
--- a/electrum/lnrouter.py
+++ b/electrum/lnrouter.py
@@ -452,6 +452,7 @@ class LNPathFinder(Logger):
end_node: bytes,
payment_amt_msat: int,
ignore_costs=False,
+ ignore_amount_constraints: bool = False,
is_mine=False,
my_channels: Dict[ShortChannelID, 'Channel'] = None,
private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
@@ -481,14 +482,15 @@ class LNPathFinder(Logger):
return float('inf'), 0
if channel_policy.is_disabled():
return float('inf'), 0
- if payment_amt_msat < channel_policy.htlc_minimum_msat:
- return float('inf'), 0 # payment amount too little
- if channel_info.capacity_sat is not None and \
- payment_amt_msat // 1000 > channel_info.capacity_sat:
- return float('inf'), 0 # payment amount too large
- if channel_policy.htlc_maximum_msat is not None and \
- payment_amt_msat > channel_policy.htlc_maximum_msat:
- return float('inf'), 0 # payment amount too large
+ if not ignore_amount_constraints:
+ if payment_amt_msat < channel_policy.htlc_minimum_msat:
+ return float('inf'), 0 # payment amount too little
+ if channel_info.capacity_sat is not None and \
+ payment_amt_msat // 1000 > channel_info.capacity_sat:
+ return float('inf'), 0 # payment amount too large
+ if channel_policy.htlc_maximum_msat is not None and \
+ payment_amt_msat > channel_policy.htlc_maximum_msat:
+ return float('inf'), 0 # payment amount too large
route_edge = private_route_edges.get(short_channel_id, None)
if route_edge is None:
node_info = self.channel_db.get_node_info_for_node_id(node_id=end_node)
@@ -513,7 +515,7 @@ class LNPathFinder(Logger):
# - The larger the payment amount, and the longer the CLTV,
# the more irritating it is if the HTLC gets stuck.
# - Paying lower fees is better. :)
- if ignore_costs:
+ if ignore_costs or ignore_amount_constraints:
return DEFAULT_PENALTY_BASE_MSAT, 0
fee_msat = route_edge.fee_for_edge(payment_amt_msat)
cltv_cost = route_edge.cltv_delta * payment_amt_msat * 15 / 1_000_000_000
@@ -528,10 +530,10 @@ class LNPathFinder(Logger):
*,
nodeA: bytes, # nodeA is expected to be our node id if channels are passed in my_sending_channels
nodeB: bytes,
- invoice_amount_msat: int,
+ invoice_amount_msat: Optional[int],
my_sending_channels: Dict[ShortChannelID, 'Channel'] = None,
private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
- node_filter: Optional[Callable[[bytes, NodeInfo], bool]] = None
+ node_filter: Optional[Callable[[bytes, Optional[NodeInfo]], bool]] = None,
) -> Dict[bytes, PathEdge]:
# note: we don't lock self.channel_db, so while the path finding runs,
# the underlying graph could potentially change... (not good but maybe ~OK?)
@@ -545,11 +547,12 @@ class LNPathFinder(Logger):
# run Dijkstra
# The search is run in the REVERSE direction, from nodeB to nodeA,
# to properly calculate compound routing fees.
+ ignore_amount_constraints = invoice_amount_msat is None # e.g. onion messages
distance_from_start = defaultdict(lambda: float('inf'))
distance_from_start[nodeB] = 0
previous_hops = {} # type: Dict[bytes, PathEdge]
nodes_to_explore = queue.PriorityQueue()
- nodes_to_explore.put((0, invoice_amount_msat, nodeB)) # order of fields (in tuple) matters!
+ nodes_to_explore.put((0, invoice_amount_msat or 0, nodeB)) # order of fields (in tuple) matters!
now = int(time.time())
# main loop of search
@@ -592,7 +595,8 @@ class LNPathFinder(Logger):
if edge_startnode == nodeA and my_sending_channels: # payment outgoing, on our channel
if edge_channel_id not in my_sending_channels:
continue
- if not my_sending_channels[edge_channel_id].can_pay(amount_msat, check_frozen=True):
+ if not ignore_amount_constraints \
+ and not my_sending_channels[edge_channel_id].can_pay(amount_msat, check_frozen=True):
continue
edge_cost, fee_for_edge_msat = self._edge_cost(
short_channel_id=edge_channel_id,
@@ -600,6 +604,7 @@ class LNPathFinder(Logger):
end_node=edge_endnode,
payment_amt_msat=amount_msat,
ignore_costs=(edge_startnode == nodeA),
+ ignore_amount_constraints=ignore_amount_constraints,
is_mine=is_mine,
my_channels=my_sending_channels,
private_route_edges=private_route_edges,
@@ -626,15 +631,15 @@ class LNPathFinder(Logger):
*,
nodeA: bytes,
nodeB: bytes,
- invoice_amount_msat: int,
+ invoice_amount_msat: Optional[int],
my_sending_channels: Dict[ShortChannelID, 'Channel'] = None,
private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
- node_filter: Optional[Callable[[bytes, NodeInfo], bool]] = None
+ node_filter: Optional[Callable[[bytes, Optional[NodeInfo]], bool]] = None
) -> Optional[LNPaymentPath]:
"""Return a path from nodeA to nodeB."""
assert type(nodeA) is bytes
assert type(nodeB) is bytes
- assert type(invoice_amount_msat) is int
+ assert type(invoice_amount_msat) is int or invoice_amount_msat is None
if my_sending_channels is None:
my_sending_channels = {}
@@ -659,6 +664,28 @@ class LNPathFinder(Logger):
edge_startnode = edge.node_id
return path
+ def find_path_for_onion_message(
+ self,
+ *,
+ nodeA: bytes,
+ nodeB: bytes,
+ my_sending_channels: Dict[ShortChannelID, 'Channel'] = None,
+ private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
+ ) -> Optional[LNPaymentPath]:
+ from .onion_message import is_onion_message_node
+ def _node_filter(edge_startnode, node_info):
+ if edge_startnode == nodeA:
+ return True # assume the sending node does support onion messages
+ return is_onion_message_node(edge_startnode, node_info)
+ return self.find_path_for_payment(
+ nodeA=nodeA,
+ nodeB=nodeB,
+ my_sending_channels=my_sending_channels,
+ private_route_edges=private_route_edges,
+ node_filter=_node_filter,
+ invoice_amount_msat=None,
+ )
+
def create_route_from_path(
self,
path: Optional[LNPaymentPath],
diff --git a/electrum/onion_message.py b/electrum/onion_message.py
index 9d802eb..b6499f2 100644
--- a/electrum/onion_message.py
+++ b/electrum/onion_message.py
@@ -173,11 +173,10 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque
if chan.short_channel_id is not None
}
- if path := lnwallet.network.path_finder.find_path_for_payment(
+ # TODO: if this blocks the event loop too long it might needs to go on a thread
+ if path := lnwallet.network.path_finder.find_path_for_onion_message(
nodeA=lnwallet.node_keypair.pubkey,
nodeB=node_id,
- invoice_amount_msat=10000, # TODO: do this without amount constraints
- node_filter=lambda x, y: True if x == lnwallet.node_keypair.pubkey else is_onion_message_node(x, y),
my_sending_channels=my_sending_channels
):
# first edge must be to our peer
diff --git a/tests/test_lnrouter.py b/tests/test_lnrouter.py
index acc8f40..3ad775c 100644
--- a/tests/test_lnrouter.py
+++ b/tests/test_lnrouter.py
@@ -523,33 +523,40 @@ class Test_LNRouter(ElectrumTestCase):
async def test_find_path_for_onion_message(self):
self.prepare_graph()
- amount_to_send = 1000 # we route along channels, and we use find_path_for_payment, so dummy this.
- path = self.path_finder.find_path_for_payment(
- nodeA=node('a'),
- nodeB=node('c'),
- invoice_amount_msat=amount_to_send,
- node_filter=is_onion_message_node)
+ path = self.path_finder.find_path_for_onion_message(nodeA=node('a'), nodeB=node('c'))
self.assertEqual([
PathEdge(start_node=node('a'), end_node=node('d'), short_channel_id=channel(6)),
PathEdge(start_node=node('d'), end_node=node('c'), short_channel_id=channel(4)),
], path)
- # impossible routes
- path = self.path_finder.find_path_for_payment(
- nodeA=node('e'),
- nodeB=node('a'),
- invoice_amount_msat=amount_to_send,
- node_filter=is_onion_message_node)
+ # node e doesn't support onion messages
+ path = self.path_finder.find_path_for_onion_message(nodeA=node('a'), nodeB=node('e'))
self.assertIsNone(path)
+ async def test_find_path_for_onion_message_ignores_amount_constraints(self):
+ self.prepare_graph()
+
+ # bump htlc_minimum_msat on channel(4) d->c direction
+ key = (node('d'), channel(4))
+ self.cdb._policies[key] = self.cdb._policies[key]._replace(htlc_minimum_msat=10_000_000)
+
+ # a small payment can no longer be routed
path = self.path_finder.find_path_for_payment(
nodeA=node('a'),
- nodeB=node('e'),
- invoice_amount_msat=amount_to_send,
- node_filter=is_onion_message_node)
+ nodeB=node('c'),
+ invoice_amount_msat=1000,
+ node_filter=is_onion_message_node,
+ )
self.assertIsNone(path)
+ # but an onion message still routes through the same hop
+ path = self.path_finder.find_path_for_onion_message(nodeA=node('a'), nodeB=node('c'))
+ self.assertEqual([
+ PathEdge(start_node=node('a'), end_node=node('d'), short_channel_id=channel(6)),
+ PathEdge(start_node=node('d'), end_node=node('c'), short_channel_id=channel(4)),
+ ], path)
+
def _tramp_edge(start: str, end: str, *, fee_base=PLACEHOLDER_FEE, fee_prop=PLACEHOLDER_FEE, cltv=576) -> TrampolineEdge:
return TrampolineEdge(
Why this scored 29/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.