tsave channel timestamps, and show lightning payments in history tab - electrum - Electrum Bitcoin wallet (HTM) git clone https://git.parazyd.org/electrum (DIR) Log (DIR) Files (DIR) Refs (DIR) Submodules --- (DIR) commit f04e10f61afafaf66b23a887b87da9ecf40314bd (DIR) parent ae402303caa0b8b88b97bc74f8e6a836afcc7d9c (HTM) Author: ThomasV <thomasv@electrum.org> Date: Thu, 31 Jan 2019 16:41:43 +0100 save channel timestamps, and show lightning payments in history tab Diffstat: M electrum/commands.py | 19 +------------------ M electrum/gui/qt/history_list.py | 131 +++++++++++++++++++------------ M electrum/lnwatcher.py | 11 +++++++---- M electrum/lnworker.py | 68 ++++++++++++++++++++++++++++--- 4 files changed, 151 insertions(+), 78 deletions(-) --- (DIR) diff --git a/electrum/commands.py b/electrum/commands.py t@@ -830,24 +830,7 @@ class Commands: @command('w') def lightning_history(self): - out = [] - for chan_id, htlc, direction, status in self.lnworker.get_payments().values(): - payment_hash = bh2u(htlc.payment_hash) - timestamp = self.lnworker.invoices[payment_hash][3] if payment_hash in self.lnworker.invoices else None - item = { - 'timestamp':timestamp or 0, - 'date':timestamp_to_datetime(timestamp), - 'direction': 'sent' if direction == SENT else 'received', - 'status':status, - 'amout_msat':htlc.amount_msat, - 'payment_hash':bh2u(htlc.payment_hash), - 'chan_id':bh2u(chan_id), - 'htlc_id':htlc.htlc_id, - 'cltv_expiry':htlc.cltv_expiry - } - out.append(item) - out.sort(key=operator.itemgetter('timestamp')) - return out + return self.lnworker.get_history() @command('wn') def closechannel(self, channel_point, force=False): (DIR) diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py t@@ -41,7 +41,8 @@ from PyQt5.QtWidgets import (QMenu, QHeaderView, QLabel, QMessageBox, from electrum.address_synchronizer import TX_HEIGHT_LOCAL from electrum.i18n import _ from electrum.util import (block_explorer_URL, profiler, TxMinedInfo, - OrderedDictWithIndex, timestamp_to_datetime) + OrderedDictWithIndex, timestamp_to_datetime, + Satoshis, format_time) from electrum.logging import get_logger, Logger from .util import (read_QIcon, MONOSPACE_FONT, Buttons, CancelButton, OkButton, t@@ -76,11 +77,11 @@ TX_ICONS = [ ] class HistoryColumns(IntEnum): - STATUS_ICON = 0 - STATUS_TEXT = 1 - DESCRIPTION = 2 - COIN_VALUE = 3 - RUNNING_COIN_BALANCE = 4 + STATUS = 0 + DESCRIPTION = 1 + COIN_VALUE = 2 + ONCHAIN_BALANCE = 3 + CHANNELS_BALANCE = 4 FIAT_VALUE = 5 FIAT_ACQ_PRICE = 6 FIAT_CAP_GAINS = 7 t@@ -133,50 +134,66 @@ class HistoryModel(QAbstractItemModel, Logger): assert index.isValid() col = index.column() tx_item = self.transactions.value_from_pos(index.row()) - tx_hash = tx_item['txid'] - conf = tx_item['confirmations'] - txpos = tx_item['txpos_in_block'] or 0 - height = tx_item['height'] - try: - status, status_str = self.tx_status_cache[tx_hash] - except KeyError: - tx_mined_info = self.tx_mined_info_from_tx_item(tx_item) - status, status_str = self.parent.wallet.get_tx_status(tx_hash, tx_mined_info) + is_lightning = tx_item.get('lightning', False) + timestamp = tx_item['timestamp'] + if is_lightning: + status = 0 + status_str = format_time(int(timestamp)) + else: + tx_hash = tx_item['txid'] + conf = tx_item['confirmations'] + txpos = tx_item['txpos_in_block'] or 0 + height = tx_item['height'] + try: + status, status_str = self.tx_status_cache[tx_hash] + except KeyError: + tx_mined_info = self.tx_mined_info_from_tx_item(tx_item) + status, status_str = self.parent.wallet.get_tx_status(tx_hash, tx_mined_info) + # we sort by timestamp + if conf<=0: + timestamp = time.time() + if role == Qt.UserRole: # for sorting d = { - HistoryColumns.STATUS_ICON: + HistoryColumns.STATUS: # height breaks ties for unverified txns # txpos breaks ties for verified same block txns - (conf, -status, -height, -txpos), - HistoryColumns.STATUS_TEXT: status_str, - HistoryColumns.DESCRIPTION: tx_item['label'], - HistoryColumns.COIN_VALUE: tx_item['value'].value, - HistoryColumns.RUNNING_COIN_BALANCE: tx_item['balance'].value, + (-timestamp, conf, -status, -height, -txpos) if not is_lightning else (-timestamp, 0,0,0,0), + HistoryColumns.DESCRIPTION: + tx_item['label'] if 'label' in tx_item else None, + HistoryColumns.COIN_VALUE: + tx_item['value'].value if 'value' in tx_item else None, + HistoryColumns.ONCHAIN_BALANCE: + tx_item['balance'].value if not is_lightning else None, + HistoryColumns.CHANNELS_BALANCE: + tx_item['balance_msat'] if is_lightning else None, HistoryColumns.FIAT_VALUE: tx_item['fiat_value'].value if 'fiat_value' in tx_item else None, HistoryColumns.FIAT_ACQ_PRICE: tx_item['acquisition_price'].value if 'acquisition_price' in tx_item else None, HistoryColumns.FIAT_CAP_GAINS: tx_item['capital_gain'].value if 'capital_gain' in tx_item else None, - HistoryColumns.TXID: tx_hash, + HistoryColumns.TXID: tx_hash if not is_lightning else None, } return QVariant(d[col]) if role not in (Qt.DisplayRole, Qt.EditRole): - if col == HistoryColumns.STATUS_ICON and role == Qt.DecorationRole: - return QVariant(read_QIcon(TX_ICONS[status])) - elif col == HistoryColumns.STATUS_ICON and role == Qt.ToolTipRole: - return QVariant(str(conf) + _(" confirmation" + ("s" if conf != 1 else ""))) + if col == HistoryColumns.STATUS and role == Qt.DecorationRole: + icon = "lightning" if is_lightning else TX_ICONS[status] + return QVariant(read_QIcon(icon)) + elif col == HistoryColumns.STATUS and role == Qt.ToolTipRole: + msg = 'lightning transaction' if is_lightning else str(conf) + _(" confirmation" + ("s" if conf != 1 else "")) + return QVariant(msg) elif col > HistoryColumns.DESCRIPTION and role == Qt.TextAlignmentRole: return QVariant(Qt.AlignRight | Qt.AlignVCenter) - elif col != HistoryColumns.STATUS_TEXT and role == Qt.FontRole: + elif col != HistoryColumns.STATUS and role == Qt.FontRole: monospace_font = QFont(MONOSPACE_FONT) return QVariant(monospace_font) - elif col == HistoryColumns.DESCRIPTION and role == Qt.DecorationRole \ + elif col == HistoryColumns.DESCRIPTION and role == Qt.DecorationRole and not is_lightning\ and self.parent.wallet.invoices.paid.get(tx_hash): return QVariant(read_QIcon("seal")) elif col in (HistoryColumns.DESCRIPTION, HistoryColumns.COIN_VALUE) \ - and role == Qt.ForegroundRole and tx_item['value'].value < 0: + and role == Qt.ForegroundRole and not is_lightning and tx_item['value'].value < 0: red_brush = QBrush(QColor("#BC1E1E")) return QVariant(red_brush) elif col == HistoryColumns.FIAT_VALUE and role == Qt.ForegroundRole \ t@@ -184,18 +201,22 @@ class HistoryModel(QAbstractItemModel, Logger): blue_brush = QBrush(QColor("#1E1EFF")) return QVariant(blue_brush) return QVariant() - if col == HistoryColumns.STATUS_TEXT: + if col == HistoryColumns.STATUS: return QVariant(status_str) - elif col == HistoryColumns.DESCRIPTION: + elif col == HistoryColumns.DESCRIPTION and 'label' in tx_item: return QVariant(tx_item['label']) - elif col == HistoryColumns.COIN_VALUE: + elif col == HistoryColumns.COIN_VALUE and 'value' in tx_item: value = tx_item['value'].value v_str = self.parent.format_amount(value, is_diff=True, whitespaces=True) return QVariant(v_str) - elif col == HistoryColumns.RUNNING_COIN_BALANCE: + elif col == HistoryColumns.ONCHAIN_BALANCE and not is_lightning: balance = tx_item['balance'].value balance_str = self.parent.format_amount(balance, whitespaces=True) return QVariant(balance_str) + elif col == HistoryColumns.CHANNELS_BALANCE and is_lightning: + balance = tx_item['balance_msat']//1000 + balance_str = self.parent.format_amount(balance, whitespaces=True) + return QVariant(balance_str) elif col == HistoryColumns.FIAT_VALUE and 'fiat_value' in tx_item: value_str = self.parent.fx.format_fiat(tx_item['fiat_value'].value) return QVariant(value_str) t@@ -239,18 +260,24 @@ class HistoryModel(QAbstractItemModel, Logger): fx = self.parent.fx if fx: fx.history_used_spot = False r = self.parent.wallet.get_full_history(domain=self.get_domain(), from_timestamp=None, to_timestamp=None, fx=fx) + lightning_history = self.parent.wallet.lnworker.get_history() self.set_visibility_of_columns() - if r['transactions'] == list(self.transactions.values()): - return + #if r['transactions'] == list(self.transactions.values()): + # return old_length = len(self.transactions) if old_length != 0: self.beginRemoveRows(QModelIndex(), 0, old_length) self.transactions.clear() self.endRemoveRows() - self.beginInsertRows(QModelIndex(), 0, len(r['transactions'])-1) + self.beginInsertRows(QModelIndex(), 0, len(r['transactions'])+len(lightning_history)-1) for tx_item in r['transactions']: txid = tx_item['txid'] self.transactions[txid] = tx_item + for tx_item in lightning_history: + tx_item['lightning'] = True + tx_item['value'] = Satoshis(tx_item['amount_msat']/1000 * (-1 if tx_item['direction'] =='sent' else 1)) + key = tx_item['payment_hash'] if 'payment_hash' in tx_item else tx_item['type'] + tx_item['channel_id'] + self.transactions[key] = tx_item self.endInsertRows() if selected_row: self.view.selectionModel().select(self.createIndex(selected_row, 0), QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent) t@@ -268,8 +295,9 @@ class HistoryModel(QAbstractItemModel, Logger): # update tx_status_cache self.tx_status_cache.clear() for txid, tx_item in self.transactions.items(): - tx_mined_info = self.tx_mined_info_from_tx_item(tx_item) - self.tx_status_cache[txid] = self.parent.wallet.get_tx_status(txid, tx_mined_info) + if not tx_item.get('lightning', False): + tx_mined_info = self.tx_mined_info_from_tx_item(tx_item) + self.tx_status_cache[txid] = self.parent.wallet.get_tx_status(txid, tx_mined_info) def set_visibility_of_columns(self): def set_visible(col: int, b: bool): t@@ -311,6 +339,8 @@ class HistoryModel(QAbstractItemModel, Logger): def on_fee_histogram(self): for tx_hash, tx_item in list(self.transactions.items()): + if tx_item.get('lightning'): + continue tx_mined_info = self.tx_mined_info_from_tx_item(tx_item) if tx_mined_info.conf > 0: # note: we could actually break here if we wanted to rely on the order of txns in self.transactions t@@ -330,11 +360,11 @@ class HistoryModel(QAbstractItemModel, Logger): fiat_acq_title = '%s '%fx.ccy + _('Acquisition price') fiat_cg_title = '%s '%fx.ccy + _('Capital Gains') return { - HistoryColumns.STATUS_ICON: '', - HistoryColumns.STATUS_TEXT: _('Date'), + HistoryColumns.STATUS: _('Date'), HistoryColumns.DESCRIPTION: _('Description'), HistoryColumns.COIN_VALUE: _('Amount'), - HistoryColumns.RUNNING_COIN_BALANCE: _('Balance'), + HistoryColumns.ONCHAIN_BALANCE: _('Balance'), + HistoryColumns.CHANNELS_BALANCE: u'\U0001f5f2 ' + _('Channels balance'), HistoryColumns.FIAT_VALUE: fiat_title, HistoryColumns.FIAT_ACQ_PRICE: fiat_acq_title, HistoryColumns.FIAT_CAP_GAINS: fiat_cg_title, t@@ -355,7 +385,7 @@ class HistoryModel(QAbstractItemModel, Logger): return tx_mined_info class HistoryList(MyTreeView, AcceptFileDragDrop): - filter_columns = [HistoryColumns.STATUS_TEXT, + filter_columns = [HistoryColumns.STATUS, HistoryColumns.DESCRIPTION, HistoryColumns.COIN_VALUE, HistoryColumns.TXID] t@@ -389,7 +419,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): self.years = [] self.create_toolbar_buttons() self.wallet = self.parent.wallet # type: Abstract_Wallet - self.sortByColumn(HistoryColumns.STATUS_ICON, Qt.AscendingOrder) + self.sortByColumn(HistoryColumns.STATUS, Qt.AscendingOrder) self.editable_columns |= {HistoryColumns.FIAT_VALUE} self.header().setStretchLastSection(False) t@@ -564,12 +594,10 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): return tx_item = self.hm.transactions.value_from_pos(idx.row()) column = idx.column() - if column == HistoryColumns.STATUS_ICON: - column_title = _('Transaction ID') - column_data = tx_item['txid'] - else: - column_title = self.hm.headerData(column, Qt.Horizontal, Qt.DisplayRole) - column_data = self.hm.data(idx, Qt.DisplayRole).value() + column_title = self.hm.headerData(column, Qt.Horizontal, Qt.DisplayRole) + column_data = self.hm.data(idx, Qt.DisplayRole).value() + if tx_item.get('lightning'): + return tx_hash = tx_item['txid'] tx = self.wallet.db.get_transaction(tx_hash) if not tx: t@@ -582,12 +610,13 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): menu = QMenu() if height == TX_HEIGHT_LOCAL: menu.addAction(_("Remove"), lambda: self.remove_local_tx(tx_hash)) + menu.addAction(_("Copy Transaction ID"), lambda: self.parent.app.clipboard().setText(tx_hash)) - amount_columns = [HistoryColumns.COIN_VALUE, HistoryColumns.RUNNING_COIN_BALANCE, HistoryColumns.FIAT_VALUE, HistoryColumns.FIAT_ACQ_PRICE, HistoryColumns.FIAT_CAP_GAINS] + amount_columns = [HistoryColumns.COIN_VALUE, HistoryColumns.ONCHAIN_BALANCE, HistoryColumns.CHANNELS_BALANCE, + HistoryColumns.FIAT_VALUE, HistoryColumns.FIAT_ACQ_PRICE, HistoryColumns.FIAT_CAP_GAINS] if column in amount_columns: column_data = column_data.strip() menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) - for c in self.editable_columns: if self.isColumnHidden(c): continue label = self.hm.headerData(c, Qt.Horizontal, Qt.DisplayRole) (DIR) diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py t@@ -140,11 +140,14 @@ class LNWatcher(AddressSynchronizer): async def check_onchain_situation(self, address, funding_outpoint): keep_watching, spenders = self.inspect_tx_candidate(funding_outpoint, 0) - txid = spenders.get(funding_outpoint) - if txid is None: - self.network.trigger_callback('channel_open', funding_outpoint) + funding_txid = funding_outpoint.split(':')[0] + funding_height = self.get_tx_height(funding_txid) + closing_txid = spenders.get(funding_outpoint) + if closing_txid is None: + self.network.trigger_callback('channel_open', funding_outpoint, funding_txid, funding_height) else: - self.network.trigger_callback('channel_closed', funding_outpoint, txid, spenders) + closing_height = self.get_tx_height(closing_txid) + self.network.trigger_callback('channel_closed', funding_outpoint, spenders, funding_txid, funding_height, closing_txid, closing_height) await self.do_breach_remedy(funding_outpoint, spenders) if not keep_watching: self.unwatch_channel(address, funding_outpoint) (DIR) diff --git a/electrum/lnworker.py b/electrum/lnworker.py t@@ -11,6 +11,7 @@ from typing import Optional, Sequence, Tuple, List, Dict, TYPE_CHECKING import threading import socket import json +import operator from datetime import datetime, timezone from functools import partial t@@ -26,6 +27,7 @@ from .transaction import Transaction from .crypto import sha256 from .bip32 import bip32_root from .util import bh2u, bfh, PrintError, InvoiceError, resolve_dns_srv, is_ip_address, log_exceptions +from .util import timestamp_to_datetime from .lntransport import LNResponderTransport from .lnbase import Peer from .lnaddr import lnencode, LnAddr, lndecode t@@ -81,6 +83,8 @@ class LNWorker(PrintError): self.channels[c.channel_id] = c c.set_remote_commitment() c.set_local_commitment(c.current_commitment(LOCAL)) + # timestamps of opening and closing transactions + self.channel_timestamps = self.wallet.storage.get('lightning_channel_timestamps', {}) def start_network(self, network: 'Network'): self.network = network t@@ -150,6 +154,56 @@ class LNWorker(PrintError): out.update(chan.get_payments()) return out + def get_history(self): + out = [] + for chan_id, htlc, direction, status in self.get_payments().values(): + key = bh2u(htlc.payment_hash) + timestamp = self.invoices[key][3] if key in self.invoices else None + item = { + 'type':'payment', + 'timestamp':timestamp or 0, + 'date':timestamp_to_datetime(timestamp), + 'direction': 'sent' if direction == SENT else 'received', + 'status':status, + 'amount_msat':htlc.amount_msat, + 'payment_hash':bh2u(htlc.payment_hash), + 'channel_id':bh2u(chan_id), + 'htlc_id':htlc.htlc_id, + 'cltv_expiry':htlc.cltv_expiry, + } + out.append(item) + # add funding events + for chan in self.channels.values(): + funding_txid, funding_height, funding_timestamp, closing_txid, closing_height, closing_timestamp = self.channel_timestamps.get(bh2u(chan.channel_id)) + item = { + 'channel_id': bh2u(chan.channel_id), + 'type': 'channel_opening', + 'label': _('Channel opening'), + 'txid': funding_txid, + 'amount_msat': chan.balance(LOCAL, ctn=0), + 'direction': 'received', + 'timestamp': funding_timestamp, + } + out.append(item) + if not chan.is_closed(): + continue + item = { + 'channel_id': bh2u(chan.channel_id), + 'txid':closing_txid, + 'label': _('Channel closure'), + 'type': 'channel_closure', + 'amount_msat': chan.balance(LOCAL), + 'direction': 'sent', + 'timestamp': closing_timestamp, + } + out.append(item) + out.sort(key=operator.itemgetter('timestamp')) + balance_msat = 0 + for item in out: + balance_msat += item['amount_msat'] * (1 if item['direction']=='received' else -1) + item['balance_msat'] = balance_msat + return out + def _read_ln_keystore(self) -> BIP32_KeyStore: xprv = self.wallet.storage.get('lightning_privkey2') if xprv is None: t@@ -240,21 +294,25 @@ class LNWorker(PrintError): if chan.funding_outpoint.to_str() == txo: return chan - def on_channel_open(self, event, funding_outpoint): + def on_channel_open(self, event, funding_outpoint, funding_txid, funding_height): chan = self.channel_by_txo(funding_outpoint) if not chan: return self.print_error('on_channel_open', funding_outpoint) + self.channel_timestamps[bh2u(chan.channel_id)] = funding_txid, funding_height.height, funding_height.timestamp, None, None, None + self.wallet.storage.put('lightning_channel_timestamps', self.channel_timestamps) chan.set_funding_txo_spentness(False) # send event to GUI self.network.trigger_callback('channel', chan) @log_exceptions - async def on_channel_closed(self, event, funding_outpoint, txid, spenders): + async def on_channel_closed(self, event, funding_outpoint, spenders, funding_txid, funding_height, closing_txid, closing_height): chan = self.channel_by_txo(funding_outpoint) if not chan: return self.print_error('on_channel_closed', funding_outpoint) + self.channel_timestamps[bh2u(chan.channel_id)] = funding_txid, funding_height.height, funding_height.timestamp, closing_txid, closing_height.height, closing_height.timestamp + self.wallet.storage.put('lightning_channel_timestamps', self.channel_timestamps) chan.set_funding_txo_spentness(True) if chan.get_state() != 'FORCE_CLOSING': chan.set_state("CLOSED") t@@ -263,14 +321,14 @@ class LNWorker(PrintError): # remove from channel_db self.channel_db.remove_channel(chan.short_channel_id) # detect who closed - if txid == chan.local_commitment.txid(): + if closing_txid == chan.local_commitment.txid(): self.print_error('we force closed', funding_outpoint) encumbered_sweeptxs = chan.local_sweeptxs - elif txid == chan.remote_commitment.txid(): + elif closing_txid == chan.remote_commitment.txid(): self.print_error('they force closed', funding_outpoint) encumbered_sweeptxs = chan.remote_sweeptxs else: - self.print_error('not sure who closed', funding_outpoint, txid) + self.print_error('not sure who closed', funding_outpoint, closing_txid) return # sweep for prevout, spender in spenders.items():