1
0

Stop including all invoice r_tags in legacy trampoline onion

This change modifies create_trampoline_onion to only include as many
available r_tags as there is space left in the trampoline onion payload.

Previously we tried to include all passed invoice r_tags of legacy
trampoline payments into the payload which caused an user facing
exception and payment failure as the onion can only store a max of 400
bytes.
A single, single hop r_tag is around 52 bytes and the payload
without r_tags is already at ~280 bytes. So usually there is enough
space for 2 r_tags.
The implementation shuffles the r_tags on each call
so the payment will try different route hints on the attempts (fee level
increase or user retry).

I have logged the following byte sizes of the trampoline onion with a 2
trampoline onion hop and changing amounts of r_tags:

3 rtags:
payload size [0]: 113 (hop size: 81)
payload size [1]: 440 (hop size: 295) ( 52 bytes/rtag )
payload size [2]: 550 (hop size: 78)

2 rtags:
payload size [0]: 113 (hop size: 81)
payload size [1]: 386 (hop size: 241) ( 52 bytes/rtag )
payload size [2]: 496 (hop size: 78)

1 rtag:
payload size [0]: 113 (hop size: 81)
payload size [1]: 334 (hop size: 189) ( 52 bytes/rtag )
payload size [2]: 444 (hop size: 78)

0 rtags:
payload size [0]: 113 (hop size: 81)
payload size [1]: 282 (hop size: 137)
payload size [2]: 392 (hop size: 78)

As can be seen in the data, using 2 trampoline hops there is not enough
space for even a single r_tag which is why this option is being removed
too.
This commit is contained in:
f321x
2025-04-14 12:12:14 +02:00
parent eff8b65355
commit 8d79c58c5e
3 changed files with 83 additions and 12 deletions

View File

@@ -115,7 +115,7 @@ class RouteEdge(PathEdge):
@attr.s
class TrampolineEdge(RouteEdge):
invoice_routing_info = attr.ib(type=bytes, default=None)
invoice_routing_info = attr.ib(type=Sequence[bytes], default=None)
invoice_features = attr.ib(type=int, default=None)
# this is re-defined from parent just to specify a default value:
short_channel_id = attr.ib(default=ShortChannelID(8), repr=lambda val: str(val))

View File

