Browse Source

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)
master
ThomasV 2 years ago
parent
commit
a338459d45
  1. 3
      electrum/lnchannel.py
  2. 102
      electrum/lnpeer.py
  3. 2
      electrum/lnwire/peer_wire.csv
  4. 86
      electrum/lnworker.py
  5. 3
      electrum/simple_config.py
  6. 36
      electrum/tests/regtest.py
  7. 15
      electrum/tests/regtest/regtest.sh

3
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"])

102
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

2
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,

1 msgtype,init,16
61 tlvdata,open_channel_tlvs,upfront_shutdown_script,shutdown_scriptpubkey,byte,...
62 tlvtype,open_channel_tlvs,channel_type,1
63 tlvdata,open_channel_tlvs,channel_type,type,byte,...
64 tlvtype,open_channel_tlvs,channel_opening_fee,10000
65 tlvdata,open_channel_tlvs,channel_opening_fee,channel_opening_fee,u64,
66 msgtype,accept_channel,33
67 msgdata,accept_channel,temporary_channel_id,byte,32
68 msgdata,accept_channel,dust_limit_satoshis,u64,

86
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

3
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(

36
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',
}
}

15
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

Loading…
Cancel
Save