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)
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user