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:
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