@@ -1,15 +1,18 @@
import io
import os
import random
from typing import Mapping, DefaultDict, Tuple, Optional, Dict, List, Iterable, Sequence, Set, Any
from typing import Mapping, DefaultDict, Tuple, Optional, Dict, List, Iterable, Sequence, Set, Any, \
MutableSequence
from .lnutil import LnFeatures, PaymentFeeBudget, FeeBudgetExceeded
from .lnonion import calc_hops_data_for_payment, new_onion_packet, OnionPacket
from .lnonion import calc_hops_data_for_payment, new_onion_packet, OnionPacket, \
TRAMPOLINE_HOPS_DATA_SIZE, PER_HOP_HMAC_SIZE
from .lnrouter import RouteEdge, TrampolineEdge, LNPaymentRoute, is_route_within_budget, LNPaymentTRoute
from .lnutil import NoPathFound
from .lntransport import LNPeerAddr
from . import constants
from .logging import get_logger
from .util import random_shuffled_copy
_logger = get_logger(__name__)
@@ -55,10 +58,10 @@ def trampolines_by_id():
def is_hardcoded_trampoline(node_id: bytes) -> bool:
return node_id in trampolines_by_id()
def encode_routing_info(r_tags: Sequence[Sequence[Sequence[Any]]]) -> bytes:
result = bytearray()
def encode_routing_info(r_tags: Sequence[Sequence[Sequence[Any]]]) -> List[bytes]:
routes = []
for route in r_tags:
result += bytes([len(route)])
result = bytes([len(route)])
for step in route:
pubkey, scid, feebase, feerate, cltv = step
result += pubkey
@@ -66,7 +69,8 @@ def encode_routing_info(r_tags: Sequence[Sequence[Sequence[Any]]]) -> bytes:
result += int.to_bytes(feebase, length=4, byteorder="big", signed=False)
result += int.to_bytes(feerate, length=4, byteorder="big", signed=False)
result += int.to_bytes(cltv, length=2, byteorder="big", signed=False)
return bytes(result)
routes.append(result)
return routes
def decode_routing_info(rinfo: bytes) -> Sequence[Sequence[Sequence[Any]]]:
@@ -228,8 +232,9 @@ def create_trampoline_route(
_extend_trampoline_route(route, end_node=second_trampoline)
# the last trampoline onion must contain routing hints for the last trampoline
# node to find the recipient
invoice_routing_info = encode_routing_info(r_tags)
assert invoice_routing_info == encode_routing_info(decode_routing_info(invoice_routing_info))
# Due to space constraints it is not guaranteed for all route hints to get included in the onion
invoice_routing_info: List[bytes] = encode_routing_info(r_tags)
assert invoice_routing_info == encode_routing_info(decode_routing_info(b''.join(invoice_routing_info)))
# lnwire invoice_features for trampoline is u64
invoice_features = invoice_features & 0xffffffffffffffff
route[-1].invoice_routing_info = invoice_routing_info
@@ -287,6 +292,7 @@ def create_trampoline_onion(
# detect trampoline hops.
payment_path_pubkeys = [x.node_id for x in route]
num_hops = len(payment_path_pubkeys)
routing_info_payload_index: Optional[int] = None
for i in range(num_hops):
route_edge = route[i]
assert route_edge.is_trampoline()
@@ -305,11 +311,32 @@ def create_trampoline_onion(
# legacy
if i == num_hops - 2 and route_edge.invoice_features:
payload["invoice_features"] = {"invoice_features":route_edge.invoice_features}
payload["invoice_routing_info"] = {"invoice_routing_info":route_edge.invoice_routing_info}
routing_info_payload_index = i
payload["payment_data"] = {
"payment_secret": payment_secret,
"total_msat": total_msat
}
if (index := routing_info_payload_index) is not None:
# fill the remaining payload space with available routing hints (r_tags)
payload: dict = hops_data[index].payload
# try different r_tag order on each attempt
invoice_routing_info = random_shuffled_copy(route[index].invoice_routing_info)
remaining_payload_space = TRAMPOLINE_HOPS_DATA_SIZE \
- sum(len(hop.to_bytes()) + PER_HOP_HMAC_SIZE for hop in hops_data)
routing_info_to_use = []
for encoded_r_tag in invoice_routing_info:
if remaining_payload_space < 50:
break # no r_tag will fit here anymore
r_tag_size = len(encoded_r_tag)
if r_tag_size > remaining_payload_space:
continue
routing_info_to_use.append(encoded_r_tag)
remaining_payload_space -= r_tag_size
# add the chosen r_tags to the payload
payload["invoice_routing_info"] = {"invoice_routing_info": b''.join(routing_info_to_use)}
_logger.debug(f"Using {len(routing_info_to_use)} of {len(invoice_routing_info)} r_tags")
trampoline_session_key = os.urandom(32)
trampoline_onion = new_onion_packet(payment_path_pubkeys, trampoline_session_key, hops_data, associated_data=payment_hash, trampoline=True)
trampoline_onion._debug_hops_data = hops_data

View File

@@ -4,19 +4,22 @@ import tempfile
import shutil
import asyncio
from typing import Optional
from os import urandom
from electrum import util
from electrum.channel_db import NodeInfo
from electrum.onion_message import is_onion_message_node
from electrum.trampoline import create_trampoline_onion
from electrum.util import bfh
from electrum.lnutil import ShortChannelID, LnFeatures
from electrum.lnonion import (OnionHopsDataSingle, new_onion_packet,
process_onion_packet, _decode_onion_error, decode_onion_error,
OnionFailureCode, OnionPacket)
OnionFailureCode)
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
from electrum.lnrouter import (PathEdge, LiquidityHintMgr, DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH,
DEFAULT_PENALTY_BASE_MSAT, fee_for_edge_msat, LNPaymentTRoute, TrampolineEdge)
from . import ElectrumTestCase
from .test_bitcoin import needs_test_with_all_chacha20_implementations
@@ -450,6 +453,47 @@ class Test_LNRouter(ElectrumTestCase):
self.assertEqual(hops_data[i].to_bytes(), processed_packet.hop_data.to_bytes())
packet = processed_packet.next_packet
def test_create_legacy_trampoline_onion_multiple_rtags(self):
"""Test to verify we don't overfill the trampoline onion with r_tags if there are more tags than available space"""
dummy_route: LNPaymentTRoute = [
TrampolineEdge(
invoice_routing_info=[
bfh("010305061295fa30847df41ae6ee809b560e78d65c2a7337a41c725ea3920b65e08a03b62b00003a0002000003e8000000010050"),
bfh("01037414fe3dcfedc4a0a0e153205d9a973af5096d1cd1c8c53d07ed12d7dd966f19f424000000000020000003e8000008ca0050"),
bfh("01038550162fa86287884a6a052471934abb5cb261c5a2b15386df8104d3c7bcb85dddd92ee1898ee15c000003e8000000010090"),
bfh("010244bb7ba2392ab2d493ad04ad4afcd482ca44a2bfe5b42bcc830bfe00e5b08082f424000000000029000003e8000008ca0050")
],
invoice_features=LnFeatures.VAR_ONION_REQ | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.BASIC_MPP_OPT,
short_channel_id=ShortChannelID.from_str("0x0x0"),
start_node=node('a'),
end_node=node('b'),
fee_base_msat=0,
fee_proportional_millionths=0,
cltv_delta=0,
node_features=0
),
TrampolineEdge(
invoice_routing_info=[],
invoice_features=None,
short_channel_id=ShortChannelID.from_str("0x0x0"),
start_node=node('b'),
end_node=node('c'),
fee_base_msat=0,
fee_proportional_millionths=0,
cltv_delta=0,
node_features=0
),
]
# create a trampoline onion, this shouldn't raise InvalidPayloadSize
create_trampoline_onion(
route=dummy_route,
amount_msat=0,
final_cltv_abs=0,
total_msat=0,
payment_hash=urandom(32),
payment_secret=urandom(32),
)
@needs_test_with_all_chacha20_implementations
def test_decode_onion_error(self):
# test vector from bolt-04