electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit e3409d32ef57a5b85da30a2c370f75896d16f2fe
parent 9d32031ca2ee5f69cfc5b46a6e154f1b1ec04ae4
Author: Janus <ysangkok@gmail.com>
Date:   Mon, 19 Nov 2018 18:09:43 +0100

channel details with list of htlcs

Diffstat:
Melectrum/gui/kivy/uix/screens.py | 2+-
Aelectrum/gui/qt/channel_details.py | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Melectrum/gui/qt/channels_list.py | 6++++++
Melectrum/gui/qt/main_window.py | 2+-
Melectrum/lnaddr.py | 2+-
Melectrum/lnbase.py | 14++++++++------
Melectrum/lnchan.py | 4++--
Melectrum/lnutil.py | 2+-
Melectrum/lnworker.py | 48+++++++++++++++++++++++++++++++++---------------
Melectrum/tests/test_lnbase.py | 2+-
10 files changed, 195 insertions(+), 28 deletions(-)

diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py @@ -294,7 +294,7 @@ class SendScreen(CScreen): fut = asyncio.run_coroutine_threadsafe(coro, self.app.network.asyncio_loop) fut.add_done_callback(self.ln_payment_result) - def payment_completed_async_thread(self, event, direction, htlc, preimage): + def payment_completed_async_thread(self, event, date, direction, htlc, preimage, chan_id): Clock.schedule_once(lambda dt: self.payment_completed(direction, htlc, preimage)) def payment_completed(self, direction, htlc, preimage): diff --git a/electrum/gui/qt/channel_details.py b/electrum/gui/qt/channel_details.py @@ -0,0 +1,141 @@ +from typing import Optional, TYPE_CHECKING + +import PyQt5.QtGui as QtGui +import PyQt5.QtWidgets as QtWidgets +import PyQt5.QtCore as QtCore + +from electrum.i18n import _ +from electrum.lnchan import UpdateAddHtlc +from electrum.util import bh2u, format_time +from electrum.lnchan import HTLCOwner +from electrum.lnaddr import LnAddr, lndecode +if TYPE_CHECKING: + from .main_window import ElectrumWindow + +class HTLCItem(QtGui.QStandardItem): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setEditable(False) + +class ChannelDetailsDialog(QtWidgets.QDialog): + + def make_inflight(self, lnaddr, i: UpdateAddHtlc): + it = HTLCItem(_('HTLC with ID ') + str(i.htlc_id)) + it.appendRow([HTLCItem(_('Amount')),HTLCItem(self.format(i.amount_msat))]) + it.appendRow([HTLCItem(_('CLTV expiry')),HTLCItem(str(i.cltv_expiry))]) + it.appendRow([HTLCItem(_('Payment hash')),HTLCItem(bh2u(i.payment_hash))]) + invoice = HTLCItem(_('Invoice')) + invoice.appendRow([HTLCItem(_('Remote node public key')), HTLCItem(bh2u(lnaddr.pubkey.serialize()))]) + invoice.appendRow([HTLCItem(_('Amount in BTC')), HTLCItem(str(lnaddr.amount))]) + invoice.appendRow([HTLCItem(_('Description')), HTLCItem(dict(lnaddr.tags).get('d', _('N/A')))]) + invoice.appendRow([HTLCItem(_('Date')), HTLCItem(format_time(lnaddr.date))]) + it.appendRow([invoice]) + return it + + def make_model(self, htlcs): + model = QtGui.QStandardItemModel(0, 2) + model.setHorizontalHeaderLabels(['HTLC', 'Property value']) + parentItem = model.invisibleRootItem() + folder_types = {'settled': _('Fulfilled HTLCs'), 'inflight': _('HTLCs in current commitment transaction')} + self.folders = {} + + self.keyname_rows = {} + + for keyname, i in folder_types.items(): + myFont=QtGui.QFont() + myFont.setBold(True) + folder = HTLCItem(i) + folder.setFont(myFont) + parentItem.appendRow(folder) + self.folders[keyname] = folder + mapping = {} + num = 0 + if keyname == 'inflight': + for lnaddr, i in htlcs[keyname]: + it = self.make_inflight(lnaddr, i) + self.folders[keyname].appendRow(it) + mapping[i.payment_hash] = num + num += 1 + elif keyname == 'settled': + for date, direction, i, preimage in htlcs[keyname]: + it = HTLCItem(_('HTLC with ID ') + str(i.htlc_id)) + it.appendRow([HTLCItem(_('Amount')),HTLCItem(self.format(i.amount_msat))]) + it.appendRow([HTLCItem(_('CLTV expiry')),HTLCItem(str(i.cltv_expiry))]) + it.appendRow([HTLCItem(_('Payment hash')),HTLCItem(bh2u(i.payment_hash))]) + # NOTE no invoices because user can delete invoices after settlement + self.folders[keyname].appendRow(it) + mapping[i.payment_hash] = num + num += 1 + + self.keyname_rows[keyname] = mapping + return model + + def move(self, fro: str, to: str, payment_hash: bytes): + assert fro != to + row_idx = self.keyname_rows[fro].pop(payment_hash) + row = self.folders[fro].takeRow(row_idx) + self.folders[to].appendRow(row) + dest_mapping = self.keyname_rows[to] + dest_mapping[payment_hash] = len(dest_mapping) + + ln_payment_completed = QtCore.pyqtSignal(str, float, HTLCOwner, UpdateAddHtlc, bytes, bytes) + htlc_added = QtCore.pyqtSignal(str, UpdateAddHtlc, LnAddr, HTLCOwner) + + @QtCore.pyqtSlot(str, UpdateAddHtlc, LnAddr, HTLCOwner) + def do_htlc_added(self, evtname, htlc, lnaddr, direction): + mapping = self.keyname_rows['inflight'] + mapping[htlc.payment_hash] = len(mapping) + self.folders['inflight'].appendRow(self.make_inflight(lnaddr, htlc)) + + @QtCore.pyqtSlot(str, float, HTLCOwner, UpdateAddHtlc, bytes, bytes) + def do_ln_payment_completed(self, evtname, date, direction, htlc, preimage, chan_id): + self.move('inflight', 'settled', htlc.payment_hash) + + def __init__(self, window: Optional['ElectrumWindow'], chan_id: bytes): + super().__init__(window) + self.window = window + assert type(window).__name__ in ['NoneType', 'ElectrumWindow'] + self.ln_payment_completed.connect(self.do_ln_payment_completed) + self.htlc_added.connect(self.do_htlc_added) + if not window: + self.format = str + htlcs = { + 'settled': + [ + ], + 'inflight': + [ + (lndecode("lnbcrt100n1pdl9c2vpp5z6ztyjy8an80te3u6l0fxuhjzt9pfa27a27uqap3xt8nv6dq47esdqgw3jhxapncqzy3rzjq2j0zgr9slpsefhaem0rq9w3kgjx6mjfd9tp7pe8yw23jqydcdtrsqqrc5qqqqgqqqqqqqlgqqqqqqgqjq5v97p0f0ftkwzmpxhjj6magd5ars465krljcp5z28j3nxl8d0kqjkzf6acjerxdu3yvtus75kakx3yvyus6c68hdwm2hpunusr47w3gpee4hgp"), UpdateAddHtlc(amount_msat=10001, payment_hash=b"\x01"*32, cltv_expiry=500, htlc_id=1)), + (lndecode('lnbcrt22m1pdl9kc7pp5qw903tar0e3ar4mu4h8m3zratj0sddqhfftpsjgcx0jsekzk43dsdqqcqzy3a6ev4vh6lt62xrzlq5l23g59pv0g3tur6drnduhczqg8smqlm75nklwx8r0mm535e4x8uq6tzqw7j7tvy70qaapfnt3e9n6rltvcs7cppzmqys'), UpdateAddHtlc(amount_msat=10002, payment_hash=b"\x02"*32, cltv_expiry=501, htlc_id=2)), + (lndecode('lnbcrt1u1pdl9k6tpp58la47qfxz6mvtgjmnmkl8xe8vcrkhluxrldlhv3dgdlla6tr3mvqdqgw3jhxapncqzy3rzjq2j0zgr9slpsefhaem0rq9w3kgjx6mjfd9tp7pe8yw23jqydcdtrsqqrc5qqqqgqqqqqqqlgqqqqqqgqjqavsdk9qdjwgfdywhlqtuzn5atkhzt9sgjz6tfll67wc34rh80mqzjme3meqyutrj0p7tvxczeuag956h6fv0356ezstgpfgqy47d7vsq7vhx6l'), UpdateAddHtlc(amount_msat=10003, payment_hash=b"\x03"*32, cltv_expiry=502, htlc_id=3)), + ], + } + else: + htlcs = self.window.wallet.lnworker._list_invoices(chan_id) + self.format = lambda msat: self.window.format_amount_and_units(msat / 1000) + self.window.network.register_callback(self.ln_payment_completed.emit, ['ln_payment_completed']) + self.window.network.register_callback(self.htlc_added.emit, ['htlc_added']) + self.setWindowTitle(_('Channel Details')) + self.setMinimumSize(800, 400) + vbox = QtWidgets.QVBoxLayout(self) + w = QtWidgets.QTreeView(self) + w.setModel(self.make_model(htlcs)) + #w.header().setStretchLastSection(False) + w.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) + vbox.addWidget(w) + + +if __name__ == '__main__': + import sys + app = QtWidgets.QApplication(sys.argv) + d = ChannelDetailsDialog(None, b"\x01"*32) + d.show() + + timer = QtCore.QTimer() + timer.setSingleShot(True) + def tick(): + d.move('inflight', 'settled', b'\x02' * 32) + timer.timeout.connect(tick) + timer.start(3000) + + sys.exit(app.exec_()) diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py @@ -11,6 +11,7 @@ from electrum.lnutil import LOCAL, REMOTE, ConnStringFormatError from .util import MyTreeWidget, SortableTreeWidgetItem, WindowModalDialog, Buttons, OkButton, CancelButton from .amountedit import BTCAmountEdit +from .channel_details import ChannelDetailsDialog class ChannelsList(MyTreeWidget): update_rows = QtCore.pyqtSignal() @@ -62,10 +63,15 @@ class ChannelsList(MyTreeWidget): coro = lnworker.force_close_channel(channel_id) return network.run_from_another_thread(coro) WaitingDialog(self, 'please wait..', task, on_success, on_failure) + menu.addAction(_("Details..."), lambda: self.details(channel_id)) menu.addAction(_("Close channel"), close) menu.addAction(_("Force-close channel"), force_close) menu.exec_(self.viewport().mapToGlobal(position)) + def details(self, channel_id): + assert self.parent.wallet + ChannelDetailsDialog(self.parent, channel_id).show() + @QtCore.pyqtSlot(Channel) def do_update_single_row(self, chan): for i in range(self.topLevelItemCount()): diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py @@ -396,7 +396,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.require_fee_update = True self.history_model.on_fee_histogram() elif event == 'ln_message': - lnworker, message = args + lnworker, message, htlc_id = args if lnworker == self.wallet.lnworker: self.show_message(message) else: diff --git a/electrum/lnaddr.py b/electrum/lnaddr.py @@ -250,7 +250,7 @@ class LnAddr(object): def __str__(self): return "LnAddr[{}, amount={}{} tags=[{}]]".format( - hexlify(self.pubkey.serialize()).decode('utf-8'), + hexlify(self.pubkey.serialize()).decode('utf-8') if self.pubkey else None, self.amount, self.currency, ", ".join([k + '=' + str(v) for k, v in self.tags]) ) diff --git a/electrum/lnbase.py b/electrum/lnbase.py @@ -25,8 +25,8 @@ from .util import PrintError, bh2u, print_error, bfh, log_exceptions, list_enabl from .transaction import Transaction, TxOutput from .lnonion import (new_onion_packet, decode_onion_error, OnionFailureCode, calc_hops_data_for_payment, process_onion_packet, OnionPacket, construct_onion_error, OnionRoutingFailureMessage) -from .lnchan import Channel, RevokeAndAck, htlcsum -from .lnutil import (Outpoint, LocalConfig, +from .lnchan import Channel, RevokeAndAck, htlcsum, UpdateAddHtlc +from .lnutil import (Outpoint, LocalConfig, RECEIVED, RemoteConfig, OnlyPubkeyKeypair, ChannelConstraints, RevocationStore, funding_output_script, get_per_commitment_secret_from_seed, secret_to_pubkey, LNPeerAddr, PaymentFailure, LnLocalFeatures, @@ -896,7 +896,7 @@ class Peer(PrintError): self.revoke(chan) self.send_commitment(chan) # htlc will be removed await self.receive_revoke(chan) - self.network.trigger_callback('ln_message', self.lnworker, 'Payment failed') + self.network.trigger_callback('ln_message', self.lnworker, 'Payment failed', htlc_id) async def _handle_error_code_from_failed_htlc(self, error_reason, route: List['RouteEdge'], channel_id, htlc_id): chan = self.channels[channel_id] @@ -969,6 +969,7 @@ class Peer(PrintError): self.attempted_route[(chan.channel_id, htlc_id)] = route self.print_error(f"starting payment. route: {route}") await self.update_channel(chan, "update_add_htlc", channel_id=chan.channel_id, id=htlc_id, cltv_expiry=cltv, amount_msat=amount_msat, payment_hash=payment_hash, onion_routing_packet=onion.to_bytes()) + return UpdateAddHtlc(**htlc, htlc_id=htlc_id) async def receive_revoke(self, chan: Channel): revoke_and_ack_msg = await self.revoke_and_ack[chan.channel_id].get() @@ -1007,7 +1008,7 @@ class Peer(PrintError): self.revoke(chan) self.send_commitment(chan) # htlc will be removed await self.receive_revoke(chan) - self.network.trigger_callback('ln_message', self.lnworker, 'Payment sent') + self.network.trigger_callback('ln_message', self.lnworker, 'Payment sent', htlc_id) # used in lightning-integration self.payment_preimages[sha256(preimage)].put_nowait(preimage) @@ -1034,7 +1035,7 @@ class Peer(PrintError): pass # TODO fail the channel # add htlc htlc = {'amount_msat': amount_msat_htlc, 'payment_hash':payment_hash, 'cltv_expiry':cltv_expiry} - chan.receive_htlc(htlc) + htlc_id = chan.receive_htlc(htlc) await self.receive_commitment(chan) self.revoke(chan) self.send_commitment(chan) @@ -1074,6 +1075,7 @@ class Peer(PrintError): data=amount_msat_htlc.to_bytes(8, byteorder="big")) await self.fail_htlc(chan, htlc_id, onion_packet, reason) return + self.network.trigger_callback('htlc_added', UpdateAddHtlc(**htlc, htlc_id=htlc_id), invoice, RECEIVED) # settle htlc await self.settle_htlc(chan, htlc_id, preimage) @@ -1083,7 +1085,7 @@ class Peer(PrintError): channel_id=chan.channel_id, id=htlc_id, payment_preimage=preimage) - self.network.trigger_callback('ln_message', self.lnworker, 'Payment received') + self.network.trigger_callback('ln_message', self.lnworker, 'Payment received', htlc_id) async def fail_htlc(self, chan: Channel, htlc_id: int, onion_packet: OnionPacket, reason: OnionRoutingFailureMessage): diff --git a/electrum/lnchan.py b/electrum/lnchan.py @@ -149,7 +149,7 @@ class Channel(PrintError): def __init__(self, state, name = None, payment_completed : Optional[Callable[[HTLCOwner, UpdateAddHtlc, bytes], None]] = None): self.preimages = {} if not payment_completed: - payment_completed = lambda x, y, z: None + payment_completed = lambda this, x, y, z: None self.payment_completed = payment_completed assert 'local_state' not in state self.config = {} @@ -505,7 +505,7 @@ class Channel(PrintError): preimage = self.preimages.pop(htlc_id) else: preimage = None - self.payment_completed(subject, htlc, preimage) + self.payment_completed(self, subject, htlc, preimage) self.log[subject].settles.clear() return old_amount - htlcsum(self.htlcs(subject, False)) diff --git a/electrum/lnutil.py b/electrum/lnutil.py @@ -595,7 +595,7 @@ def extract_nodeid(connect_contents: str) -> Tuple[bytes, str]: raise ConnStringFormatError(_('At least a hostname must be supplied after the at symbol.')) try: node_id = bfh(nodeid_hex) - assert len(node_id) == 33 + assert len(node_id) == 33, len(node_id) except: raise ConnStringFormatError(_('Invalid node ID, must be 33 bytes and hexadecimal')) return node_id, rest diff --git a/electrum/lnworker.py b/electrum/lnworker.py @@ -12,6 +12,7 @@ import threading import socket import json from datetime import datetime, timezone +from functools import partial import dns.resolver import dns.exception @@ -62,7 +63,7 @@ class LNWorker(PrintError): def __init__(self, wallet: 'Abstract_Wallet', network: 'Network'): self.wallet = wallet # invoices we are currently trying to pay (might be pending HTLCs on a commitment transaction) - self.paying = self.wallet.storage.get('lightning_payments_inflight', {}) # type: Dict[bytes, Tuple[str, Optional[int]]] + self.paying = self.wallet.storage.get('lightning_payments_inflight', {}) # type: Dict[bytes, Tuple[str, Optional[int], bytes]] self.sweep_address = wallet.get_receiving_address() self.network = network self.channel_db = self.network.channel_db @@ -71,9 +72,11 @@ class LNWorker(PrintError): self.node_keypair = generate_keypair(self.ln_keystore, LnKeyFamily.NODE_KEY, 0) self.config = network.config self.peers = {} # type: Dict[bytes, Peer] # pubkey -> Peer - channels_map = map(lambda x: Channel(x, payment_completed=self.payment_completed), wallet.storage.get("channels", [])) - self.channels = {x.channel_id: x for x in channels_map} # type: Dict[bytes, Channel] - for c in self.channels.values(): + self.channels = {} # type: Dict[bytes, Channel] + for x in wallet.storage.get("channels", []): + c = Channel(x, payment_completed=self.payment_completed) + self.channels[c.channel_id] = c + c.lnwatcher = network.lnwatcher c.sweep_address = self.sweep_address self.invoices = wallet.storage.get('lightning_invoices', {}) # type: Dict[str, Tuple[str,str]] # RHASH -> (preimage, invoice) @@ -86,7 +89,8 @@ class LNWorker(PrintError): self.network.register_callback(self.on_channel_txo, ['channel_txo']) asyncio.run_coroutine_threadsafe(self.network.main_taskgroup.spawn(self.main_loop()), self.network.asyncio_loop) - def payment_completed(self, direction, htlc, preimage): + def payment_completed(self, chan, direction, htlc, preimage): + chan_id = chan.channel_id if direction == SENT: assert htlc.payment_hash not in self.invoices self.paying.pop(bh2u(htlc.payment_hash)) @@ -94,10 +98,11 @@ class LNWorker(PrintError): l = self.wallet.storage.get('lightning_payments_completed', []) if not preimage: preimage, _addr = self.get_invoice(htlc.payment_hash) - l.append((time.time(), direction, json.loads(encoder.encode(htlc)), bh2u(preimage))) + tupl = (time.time(), direction, json.loads(encoder.encode(htlc)), bh2u(preimage), bh2u(chan_id)) + l.append(tupl) self.wallet.storage.put('lightning_payments_completed', l) self.wallet.storage.write() - self.network.trigger_callback('ln_payment_completed', direction, htlc, preimage) + self.network.trigger_callback('ln_payment_completed', tupl[0], direction, htlc, preimage, chan_id) def list_invoices(self): report = self._list_invoices() @@ -128,13 +133,16 @@ class LNWorker(PrintError): yield str(htlc) yield '' - def _list_invoices(self): + def _list_invoices(self, chan_id=None): invoices = dict(self.invoices) completed = self.wallet.storage.get('lightning_payments_completed', []) settled = [] unsettled = [] inflight = [] - for date, direction, htlc, hex_preimage in completed: + for date, direction, htlc, hex_preimage, hex_chan_id in completed: + if chan_id is not None: + if bfh(hex_chan_id) != chan_id: + continue htlcobj = UpdateAddHtlc(*htlc) if direction == RECEIVED: preimage = bfh(invoices.pop(bh2u(htlcobj.payment_hash))[0]) @@ -145,18 +153,21 @@ class LNWorker(PrintError): for preimage, pay_req in invoices.values(): addr = lndecode(pay_req, expected_hrp=constants.net.SEGWIT_HRP) unsettled.append((addr, bfh(preimage), pay_req)) - for pay_req, amount_sat in self.paying.values(): + for pay_req, amount_sat, this_chan_id in self.paying.values(): + if chan_id is not None and this_chan_id != chan_id: + continue addr = lndecode(pay_req, expected_hrp=constants.net.SEGWIT_HRP) if amount_sat is not None: addr.amount = Decimal(amount_sat) / COIN - htlc = self.find_htlc_for_addr(addr) + htlc = self.find_htlc_for_addr(addr, None if chan_id is None else [chan_id]) if not htlc: self.print_error('Warning, in flight HTLC not found in any channel') inflight.append((addr, htlc)) return {'settled': settled, 'unsettled': unsettled, 'inflight': inflight} - def find_htlc_for_addr(self, addr): - for chan in self.channels.values(): + def find_htlc_for_addr(self, addr, whitelist=None): + channels = [y for x,y in self.channels.items() if x in whitelist or whitelist is None] + for chan in channels: for htlc in chan.log[LOCAL].adds.values(): if htlc.payment_hash == addr.paymenthash: return htlc @@ -362,7 +373,13 @@ class LNWorker(PrintError): addr = self._check_invoice(invoice, amount_sat) route = self._create_route_from_invoice(decoded_invoice=addr) peer = self.peers[route[0].node_id] - self.paying[bh2u(addr.paymenthash)] = (invoice, amount_sat) + for chan in self.channels.values(): + if chan.short_channel_id == route[0].short_channel_id: + chan_id = chan.channel_id + break + else: + assert False, 'Found route with short channel ID we don\'t have: ' + repr(route[0].short_channel_id) + self.paying[bh2u(addr.paymenthash)] = (invoice, amount_sat, chan_id) self.wallet.storage.put('lightning_payments_inflight', self.paying) self.wallet.storage.write() return addr, peer, self._pay_to_route(route, addr) @@ -377,7 +394,8 @@ class LNWorker(PrintError): else: raise Exception("PathFinder returned path with short_channel_id {} that is not in channel list".format(bh2u(short_channel_id))) peer = self.peers[route[0].node_id] - return await peer.pay(route, chan, int(addr.amount * COIN * 1000), addr.paymenthash, addr.get_min_final_cltv_expiry()) + htlc = await peer.pay(route, chan, int(addr.amount * COIN * 1000), addr.paymenthash, addr.get_min_final_cltv_expiry()) + self.network.trigger_callback('htlc_added', htlc, addr, SENT) @staticmethod def _check_invoice(invoice, amount_sat=None): diff --git a/electrum/tests/test_lnbase.py b/electrum/tests/test_lnbase.py @@ -195,7 +195,7 @@ class TestPeer(unittest.TestCase): def prepare_ln_message_future(w2 # receiver ): fut = asyncio.Future() - def evt_set(event, _lnworker, msg): + def evt_set(event, _lnworker, msg, _htlc_id): fut.set_result(msg) w2.network.register_callback(evt_set, ['ln_message']) return fut