tTrampoline MPP aggregation: - trampoline node is the final recipient of MPP - each trampoline receives a bucket of HTLCs - if a HTLC from a bucket fails, wait for the entire bucket to fail - move trampoline route and onion code into trampoline module - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 259dacd56f8d272738682875f7a88b037981fecd DIR parent 2da90add8f3df5f4302631abcecc46100d0cc53f HTML Author: ThomasV <thomasv@electrum.org> Date: Sat, 27 Feb 2021 09:45:19 +0100 Trampoline MPP aggregation: - trampoline node is the final recipient of MPP - each trampoline receives a bucket of HTLCs - if a HTLC from a bucket fails, wait for the entire bucket to fail - move trampoline route and onion code into trampoline module Diffstat: M electrum/lnpeer.py | 81 +++++++------------------------ M electrum/lnworker.py | 370 +++++++++++++------------------ M electrum/tests/test_lnpeer.py | 9 +++++---- A electrum/trampoline.py | 228 +++++++++++++++++++++++++++++++ 4 files changed, 406 insertions(+), 282 deletions(-) --- DIR diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py t@@ -1195,9 +1195,16 @@ class Peer(Logger): sig_64, htlc_sigs = chan.sign_next_commitment() self.send_message("commitment_signed", channel_id=chan.channel_id, signature=sig_64, num_htlcs=len(htlc_sigs), htlc_signature=b"".join(htlc_sigs)) - def pay(self, *, route: 'LNPaymentRoute', chan: Channel, amount_msat: int, - total_msat: int, payment_hash: bytes, min_final_cltv_expiry: int, - payment_secret: bytes = None, fwd_trampoline_onion=None) -> UpdateAddHtlc: + def pay(self, *, + route: 'LNPaymentRoute', + chan: Channel, + amount_msat: int, + total_msat: int, + payment_hash: bytes, + min_final_cltv_expiry: int, + payment_secret: bytes = None, + trampoline_onion=None) -> UpdateAddHtlc: + assert amount_msat > 0, "amount_msat is not greater zero" assert len(route) > 0 if not chan.can_send_update_add_htlc(): t@@ -1211,78 +1218,26 @@ class Peer(Logger): amount_msat, final_cltv, total_msat=total_msat, - payment_secret=payment_secret - ) + payment_secret=payment_secret) + num_hops = len(hops_data) self.logger.info(f"lnpeer.pay len(route)={len(route)}") for i in range(len(route)): self.logger.info(f" {i}: edge={route[i].short_channel_id} hop_data={hops_data[i]!r}") assert final_cltv <= cltv, (final_cltv, cltv) session_key = os.urandom(32) # session_key - # detect trampoline hops - payment_path_pubkeys = [x.node_id for x in route] - num_hops = len(payment_path_pubkeys) - for i in range(num_hops-1): - route_edge = route[i] - next_edge = route[i+1] - if route_edge.is_trampoline(): - assert next_edge.is_trampoline() - self.logger.info(f'trampoline hop at position {i}') - hops_data[i].payload["outgoing_node_id"] = {"outgoing_node_id":next_edge.node_id} - if route_edge.invoice_features: - hops_data[i].payload["invoice_features"] = {"invoice_features":route_edge.invoice_features} - if route_edge.invoice_routing_info: - hops_data[i].payload["invoice_routing_info"] = {"invoice_routing_info":route_edge.invoice_routing_info} - - # only for final, legacy - if i == num_hops - 2: - self.logger.info(f'adding payment secret for legacy trampoline') - hops_data[i].payload["payment_data"] = { - "payment_secret":payment_secret, - "total_msat": amount_msat, - } - # if we are forwarding a trampoline payment, add trampoline onion - if fwd_trampoline_onion: + if trampoline_onion: self.logger.info(f'adding trampoline onion to final payload') trampoline_payload = hops_data[num_hops-2].payload trampoline_payload["trampoline_onion_packet"] = { - "version": fwd_trampoline_onion.version, - "public_key": fwd_trampoline_onion.public_key, - "hops_data": fwd_trampoline_onion.hops_data, - "hmac": fwd_trampoline_onion.hmac + "version": trampoline_onion.version, + "public_key": trampoline_onion.public_key, + "hops_data": trampoline_onion.hops_data, + "hmac": trampoline_onion.hmac } - - # create trampoline onion - for i in range(num_hops): - route_edge = route[i] - if route_edge.is_trampoline(): - self.logger.info(f'first trampoline hop at position {i}') - self.logger.info(f'inner onion: {hops_data[i:]}') - trampoline_session_key = os.urandom(32) - trampoline_onion = new_onion_packet(payment_path_pubkeys[i:], trampoline_session_key, hops_data[i:], associated_data=payment_hash, trampoline=True) - # drop hop_data - payment_path_pubkeys = payment_path_pubkeys[:i] - hops_data = hops_data[:i] - # we must generate a different secret for the outer onion - outer_payment_secret = os.urandom(32) - # trampoline_payload is a final payload - trampoline_payload = hops_data[i-1].payload - p = trampoline_payload.pop('short_channel_id') - amt_to_forward = trampoline_payload["amt_to_forward"]["amt_to_forward"] - trampoline_payload["payment_data"] = { - "payment_secret":outer_payment_secret, - "total_msat": amt_to_forward - } - trampoline_payload["trampoline_onion_packet"] = { - "version": trampoline_onion.version, - "public_key": trampoline_onion.public_key, - "hops_data": trampoline_onion.hops_data, - "hmac": trampoline_onion.hmac - } - break # create onion packet + payment_path_pubkeys = [x.node_id for x in route] onion = new_onion_packet(payment_path_pubkeys, session_key, hops_data, associated_data=payment_hash) # must use another sessionkey - self.logger.info(f"starting payment. len(route)={len(hops_data)}.") # create htlc if cltv > local_height + lnutil.NBLOCK_CLTV_EXPIRY_TOO_FAR_INTO_FUTURE: DIR diff --git a/electrum/lnworker.py b/electrum/lnworker.py t@@ -78,6 +78,7 @@ from .channel_db import get_mychannel_info, get_mychannel_policy from .submarine_swaps import SwapManager from .channel_db import ChannelInfo, Policy from .mpp_split import suggest_splits +from .trampoline import create_trampoline_route_and_onion if TYPE_CHECKING: from .network import Network t@@ -163,45 +164,6 @@ def trampolines_by_id(): is_hardcoded_trampoline = lambda node_id: node_id in trampolines_by_id().keys() -# trampoline nodes are supposed to advertise their fee and cltv in node_update message -TRAMPOLINE_FEES = [ - { - 'fee_base_msat': 0, - 'fee_proportional_millionths': 0, - 'cltv_expiry_delta': 576, - }, - { - 'fee_base_msat': 1000, - 'fee_proportional_millionths': 100, - 'cltv_expiry_delta': 576, - }, - { - 'fee_base_msat': 3000, - 'fee_proportional_millionths': 100, - 'cltv_expiry_delta': 576, - }, - { - 'fee_base_msat': 5000, - 'fee_proportional_millionths': 500, - 'cltv_expiry_delta': 576, - }, - { - 'fee_base_msat': 7000, - 'fee_proportional_millionths': 1000, - 'cltv_expiry_delta': 576, - }, - { - 'fee_base_msat': 12000, - 'fee_proportional_millionths': 3000, - 'cltv_expiry_delta': 576, - }, - { - 'fee_base_msat': 100000, - 'fee_proportional_millionths': 3000, - 'cltv_expiry_delta': 576, - }, -] - class PaymentInfo(NamedTuple): payment_hash: bytes t@@ -658,7 +620,8 @@ class LNWallet(LNWorker): self._channels[bfh(channel_id)] = Channel(c, sweep_address=self.sweep_address, lnworker=self) self.sent_htlcs = defaultdict(asyncio.Queue) # type: Dict[bytes, asyncio.Queue[HtlcLog]] - self.sent_htlcs_routes = dict() # (RHASH, scid, htlc_id) -> route, amount_for_receiver + self.sent_htlcs_routes = dict() # (RHASH, scid, htlc_id) -> route, payment_secret, amount_msat, bucket_msat + self.sent_buckets = defaultdict(set) self.received_htlcs = dict() # RHASH -> mpp_status, htlc_set self.swap_manager = SwapManager(wallet=self.wallet, lnworker=self) t@@ -1019,6 +982,8 @@ class LNWallet(LNWorker): r_tags=decoded_invoice.get_routing_info('r'), t_tags=decoded_invoice.get_routing_info('t'), invoice_features=decoded_invoice.get_tag('9') or 0, + payment_hash=decoded_invoice.paymenthash, + payment_secret=decoded_invoice.payment_secret, full_path=full_path) @log_exceptions t@@ -1113,17 +1078,25 @@ class LNWallet(LNWorker): # note: path-finding runs in a separate thread so that we don't block the asyncio loop # graph updates might occur during the computation routes = await run_in_thread(partial( - self.create_routes_for_payment, amount_to_send, node_pubkey, - min_cltv_expiry, r_tags, t_tags, invoice_features, full_path=full_path)) + self.create_routes_for_payment, + amount_msat=amount_to_send, + invoice_pubkey=node_pubkey, + min_cltv_expiry=min_cltv_expiry, + r_tags=r_tags, + t_tags=t_tags, + invoice_features=invoice_features, + full_path=full_path, + payment_hash=payment_hash, + payment_secret=payment_secret)) # 2. send htlcs - for route, amount_msat in routes: + for route, amount_msat, total_msat, cltv_delta, bucket_payment_secret, trampoline_onion in routes: await self.pay_to_route( route=route, amount_msat=amount_msat, - total_msat=amount_to_pay, + total_msat=total_msat, payment_hash=payment_hash, - payment_secret=payment_secret, - min_cltv_expiry=min_cltv_expiry, + payment_secret=bucket_payment_secret, + min_cltv_expiry=cltv_delta, trampoline_onion=trampoline_onion) amount_inflight += amount_msat util.trigger_callback('invoice_status', self.wallet, payment_hash.hex()) t@@ -1165,8 +1138,11 @@ class LNWallet(LNWorker): payment_hash=payment_hash, min_final_cltv_expiry=min_cltv_expiry, payment_secret=payment_secret, - fwd_trampoline_onion=trampoline_onion) - self.sent_htlcs_routes[(payment_hash, short_channel_id, htlc.htlc_id)] = route, amount_msat + trampoline_onion=trampoline_onion) + + key = (payment_hash, short_channel_id, htlc.htlc_id) + self.sent_htlcs_routes[key] = route, payment_secret, amount_msat, total_msat + self.sent_buckets[payment_secret] = total_msat util.trigger_callback('htlc_added', chan, htlc, SENT) def handle_error_code_from_failed_htlc(self, htlc_log): t@@ -1293,16 +1269,6 @@ class LNWallet(LNWorker): f"min_final_cltv_expiry: {addr.get_min_final_cltv_expiry()}")) return addr - def encode_routing_info(self, r_tags): - import bitstring - result = bitstring.BitArray() - for route in r_tags: - result.append(bitstring.pack('uint:8', len(route))) - for step in route: - pubkey, channel, feebase, feerate, cltv = step - result.append(bitstring.BitArray(pubkey) + bitstring.BitArray(channel) + bitstring.pack('intbe:32', feebase) + bitstring.pack('intbe:32', feerate) + bitstring.pack('intbe:16', cltv)) - return result.tobytes() - def is_trampoline_peer(self, node_id: bytes) -> bool: # until trampoline is advertised in lnfeatures, check against hardcoded list if is_hardcoded_trampoline(node_id): t@@ -1318,178 +1284,150 @@ class LNWallet(LNWorker): else: return random.choice(list(hardcoded_trampoline_nodes().values())).pubkey - def create_trampoline_route( - self, amount_msat:int, - min_cltv_expiry:int, - invoice_pubkey:bytes, - invoice_features:int, - channels: List[Channel], - r_tags, t_tags) -> LNPaymentRoute: - """ return the route that leads to trampoline, and the trampoline fake edge""" - - invoice_features = LnFeatures(invoice_features) - - # We do not set trampoline_routing_opt in our invoices, because the spec is not ready - # Do not use t_tags if the flag is set, because we the format is not decided yet - if invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT): - is_legacy = False - if len(r_tags) > 0 and len(r_tags[0]) == 1: - pubkey, scid, feebase, feerate, cltv = r_tags[0][0] - t_tag = pubkey, feebase, feerate, cltv - else: - t_tag = None - elif len(t_tags) > 0: - is_legacy = False - t_tag = t_tags[0] - else: - is_legacy = True - - # fee level. the same fee is used for all trampolines - if self.trampoline_fee_level < len(TRAMPOLINE_FEES): - params = TRAMPOLINE_FEES[self.trampoline_fee_level] - else: - raise NoPathFound() - # Find a trampoline. We assume we have a direct channel to trampoline - for chan in channels: - if not self.is_trampoline_peer(chan.node_id): - continue - trampoline_short_channel_id = chan.short_channel_id - trampoline_node_id = chan.node_id - # use attempt number to decide fee and second trampoline - # we need a state with the list of nodes we have not tried - # add optional second trampoline - trampoline2 = None - if is_legacy: - for node_id in self.trampoline2_list: - if node_id != trampoline_node_id: - trampoline2 = node_id - break - # node_features is only used to determine is_tlv - trampoline_features = LnFeatures.VAR_ONION_OPT - # hop to trampoline - route = [ - RouteEdge( - node_id=trampoline_node_id, - short_channel_id=trampoline_short_channel_id, - fee_base_msat=0, - fee_proportional_millionths=0, - cltv_expiry_delta=0, - node_features=trampoline_features) - ] - # trampoline hop - route.append( - TrampolineEdge( - node_id=trampoline_node_id, - fee_base_msat=params['fee_base_msat'], - fee_proportional_millionths=params['fee_proportional_millionths'], - cltv_expiry_delta=params['cltv_expiry_delta'], - node_features=trampoline_features)) - if trampoline2: - route.append( - TrampolineEdge( - node_id=trampoline2, - fee_base_msat=params['fee_base_msat'], - fee_proportional_millionths=params['fee_proportional_millionths'], - cltv_expiry_delta=params['cltv_expiry_delta'], - node_features=trampoline_features)) - # add routing info - if is_legacy: - invoice_routing_info = self.encode_routing_info(r_tags) - route[-1].invoice_routing_info = invoice_routing_info - route[-1].invoice_features = invoice_features - else: - if t_tag: - pubkey, feebase, feerate, cltv = t_tag - if route[-1].node_id != pubkey: - route.append( - TrampolineEdge( - node_id=pubkey, - fee_base_msat=feebase, - fee_proportional_millionths=feerate, - cltv_expiry_delta=cltv, - node_features=trampoline_features)) - # Fake edge (not part of actual route, needed by calc_hops_data) - route.append( - TrampolineEdge( - node_id=invoice_pubkey, - fee_base_msat=0, - fee_proportional_millionths=0, - cltv_expiry_delta=0, - node_features=trampoline_features)) - # check that we can pay amount and fees - for edge in route[::-1]: - amount_msat += edge.fee_for_edge(amount_msat) - if not chan.can_pay(amount_msat, check_frozen=True): - continue - if not is_route_sane_to_use(route, amount_msat, min_cltv_expiry): - continue - break - else: - raise NoPathFound() - self.logger.info(f'created route with trampoline: fee_level={self.trampoline_fee_level}, is legacy: {is_legacy}') - self.logger.info(f'first trampoline: {trampoline_node_id.hex()}') - self.logger.info(f'second trampoline: {trampoline2.hex() if trampoline2 else None}') - self.logger.info(f'params: {params}') - return route - @profiler def create_routes_for_payment( - self, + self, *, amount_msat: int, invoice_pubkey, min_cltv_expiry, r_tags, t_tags, invoice_features: int, - *, - full_path: LNPaymentPath = None, - ) -> Sequence[Tuple[LNPaymentRoute, int]]: + payment_hash, + payment_secret, + full_path: LNPaymentPath = None) -> Sequence[Tuple[LNPaymentRoute, int]]: + """Creates multiple routes for splitting a payment over the available private channels. We first try to conduct the payment over a single channel. If that fails and mpp is supported by the receiver, we will split the payment.""" + # It could happen that the pathfinding uses a channel + # in the graph multiple times, meaning we could exhaust + # its capacity. This could be dealt with by temporarily + # iteratively blacklisting channels for this mpp attempt. invoice_features = LnFeatures(invoice_features) - # try to send over a single channel + trampoline_features = LnFeatures.VAR_ONION_OPT + local_height = self.network.get_local_height() try: - routes = [self.create_route_for_payment( - amount_msat=amount_msat, - invoice_pubkey=invoice_pubkey, - min_cltv_expiry=min_cltv_expiry, - r_tags=r_tags, - t_tags=t_tags, - invoice_features=invoice_features, - outgoing_channel=None, - full_path=full_path, - )] + # try to send over a single channel + if not self.channel_db: + for chan in self.channels.values(): + if not self.is_trampoline_peer(chan.node_id): + continue + if chan.is_frozen_for_sending(): + continue + trampoline_onion, trampoline_fee, amount_with_fees, cltv_delta = create_trampoline_route_and_onion( + amount_msat=amount_msat, + bucket_amount_msat=amount_msat, + min_cltv_expiry=min_cltv_expiry, + invoice_pubkey=invoice_pubkey, + invoice_features=invoice_features, + node_id=chan.node_id, + r_tags=r_tags, + t_tags=t_tags, + payment_hash=payment_hash, + payment_secret=payment_secret, + local_height=local_height, + trampoline_fee_level=self.trampoline_fee_level, + trampoline2_list=self.trampoline2_list) + trampoline_payment_secret = os.urandom(32) + amount_to_send = amount_with_fees + trampoline_fee + if chan.available_to_spend(LOCAL, strict=True) < amount_to_send: + continue + route = [ + RouteEdge( + node_id=chan.node_id, + short_channel_id=chan.short_channel_id, + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_expiry_delta=0, + node_features=trampoline_features) + ] + routes = [(route, amount_to_send, amount_to_send, cltv_delta, trampoline_payment_secret, trampoline_onion)] + break + else: + raise NoPathFound() + else: + route = self.create_route_for_payment( + amount_msat=amount_msat, + invoice_pubkey=invoice_pubkey, + min_cltv_expiry=min_cltv_expiry, + r_tags=r_tags, t_tags=t_tags, + invoice_features=invoice_features, + outgoing_channel=None, full_path=full_path) + routes = [(route, amount_msat, amount_msat, min_cltv_expiry, payment_secret, None)] except NoPathFound: if not invoice_features.supports(LnFeatures.BASIC_MPP_OPT): raise channels_with_funds = dict([ (cid, int(chan.available_to_spend(HTLCOwner.LOCAL))) - for cid, chan in self._channels.items()]) + for cid, chan in self._channels.items() if not chan.is_frozen_for_sending()]) + self.logger.info(f"channels_with_funds: {channels_with_funds}") # Create split configurations that are rated according to our # preference -funds = (low rating=high preference). split_configurations = suggest_splits(amount_msat, channels_with_funds) + self.logger.info(f'suggest_split {amount_msat} returned {len(split_configurations)} configurations') for s in split_configurations: + self.logger.info(f"trying split configuration: {s[0].values()} rating: {s[1]}") routes = [] try: - for chanid, part_amount_msat in s[0].items(): + buckets = defaultdict(list) + for chan_id, part_amount_msat in s[0].items(): + chan = self.channels[chan_id] if part_amount_msat: - channel = self.channels[chanid] - # It could happen that the pathfinding uses a channel - # in the graph multiple times, meaning we could exhaust - # its capacity. This could be dealt with by temporarily - # iteratively blacklisting channels for this mpp attempt. - route, amt = self.create_route_for_payment( - amount_msat=part_amount_msat, - invoice_pubkey=invoice_pubkey, + buckets[chan.node_id].append((chan_id, part_amount_msat)) + for node_id, bucket in buckets.items(): + bucket_amount_msat = sum([x[1] for x in bucket]) + if not self.channel_db: + trampoline_onion, trampoline_fee, bucket_amount_with_fees, bucket_cltv_delta = create_trampoline_route_and_onion( + amount_msat=amount_msat, + bucket_amount_msat=bucket_amount_msat, min_cltv_expiry=min_cltv_expiry, + invoice_pubkey=invoice_pubkey, + invoice_features=invoice_features, + node_id=node_id, r_tags=r_tags, t_tags=t_tags, - invoice_features=invoice_features, - outgoing_channel=channel, - full_path=None) - routes.append((route, amt)) + payment_hash=payment_hash, + payment_secret=payment_secret, + local_height=local_height, + trampoline_fee_level=self.trampoline_fee_level, + trampoline2_list=self.trampoline2_list) + self.logger.info(f'trampoline fee {trampoline_fee}') + # node_features is only used to determine is_tlv + bucket_payment_secret = os.urandom(32) + for chan_id, part_amount_msat in bucket: + chan = self.channels[chan_id] + margin = chan.available_to_spend(LOCAL, strict=True) - part_amount_msat + delta_fee = min(trampoline_fee, margin) + part_amount_msat_with_fees = part_amount_msat + delta_fee + trampoline_fee -= delta_fee + route = [ + RouteEdge( + node_id=node_id, + short_channel_id=chan.short_channel_id, + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_expiry_delta=0, + node_features=trampoline_features) + ] + self.logger.info(f'adding route {part_amount_msat} {delta_fee} {margin}') + routes.append((route, part_amount_msat_with_fees, bucket_amount_with_fees, bucket_cltv_delta, bucket_payment_secret, trampoline_onion)) + if trampoline_fee > 0: + self.logger.info('not enough marging to pay trampoline fee') + raise NoPathFound() + else: + # then we need bucket_amount_msat that includes the trampoline fees.. then create small routes here + for chan_id, part_amount_msat in bucket: + channel = self.channels[chan_id] + route = self.create_route_for_payment( + amount_msat=part_amount_msat, + invoice_pubkey=invoice_pubkey, + min_cltv_expiry=min_cltv_expiry, + r_tags=r_tags, t_tags=t_tags, + invoice_features=invoice_features, + outgoing_channel=channel, full_path=None) + routes.append((route, part_amount_msat, bucket_amount_msat, bucket_payment_secret)) self.logger.info(f"found acceptable split configuration: {list(s[0].values())} rating: {s[1]}") break except NoPathFound: t@@ -1499,24 +1437,16 @@ class LNWallet(LNWorker): return routes def create_route_for_payment( - self, - *, + self, *, amount_msat: int, invoice_pubkey: bytes, min_cltv_expiry: int, - r_tags, - t_tags, + r_tags, t_tags, invoice_features: int, outgoing_channel: Channel = None, - full_path: Optional[LNPaymentPath], - ) -> Tuple[LNPaymentRoute, int]: + full_path: Optional[LNPaymentPath]) -> Tuple[LNPaymentRoute, int]: channels = [outgoing_channel] if outgoing_channel else list(self.channels.values()) - if not self.channel_db: - route = self.create_trampoline_route( - amount_msat, min_cltv_expiry, invoice_pubkey, invoice_features, channels, r_tags, t_tags) - return route, amount_msat - route = None scid_to_my_channels = { chan.short_channel_id: chan for chan in channels t@@ -1591,7 +1521,7 @@ class LNWallet(LNWorker): raise LNPathInconsistent("last node_id != invoice pubkey") # add features from invoice route[-1].node_features |= invoice_features - return route, amount_msat + return route def add_request(self, amount_sat, message, expiry) -> str: coro = self._add_request_coro(amount_sat, message, expiry) t@@ -1743,7 +1673,7 @@ class LNWallet(LNWorker): util.trigger_callback('htlc_fulfilled', payment_hash, chan.channel_id) q = self.sent_htlcs.get(payment_hash) if q: - route, amount_msat = self.sent_htlcs_routes[(payment_hash, chan.short_channel_id, htlc_id)] + route, payment_secret, amount_msat, bucket_msat = self.sent_htlcs_routes[(payment_hash, chan.short_channel_id, htlc_id)] htlc_log = HtlcLog( success=True, route=route, t@@ -1765,7 +1695,10 @@ class LNWallet(LNWorker): util.trigger_callback('htlc_failed', payment_hash, chan.channel_id) q = self.sent_htlcs.get(payment_hash) if q: - route, amount_msat = self.sent_htlcs_routes[(payment_hash, chan.short_channel_id, htlc_id)] + # detect if it is part of a bucket + # if yes, wait until the bucket completely failed + key = (payment_hash, chan.short_channel_id, htlc_id) + route, payment_secret, amount_msat, bucket_msat = self.sent_htlcs_routes[key] if error_bytes: # TODO "decode_onion_error" might raise, catch and maybe blacklist/penalise someone? try: t@@ -1778,6 +1711,13 @@ class LNWallet(LNWorker): assert failure_message is not None sender_idx = None self.logger.info(f"htlc_failed {failure_message}") + + if payment_secret in self.sent_buckets: + self.sent_buckets[payment_secret] -= amount_msat + if self.sent_buckets[payment_secret] > 0: + return + else: + amount_msat = bucket_msat htlc_log = HtlcLog( success=False, route=route, DIR diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py t@@ -135,6 +135,7 @@ class MockLNWallet(Logger, NetworkRetryManager[LNPeerAddr]): self.received_htlcs = dict() self.sent_htlcs = defaultdict(asyncio.Queue) self.sent_htlcs_routes = dict() + self.sent_buckets = defaultdict(set) def get_invoice_status(self, key): pass t@@ -497,7 +498,7 @@ class TestPeer(ElectrumTestCase): q2 = w2.sent_htlcs[lnaddr1.paymenthash] # alice sends htlc BUT NOT COMMITMENT_SIGNED p1.maybe_send_commitment = lambda x: None - route1, amount_msat1 = w1.create_routes_from_invoice(lnaddr2.get_amount_msat(), decoded_invoice=lnaddr2)[0] + route1, amount_msat1 = w1.create_routes_from_invoice(lnaddr2.get_amount_msat(), decoded_invoice=lnaddr2)[0][0:2] await w1.pay_to_route( route=route1, amount_msat=lnaddr2.get_amount_msat(), t@@ -509,7 +510,7 @@ class TestPeer(ElectrumTestCase): p1.maybe_send_commitment = _maybe_send_commitment1 # bob sends htlc BUT NOT COMMITMENT_SIGNED p2.maybe_send_commitment = lambda x: None - route2, amount_msat2 = w2.create_routes_from_invoice(lnaddr1.get_amount_msat(), decoded_invoice=lnaddr1)[0] + route2, amount_msat2 = w2.create_routes_from_invoice(lnaddr1.get_amount_msat(), decoded_invoice=lnaddr1)[0][0:2] await w2.pay_to_route( route=route2, amount_msat=lnaddr1.get_amount_msat(), t@@ -664,7 +665,7 @@ class TestPeer(ElectrumTestCase): await asyncio.wait_for(p1.initialized, 1) await asyncio.wait_for(p2.initialized, 1) # alice sends htlc - route, amount_msat = w1.create_routes_from_invoice(lnaddr.get_amount_msat(), decoded_invoice=lnaddr)[0] + route, amount_msat = w1.create_routes_from_invoice(lnaddr.get_amount_msat(), decoded_invoice=lnaddr)[0][0:2] htlc = p1.pay(route=route, chan=alice_channel, amount_msat=lnaddr.get_amount_msat(), t@@ -760,7 +761,7 @@ class TestPeer(ElectrumTestCase): pay_req = run(self.prepare_invoice(w2)) lnaddr = w1._check_invoice(pay_req) - route, amount_msat = w1.create_routes_from_invoice(lnaddr.get_amount_msat(), decoded_invoice=lnaddr)[0] + route, amount_msat = w1.create_routes_from_invoice(lnaddr.get_amount_msat(), decoded_invoice=lnaddr)[0][0:2] assert amount_msat == lnaddr.get_amount_msat() run(w1.force_close_channel(alice_channel.channel_id)) DIR diff --git a/electrum/trampoline.py b/electrum/trampoline.py t@@ -0,0 +1,228 @@ +import os +import bitstring + +from .lnutil import LnFeatures +from .lnonion import calc_hops_data_for_payment, new_onion_packet +from .lnrouter import RouteEdge, TrampolineEdge, LNPaymentRoute, is_route_sane_to_use +from .lnutil import NoPathFound + +from .logging import get_logger, Logger + +_logger = get_logger(__name__) + +# trampoline nodes are supposed to advertise their fee and cltv in node_update message +TRAMPOLINE_FEES = [ + { + 'fee_base_msat': 0, + 'fee_proportional_millionths': 0, + 'cltv_expiry_delta': 576, + }, + { + 'fee_base_msat': 1000, + 'fee_proportional_millionths': 100, + 'cltv_expiry_delta': 576, + }, + { + 'fee_base_msat': 3000, + 'fee_proportional_millionths': 100, + 'cltv_expiry_delta': 576, + }, + { + 'fee_base_msat': 5000, + 'fee_proportional_millionths': 500, + 'cltv_expiry_delta': 576, + }, + { + 'fee_base_msat': 7000, + 'fee_proportional_millionths': 1000, + 'cltv_expiry_delta': 576, + }, + { + 'fee_base_msat': 12000, + 'fee_proportional_millionths': 3000, + 'cltv_expiry_delta': 576, + }, + { + 'fee_base_msat': 100000, + 'fee_proportional_millionths': 3000, + 'cltv_expiry_delta': 576, + }, +] + + +def encode_routing_info(r_tags): + result = bitstring.BitArray() + for route in r_tags: + result.append(bitstring.pack('uint:8', len(route))) + for step in route: + pubkey, channel, feebase, feerate, cltv = step + result.append(bitstring.BitArray(pubkey) + bitstring.BitArray(channel) + bitstring.pack('intbe:32', feebase) + bitstring.pack('intbe:32', feerate) + bitstring.pack('intbe:16', cltv)) + return result.tobytes() + + +def create_trampoline_route( + amount_msat:int, + bucket_amount_msat:int, + min_cltv_expiry:int, + invoice_pubkey:bytes, + invoice_features:int, + trampoline_node_id, + r_tags, t_tags, + trampoline_fee_level, + trampoline2_list) -> LNPaymentRoute: + + invoice_features = LnFeatures(invoice_features) + # We do not set trampoline_routing_opt in our invoices, because the spec is not ready + # Do not use t_tags if the flag is set, because we the format is not decided yet + if invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT): + is_legacy = False + if len(r_tags) > 0 and len(r_tags[0]) == 1: + pubkey, scid, feebase, feerate, cltv = r_tags[0][0] + t_tag = pubkey, feebase, feerate, cltv + else: + t_tag = None + elif len(t_tags) > 0: + is_legacy = False + t_tag = t_tags[0] + else: + is_legacy = True + + # fee level. the same fee is used for all trampolines + if trampoline_fee_level < len(TRAMPOLINE_FEES): + params = TRAMPOLINE_FEES[trampoline_fee_level] + else: + raise NoPathFound() + # add optional second trampoline + trampoline2 = None + if is_legacy: + for node_id in trampoline2_list: + if node_id != trampoline_node_id: + trampoline2 = node_id + break + # node_features is only used to determine is_tlv + trampoline_features = LnFeatures.VAR_ONION_OPT + # hop to trampoline + route = [] + # trampoline hop + route.append( + TrampolineEdge( + node_id=trampoline_node_id, + fee_base_msat=params['fee_base_msat'], + fee_proportional_millionths=params['fee_proportional_millionths'], + cltv_expiry_delta=params['cltv_expiry_delta'], + node_features=trampoline_features)) + if trampoline2: + route.append( + TrampolineEdge( + node_id=trampoline2, + fee_base_msat=params['fee_base_msat'], + fee_proportional_millionths=params['fee_proportional_millionths'], + cltv_expiry_delta=params['cltv_expiry_delta'], + node_features=trampoline_features)) + # add routing info + if is_legacy: + invoice_routing_info = encode_routing_info(r_tags) + route[-1].invoice_routing_info = invoice_routing_info + route[-1].invoice_features = invoice_features + else: + if t_tag: + pubkey, feebase, feerate, cltv = t_tag + if route[-1].node_id != pubkey: + route.append( + TrampolineEdge( + node_id=pubkey, + fee_base_msat=feebase, + fee_proportional_millionths=feerate, + cltv_expiry_delta=cltv, + node_features=trampoline_features)) + # Fake edge (not part of actual route, needed by calc_hops_data) + route.append( + TrampolineEdge( + node_id=invoice_pubkey, + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_expiry_delta=0, + node_features=trampoline_features)) + # check that we can pay amount and fees + for edge in route[::-1]: + amount_msat += edge.fee_for_edge(amount_msat) + if not is_route_sane_to_use(route, amount_msat, min_cltv_expiry): + raise NoPathFound() + _logger.info(f'created route with trampoline: fee_level={trampoline_fee_level}, is legacy: {is_legacy}') + _logger.info(f'first trampoline: {trampoline_node_id.hex()}') + _logger.info(f'second trampoline: {trampoline2.hex() if trampoline2 else None}') + _logger.info(f'params: {params}') + return route + + +def create_trampoline_onion(route, amount_msat, final_cltv, total_msat, payment_hash, payment_secret): + # all edges are trampoline + hops_data, amount_msat, cltv = calc_hops_data_for_payment( + route, + amount_msat, + final_cltv, + total_msat=total_msat, + payment_secret=payment_secret) + # detect trampoline hops. + payment_path_pubkeys = [x.node_id for x in route] + num_hops = len(payment_path_pubkeys) + for i in range(num_hops-1): + route_edge = route[i] + next_edge = route[i+1] + assert route_edge.is_trampoline() + assert next_edge.is_trampoline() + hops_data[i].payload["outgoing_node_id"] = {"outgoing_node_id":next_edge.node_id} + if route_edge.invoice_features: + hops_data[i].payload["invoice_features"] = {"invoice_features":route_edge.invoice_features} + if route_edge.invoice_routing_info: + hops_data[i].payload["invoice_routing_info"] = {"invoice_routing_info":route_edge.invoice_routing_info} + # only for final, legacy + if i == num_hops - 2: + hops_data[i].payload["payment_data"] = { + "payment_secret":payment_secret, + "total_msat": total_msat, + } + 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) + return trampoline_onion, amount_msat, cltv + + +def create_trampoline_route_and_onion( + *, + amount_msat, + bucket_amount_msat, + min_cltv_expiry, + invoice_pubkey, + invoice_features, + node_id, + r_tags, t_tags, + payment_hash, + payment_secret, + local_height:int, + trampoline_fee_level, + trampoline2_list): + # create route for the trampoline_onion + trampoline_route = create_trampoline_route( + amount_msat, + bucket_amount_msat, + min_cltv_expiry, + invoice_pubkey, + invoice_features, + node_id, + r_tags, t_tags, + trampoline_fee_level, + trampoline2_list) + # compute onion and fees + final_cltv = local_height + min_cltv_expiry + trampoline_onion, bucket_amount_with_fees, bucket_cltv = create_trampoline_onion( + trampoline_route, + bucket_amount_msat, + final_cltv, + amount_msat, + payment_hash, + payment_secret) + bucket_cltv_delta = bucket_cltv - local_height + bucket_cltv_delta += trampoline_route[0].cltv_expiry_delta + # trampoline fee for this very trampoline + trampoline_fee = trampoline_route[0].fee_for_edge(bucket_amount_with_fees) + return trampoline_onion, trampoline_fee, bucket_amount_with_fees, bucket_cltv_delta