electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit cf818fe08cdb1dcfc5651d9c2ea3edeee68c7e3e
parent ded449233ebf7cd863bc63da9af19c5561813128
Author: ThomasV <thomasv@electrum.org>
Date:   Tue,  9 Feb 2021 15:09:27 +0100

Trampoline routing:
 - add support for trampoline forwarding
 - add regtest with trampoline payment

Diffstat:
Melectrum/lnonion.py | 10++++++----
Melectrum/lnpeer.py | 121++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Melectrum/lnworker.py | 22+++++++++++++++++-----
Melectrum/tests/regtest.py | 3+++
Melectrum/tests/regtest/regtest.sh | 26++++++++++++++++++++++++++
5 files changed, 166 insertions(+), 16 deletions(-)

diff --git a/electrum/lnonion.py b/electrum/lnonion.py @@ -349,7 +349,8 @@ class ProcessedOnionPacket(NamedTuple): def process_onion_packet( onion_packet: OnionPacket, associated_data: bytes, - our_onion_private_key: bytes) -> ProcessedOnionPacket: + our_onion_private_key: bytes, + is_trampoline=False) -> ProcessedOnionPacket: if not ecc.ECPubkey.is_pubkey_bytes(onion_packet.public_key): raise InvalidOnionPubkey() shared_secret = get_ecdh(our_onion_private_key, onion_packet.public_key) @@ -362,8 +363,9 @@ def process_onion_packet( raise InvalidOnionMac() # peel an onion layer off rho_key = get_bolt04_onion_key(b'rho', shared_secret) - stream_bytes = generate_cipher_stream(rho_key, 2 * HOPS_DATA_SIZE) - padded_header = onion_packet.hops_data + bytes(HOPS_DATA_SIZE) + data_size = TRAMPOLINE_HOPS_DATA_SIZE if is_trampoline else HOPS_DATA_SIZE + stream_bytes = generate_cipher_stream(rho_key, 2 * data_size) + padded_header = onion_packet.hops_data + bytes(data_size) next_hops_data = xor_bytes(padded_header, stream_bytes) next_hops_data_fd = io.BytesIO(next_hops_data) hop_data = OnionHopsDataSingle.from_fd(next_hops_data_fd) @@ -386,7 +388,7 @@ def process_onion_packet( next_public_key = next_public_key_int.get_public_key_bytes() next_onion_packet = OnionPacket( public_key=next_public_key, - hops_data=next_hops_data_fd.read(HOPS_DATA_SIZE), + hops_data=next_hops_data_fd.read(data_size), hmac=hop_data.hmac) if hop_data.hmac == bytes(PER_HOP_HMAC_SIZE): # we are the destination / exit node diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py @@ -1196,7 +1196,8 @@ class Peer(Logger): 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, - payment_hash: bytes, min_final_cltv_expiry: int, payment_secret: bytes = None) -> UpdateAddHtlc: + payment_hash: bytes, min_final_cltv_expiry: int, + payment_secret: bytes = None, fwd_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(): @@ -1227,6 +1228,25 @@ class Peer(Logger): 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: + 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 + } + # create trampoline onion for i in range(num_hops): route_edge = route[i] @@ -1424,6 +1444,62 @@ class Peer(Logger): raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=data) return next_chan_scid, next_htlc.htlc_id + def maybe_forward_trampoline( + self, *, + chan: Channel, + htlc: UpdateAddHtlc, + trampoline_onion: ProcessedOnionPacket): + + payload = trampoline_onion.hop_data.payload + payment_hash = htlc.payment_hash + try: + outgoing_node_id = payload["outgoing_node_id"]["outgoing_node_id"] + payment_secret = payload["payment_data"]["payment_secret"] + amt_to_forward = payload["amt_to_forward"]["amt_to_forward"] + cltv_from_onion = payload["outgoing_cltv_value"]["outgoing_cltv_value"] + if "invoice_features" in payload: + self.logger.info('forward_trampoline: legacy') + next_trampoline_onion = None + invoice_features = payload["invoice_features"]["invoice_features"] + invoice_routing_info = payload["invoice_routing_info"]["invoice_routing_info"] + else: + self.logger.info('forward_trampoline: end-to-end') + invoice_features = 0 + next_trampoline_onion = trampoline_onion.next_packet + except Exception as e: + self.logger.exception('') + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + + trampoline_cltv_delta = htlc.cltv_expiry - cltv_from_onion + trampoline_fee = htlc.amount_msat - amt_to_forward + + @log_exceptions + async def forward_trampoline_payment(): + try: + await self.lnworker.pay_to_node( + node_pubkey=outgoing_node_id, + payment_hash=payment_hash, + payment_secret=payment_secret, + amount_to_pay=amt_to_forward, + min_cltv_expiry=cltv_from_onion, + r_tags=[], + t_tags=[], + invoice_features=invoice_features, + trampoline_onion=next_trampoline_onion, + trampoline_fee=trampoline_fee, + trampoline_cltv_delta=trampoline_cltv_delta, + attempts=1) + except OnionRoutingFailure as e: + # FIXME: cannot use payment_hash as key + self.lnworker.trampoline_forwarding_failures[payment_hash] = e + except PaymentFailure as e: + # FIXME: adapt the error code + error_reason = OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT, data=b'') + self.lnworker.trampoline_forwarding_failures[payment_hash] = error_reason + + asyncio.ensure_future(forward_trampoline_payment()) + + def maybe_fulfill_htlc( self, *, chan: Channel, @@ -1444,10 +1520,12 @@ class Peer(Logger): cltv_from_onion = processed_onion.hop_data.payload["outgoing_cltv_value"]["outgoing_cltv_value"] except: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - if cltv_from_onion != htlc.cltv_expiry: - raise OnionRoutingFailure( - code=OnionFailureCode.FINAL_INCORRECT_CLTV_EXPIRY, - data=htlc.cltv_expiry.to_bytes(4, byteorder="big")) + + if not is_trampoline: + if cltv_from_onion != htlc.cltv_expiry: + raise OnionRoutingFailure( + code=OnionFailureCode.FINAL_INCORRECT_CLTV_EXPIRY, + data=htlc.cltv_expiry.to_bytes(4, byteorder="big")) try: amt_to_forward = processed_onion.hop_data.payload["amt_to_forward"]["amt_to_forward"] except: @@ -1462,6 +1540,10 @@ class Peer(Logger): code=OnionFailureCode.FINAL_INCORRECT_HTLC_AMOUNT, data=total_msat.to_bytes(8, byteorder="big")) + outgoing_node_id = processed_onion.hop_data.payload.get("outgoing_node_id") + if is_trampoline and outgoing_node_id: + return + # if there is a trampoline_onion, perform the above checks on it if processed_onion.trampoline_onion_packet: trampoline_onion = process_onion_packet( @@ -1787,6 +1869,27 @@ class Peer(Logger): chan=chan, htlc=htlc, processed_onion=processed_onion) + # trampoline forwarding + if not preimage and processed_onion.trampoline_onion_packet: + if not forwarding_info: + trampoline_onion = self.process_onion_packet( + processed_onion.trampoline_onion_packet, + htlc.payment_hash, + onion_packet_bytes, + is_trampoline=True) + self.maybe_forward_trampoline( + chan=chan, + htlc=htlc, + trampoline_onion=trampoline_onion) + # we return True so that this code gets executed only once + return None, True, None + else: + preimage = self.lnworker.get_preimage(payment_hash) + error_reason = self.lnworker.trampoline_forwarding_failures.pop(payment_hash, None) + if error_reason: + self.logger.info(f'trampoline forwarding failure {error_reason}') + raise error_reason + elif not forwarding_info: next_chan_id, next_htlc_id = self.maybe_forward_htlc( chan=chan, @@ -1810,10 +1913,14 @@ class Peer(Logger): return preimage, None, None return None, None, None - def process_onion_packet(self, onion_packet, payment_hash, onion_packet_bytes): + def process_onion_packet(self, onion_packet, payment_hash, onion_packet_bytes, is_trampoline=False): failure_data = sha256(onion_packet_bytes) try: - processed_onion = process_onion_packet(onion_packet, associated_data=payment_hash, our_onion_private_key=self.privkey) + processed_onion = process_onion_packet( + onion_packet, + associated_data=payment_hash, + our_onion_private_key=self.privkey, + is_trampoline=is_trampoline) except UnsupportedOnionPacketVersion: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_VERSION, data=failure_data) except InvalidOnionPubkey: diff --git a/electrum/lnworker.py b/electrum/lnworker.py @@ -660,6 +660,8 @@ class LNWallet(LNWorker): for payment_hash in self.get_payments(status='inflight').keys(): self.set_invoice_status(payment_hash.hex(), PR_INFLIGHT) + self.trampoline_forwarding_failures = {} # todo: should be persisted + @property def channels(self) -> Mapping[bytes, Channel]: """Returns a read-only copy of channels.""" @@ -1063,8 +1065,16 @@ class LNWallet(LNWorker): async def pay_to_node( self, node_pubkey, payment_hash, payment_secret, amount_to_pay, - min_cltv_expiry, r_tags, t_tags, invoice_features, *, attempts: int = 1, - full_path: LNPaymentPath = None): + min_cltv_expiry, r_tags, t_tags, invoice_features, *, + attempts: int = 1, full_path: LNPaymentPath=None, + trampoline_onion=None, trampoline_fee=None, trampoline_cltv_delta=None): + + if trampoline_onion: + # todo: compare to the fee of the actual route we found + if trampoline_fee < 1000: + raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT, data=b'') + if trampoline_cltv_delta < 576: + raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'') self.logs[payment_hash.hex()] = log = [] amount_inflight = 0 # what we sent in htlcs @@ -1084,7 +1094,7 @@ class LNWallet(LNWorker): routes = [(route, amount_to_send)] # 2. send htlcs for route, amount_msat in routes: - await self.pay_to_route(route, amount_msat, payment_hash, payment_secret, min_cltv_expiry) + await self.pay_to_route(route, amount_msat, payment_hash, payment_secret, min_cltv_expiry, trampoline_onion) amount_inflight += amount_msat util.trigger_callback('invoice_status', self.wallet, payment_hash.hex()) # 3. await a queue @@ -1101,7 +1111,7 @@ class LNWallet(LNWorker): self.handle_error_code_from_failed_htlc(htlc_log) - async def pay_to_route(self, route: LNPaymentRoute, amount_msat:int, payment_hash:bytes, payment_secret:bytes, min_cltv_expiry:int): + async def pay_to_route(self, route: LNPaymentRoute, amount_msat:int, payment_hash:bytes, payment_secret:bytes, min_cltv_expiry:int, trampoline_onion:bytes =None): # send a single htlc short_channel_id = route[0].short_channel_id chan = self.get_channel_by_short_id(short_channel_id) @@ -1115,7 +1125,8 @@ class LNWallet(LNWorker): amount_msat=amount_msat, payment_hash=payment_hash, min_final_cltv_expiry=min_cltv_expiry, - payment_secret=payment_secret) + payment_secret=payment_secret, + fwd_trampoline_onion=trampoline_onion) self.htlc_routes[(payment_hash, short_channel_id, htlc.htlc_id)] = route util.trigger_callback('htlc_added', chan, htlc, SENT) @@ -1383,6 +1394,7 @@ class LNWallet(LNWorker): channels = list(self.channels.values()) scid_to_my_channels = {chan.short_channel_id: chan for chan in channels if chan.short_channel_id is not None} + blacklist = self.network.channel_blacklist.get_current_list() for private_route in r_tags: if len(private_route) == 0: diff --git a/electrum/tests/regtest.py b/electrum/tests/regtest.py @@ -58,5 +58,8 @@ class TestLightningABC(TestLightning): def test_forwarding(self): self.run_shell(['forwarding']) + def test_trampoline(self): + self.run_shell(['trampoline']) + def test_watchtower(self): self.run_shell(['watchtower']) diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh @@ -128,6 +128,32 @@ if [[ $1 == "forwarding" ]]; then $carol close_channel $chan2 fi +if [[ $1 == "trampoline" ]]; then + $alice stop + $alice setconfig -o use_gossip False + $alice daemon -d + $alice load_wallet + sleep 1 + $bob setconfig lightning_forward_payments true + bob_node=$($bob nodeid) + channel_id1=$($alice open_channel $bob_node 0.002 --push_amount 0.001) + channel_id2=$($carol open_channel $bob_node 0.002 --push_amount 0.001) + echo "mining 3 blocks" + new_blocks 3 + sleep 10 # time for channelDB + request=$($carol add_lightning_request 0.0001 -m "blah" | jq -r ".invoice") + $alice lnpay --attempts=2 $request + carol_balance=$($carol list_channels | jq -r '.[0].local_balance') + echo "carol balance: $carol_balance" + if [[ $carol_balance != 110000 ]]; then + exit 1 + fi + chan1=$($alice list_channels | jq -r ".[0].channel_point") + chan2=$($carol list_channels | jq -r ".[0].channel_point") + $alice close_channel $chan1 + $carol close_channel $chan2 +fi + # alice sends two payments, then broadcast ctx after first payment. # thus, bob needs to redeem both to_local and to_remote