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():