From a338459d452d27e5176c718827a9c5ad304de8ce Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 8 Aug 2023 05:09:58 +0200 Subject: [PATCH] just-in-time channels: - a node scid alias is derived from the node ID - the channel opening fee is sent in a TLV field of open_channel - the server requires htlc settlement before broadcasting (server does not trust client) --- electrum/lnchannel.py | 3 +- electrum/lnpeer.py | 102 +++++++++++++++++++++++++++++- electrum/lnwire/peer_wire.csv | 2 + electrum/lnworker.py | 86 +++++++++++++++++++++++-- electrum/simple_config.py | 3 +- electrum/tests/regtest.py | 36 +++++++++++ electrum/tests/regtest/regtest.sh | 15 +++++ 7 files changed, 236 insertions(+), 11 deletions(-) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index 9d1bec3f3..22f0e4f76 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -639,7 +639,8 @@ class Channel(AbstractChannel): def __repr__(self): return "Channel(%s)"%self.get_id_for_log() - def __init__(self, state: 'StoredDict', *, name=None, lnworker=None, initial_feerate=None): + def __init__(self, state: 'StoredDict', *, name=None, lnworker=None, initial_feerate=None, opening_fee=None): + self.opening_fee = opening_fee self.name = name self.channel_id = bfh(state["channel_id"]) self.short_channel_id = ShortChannelID.normalize(state["short_channel_id"]) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index f80dc2a62..70ab8d3b3 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -27,6 +27,7 @@ from . import transaction from .bitcoin import make_op_return, DummyAddress from .transaction import PartialTxOutput, match_script_against_template, Sighash from .logging import Logger +from .lnrouter import RouteEdge from .lnonion import (new_onion_packet, OnionFailureCode, calc_hops_data_for_payment, process_onion_packet, OnionPacket, construct_onion_error, obfuscate_onion_error, OnionRoutingFailure, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, @@ -119,6 +120,7 @@ class Peer(Logger): self._received_revack_event = asyncio.Event() self.received_commitsig_event = asyncio.Event() self.downstream_htlc_resolved_event = asyncio.Event() + self.jit_failures = {} def send_message(self, message_name: str, **kwargs): assert util.get_running_loop() == util.get_asyncio_loop(), f"this must be run on the asyncio thread!" @@ -714,7 +716,8 @@ class Peer(Logger): push_msat: int, public: bool, zeroconf: bool = False, - temp_channel_id: bytes + temp_channel_id: bytes, + opening_fee: int = None, ) -> Tuple[Channel, 'PartialTransaction']: """Implements the channel opening flow. @@ -757,7 +760,11 @@ class Peer(Logger): open_channel_tlvs['upfront_shutdown_script'] = { 'shutdown_scriptpubkey': local_config.upfront_shutdown_script } - + if opening_fee: + # todo: maybe add payment hash + open_channel_tlvs['channel_opening_fee'] = { + 'channel_opening_fee': opening_fee + } # for the first commitment transaction per_commitment_secret_first = get_per_commitment_secret_from_seed( local_config.per_commitment_secret_seed, @@ -963,6 +970,11 @@ class Peer(Logger): open_channel_tlvs = payload.get('open_channel_tlvs') channel_type = open_channel_tlvs.get('channel_type') if open_channel_tlvs else None + + channel_opening_fee = open_channel_tlvs.get('channel_opening_fee') if open_channel_tlvs else None + if channel_opening_fee: + # todo check that the fee is reasonable + pass # The receiving node MAY fail the channel if: # option_channel_type was negotiated but the message doesn't include a channel_type if self.is_channel_type() and channel_type is None: @@ -1073,7 +1085,8 @@ class Peer(Logger): chan = Channel( chan_dict, lnworker=self.lnworker, - initial_feerate=feerate + initial_feerate=feerate, + opening_fee = channel_opening_fee, ) chan.storage['init_timestamp'] = int(time.time()) if isinstance(self.transport, LNTransport): @@ -1714,7 +1727,36 @@ class Peer(Logger): next_cltv_abs = processed_onion.hop_data.payload["outgoing_cltv_value"]["outgoing_cltv_value"] except Exception: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + next_chan = self.lnworker.get_channel_by_short_id(next_chan_scid) + + if self.lnworker.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): + next_peer = self.lnworker.get_peer_by_scid_alias(next_chan_scid) + else: + next_peer = None + + if not next_chan and next_peer and next_peer.accepts_zeroconf(): + # check if an already existing channel can be used. + # todo: split the payment + for next_chan in next_peer.channels.values(): + if next_chan.can_pay(next_amount_msat_htlc): + break + else: + async def wrapped_callback(): + coro = self.lnworker.open_channel_just_in_time( + next_peer, + next_amount_msat_htlc, + next_cltv_abs, + htlc.payment_hash, + processed_onion.next_packet) + try: + await coro + except OnionRoutingFailure as e: + self.jit_failures[next_chan_scid.hex()] = e + + asyncio.ensure_future(wrapped_callback()) + return next_chan_scid, -1 + local_height = chain.height() if next_chan is None: log_fail_reason(f"cannot find next_chan {next_chan_scid}") @@ -1754,6 +1796,7 @@ class Peer(Logger): self.logger.info( f"maybe_forward_htlc. will forward HTLC: inc_chan={incoming_chan.short_channel_id}. inc_htlc={str(htlc)}. " f"next_chan={next_chan.get_id_for_log()}.") + next_peer = self.lnworker.peers.get(next_chan.node_id) if next_peer is None: log_fail_reason(f"next_peer offline ({next_chan.node_id.hex()})") @@ -1837,6 +1880,43 @@ class Peer(Logger): if budget.cltv < 576: raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'') + # do we have a connection to the node? + next_peer = self.lnworker.peers.get(outgoing_node_id) + if next_peer and next_peer.accepts_zeroconf(): + self.logger.info(f'JIT: found next_peer') + for next_chan in next_peer.channels.values(): + if next_chan.can_pay(amt_to_forward): + # todo: detect if we can do mpp + self.logger.info(f'jit: next_chan can pay') + break + else: + scid_alias = self.lnworker._scid_alias_of_node(next_peer.pubkey) + route = [RouteEdge( + start_node=next_peer.pubkey, + end_node=outgoing_node_id, + short_channel_id=scid_alias, + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=144, + node_features=0 + )] + next_onion, amount_msat, cltv_abs, session_key = self.create_onion_for_route( + route=route, + amount_msat=amt_to_forward, + total_msat=amt_to_forward, + payment_hash=payment_hash, + min_final_cltv_delta=cltv_budget_for_rest_of_route, + payment_secret=payment_secret, + trampoline_onion=next_trampoline_onion, + ) + await self.lnworker.open_channel_just_in_time( + next_peer, + amt_to_forward, + cltv_abs, + payment_hash, + next_onion) + return + try: await self.lnworker.pay_to_node( node_pubkey=outgoing_node_id, @@ -1926,6 +2006,13 @@ class Peer(Logger): log_fail_reason(f"'total_msat' missing from onion") raise exc_incorrect_or_unknown_pd + if chan.opening_fee: + channel_opening_fee = chan.opening_fee['channel_opening_fee'] + total_msat -= channel_opening_fee + amt_to_forward -= channel_opening_fee + else: + channel_opening_fee = 0 + if amt_to_forward > htlc.amount_msat: log_fail_reason(f"amt_to_forward != htlc.amount_msat") raise OnionRoutingFailure( @@ -2006,6 +2093,9 @@ class Peer(Logger): log_fail_reason(f'incorrect payment secret {payment_secret_from_onion.hex()} != {expected_payment_secrets[0].hex()}') raise exc_incorrect_or_unknown_pd invoice_msat = info.amount_msat + if channel_opening_fee: + invoice_msat -= channel_opening_fee + if not (invoice_msat is None or invoice_msat <= total_msat <= 2 * invoice_msat): log_fail_reason(f"total_msat={total_msat} too different from invoice_msat={invoice_msat}") raise exc_incorrect_or_unknown_pd @@ -2018,6 +2108,7 @@ class Peer(Logger): self.logger.info(f"missing preimage and no hold invoice callback {payment_hash.hex()}") raise exc_incorrect_or_unknown_pd + chan.opening_fee = None self.logger.info(f"maybe_fulfill_htlc. will FULFILL HTLC: chan {chan.short_channel_id}. htlc={str(htlc)}") return preimage, None @@ -2586,6 +2677,11 @@ class Peer(Logger): return None, None, error_bytes if error_reason: raise error_reason + # just-in-time channel + if htlc_id == -1: + error_reason = self.jit_failures.pop(next_chan_id_hex, None) + if error_reason: + raise error_reason if preimage: return preimage, None, None return None, None, None diff --git a/electrum/lnwire/peer_wire.csv b/electrum/lnwire/peer_wire.csv index b4018f0fc..063772c3d 100644 --- a/electrum/lnwire/peer_wire.csv +++ b/electrum/lnwire/peer_wire.csv @@ -61,6 +61,8 @@ tlvtype,open_channel_tlvs,upfront_shutdown_script,0 tlvdata,open_channel_tlvs,upfront_shutdown_script,shutdown_scriptpubkey,byte,... tlvtype,open_channel_tlvs,channel_type,1 tlvdata,open_channel_tlvs,channel_type,type,byte,... +tlvtype,open_channel_tlvs,channel_opening_fee,10000 +tlvdata,open_channel_tlvs,channel_opening_fee,channel_opening_fee,u64, msgtype,accept_channel,33 msgdata,accept_channel,temporary_channel_id,byte,32 msgdata,accept_channel,dust_limit_satoshis,u64, diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 2e3139101..dfedebf90 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1221,12 +1221,72 @@ class LNWallet(LNWorker): self.logger.info('REBROADCASTING CLOSING TX') await self.network.try_broadcasting(force_close_tx, 'force-close') + def get_peer_by_scid_alias(self, scid_alias): + for nodeid, peer in self.peers.items(): + if scid_alias == self._scid_alias_of_node(nodeid): + return peer + + def _scid_alias_of_node(self, nodeid): + # scid alias for just-in-time channels + return sha256(b'Electrum' + nodeid)[0:8] + + def get_scid_alias(self): + return self._scid_alias_of_node(self.node_keypair.pubkey) + + @log_exceptions + async def open_channel_just_in_time(self, next_peer, next_amount_msat_htlc, next_cltv_abs, payment_hash, next_onion): + # if an exception is raised during negotiation, we raise an OnionRoutingFailure. + # this will cancel the incoming HTLC + try: + funding_sat = 2 * (next_amount_msat_htlc // 1000) # try to fully spend htlcs + password = self.wallet.get_unlocked_password() if self.wallet.has_password() else None + channel_opening_fee = next_amount_msat_htlc // 100 + if channel_opening_fee // 1000 < self.config.ZEROCONF_MIN_OPENING_FEE: + self.logger.info(f'rejecting JIT channel: payment too low') + raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'payment too low') + self.logger.info(f'channel opening fee (sats): {channel_opening_fee//1000}') + next_chan, funding_tx = await self.open_channel_with_peer( + next_peer, funding_sat, + push_sat=0, + zeroconf=True, + public=False, + opening_fee=channel_opening_fee, + password=password, + ) + async def wait_for_channel(): + while not next_chan.is_open(): + await asyncio.sleep(1) + await util.wait_for2(wait_for_channel(), LN_P2P_NETWORK_TIMEOUT) + next_chan.save_remote_scid_alias(self._scid_alias_of_node(next_peer.pubkey)) + self.logger.info(f'JIT channel is open') + next_amount_msat_htlc -= channel_opening_fee + # fixme: some checks are missing + htlc = next_peer.send_htlc( + chan=next_chan, + payment_hash=payment_hash, + amount_msat=next_amount_msat_htlc, + cltv_abs=next_cltv_abs, + onion=next_onion) + async def wait_for_preimage(): + while self.get_preimage(payment_hash) is None: + await asyncio.sleep(1) + await util.wait_for2(wait_for_preimage(), LN_P2P_NETWORK_TIMEOUT) + except OnionRoutingFailure: + raise + except Exception: + raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') + # We have been paid and can broadcast + # if broadcasting raise an exception, we should try to rebroadcast + await self.network.broadcast_transaction(funding_tx) + return next_chan, funding_tx + @log_exceptions async def open_channel_with_peer( self, peer, funding_sat, *, push_sat: int = 0, public: bool = False, zeroconf: bool = False, + opening_fee: int = None, password=None): coins = self.wallet.get_spendable_coins(None) node_id = peer.pubkey @@ -1242,6 +1302,7 @@ class LNWallet(LNWorker): push_sat=push_sat, public=public, zeroconf=zeroconf, + opening_fee=opening_fee, password=password) return chan, funding_tx @@ -1254,6 +1315,7 @@ class LNWallet(LNWorker): push_sat: int, public: bool, zeroconf=False, + opening_fee=None, password: Optional[str]) -> Tuple[Channel, PartialTransaction]: coro = peer.channel_establishment_flow( @@ -1262,13 +1324,14 @@ class LNWallet(LNWorker): push_msat=push_sat * 1000, public=public, zeroconf=zeroconf, + opening_fee=opening_fee, temp_channel_id=os.urandom(32)) chan, funding_tx = await util.wait_for2(coro, LN_P2P_NETWORK_TIMEOUT) util.trigger_callback('channels_updated', self.wallet) self.wallet.adb.add_transaction(funding_tx) # save tx as local into the wallet self.wallet.sign_transaction(funding_tx, password) self.wallet.set_label(funding_tx.txid(), _('Open channel')) - if funding_tx.is_complete(): + if funding_tx.is_complete() and not zeroconf: await self.network.try_broadcasting(funding_tx, 'open_channel') return chan, funding_tx @@ -2412,11 +2475,20 @@ class LNWallet(LNWorker): def calc_routing_hints_for_invoice(self, amount_msat: Optional[int], channels=None): """calculate routing hints (BOLT-11 'r' field)""" routing_hints = [] - if channels is None: - channels = list(self.get_channels_for_receiving(amount_msat)) - random.shuffle(channels) # let's not leak channel order - scid_to_my_channels = {chan.short_channel_id: chan for chan in channels - if chan.short_channel_id is not None} + if self.config.ZEROCONF_TRUSTED_NODE: + node_id, rest = extract_nodeid(self.config.ZEROCONF_TRUSTED_NODE) + alias_or_scid = self.get_scid_alias() + routing_hints.append(('r', [(node_id, alias_or_scid, 0, 0, 144)])) + # no need for more + channels = [] + else: + if channels is None: + channels = list(self.get_channels_for_receiving(amount_msat)) + random.shuffle(channels) # let's not leak channel order + scid_to_my_channels = { + chan.short_channel_id: chan for chan in channels + if chan.short_channel_id is not None + } for chan in channels: alias_or_scid = chan.get_remote_scid_alias() or chan.short_channel_id assert isinstance(alias_or_scid, bytes), alias_or_scid @@ -2758,6 +2830,8 @@ class LNWallet(LNWorker): await asyncio.sleep(1) if self.stopping_soon: return + if self.config.ZEROCONF_TRUSTED_NODE: + await self.add_peer(self.config.ZEROCONF_TRUSTED_NODE) for chan in self.channels.values(): if chan.is_closed(): continue diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 454198add..9d9c403be 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -1169,9 +1169,10 @@ This will result in longer routes; it might increase your fees and decrease the SWAPSERVER_PORT = ConfigVar('swapserver_port', default=5455, type_=int) TEST_SWAPSERVER_REFUND = ConfigVar('test_swapserver_refund', default=False, type_=bool) - # zeroconf + # zeroconf channels ACCEPT_ZEROCONF_CHANNELS = ConfigVar('accept_zeroconf_channels', default=False, type_=bool) ZEROCONF_TRUSTED_NODE = ConfigVar('zeroconf_trusted_node', default='', type_=str) + ZEROCONF_MIN_OPENING_FEE = ConfigVar('zeroconf_min_opening_fee', default=5000, type_=int) # connect to remote WT WATCHTOWER_CLIENT_ENABLED = ConfigVar( diff --git a/electrum/tests/regtest.py b/electrum/tests/regtest.py index de6ea9351..2c66a6888 100644 --- a/electrum/tests/regtest.py +++ b/electrum/tests/regtest.py @@ -111,3 +111,39 @@ class TestLightningWatchtower(TestLightning): def test_watchtower(self): self.run_shell(['watchtower']) + + +class TestLightningJIT(TestLightning): + agents = { + 'alice':{ + 'accept_zeroconf_channels': 'true', + }, + 'bob':{ + 'lightning_listen': 'localhost:9735', + 'lightning_forward_payments': 'true', + 'accept_zeroconf_channels': 'true', + }, + 'carol':{ + } + } + + def test_just_in_time(self): + self.run_shell(['just_in_time']) + + +class TestLightningJITTrampoline(TestLightningJIT): + agents = { + 'alice':{ + 'use_gossip': 'false', + 'accept_zeroconf_channels': 'true', + }, + 'bob':{ + 'lightning_listen': 'localhost:9735', + 'lightning_forward_payments': 'true', + 'lightning_forward_trampoline_payments': 'true', + 'accept_zeroconf_channels': 'true', + }, + 'carol':{ + 'use_gossip': 'false', + } + } diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh index bcce18464..76c06b5b6 100755 --- a/electrum/tests/regtest/regtest.sh +++ b/electrum/tests/regtest/regtest.sh @@ -440,6 +440,21 @@ if [[ $1 == "watchtower" ]]; then wait_until_spent $ctx_id 1 # alice's to_local gets punished immediately fi +if [[ $1 == "just_in_time" ]]; then + bob_node=$($bob nodeid) + $alice setconfig zeroconf_trusted_node $bob_node + $alice setconfig use_recoverable_channels false + wait_for_balance carol 1 + echo "carol opens channel with bob" + $carol open_channel $bob_node 0.15 --password='' + new_blocks 3 + wait_until_channel_open carol + echo "carol pays alice" + # note: set amount to 0.001 to test failure: 'payment too low' + invoice=$($alice add_request 0.01 -m "invoice" | jq -r ".lightning_invoice") + $carol lnpay $invoice +fi + if [[ $1 == "unixsockets" ]]; then # This looks different because it has to run the entire daemon # Test domain socket behavior