Skip to content

Commit 3f45c41

Browse files
authored
Merge pull request #10230 from f321x/refactor_htlc_handling
lightning: refactor htlc switch
2 parents 09af778 + 59586d6 commit 3f45c41

File tree

12 files changed

+1870
-484
lines changed

12 files changed

+1870
-484
lines changed

electrum/commands.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,7 +1389,9 @@ async def add_hold_invoice(
13891389
) -> dict:
13901390
"""
13911391
Create a lightning hold invoice for the given payment hash. Hold invoices have to get settled manually later.
1392-
HTLCs will get failed automatically if block_height + 144 > htlc.cltv_abs.
1392+
HTLCs will get failed automatically if block_height + 144 > htlc.cltv_abs, if the intention is to
1393+
settle them as late as possible a safety margin of some blocks should be used to prevent them
1394+
from getting failed accidentally.
13931395
13941396
arg:str:payment_hash:Hex encoded payment hash to be used for the invoice
13951397
arg:decimal:amount:Optional requested amount (in btc)
@@ -1399,7 +1401,7 @@ async def add_hold_invoice(
13991401
"""
14001402
assert len(payment_hash) == 64, f"Invalid payment hash length: {len(payment_hash)} != 64"
14011403
assert payment_hash not in wallet.lnworker.payment_info, "Payment hash already used!"
1402-
assert payment_hash not in wallet.lnworker.dont_settle_htlcs, "Payment hash already used!"
1404+
assert payment_hash not in wallet.lnworker.dont_expire_htlcs, "Payment hash already used!"
14031405
assert wallet.lnworker.get_preimage(bfh(payment_hash)) is None, "Already got a preimage for this payment hash!"
14041406
assert MIN_FINAL_CLTV_DELTA_ACCEPTED < min_final_cltv_expiry_delta < 576, "Use a sane min_final_cltv_expiry_delta value"
14051407
amount = amount if amount and satoshis(amount) > 0 else None # make amount either >0 or None
@@ -1419,7 +1421,9 @@ async def add_hold_invoice(
14191421
message=memo,
14201422
fallback_address=None
14211423
)
1422-
wallet.lnworker.dont_settle_htlcs[payment_hash] = None
1424+
# this prevents incoming htlcs from getting expired while the preimage isn't set.
1425+
# If their blocks to expiry fall below MIN_FINAL_CLTV_DELTA_ACCEPTED they will get failed.
1426+
wallet.lnworker.dont_expire_htlcs[payment_hash] = MIN_FINAL_CLTV_DELTA_ACCEPTED
14231427
wallet.set_label(payment_hash, memo)
14241428
result = {
14251429
"invoice": invoice
@@ -1439,12 +1443,11 @@ async def settle_hold_invoice(self, preimage: str, wallet: Abstract_Wallet = Non
14391443
assert payment_hash not in wallet.lnworker._preimages, f"Invoice {payment_hash=} already settled"
14401444
assert payment_hash in wallet.lnworker.payment_info, \
14411445
f"Couldn't find lightning invoice for {payment_hash=}"
1442-
assert payment_hash in wallet.lnworker.dont_settle_htlcs, f"Invoice {payment_hash=} not a hold invoice?"
1446+
assert payment_hash in wallet.lnworker.dont_expire_htlcs, f"Invoice {payment_hash=} not a hold invoice?"
14431447
assert wallet.lnworker.is_complete_mpp(bfh(payment_hash)), \
14441448
f"MPP incomplete, cannot settle hold invoice {payment_hash} yet"
14451449
info: Optional['PaymentInfo'] = wallet.lnworker.get_payment_info(bfh(payment_hash))
14461450
assert (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) >= (info.amount_msat or 0)
1447-
del wallet.lnworker.dont_settle_htlcs[payment_hash]
14481451
wallet.lnworker.save_preimage(bfh(payment_hash), bfh(preimage))
14491452
util.trigger_callback('wallet_updated', wallet)
14501453
result = {
@@ -1462,15 +1465,15 @@ async def cancel_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet =
14621465
assert payment_hash in wallet.lnworker.payment_info, \
14631466
f"Couldn't find lightning invoice for payment hash {payment_hash}"
14641467
assert payment_hash not in wallet.lnworker._preimages, "Cannot cancel anymore, preimage already given."
1465-
assert payment_hash in wallet.lnworker.dont_settle_htlcs, f"{payment_hash=} not a hold invoice?"
1468+
assert payment_hash in wallet.lnworker.dont_expire_htlcs, f"{payment_hash=} not a hold invoice?"
14661469
# set to PR_UNPAID so it can get deleted
14671470
wallet.lnworker.set_payment_status(bfh(payment_hash), PR_UNPAID)
14681471
wallet.lnworker.delete_payment_info(payment_hash)
14691472
wallet.set_label(payment_hash, None)
1473+
del wallet.lnworker.dont_expire_htlcs[payment_hash]
14701474
while wallet.lnworker.is_complete_mpp(bfh(payment_hash)):
1471-
# wait until the htlcs got failed so the payment won't get settled accidentally in a race
1475+
# block until the htlcs got failed
14721476
await asyncio.sleep(0.1)
1473-
del wallet.lnworker.dont_settle_htlcs[payment_hash]
14741477
result = {
14751478
"cancelled": payment_hash
14761479
}
@@ -1503,15 +1506,14 @@ async def check_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet =
15031506
elif not is_complete_mpp and not wallet.lnworker.get_preimage_hex(payment_hash):
15041507
# is_complete_mpp is False for settled payments
15051508
result["status"] = "unpaid"
1506-
elif is_complete_mpp and payment_hash in wallet.lnworker.dont_settle_htlcs:
1509+
elif is_complete_mpp and payment_hash in wallet.lnworker.dont_expire_htlcs:
15071510
result["status"] = "paid"
15081511
payment_key: str = wallet.lnworker._get_payment_key(bfh(payment_hash)).hex()
15091512
htlc_status = wallet.lnworker.received_mpp_htlcs[payment_key]
15101513
result["closest_htlc_expiry_height"] = min(
1511-
htlc.cltv_abs for _, htlc in htlc_status.htlc_set
1514+
mpp_htlc.htlc.cltv_abs for mpp_htlc in htlc_status.htlcs
15121515
)
1513-
elif wallet.lnworker.get_preimage_hex(payment_hash) is not None \
1514-
and payment_hash not in wallet.lnworker.dont_settle_htlcs:
1516+
elif wallet.lnworker.get_preimage_hex(payment_hash) is not None:
15151517
result["status"] = "settled"
15161518
plist = wallet.lnworker.get_payments(status='settled')[bfh(payment_hash)]
15171519
_dir, amount_msat, _fee, _ts = wallet.lnworker.get_payment_value(info, plist)

electrum/lnchannel.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -783,8 +783,8 @@ def __init__(self, state: 'StoredDict', *, name=None, lnworker=None, initial_fee
783783
self.onion_keys = state['onion_keys'] # type: Dict[int, bytes]
784784
self.data_loss_protect_remote_pcp = state['data_loss_protect_remote_pcp']
785785
self.hm = HTLCManager(log=state['log'], initial_feerate=initial_feerate)
786-
self.unfulfilled_htlcs = state["unfulfilled_htlcs"] # type: Dict[int, Tuple[str, Optional[str]]]
787-
# ^ htlc_id -> onion_packet_hex, forwarding_key
786+
self.unfulfilled_htlcs = state["unfulfilled_htlcs"] # type: Dict[int, Optional[str]]
787+
# ^ htlc_id -> onion_packet_hex
788788
self._state = ChannelState[state['state']]
789789
self.peer_state = PeerState.DISCONNECTED
790790
self._outgoing_channel_update = None # type: Optional[bytes]
@@ -1112,6 +1112,7 @@ def _assert_can_add_htlc(self, *, htlc_proposer: HTLCOwner, amount_msat: int,
11121112
if amount_msat <= 0:
11131113
raise PaymentFailure("HTLC value must be positive")
11141114
if amount_msat < chan_config.htlc_minimum_msat:
1115+
# todo: for incoming htlcs this could be handled more gracefully with `amount_below_minimum`
11151116
raise PaymentFailure(f'HTLC value too small: {amount_msat} msat')
11161117

11171118
if self.htlc_slots_left(htlc_proposer) == 0:
@@ -1226,7 +1227,7 @@ def receive_htlc(self, htlc: UpdateAddHtlc, onion_packet:bytes = None) -> Update
12261227
with self.db_lock:
12271228
self.hm.recv_htlc(htlc)
12281229
if onion_packet:
1229-
self.unfulfilled_htlcs[htlc.htlc_id] = onion_packet.hex(), None
1230+
self.unfulfilled_htlcs[htlc.htlc_id] = onion_packet.hex()
12301231

12311232
self.logger.info("receive_htlc")
12321233
return htlc

electrum/lnonion.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
import io
2727
import hashlib
2828
from functools import cached_property
29-
from typing import Sequence, List, Tuple, NamedTuple, TYPE_CHECKING, Dict, Any, Optional, Union, Mapping
29+
from typing import (Sequence, List, Tuple, NamedTuple, TYPE_CHECKING, Dict, Any, Optional, Union,
30+
Mapping, Iterator)
3031
from enum import IntEnum
3132
from dataclasses import dataclass, field, replace
3233
from types import MappingProxyType
@@ -485,6 +486,55 @@ def process_onion_packet(
485486
return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet)
486487

487488

489+
def compare_trampoline_onions(
490+
trampoline_onions: Iterator[Optional[ProcessedOnionPacket]],
491+
*,
492+
exclude_amt_to_fwd: bool = False,
493+
) -> bool:
494+
"""
495+
compare values of trampoline onions payloads and are_we_final.
496+
If we are receiver of a multi trampoline payment amt_to_fwd can differ between the trampoline
497+
parts of the payment, so it needs to be excluded from the comparison when comparing all trampoline
498+
onions of the whole payment (however it can be compared between the onions in a single trampoline part).
499+
"""
500+
try:
501+
first_onion = next(trampoline_onions)
502+
except StopIteration:
503+
raise ValueError("nothing to compare")
504+
505+
if first_onion is None:
506+
# we don't support mixed mpp sets of htlcs with trampoline onions and regular non-trampoline htlcs.
507+
# In theory this could happen if a sender e.g. uses trampoline as fallback to deliver
508+
# outstanding mpp parts if local pathfinding wasn't successful for the whole payment,
509+
# resulting in a mixed payment. However, it's not even clear if the spec allows for such a constellation.
510+
return all(onion is None for onion in trampoline_onions)
511+
assert isinstance(first_onion, ProcessedOnionPacket), f"{first_onion=}"
512+
513+
are_we_final = first_onion.are_we_final
514+
payload = first_onion.hop_data.payload
515+
total_msat = first_onion.total_msat
516+
outgoing_cltv = first_onion.outgoing_cltv_value
517+
payment_secret = first_onion.payment_secret
518+
for onion in trampoline_onions:
519+
if onion is None:
520+
return False
521+
assert isinstance(onion, ProcessedOnionPacket), f"{onion=}"
522+
assert onion.trampoline_onion_packet is None, f"{onion=} cannot have trampoline_onion_packet"
523+
if onion.are_we_final != are_we_final:
524+
return False
525+
if not exclude_amt_to_fwd:
526+
if onion.hop_data.payload != payload:
527+
return False
528+
else:
529+
if onion.total_msat != total_msat:
530+
return False
531+
if onion.outgoing_cltv_value != outgoing_cltv:
532+
return False
533+
if onion.payment_secret != payment_secret:
534+
return False
535+
return True
536+
537+
488538
class FailedToDecodeOnionError(Exception): pass
489539

490540

@@ -521,6 +571,21 @@ def decode_data(self) -> Optional[Dict[str, Any]]:
521571
payload = None
522572
return payload
523573

574+
def to_wire_msg(self, onion_packet: OnionPacket, privkey: bytes, local_height: int) -> bytes:
575+
onion_error = construct_onion_error(self, onion_packet.public_key, privkey, local_height)
576+
error_bytes = obfuscate_onion_error(onion_error, onion_packet.public_key, privkey)
577+
return error_bytes
578+
579+
580+
class OnionParsingError(OnionRoutingFailure):
581+
"""
582+
Onion parsing error will cause a htlc to get failed with update_fail_malformed_htlc.
583+
Using INVALID_ONION_VERSION as there is no unspecific BADONION failure code defined in the spec
584+
for the case we just cannot parse the onion.
585+
"""
586+
def __init__(self, data: bytes):
587+
OnionRoutingFailure.__init__(self, code=OnionFailureCode.INVALID_ONION_VERSION, data=data)
588+
524589

525590
def construct_onion_error(
526591
error: OnionRoutingFailure,

0 commit comments

Comments
 (0)