tReorganize code so that we can send Multi Part Payments: - LNWorker is notified about htlc events and creates payment events. - LNWorker._pay is a while loop that calls create_routes_from_invoice. - create_route_from_invoices should decide whether to split the payment, using graph knowledge and feedback from previous attempts (not in this commit) - data structures for payment logs are simplified into a single type, HtlcLog - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit f28a2aae73b8a4a2067f41d2f69809e2b1ec6a20 DIR parent 1102ea50e878d02c126cbb27480abb39e74e0534 HTML Author: ThomasV <thomasv@electrum.org> Date: Sat, 30 Jan 2021 16:10:51 +0100 Reorganize code so that we can send Multi Part Payments: - LNWorker is notified about htlc events and creates payment events. - LNWorker._pay is a while loop that calls create_routes_from_invoice. - create_route_from_invoices should decide whether to split the payment, using graph knowledge and feedback from previous attempts (not in this commit) - data structures for payment logs are simplified into a single type, HtlcLog Diffstat: M electrum/gui/qt/channel_details.py | 20 ++++++++++---------- M electrum/gui/qt/invoice_list.py | 4 ++-- M electrum/lnchannel.py | 8 ++++---- M electrum/lnutil.py | 58 +++++++++++-------------------- M electrum/lnworker.py | 254 +++++++++++++++++-------------- M electrum/tests/test_lnpeer.py | 56 ++++++++++++++++---------------- 6 files changed, 206 insertions(+), 194 deletions(-) --- DIR diff --git a/electrum/gui/qt/channel_details.py b/electrum/gui/qt/channel_details.py t@@ -82,8 +82,8 @@ class ChannelDetailsDialog(QtWidgets.QDialog): dest_mapping = self.keyname_rows[to] dest_mapping[payment_hash] = len(dest_mapping) - ln_payment_completed = QtCore.pyqtSignal(str, bytes, bytes) - ln_payment_failed = QtCore.pyqtSignal(str, bytes, bytes) + htlc_fulfilled = QtCore.pyqtSignal(str, bytes, bytes) + htlc_failed = QtCore.pyqtSignal(str, bytes, bytes) htlc_added = QtCore.pyqtSignal(str, Channel, UpdateAddHtlc, Direction) state_changed = QtCore.pyqtSignal(str, Abstract_Wallet, AbstractChannel) t@@ -95,7 +95,7 @@ class ChannelDetailsDialog(QtWidgets.QDialog): self.update() @QtCore.pyqtSlot(str, Channel, UpdateAddHtlc, Direction) - def do_htlc_added(self, evtname, chan, htlc, direction): + def on_htlc_added(self, evtname, chan, htlc, direction): if chan != self.chan: return mapping = self.keyname_rows['inflight'] t@@ -103,14 +103,14 @@ class ChannelDetailsDialog(QtWidgets.QDialog): self.folders['inflight'].appendRow(self.make_htlc_item(htlc, direction)) @QtCore.pyqtSlot(str, bytes, bytes) - def do_ln_payment_completed(self, evtname, payment_hash, chan_id): + def on_htlc_fulfilled(self, evtname, payment_hash, chan_id): if chan_id != self.chan.channel_id: return self.move('inflight', 'settled', payment_hash) self.update() @QtCore.pyqtSlot(str, bytes, bytes) - def do_ln_payment_failed(self, evtname, payment_hash, chan_id): + def on_htlc_failed(self, evtname, payment_hash, chan_id): if chan_id != self.chan.channel_id: return self.move('inflight', 'failed', payment_hash) t@@ -137,14 +137,14 @@ class ChannelDetailsDialog(QtWidgets.QDialog): self.format_msat = lambda msat: window.format_amount_and_units(msat / 1000) # connect signals with slots - self.ln_payment_completed.connect(self.do_ln_payment_completed) - self.ln_payment_failed.connect(self.do_ln_payment_failed) + self.htlc_fulfilled.connect(self.on_htlc_fulfilled) + self.htlc_failed.connect(self.on_htlc_failed_failed) self.state_changed.connect(self.do_state_changed) - self.htlc_added.connect(self.do_htlc_added) + self.htlc_added.connect(self.on_htlc_added) # register callbacks for updating - util.register_callback(self.ln_payment_completed.emit, ['ln_payment_completed']) - util.register_callback(self.ln_payment_failed.emit, ['ln_payment_failed']) + util.register_callback(self.htlc_fulfilled.emit, ['htlc_fulfilled']) + util.register_callback(self.htlc_failed.emit, ['htlc_failed']) util.register_callback(self.htlc_added.emit, ['htlc_added']) util.register_callback(self.state_changed.emit, ['channel']) DIR diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py t@@ -34,7 +34,7 @@ from PyQt5.QtWidgets import QMenu, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QH from electrum.i18n import _ from electrum.util import format_time from electrum.invoices import Invoice, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_TYPE_ONCHAIN, PR_TYPE_LN -from electrum.lnutil import PaymentAttemptLog +from electrum.lnutil import HtlcLog from .util import MyTreeView, read_QIcon, MySortModel, pr_icons from .util import CloseButton, Buttons t@@ -173,7 +173,7 @@ class InvoiceList(MyTreeView): menu.addAction(_("Delete"), lambda: self.parent.delete_invoices([key])) menu.exec_(self.viewport().mapToGlobal(position)) - def show_log(self, key, log: Sequence[PaymentAttemptLog]): + def show_log(self, key, log: Sequence[HtlcLog]): d = WindowModalDialog(self, _("Payment log")) d.setMinimumWidth(600) vbox = QVBoxLayout(d) DIR diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py t@@ -991,7 +991,7 @@ class Channel(AbstractChannel): if self.lnworker: sent = self.hm.sent_in_ctn(new_ctn) for htlc in sent: - self.lnworker.payment_sent(self, htlc.payment_hash) + self.lnworker.htlc_fulfilled(self, htlc.payment_hash, htlc.htlc_id, htlc.amount_msat) failed = self.hm.failed_in_ctn(new_ctn) for htlc in failed: try: t@@ -1002,7 +1002,7 @@ class Channel(AbstractChannel): if self.lnworker.get_payment_info(htlc.payment_hash) is None: self.save_fail_htlc_reason(htlc.htlc_id, error_bytes, failure_message) else: - self.lnworker.payment_failed(self, htlc.payment_hash, error_bytes, failure_message) + self.lnworker.htlc_failed(self, htlc.payment_hash, htlc.htlc_id, htlc.amount_msat, error_bytes, failure_message) def save_fail_htlc_reason( self, t@@ -1048,9 +1048,9 @@ class Channel(AbstractChannel): info = self.lnworker.get_payment_info(payment_hash) if info is not None and info.status != PR_PAID: if is_sent: - self.lnworker.payment_sent(self, payment_hash) + self.lnworker.htlc_fulfilled(self, payment_hash, htlc.htlc_id, htlc.amount_msat) else: - self.lnworker.payment_received(payment_hash) + self.lnworker.htlc_received(self, payment_hash) def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int: assert type(whose) is HTLCOwner DIR diff --git a/electrum/lnutil.py b/electrum/lnutil.py t@@ -249,52 +249,36 @@ class Outpoint(StoredObject): return "{}:{}".format(self.txid, self.output_index) -class PaymentAttemptFailureDetails(NamedTuple): - sender_idx: Optional[int] - failure_msg: 'OnionRoutingFailureMessage' - is_blacklisted: bool - - -class PaymentAttemptLog(NamedTuple): +class HtlcLog(NamedTuple): success: bool + amount_msat: int route: Optional['LNPaymentRoute'] = None preimage: Optional[bytes] = None - failure_details: Optional[PaymentAttemptFailureDetails] = None - exception: Optional[Exception] = None + error_bytes: Optional[bytes] = None + failure_msg: Optional['OnionRoutingFailureMessage'] = None + sender_idx: Optional[int] = None def formatted_tuple(self): - if not self.exception: - route = self.route - route_str = '%d'%len(route) - short_channel_id = None - if not self.success: - sender_idx = self.failure_details.sender_idx - failure_msg = self.failure_details.failure_msg - if sender_idx is not None: - try: - short_channel_id = route[sender_idx + 1].short_channel_id - except IndexError: - # payment destination reported error - short_channel_id = _("Destination node") - message = failure_msg.code_name() - else: - short_channel_id = route[-1].short_channel_id - message = _('Success') - chan_str = str(short_channel_id) if short_channel_id else _("Unknown") + route = self.route + route_str = '%d'%len(route) + short_channel_id = None + if not self.success: + sender_idx = self.sender_idx + failure_msg = self.failure_msg + if sender_idx is not None: + try: + short_channel_id = route[sender_idx + 1].short_channel_id + except IndexError: + # payment destination reported error + short_channel_id = _("Destination node") + message = failure_msg.code_name() else: - route_str = 'None' - chan_str = 'N/A' - message = str(self.exception) + short_channel_id = route[-1].short_channel_id + message = _('Success') + chan_str = str(short_channel_id) if short_channel_id else _("Unknown") return route_str, chan_str, message -class BarePaymentAttemptLog(NamedTuple): - success: bool - preimage: Optional[bytes] = None - error_bytes: Optional[bytes] = None - failure_message: Optional['OnionRoutingFailureMessage'] = None - - class LightningError(Exception): pass class LightningPeerConnectionClosed(LightningError): pass class UnableToDeriveSecret(LightningError): pass DIR diff --git a/electrum/lnworker.py b/electrum/lnworker.py t@@ -55,9 +55,8 @@ from .lnutil import (Outpoint, LNPeerAddr, generate_keypair, LnKeyFamily, LOCAL, REMOTE, MIN_FINAL_CLTV_EXPIRY_FOR_INVOICE, NUM_MAX_EDGES_IN_PAYMENT_PATH, SENT, RECEIVED, HTLCOwner, - UpdateAddHtlc, Direction, LnFeatures, - ShortChannelID, PaymentAttemptLog, PaymentAttemptFailureDetails, - BarePaymentAttemptLog, derive_payment_secret_from_payment_preimage) + UpdateAddHtlc, Direction, LnFeatures, ShortChannelID, + HtlcLog, derive_payment_secret_from_payment_preimage) from .lnutil import ln_dummy_address, ln_compare_features, IncompatibleLightningFeatures from .transaction import PartialTxOutput, PartialTransaction, PartialTxInput from .lnonion import OnionFailureCode, process_onion_packet, OnionPacket, OnionRoutingFailureMessage t@@ -570,7 +569,7 @@ class LNWallet(LNWorker): self.preimages = self.db.get_dict('lightning_preimages') # RHASH -> preimage # note: this sweep_address is only used as fallback; as it might result in address-reuse self.sweep_address = wallet.get_new_sweep_address_for_channel() - self.logs = defaultdict(list) # type: Dict[str, List[PaymentAttemptLog]] # key is RHASH # (not persisted) + self.logs = defaultdict(list) # type: Dict[str, List[HtlcLog]] # key is RHASH # (not persisted) # used in tests self.enable_htlc_settle = asyncio.Event() self.enable_htlc_settle.set() t@@ -581,8 +580,11 @@ class LNWallet(LNWorker): for channel_id, c in random_shuffled_copy(channels.items()): self._channels[bfh(channel_id)] = Channel(c, sweep_address=self.sweep_address, lnworker=self) - self.pending_payments = defaultdict(asyncio.Future) # type: Dict[bytes, asyncio.Future[BarePaymentAttemptLog]] + self.pending_payments = defaultdict(asyncio.Future) # type: Dict[bytes, asyncio.Future[HtlcLog]] + self.pending_sent_htlcs = defaultdict(asyncio.Queue) # type: Dict[bytes, asyncio.Future[HtlcLog]] + self.pending_htlcs = defaultdict(set) # type: Dict[bytes, set] + self.htlc_routes = defaultdict(list) self.swap_manager = SwapManager(wallet=self.wallet, lnworker=self) # detect inflight payments t@@ -930,7 +932,7 @@ class LNWallet(LNWorker): return chan, funding_tx - def pay(self, invoice: str, *, amount_msat: int = None, attempts: int = 1) -> Tuple[bool, List[PaymentAttemptLog]]: + def pay(self, invoice: str, *, amount_msat: int = None, attempts: int = 1) -> Tuple[bool, List[HtlcLog]]: """ Can be called from other threads """ t@@ -945,13 +947,11 @@ class LNWallet(LNWorker): @log_exceptions async def _pay( - self, - invoice: str, - *, + self, invoice: str, *, amount_msat: int = None, attempts: int = 1, - full_path: LNPaymentPath = None, - ) -> Tuple[bool, List[PaymentAttemptLog]]: + full_path: LNPaymentPath = None) -> Tuple[bool, List[HtlcLog]]: + lnaddr = self._check_invoice(invoice, amount_msat=amount_msat) payment_hash = lnaddr.paymenthash key = payment_hash.hex() t@@ -967,84 +967,89 @@ class LNWallet(LNWorker): self.logs[key] = log = [] success = False reason = '' - for i in range(attempts): - try: + amount_to_pay = lnaddr.get_amount_msat() + amount_inflight = 0 # what we sent in htlcs + + self.set_invoice_status(key, PR_INFLIGHT) + util.trigger_callback('invoice_status', self.wallet, key) + while True: + amount_to_send = amount_to_pay - amount_inflight + if amount_to_send > 0: + # 1. create a set of routes for remaining amount. # note: path-finding runs in a separate thread so that we don't block the asyncio loop # graph updates might occur during the computation - self.set_invoice_status(key, PR_INFLIGHT) + try: + routes = await run_in_thread(partial(self.create_routes_from_invoice, amount_to_send, lnaddr, full_path=full_path)) + except NoPathFound: + # catch this exception because we still want to return the htlc log + reason = 'No path found' + break + # 2. send htlcs + for route, amount_msat in routes: + await self.pay_to_route(route, amount_msat, lnaddr) + amount_inflight += amount_msat util.trigger_callback('invoice_status', self.wallet, key) - route = await run_in_thread(partial(self._create_route_from_invoice, lnaddr, full_path=full_path)) - payment_attempt_log = await self._pay_to_route(route, lnaddr) - except Exception as e: - log.append(PaymentAttemptLog(success=False, exception=e)) - reason = str(e) + # 3. await a queue + htlc_log = await self.pending_sent_htlcs[payment_hash].get() + amount_inflight -= htlc_log.amount_msat + log.append(htlc_log) + if htlc_log.success: + success = True break - log.append(payment_attempt_log) - success = payment_attempt_log.success - if success: + # htlc failed + # if we get a tmp channel failure, it might work to split the amount and try more routes + # if we get a channel update, we might retry the same route and amount + if len(log) >= attempts: + reason = 'Giving up after %d attempts'%len(log) break - else: - reason = _('Failed after {} attempts').format(attempts) - self.set_invoice_status(key, PR_PAID if success else PR_UNPAID) - util.trigger_callback('invoice_status', self.wallet, key) + if htlc_log.sender_idx is not None: + # apply channel update here + should_continue = self.handle_error_code_from_failed_htlc(htlc_log) + if not should_continue: + break + else: + # probably got "update_fail_malformed_htlc". well... who to penalise now? + reason = 'sender idx missing' + break + + # MPP: should we await all the inflight htlcs, or have another state? if success: + self.set_invoice_status(key, PR_PAID) util.trigger_callback('payment_succeeded', self.wallet, key) else: + self.set_invoice_status(key, PR_UNPAID) util.trigger_callback('payment_failed', self.wallet, key, reason) + util.trigger_callback('invoice_status', self.wallet, key) return success, log - async def _pay_to_route(self, route: LNPaymentRoute, lnaddr: LnAddr) -> PaymentAttemptLog: + async def pay_to_route(self, route: LNPaymentRoute, amount_msat:int, lnaddr: LnAddr): + # send a single htlc short_channel_id = route[0].short_channel_id chan = self.get_channel_by_short_id(short_channel_id) peer = self._peers.get(route[0].node_id) + payment_hash = lnaddr.paymenthash if not peer: raise Exception('Dropped peer') await peer.initialized htlc = peer.pay( route=route, chan=chan, - amount_msat=lnaddr.get_amount_msat(), - payment_hash=lnaddr.paymenthash, + amount_msat=amount_msat, + payment_hash=payment_hash, min_final_cltv_expiry=lnaddr.get_min_final_cltv_expiry(), payment_secret=lnaddr.payment_secret) + self.htlc_routes[(payment_hash, short_channel_id, htlc.htlc_id)] = route util.trigger_callback('htlc_added', chan, htlc, SENT) - payment_attempt = await self.await_payment(lnaddr.paymenthash) - if payment_attempt.success: - failure_log = None - else: - if payment_attempt.error_bytes: - # TODO "decode_onion_error" might raise, catch and maybe blacklist/penalise someone? - failure_msg, sender_idx = chan.decode_onion_error(payment_attempt.error_bytes, route, htlc.htlc_id) - is_blacklisted = self.handle_error_code_from_failed_htlc(failure_msg, sender_idx, route, peer) - if is_blacklisted: - # blacklist channel after reporter node - # TODO this should depend on the error (even more granularity) - # also, we need finer blacklisting (directed edges; nodes) - try: - short_chan_id = route[sender_idx + 1].short_channel_id - except IndexError: - self.logger.info("payment destination reported error") - else: - self.logger.info(f'blacklisting channel {short_chan_id}') - self.network.channel_blacklist.add(short_chan_id) - else: - # probably got "update_fail_malformed_htlc". well... who to penalise now? - assert payment_attempt.failure_message is not None - sender_idx = None - failure_msg = payment_attempt.failure_message - is_blacklisted = False - failure_log = PaymentAttemptFailureDetails(sender_idx=sender_idx, - failure_msg=failure_msg, - is_blacklisted=is_blacklisted) - return PaymentAttemptLog(route=route, - success=payment_attempt.success, - preimage=payment_attempt.preimage, - failure_details=failure_log) - - def handle_error_code_from_failed_htlc(self, failure_msg, sender_idx, route, peer): + + def handle_error_code_from_failed_htlc(self, htlc_log): + route = htlc_log.route + sender_idx = htlc_log.sender_idx + failure_msg = htlc_log.failure_msg code, data = failure_msg.code, failure_msg.data self.logger.info(f"UPDATE_FAIL_HTLC {repr(code)} {data}") self.logger.info(f"error reported by {bh2u(route[sender_idx].node_id)}") + if code == OnionFailureCode.MPP_TIMEOUT: + return False # handle some specific error codes failure_codes = { OnionFailureCode.TEMPORARY_CHANNEL_FAILURE: 0, t@@ -1067,7 +1072,10 @@ class LNWallet(LNWorker): short_channel_id = ShortChannelID(payload['short_channel_id']) if r == UpdateStatus.GOOD: self.logger.info(f"applied channel update to {short_channel_id}") - peer.maybe_save_remote_update(payload) + # TODO: test this + for chan in self.channels.values(): + if chan.short_channel_id == short_channel_id: + chan.set_remote_update(payload['raw']) elif r == UpdateStatus.ORPHANED: # maybe it is a private channel (and data in invoice was outdated) self.logger.info(f"Could not find {short_channel_id}. maybe update is for private channel?") t@@ -1082,7 +1090,23 @@ class LNWallet(LNWorker): blacklist = True else: blacklist = True - return blacklist + # blacklist channel after reporter node + # TODO this should depend on the error (even more granularity) + # also, we need finer blacklisting (directed edges; nodes) + if blacklist and sender_idx: + try: + short_chan_id = route[sender_idx + 1].short_channel_id + except IndexError: + self.logger.info("payment destination reported error") + short_chan_id = None + else: + # TODO: for MPP we need to save the amount for which + # we saw temporary channel failure + self.logger.info(f'blacklisting channel {short_chan_id}') + self.network.channel_blacklist.add(short_chan_id) + return True + return False + @classmethod def _decode_channel_update_msg(cls, chan_upd_msg: bytes) -> Optional[Dict[str, Any]]: t@@ -1123,9 +1147,13 @@ class LNWallet(LNWorker): return addr @profiler - def _create_route_from_invoice(self, decoded_invoice: 'LnAddr', - *, full_path: LNPaymentPath = None) -> LNPaymentRoute: - amount_msat = decoded_invoice.get_amount_msat() + def create_routes_from_invoice( + self, + amount_msat: int, + decoded_invoice: 'LnAddr', + *, full_path: LNPaymentPath = None) -> LNPaymentRoute: + # TODO: return multiples routes if we know that a single one will not work + # initially, try with less htlcs invoice_pubkey = decoded_invoice.pubkey.serialize() # use 'r' field from invoice route = None # type: Optional[LNPaymentRoute] t@@ -1211,7 +1239,8 @@ class LNWallet(LNWorker): # add features from invoice invoice_features = decoded_invoice.get_tag('9') or 0 route[-1].node_features |= invoice_features - return route + # return a list of routes + return [(route, amount_msat)] def add_request(self, amount_sat, message, expiry) -> str: coro = self._add_request_coro(amount_sat, message, expiry) t@@ -1297,7 +1326,8 @@ class LNWallet(LNWorker): expired = time.time() - first_timestamp > MPP_EXPIRY if total >= expected_msat and not expired: # status must be persisted - self.payment_received(htlc.payment_hash) + self.set_payment_status(htlc.payment_hash, PR_PAID) + util.trigger_callback('request_status', self.wallet, htlc.payment_hash.hex(), PR_PAID) return True, None if expired: return None, True t@@ -1326,12 +1356,6 @@ class LNWallet(LNWorker): if status in SAVED_PR_STATUS: self.set_payment_status(bfh(key), status) - async def await_payment(self, payment_hash: bytes) -> BarePaymentAttemptLog: - # note side-effect: Future is created and added here (defaultdict): - payment_attempt = await self.pending_payments[payment_hash] - self.pending_payments.pop(payment_hash) - return payment_attempt - def set_payment_status(self, payment_hash: bytes, status): info = self.get_payment_info(payment_hash) if info is None: t@@ -1340,48 +1364,52 @@ class LNWallet(LNWorker): info = info._replace(status=status) self.save_payment_info(info) - def payment_failed( + def htlc_fulfilled(self, chan, payment_hash: bytes, htlc_id:int, amount_msat:int): + route = self.htlc_routes.get((payment_hash, chan.short_channel_id, htlc_id)) + htlc_log = HtlcLog( + success=True, + route=route, + amount_msat=amount_msat) + q = self.pending_sent_htlcs[payment_hash] + q.put_nowait(htlc_log) + util.trigger_callback('htlc_fulfilled', payment_hash, chan.channel_id) + + def htlc_failed( self, - chan: Channel, + chan, payment_hash: bytes, + htlc_id: int, + amount_msat:int, error_bytes: Optional[bytes], - failure_message: Optional['OnionRoutingFailureMessage'], - ): - self.set_payment_status(payment_hash, PR_UNPAID) - f = self.pending_payments.get(payment_hash) - if f and not f.cancelled(): - payment_attempt = BarePaymentAttemptLog( - success=False, - error_bytes=error_bytes, - failure_message=failure_message) - f.set_result(payment_attempt) - else: - chan.logger.info('received unexpected payment_failed, probably from previous session') - key = payment_hash.hex() - util.trigger_callback('invoice_status', self.wallet, key) - util.trigger_callback('payment_failed', self.wallet, key, '') - util.trigger_callback('ln_payment_failed', payment_hash, chan.channel_id) - - def payment_sent(self, chan, payment_hash: bytes): - self.set_payment_status(payment_hash, PR_PAID) - preimage = self.get_preimage(payment_hash) - f = self.pending_payments.get(payment_hash) - if f and not f.cancelled(): - payment_attempt = BarePaymentAttemptLog( - success=True, - preimage=preimage) - f.set_result(payment_attempt) + failure_message: Optional['OnionRoutingFailureMessage']): + + route = self.htlc_routes.get((payment_hash, chan.short_channel_id, htlc_id)) + if error_bytes and route: + self.logger.info(f" {(error_bytes, route, htlc_id)}") + # TODO "decode_onion_error" might raise, catch and maybe blacklist/penalise someone? + try: + failure_message, sender_idx = chan.decode_onion_error(error_bytes, route, htlc_id) + except Exception as e: + sender_idx = None + failure_message = OnionRoutingFailureMessage(-1, str(e)) else: - chan.logger.info('received unexpected payment_sent, probably from previous session') - key = payment_hash.hex() - util.trigger_callback('invoice_status', self.wallet, key) - util.trigger_callback('payment_succeeded', self.wallet, key) - util.trigger_callback('ln_payment_completed', payment_hash, chan.channel_id) + # probably got "update_fail_malformed_htlc". well... who to penalise now? + assert failure_message is not None + sender_idx = None + + htlc_log = HtlcLog( + success=False, + route=route, + amount_msat=amount_msat, + error_bytes=error_bytes, + failure_msg=failure_message, + sender_idx=sender_idx) + + q = self.pending_sent_htlcs[payment_hash] + q.put_nowait(htlc_log) + util.trigger_callback('htlc_failed', payment_hash, chan.channel_id) + - def payment_received(self, payment_hash: bytes): - self.set_payment_status(payment_hash, PR_PAID) - util.trigger_callback('request_status', self.wallet, payment_hash.hex(), PR_PAID) - #util.trigger_callback('ln_payment_completed', payment_hash, chan.channel_id) async def _calc_routing_hints_for_invoice(self, amount_msat: Optional[int]): """calculate routing hints (BOLT-11 'r' field)""" DIR diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py t@@ -133,6 +133,8 @@ class MockLNWallet(Logger, NetworkRetryManager[LNPeerAddr]): self.enable_htlc_settle = asyncio.Event() self.enable_htlc_settle.set() self.pending_htlcs = defaultdict(set) + self.pending_sent_htlcs = defaultdict(asyncio.Queue) + self.htlc_routes = defaultdict(list) def get_invoice_status(self, key): pass t@@ -169,15 +171,13 @@ class MockLNWallet(Logger, NetworkRetryManager[LNPeerAddr]): set_payment_status = LNWallet.set_payment_status get_payment_status = LNWallet.get_payment_status htlc_received = LNWallet.htlc_received - await_payment = LNWallet.await_payment - payment_received = LNWallet.payment_received - payment_sent = LNWallet.payment_sent - payment_failed = LNWallet.payment_failed + htlc_fulfilled = LNWallet.htlc_fulfilled + htlc_failed = LNWallet.htlc_failed save_preimage = LNWallet.save_preimage get_preimage = LNWallet.get_preimage - _create_route_from_invoice = LNWallet._create_route_from_invoice + create_routes_from_invoice = LNWallet.create_routes_from_invoice _check_invoice = staticmethod(LNWallet._check_invoice) - _pay_to_route = LNWallet._pay_to_route + pay_to_route = LNWallet.pay_to_route _pay = LNWallet._pay force_close_channel = LNWallet.force_close_channel try_force_closing = LNWallet.try_force_closing t@@ -490,27 +490,27 @@ class TestPeer(ElectrumTestCase): lnaddr1 = lndecode(pay_req1, expected_hrp=constants.net.SEGWIT_HRP) # alice sends htlc BUT NOT COMMITMENT_SIGNED p1.maybe_send_commitment = lambda x: None + route1, amount_msat1 = w1.create_routes_from_invoice(lnaddr2.get_amount_msat(), decoded_invoice=lnaddr2)[0] p1.pay( - route=w1._create_route_from_invoice(decoded_invoice=lnaddr2), + route=route1, chan=alice_channel, amount_msat=lnaddr2.get_amount_msat(), payment_hash=lnaddr2.paymenthash, min_final_cltv_expiry=lnaddr2.get_min_final_cltv_expiry(), payment_secret=lnaddr2.payment_secret, ) - w1.pending_payments[lnaddr2.paymenthash] = asyncio.Future() p1.maybe_send_commitment = _maybe_send_commitment1 # bob sends htlc BUT NOT COMMITMENT_SIGNED p2.maybe_send_commitment = lambda x: None + route2, amount_msat2 = w2.create_routes_from_invoice(lnaddr1.get_amount_msat(), decoded_invoice=lnaddr1)[0] p2.pay( - route=w2._create_route_from_invoice(decoded_invoice=lnaddr1), + route=route2, chan=bob_channel, amount_msat=lnaddr1.get_amount_msat(), payment_hash=lnaddr1.paymenthash, min_final_cltv_expiry=lnaddr1.get_min_final_cltv_expiry(), payment_secret=lnaddr1.payment_secret, ) - w2.pending_payments[lnaddr1.paymenthash] = asyncio.Future() p2.maybe_send_commitment = _maybe_send_commitment2 # sleep a bit so that they both receive msgs sent so far await asyncio.sleep(0.1) t@@ -518,10 +518,10 @@ class TestPeer(ElectrumTestCase): p1.maybe_send_commitment(alice_channel) p2.maybe_send_commitment(bob_channel) - payment_attempt1 = await w1.await_payment(lnaddr2.paymenthash) - assert payment_attempt1.success - payment_attempt2 = await w2.await_payment(lnaddr1.paymenthash) - assert payment_attempt2.success + htlc_log1 = await w1.pending_sent_htlcs[lnaddr2.paymenthash].get() + assert htlc_log1.success + htlc_log2 = await w2.pending_sent_htlcs[lnaddr1.paymenthash].get() + assert htlc_log2.success raise PaymentDone() async def f(): t@@ -594,21 +594,20 @@ class TestPeer(ElectrumTestCase): with self.subTest(msg="bad path: edges do not chain together"): path = [PathEdge(node_id=graph.w_c.node_keypair.pubkey, short_channel_id=graph.chan_ab.short_channel_id), PathEdge(node_id=graph.w_d.node_keypair.pubkey, short_channel_id=graph.chan_bd.short_channel_id)] - result, log = await graph.w_a._pay(pay_req, full_path=path) - self.assertFalse(result) - self.assertTrue(isinstance(log[0].exception, LNPathInconsistent)) + with self.assertRaises(LNPathInconsistent): + await graph.w_a._pay(pay_req, full_path=path) with self.subTest(msg="bad path: last node id differs from invoice pubkey"): path = [PathEdge(node_id=graph.w_b.node_keypair.pubkey, short_channel_id=graph.chan_ab.short_channel_id)] - result, log = await graph.w_a._pay(pay_req, full_path=path) - self.assertFalse(result) - self.assertTrue(isinstance(log[0].exception, LNPathInconsistent)) + with self.assertRaises(LNPathInconsistent): + await graph.w_a._pay(pay_req, full_path=path) with self.subTest(msg="good path"): path = [PathEdge(node_id=graph.w_b.node_keypair.pubkey, short_channel_id=graph.chan_ab.short_channel_id), PathEdge(node_id=graph.w_d.node_keypair.pubkey, short_channel_id=graph.chan_bd.short_channel_id)] result, log = await graph.w_a._pay(pay_req, full_path=path) self.assertTrue(result) - self.assertEqual([edge.short_channel_id for edge in path], - [edge.short_channel_id for edge in log[0].route]) + self.assertEqual( + [edge.short_channel_id for edge in path], + [edge.short_channel_id for edge in log[0].route]) raise PaymentDone() async def f(): async with TaskGroup() as group: t@@ -630,7 +629,7 @@ class TestPeer(ElectrumTestCase): async def pay(pay_req): result, log = await graph.w_a._pay(pay_req) self.assertFalse(result) - self.assertEqual(OnionFailureCode.TEMPORARY_NODE_FAILURE, log[0].failure_details.failure_msg.code) + self.assertEqual(OnionFailureCode.TEMPORARY_NODE_FAILURE, log[0].failure_msg.code) raise PaymentDone() async def f(): async with TaskGroup() as group: t@@ -658,7 +657,7 @@ class TestPeer(ElectrumTestCase): await asyncio.wait_for(p1.initialized, 1) await asyncio.wait_for(p2.initialized, 1) # alice sends htlc - route = w1._create_route_from_invoice(decoded_invoice=lnaddr) + route, amount_msat = w1.create_routes_from_invoice(lnaddr.get_amount_msat(), decoded_invoice=lnaddr)[0] htlc = p1.pay(route=route, chan=alice_channel, amount_msat=lnaddr.get_amount_msat(), t@@ -752,21 +751,22 @@ class TestPeer(ElectrumTestCase): p1, p2, w1, w2, q1, q2 = self.prepare_peers(alice_channel, bob_channel) pay_req = run(self.prepare_invoice(w2)) - addr = w1._check_invoice(pay_req) - route = w1._create_route_from_invoice(decoded_invoice=addr) + lnaddr = w1._check_invoice(pay_req) + route, amount_msat = w1.create_routes_from_invoice(lnaddr.get_amount_msat(), decoded_invoice=lnaddr)[0] + assert amount_msat == lnaddr.get_amount_msat() run(w1.force_close_channel(alice_channel.channel_id)) # check if a tx (commitment transaction) was broadcasted: assert q1.qsize() == 1 with self.assertRaises(NoPathFound) as e: - w1._create_route_from_invoice(decoded_invoice=addr) + w1.create_routes_from_invoice(lnaddr.get_amount_msat(), decoded_invoice=lnaddr) peer = w1.peers[route[0].node_id] # AssertionError is ok since we shouldn't use old routes, and the # route finding should fail when channel is closed async def f(): - await asyncio.gather(w1._pay_to_route(route, addr), p1._message_loop(), p2._message_loop(), p1.htlc_switch(), p2.htlc_switch()) + await asyncio.gather(w1.pay_to_route(route, amount_msat, lnaddr), p1._message_loop(), p2._message_loop(), p1.htlc_switch(), p2.htlc_switch()) with self.assertRaises(PaymentFailure): run(f())