pytest: test to demonstrate that reservations of "private" channels overlap.
What changed, and why it matters
This commit adds a test showing a bug in Core Lightning's routing-reservation system. When two separate routing layers use the same fake channel ID, a reservation made through one layer is incorrectly counted against the other layer too. The test is marked as expected to fail, meaning it documents the bug rather than fixing it. This could cause valid payment routes to be rejected because the system thinks capacity is already reserved when it should not be.
Treat this as a bug report in test form. The askrene reservation logic should scope reservations by layer (or by the actual channel identity) rather than by short_channel_id alone. Reviewers should verify whether a follow-up fix exists and assess whether the bug can be triggered with real (non-fake) channel IDs in production routing.
Security signals we found
Reservation state shared across independent routing layers
Denial-of-route caused by cross-layer capacity accounting bug
Test-only commit documenting a reproducible bug
Evidence from the diff
The new pytest test_askrene_reserve_clash demonstrates that askrene reserves are tracked globally by short_channel_id (scid) even when the same scid is used in different, independent layers. The test creates two layers, layer1 and layer2, each with a synthetic channel using scid 0x0x0 but pointing to different destinations. After reserving 950,000 msat on a path using that scid, getroutes for layer1 correctly fails due to insufficient remaining capacity, but getroutes for layer2 also fails in the buggy implementation, even though layer2’s channel is logically separate. The @pytest.mark.xfail(strict=True) annotation confirms this is a failing regression test, not a fix.
Changed components
tests/test_askrene.pyaskrene routing/reservation subsystemInspect captured patch +71 / −0
diff --git a/tests/test_askrene.py b/tests/test_askrene.py
index 08b87054..66edc391 100644
--- a/tests/test_askrene.py
+++ b/tests/test_askrene.py
@@ -1845,3 +1845,74 @@ def test_askrene_timeout(node_factory, bitcoind):
layers=['auto.localchans'],
maxfee_msat=1,
final_cltv=5)
+
+
+@pytest.mark.xfail(strict=True)
+def test_askrene_reserve_clash(node_factory, bitcoind):
+ """Reserves get (erroneously) counted globally by scid, even for fake scids."""
+ l1 = node_factory.get_node()
+
+ node1 = "020000000000000000000000000000000000000000000000000000000000000001"
+ node2 = "020000000000000000000000000000000000000000000000000000000000000002"
+ l1.rpc.askrene_create_layer('layer1')
+ l1.rpc.askrene_create_layer('layer2')
+ l1.rpc.askrene_create_channel(layer="layer1",
+ source=l1.info['id'],
+ destination=node1,
+ short_channel_id="0x0x0",
+ capacity_msat=1000000)
+ l1.rpc.askrene_update_channel(layer='layer1',
+ short_channel_id_dir='0x0x0/1',
+ enabled=True,
+ htlc_minimum_msat=0,
+ htlc_maximum_msat=1000000,
+ fee_base_msat=1,
+ fee_proportional_millionths=2,
+ cltv_expiry_delta=18)
+ l1.rpc.askrene_create_channel(layer="layer2",
+ source=l1.info['id'],
+ destination=node2,
+ short_channel_id="0x0x0",
+ capacity_msat=1000000)
+ l1.rpc.askrene_update_channel(layer='layer2',
+ short_channel_id_dir='0x0x0/1',
+ enabled=True,
+ htlc_minimum_msat=0,
+ htlc_maximum_msat=1000000,
+ fee_base_msat=1,
+ fee_proportional_millionths=2,
+ cltv_expiry_delta=18)
+ l1.rpc.getroutes(source=l1.info['id'],
+ destination=node1,
+ amount_msat=500000,
+ layers=['layer1'],
+ maxfee_msat=1000,
+ final_cltv=5)
+ l1.rpc.getroutes(source=l1.info['id'],
+ destination=node2,
+ amount_msat=500000,
+ layers=['layer2'],
+ maxfee_msat=1000,
+ final_cltv=5)
+
+ l1.rpc.askrene_reserve(path=[{'short_channel_id_dir': '0x0x0/1',
+ 'amount_msat': 950000,
+# 'layer': 'layer1'
+ }])
+
+ # We can't use this on layer 1 anymore, only 50000 msat left.
+ with pytest.raises(RpcError, match=r"We could not find a usable set of paths. The shortest path is 0x0x0, but 0x0x0/1 already reserved 950000msat by command"):
+ l1.rpc.getroutes(source=l1.info['id'],
+ destination=node1,
+ amount_msat=500000,
+ layers=['layer1'],
+ maxfee_msat=1000,
+ final_cltv=5)
+
+ # But layer2 should be unaffected
+ l1.rpc.getroutes(source=l1.info['id'],
+ destination=node2,
+ amount_msat=500000,
+ layers=['layer2'],
+ maxfee_msat=1000,
+ final_cltv=5)
Why this scored 35/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.