test_lnwallet: unittest trampoline invoice_feature and r_tag
What changed, and why it matters
This commit only adds a new automated test for Electrum's Lightning wallet. It checks that when creating a Lightning invoice, the wallet correctly decides whether to advertise 'trampoline routing' support and which routing hints to include based on the types of channels the user has open. It does not change any production wallet code, so it cannot directly introduce or fix a security vulnerability on its own.
No security action required. Review the related production code paths (wallet.create_payment_info, wallet.get_bolt11_invoice, and electrum.trampoline node classification) if you suspect the existing logic is incomplete, but this commit itself is a test-only addition.
Security signals we found
No production code changes
Adds regression test for trampoline routing hint/feature consistency
Test covers boundary conditions that could affect invoice privacy/payment reliability if production logic were wrong
Evidence from the diff
The diff adds a single unit test, test_trampoline_invoice_features_and_routing_hints, to tests/test_lnwallet.py. The test exercises wallet.create_payment_info and wallet.get_bolt11_invoice under three channel configurations: mixed trampoline/non-trampoline peers, all trampoline peers, and trampoline-only with one peer removed from the known trampoline list. It asserts that the invoice feature bit OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM is only set when all open channels are with known trampoline forwarders, and that r_tags only contain trampoline nodes when that bit is set. No production logic is modified.
Changed components
tests/test_lnwallet.pyInspect captured patch +93 / −1
diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py
index f50d0d6..1b7637d 100644
--- a/tests/test_lnwallet.py
+++ b/tests/test_lnwallet.py
@@ -1,9 +1,12 @@
import logging
import os
+import electrum.trampoline
from . import ElectrumTestCase
+from .test_lnchannel import create_test_channels
-from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED
+from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, LnFeatures
+from electrum.lntransport import LNPeerAddr
from electrum.logging import console_stderr_handler
from electrum.invoices import LN_EXPIRY_NEVER, PR_UNPAID
@@ -52,3 +55,92 @@ class TestLNWallet(ElectrumTestCase):
min_final_cltv_delta=min_final_cltv_delta,
exp_delay=exp_delay,
)
+
+ async def test_trampoline_invoice_features_and_routing_hints(self):
+ """
+ When the invoice_features signal trampoline support, routing hints must only
+ contain trampoline nodes. When it does not, all channel can be added as r_tags.
+ We only signal trampoline support in the invoice if all open channels do support trampoline.
+ """
+ wallet = self.lnwallet_anchors
+ self.assertFalse(wallet.uses_trampoline())
+
+ trampoline_peer = self.create_mock_lnwallet(name='trampoline_peer', has_anchors=True)
+ trampoline_pubkey = trampoline_peer.node_keypair.pubkey
+
+ regular_peer = self.create_mock_lnwallet(name='regular_peer', has_anchors=True)
+ regular_pubkey = regular_peer.node_keypair.pubkey
+
+ chan_t, _ = create_test_channels(alice_lnwallet=wallet, bob_lnwallet=trampoline_peer, anchor_outputs=True)
+ chan_r, _ = create_test_channels(alice_lnwallet=wallet, bob_lnwallet=regular_peer, anchor_outputs=True)
+ wallet._add_channel(chan_t)
+ wallet._add_channel(chan_r)
+
+ # only trampoline_peer is a known trampoline forwarder
+ electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = {
+ 'trampoline_peer': LNPeerAddr(
+ host="127.0.0.1",
+ port=9735,
+ pubkey=trampoline_pubkey,
+ ),
+ }
+ self.addCleanup(lambda: electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS.clear())
+
+ amount_msat = 100_000
+
+ # mixed peers: trampoline feature must be stripped, all peers in hints
+ payment_hash = wallet.create_payment_info(amount_msat=amount_msat)
+ pi = wallet.get_payment_info(payment_hash, direction=RECEIVED)
+ self.assertFalse(
+ pi.invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM),
+ "trampoline bit should be stripped when not all peers are trampoline",
+ )
+
+ lnaddr, _ = wallet.get_bolt11_invoice(payment_info=pi, message='test', fallback_address=None)
+ hint_node_ids = {route[0][0] for route in lnaddr.get_routing_info('r')}
+ self.assertEqual(hint_node_ids, {trampoline_pubkey, regular_pubkey})
+
+ # trampoline feature should not be set if we use trampoline but one peer is not a trampoline
+ old_check, wallet.uses_trampoline = wallet.uses_trampoline, lambda: True
+ self.assertTrue(wallet.uses_trampoline())
+
+ payment_hash = wallet.create_payment_info(amount_msat=amount_msat)
+ pi = wallet.get_payment_info(payment_hash, direction=RECEIVED)
+ self.assertFalse(
+ pi.invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM),
+ "trampoline feature should not be set if we use trampoline but one peer is not a trampoline",
+ )
+
+ wallet.clear_invoices_cache()
+ lnaddr, _ = wallet.get_bolt11_invoice(payment_info=pi, message='test', fallback_address=None)
+ hint_node_ids = {route[0][0] for route in lnaddr.get_routing_info('r')}
+ self.assertEqual(hint_node_ids, {trampoline_pubkey, regular_pubkey})
+
+ wallet.uses_trampoline = old_check
+ self.assertFalse(wallet.uses_trampoline())
+
+ # all peers trampoline: we signal trampoline support, even with trampoline disabled
+ electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS['regular_peer'] = LNPeerAddr(
+ host="127.0.0.1",
+ port=9735,
+ pubkey=regular_pubkey,
+ )
+
+ payment_hash2 = wallet.create_payment_info(amount_msat=amount_msat)
+ pi2 = wallet.get_payment_info(payment_hash2, direction=RECEIVED)
+ self.assertTrue(
+ pi2.invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM),
+ "trampoline bit should be present when all peers are trampoline",
+ )
+
+ wallet.clear_invoices_cache()
+ lnaddr2, _ = wallet.get_bolt11_invoice(payment_info=pi2, message='test', fallback_address=None)
+ hint_node_ids2 = {route[0][0] for route in lnaddr2.get_routing_info('r')}
+ self.assertEqual(hint_node_ids2, {trampoline_pubkey, regular_pubkey})
+
+ # assert only trampoline peers are included in r_tags if the invoice_features signal trampoline
+ del electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS['regular_peer']
+ wallet.clear_invoices_cache()
+ lnaddr3, _ = wallet.get_bolt11_invoice(payment_info=pi2, message='test', fallback_address=None)
+ hint_node_ids3 = {route[0][0] for route in lnaddr3.get_routing_info('r')}
+ self.assertEqual(hint_node_ids3, {trampoline_pubkey})
Why this scored 12/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.