URI: 
       tGroup swap transactions in Qt history (fixes #6237) - use tree structure of QTreeView - grouped items have a 'group_id' field - rename 'Normal' swap as 'Forward' - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
   DIR commit 4bda8826957ed84927cf8399e62d9abbf1ee5eb0
   DIR parent f3c4b8698d83239de1985e2b480722b25e4fd171
  HTML Author: ThomasV <thomasv@electrum.org>
       Date:   Tue, 16 Jun 2020 19:30:41 +0200
       
       Group swap transactions in Qt history (fixes #6237)
        - use tree structure of QTreeView
        - grouped items have a 'group_id' field
        - rename 'Normal' swap as 'Forward'
       
       Diffstat:
         A electrum/gui/qt/custom_model.py     |      94 +++++++++++++++++++++++++++++++
         M electrum/gui/qt/history_list.py     |     139 +++++++++++++++++++------------
         M electrum/lnworker.py                |      35 ++++++++++++++++++++++++++-----
         M electrum/submarine_swaps.py         |       4 ++++
         M electrum/util.py                    |       7 +++++++
         M electrum/wallet.py                  |      33 +++++++++++++++----------------
       
       6 files changed, 237 insertions(+), 75 deletions(-)
       ---
   DIR diff --git a/electrum/gui/qt/custom_model.py b/electrum/gui/qt/custom_model.py
       t@@ -0,0 +1,94 @@
       +# loosely based on
       +# http://trevorius.com/scrapbook/uncategorized/pyqt-custom-abstractitemmodel/
       +
       +from PyQt5 import QtCore, QtWidgets
       +
       +class CustomNode:
       +
       +    def __init__(self, model, data):
       +        self.model = model
       +        self._data = data
       +        self._children = []
       +        self._parent = None
       +        self._row = 0
       +
       +    def get_data(self):
       +        return self._data
       +
       +    def get_data_for_role(self, index, role):
       +        # define in child class
       +        raise NotImplementedError()
       +
       +    def childCount(self):
       +        return len(self._children)
       +
       +    def child(self, row):
       +        if row >= 0 and row < self.childCount():
       +            return self._children[row]
       +
       +    def parent(self):
       +        return self._parent
       +
       +    def row(self):
       +        return self._row
       +
       +    def addChild(self, child):
       +        child._parent = self
       +        child._row = len(self._children)
       +        self._children.append(child)
       +
       +
       +
       +class CustomModel(QtCore.QAbstractItemModel):
       +
       +    def __init__(self, parent, columncount):
       +        QtCore.QAbstractItemModel.__init__(self, parent)
       +        self._root = CustomNode(self, None)
       +        self._columncount = columncount
       +
       +    def rowCount(self, index):
       +        if index.isValid():
       +            return index.internalPointer().childCount()
       +        return self._root.childCount()
       +
       +    def columnCount(self, index):
       +        return self._columncount
       +
       +    def addChild(self, node, _parent):
       +        if not _parent or not _parent.isValid():
       +            parent = self._root
       +        else:
       +            parent = _parent.internalPointer()
       +        parent.addChild(self, node)
       +
       +    def index(self, row, column, _parent=None):
       +        if not _parent or not _parent.isValid():
       +            parent = self._root
       +        else:
       +            parent = _parent.internalPointer()
       +
       +        if not QtCore.QAbstractItemModel.hasIndex(self, row, column, _parent):
       +            return QtCore.QModelIndex()
       +
       +        child = parent.child(row)
       +        if child:
       +            return QtCore.QAbstractItemModel.createIndex(self, row, column, child)
       +        else:
       +            return QtCore.QModelIndex()
       +
       +    def parent(self, index):
       +        if index.isValid():
       +            node = index.internalPointer()
       +            if node:
       +                p = node.parent()
       +                if p:
       +                    return QtCore.QAbstractItemModel.createIndex(self, p.row(), 0, p)
       +            else:
       +                return QtCore.QModelIndex()
       +        return QtCore.QModelIndex()
       +
       +    def data(self, index, role):
       +        if not index.isValid():
       +            return None
       +        node = index.internalPointer()
       +        return node.get_data_for_role(index, role)
   DIR diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py
       t@@ -43,9 +43,10 @@ from electrum.address_synchronizer import TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE
        from electrum.i18n import _
        from electrum.util import (block_explorer_URL, profiler, TxMinedInfo,
                                   OrderedDictWithIndex, timestamp_to_datetime,
       -                           Satoshis, format_time)
       +                           Satoshis, Fiat, format_time)
        from electrum.logging import get_logger, Logger
        
       +from .custom_model import CustomNode, CustomModel
        from .util import (read_QIcon, MONOSPACE_FONT, Buttons, CancelButton, OkButton,
                           filename_field, MyTreeView, AcceptFileDragDrop, WindowModalDialog,
                           CloseButton, webopen)
       t@@ -106,37 +107,16 @@ class HistorySortModel(QSortFilterProxyModel):
        def get_item_key(tx_item):
            return tx_item.get('txid') or tx_item['payment_hash']
        
       -class HistoryModel(QAbstractItemModel, Logger):
        
       -    def __init__(self, parent: 'ElectrumWindow'):
       -        QAbstractItemModel.__init__(self, parent)
       -        Logger.__init__(self)
       -        self.parent = parent
       -        self.view = None  # type: HistoryList
       -        self.transactions = OrderedDictWithIndex()
       -        self.tx_status_cache = {}  # type: Dict[str, Tuple[int, str]]
       -
       -    def set_view(self, history_list: 'HistoryList'):
       -        # FIXME HistoryModel and HistoryList mutually depend on each other.
       -        # After constructing both, this method needs to be called.
       -        self.view = history_list  # type: HistoryList
       -        self.set_visibility_of_columns()
       +class HistoryNode(CustomNode):
        
       -    def columnCount(self, parent: QModelIndex):
       -        return len(HistoryColumns)
       -
       -    def rowCount(self, parent: QModelIndex):
       -        return len(self.transactions)
       -
       -    def index(self, row: int, column: int, parent: QModelIndex):
       -        return self.createIndex(row, column)
       -
       -    def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant:
       +    def get_data_for_role(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant:
                # note: this method is performance-critical.
                # it is called a lot, and so must run extremely fast.
                assert index.isValid()
                col = index.column()
       -        tx_item = self.transactions.value_from_pos(index.row())
       +        window = self.model.parent
       +        tx_item = self.get_data()
                is_lightning = tx_item.get('lightning', False)
                timestamp = tx_item['timestamp']
                if is_lightning:
       t@@ -149,10 +129,10 @@ class HistoryModel(QAbstractItemModel, Logger):
                    tx_hash = tx_item['txid']
                    conf = tx_item['confirmations']
                    try:
       -                status, status_str = self.tx_status_cache[tx_hash]
       +                status, status_str = self.model.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)
       +                tx_mined_info = self.model.tx_mined_info_from_tx_item(tx_item)
       +                status, status_str = window.wallet.get_tx_status(tx_hash, tx_mined_info)
        
                if role == Qt.UserRole:
                    # for sorting
       t@@ -217,37 +197,48 @@ class HistoryModel(QAbstractItemModel, Logger):
                    bc_value = tx_item['bc_value'].value if 'bc_value' in tx_item else 0
                    ln_value = tx_item['ln_value'].value if 'ln_value' in tx_item else 0
                    value = bc_value + ln_value
       -            v_str = self.parent.format_amount(value, is_diff=True, whitespaces=True)
       +            v_str = window.format_amount(value, is_diff=True, whitespaces=True)
                    return QVariant(v_str)
                elif col == HistoryColumns.BALANCE:
                    balance = tx_item['balance'].value
       -            balance_str = self.parent.format_amount(balance, whitespaces=True)
       +            balance_str = window.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)
       +            value_str = window.fx.format_fiat(tx_item['fiat_value'].value)
                    return QVariant(value_str)
                elif col == HistoryColumns.FIAT_ACQ_PRICE and \
                        tx_item['value'].value < 0 and 'acquisition_price' in tx_item:
                    # fixme: should use is_mine
                    acq = tx_item['acquisition_price'].value
       -            return QVariant(self.parent.fx.format_fiat(acq))
       +            return QVariant(window.fx.format_fiat(acq))
                elif col == HistoryColumns.FIAT_CAP_GAINS and 'capital_gain' in tx_item:
                    cg = tx_item['capital_gain'].value
       -            return QVariant(self.parent.fx.format_fiat(cg))
       +            return QVariant(window.fx.format_fiat(cg))
                elif col == HistoryColumns.TXID:
                    return QVariant(tx_hash) if not is_lightning else QVariant('')
                return QVariant()
        
       -    def parent(self, index: QModelIndex):
       -        return QModelIndex()
        
       -    def hasChildren(self, index: QModelIndex):
       -        return not index.isValid()
       +class HistoryModel(CustomModel, Logger):
        
       -    def update_label(self, row):
       -        tx_item = self.transactions.value_from_pos(row)
       +    def __init__(self, parent: 'ElectrumWindow'):
       +        CustomModel.__init__(self, parent, len(HistoryColumns))
       +        Logger.__init__(self)
       +        self.parent = parent
       +        self.view = None  # type: HistoryList
       +        self.transactions = OrderedDictWithIndex()
       +        self.tx_status_cache = {}  # type: Dict[str, Tuple[int, str]]
       +
       +    def set_view(self, history_list: 'HistoryList'):
       +        # FIXME HistoryModel and HistoryList mutually depend on each other.
       +        # After constructing both, this method needs to be called.
       +        self.view = history_list  # type: HistoryList
       +        self.set_visibility_of_columns()
       +
       +    def update_label(self, index):
       +        tx_item = index.internalPointer().get_data()
                tx_item['label'] = self.parent.wallet.get_label(get_item_key(tx_item))
       -        topLeft = bottomRight = self.createIndex(row, HistoryColumns.DESCRIPTION)
       +        topLeft = bottomRight = self.createIndex(index.row(), HistoryColumns.DESCRIPTION)
                self.dataChanged.emit(topLeft, bottomRight, [Qt.DisplayRole])
                self.parent.utxo_list.update()
        
       t@@ -280,14 +271,56 @@ class HistoryModel(QAbstractItemModel, Logger):
                    include_lightning=self.should_include_lightning_payments())
                if transactions == list(self.transactions.values()):
                    return
       -        old_length = len(self.transactions)
       +        old_length = self._root.childCount()
                if old_length != 0:
                    self.beginRemoveRows(QModelIndex(), 0, old_length)
                    self.transactions.clear()
       +            self._root = HistoryNode(self, None)
                    self.endRemoveRows()
       -        self.beginInsertRows(QModelIndex(), 0, len(transactions)-1)
       +        parents = {}
       +        for tx_item in transactions.values():
       +            node = HistoryNode(self, tx_item)
       +            group_id = tx_item.get('group_id')
       +            if group_id is None:
       +                self._root.addChild(node)
       +            else:
       +                parent = parents.get(group_id)
       +                if parent is None:
       +                    # create parent if it does not exist
       +                    self._root.addChild(node)
       +                    parents[group_id] = node
       +                else:
       +                    # if parent has no children, create two children
       +                    if parent.childCount() == 0:
       +                        child_data = dict(parent.get_data())
       +                        node1 = HistoryNode(self, child_data)
       +                        parent.addChild(node1)
       +                        parent._data['label'] = tx_item.get('group_label')
       +                        parent._data['bc_value'] = child_data.get('bc_value', Satoshis(0))
       +                        parent._data['ln_value'] = child_data.get('ln_value', Satoshis(0))
       +                    # add child to parent
       +                    parent.addChild(node)
       +                    # update parent data
       +                    parent._data['balance'] = tx_item['balance']
       +                    parent._data['value'] += tx_item['value']
       +                    if 'bc_value' in tx_item:
       +                        parent._data['bc_value'] += tx_item['bc_value']
       +                    if 'ln_value' in tx_item:
       +                        parent._data['ln_value'] += tx_item['ln_value']
       +                    if 'fiat_value' in tx_item:
       +                        parent._data['fiat_value'] += tx_item['fiat_value']
       +                    if tx_item.get('txid') == group_id:
       +                        parent._data['lightning'] = False
       +                        parent._data['txid'] = tx_item['txid']
       +                        parent._data['timestamp'] = tx_item['timestamp']
       +                        parent._data['height'] = tx_item['height']
       +                        parent._data['confirmations'] = tx_item['confirmations']
       +
       +        new_length = self._root.childCount()
       +        self.beginInsertRows(QModelIndex(), 0, new_length-1)
                self.transactions = transactions
                self.endInsertRows()
       +
                if selected_row:
                    self.view.selectionModel().select(self.createIndex(selected_row, 0), QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent)
                self.view.filter()
       t@@ -319,8 +352,8 @@ class HistoryModel(QAbstractItemModel, Logger):
                set_visible(HistoryColumns.FIAT_ACQ_PRICE, history and cap_gains)
                set_visible(HistoryColumns.FIAT_CAP_GAINS, history and cap_gains)
        
       -    def update_fiat(self, row, idx):
       -        tx_item = self.transactions.value_from_pos(row)
       +    def update_fiat(self, idx):
       +        tx_item = idx.internalPointer().get_data()
                key = tx_item['txid']
                fee = tx_item.get('fee')
                value = tx_item['value'].value
       t@@ -399,7 +432,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
        
            def tx_item_from_proxy_row(self, proxy_row):
                hm_idx = self.model().mapToSource(self.model().index(proxy_row, 0))
       -        return self.hm.transactions.value_from_pos(hm_idx.row())
       +        return hm_idx.internalPointer().get_data()
        
            def should_hide(self, proxy_row):
                if self.start_timestamp and self.end_timestamp:
       t@@ -427,7 +460,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
                self.wallet = self.parent.wallet  # type: Abstract_Wallet
                self.sortByColumn(HistoryColumns.STATUS, Qt.AscendingOrder)
                self.editable_columns |= {HistoryColumns.FIAT_VALUE}
       -
       +        self.setRootIsDecorated(True)
                self.header().setStretchLastSection(False)
                for col in HistoryColumns:
                    sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents
       t@@ -563,18 +596,18 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
        
            def on_edited(self, index, user_role, text):
                index = self.model().mapToSource(index)
       -        row, column = index.row(), index.column()
       -        tx_item = self.hm.transactions.value_from_pos(row)
       +        tx_item = index.internalPointer().get_data()
       +        column = index.column()
                key = get_item_key(tx_item)
                if column == HistoryColumns.DESCRIPTION:
                    if self.wallet.set_label(key, text): #changed
       -                self.hm.update_label(row)
       +                self.hm.update_label(index)
                        self.parent.update_completions()
                elif column == HistoryColumns.FIAT_VALUE:
                    self.wallet.set_fiat_value(key, self.parent.fx.ccy, text, self.parent.fx, tx_item['value'].value)
                    value = tx_item['value'].value
                    if value is not None:
       -                self.hm.update_fiat(row, index)
       +                self.hm.update_fiat(index)
                else:
                    assert False
        
       t@@ -621,7 +654,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
                if not idx.isValid():
                    # can happen e.g. before list is populated for the first time
                    return
       -        tx_item = self.hm.transactions.value_from_pos(idx.row())
       +        tx_item = idx.internalPointer().get_data()
                if tx_item.get('lightning') and tx_item['type'] == 'payment':
                    menu = QMenu()
                    menu.addAction(_("View Payment"), lambda: self.parent.show_lightning_transaction(tx_item))
       t@@ -758,5 +791,5 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
        
            def get_text_and_userrole_from_coordinate(self, row, col):
                idx = self.model().mapToSource(self.model().index(row, col))
       -        tx_item = self.hm.transactions.value_from_pos(idx.row())
       +        tx_item = idx.internalPointer().get_data()
                return self.hm.data(idx, Qt.DisplayRole).value(), get_item_key(tx_item)
   DIR diff --git a/electrum/lnworker.py b/electrum/lnworker.py
       t@@ -633,15 +633,15 @@ class LNWallet(LNWorker):
                        'payment_hash': key,
                        'preimage': preimage,
                    }
       -            # add txid to merge item with onchain item
       +            # add group_id to swap transactions
                    swap = self.swap_manager.get_swap(payment_hash)
                    if swap:
                        if swap.is_reverse:
       -                    #item['txid'] = swap.spending_txid
       -                    item['label'] = 'Reverse swap' + ' ' + self.config.format_amount_and_units(swap.lightning_amount)
       +                    item['group_id'] = swap.spending_txid
       +                    item['group_label'] = 'Reverse swap' + ' ' + self.config.format_amount_and_units(swap.lightning_amount)
                        else:
       -                    #item['txid'] = swap.funding_txid
       -                    item['label'] = 'Normal swap' + ' ' + self.config.format_amount_and_units(swap.onchain_amount)
       +                    item['group_id'] = swap.funding_txid
       +                    item['group_label'] = 'Forward swap' + ' ' + self.config.format_amount_and_units(swap.onchain_amount)
                    # done
                    out[payment_hash] = item
                return out
       t@@ -680,6 +680,31 @@ class LNWallet(LNWorker):
                        'fee_msat': None,
                    }
                    out[closing_txid] = item
       +        # add info about submarine swaps
       +        settled_payments = self.get_settled_payments()
       +        current_height = self.network.get_local_height()
       +        for payment_hash_hex, swap in self.swap_manager.swaps.items():
       +            txid = swap.spending_txid if swap.is_reverse else swap.funding_txid
       +            if txid is None:
       +                continue
       +            if payment_hash_hex in settled_payments:
       +                plist = settled_payments[payment_hash_hex]
       +                info = self.get_payment_info(bytes.fromhex(payment_hash_hex))
       +                amount_msat, fee_msat, timestamp = self.get_payment_value(info, plist)
       +            else:
       +                amount_msat = 0
       +            label = 'Reverse swap' if swap.is_reverse else 'Forward swap'
       +            delta = current_height - swap.locktime
       +            if not swap.is_redeemed and swap.spending_txid is None and delta < 0:
       +                label += f' (refundable in {-delta} blocks)' # fixme: only if unspent
       +            out[txid] = {
       +                'txid': txid,
       +                'group_id': txid,
       +                'amount_msat': 0,
       +                #'amount_msat': amount_msat, # must not be added
       +                'type': 'swap',
       +                'label': label
       +            }
                return out
        
            def get_history(self):
   DIR diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
       t@@ -197,6 +197,9 @@ class SwapManager(Logger):
                swap = self.swaps.get(payment_hash.hex())
                if swap:
                    return swap
       +        payment_hash = self.prepayments.get(payment_hash)
       +        if payment_hash:
       +            return self.swaps.get(payment_hash.hex())
        
            def add_lnwatcher_callback(self, swap: SwapData) -> None:
                callback = lambda: self._claim_swap(swap)
       t@@ -361,6 +364,7 @@ class SwapManager(Logger):
                self.add_lnwatcher_callback(swap)
                # initiate payment.
                if fee_invoice:
       +            self.prepayments[prepay_hash] = preimage_hash
                    asyncio.ensure_future(self.lnworker._pay(fee_invoice, attempts=10))
                # initiate payment.
                success, log = await self.lnworker._pay(invoice, attempts=10)
   DIR diff --git a/electrum/util.py b/electrum/util.py
       t@@ -177,6 +177,9 @@ class Satoshis(object):
            def __ne__(self, other):
                return not (self == other)
        
       +    def __add__(self, other):
       +        return Satoshis(self.value + other.value)
       +
        
        # note: this is not a NamedTuple as then its json encoding cannot be customized
        class Fiat(object):
       t@@ -216,6 +219,10 @@ class Fiat(object):
            def __ne__(self, other):
                return not (self == other)
        
       +    def __add__(self, other):
       +        assert self.ccy == other.ccy
       +        return Fiat(self.value + other.value, self.ccy)
       +
        
        class MyEncoder(json.JSONEncoder):
            def default(self, obj):
   DIR diff --git a/electrum/wallet.py b/electrum/wallet.py
       t@@ -820,28 +820,27 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
                transactions_tmp = OrderedDictWithIndex()
                # add on-chain txns
                onchain_history = self.get_onchain_history(domain=onchain_domain)
       +        lnworker_history = self.lnworker.get_onchain_history() if self.lnworker and include_lightning else {}
                for tx_item in onchain_history:
                    txid = tx_item['txid']
                    transactions_tmp[txid] = tx_item
       -        # add LN txns
       -        if self.lnworker and include_lightning:
       -            lightning_history = self.lnworker.get_history()
       -        else:
       -            lightning_history = []
       -        for i, tx_item in enumerate(lightning_history):
       +            # add lnworker info here
       +            if txid in lnworker_history:
       +                item = lnworker_history[txid]
       +                tx_item['group_id'] = item.get('group_id')  # for swaps
       +                tx_item['label'] = item['label']
       +                tx_item['type'] = item['type']
       +                ln_value = Decimal(item['amount_msat']) / 1000   # for channel open/close tx
       +                tx_item['ln_value'] = Satoshis(ln_value)
       +        # add lightning_transactions
       +        lightning_history = self.lnworker.get_lightning_history() if self.lnworker and include_lightning else {}
       +        for tx_item in lightning_history.values():
                    txid = tx_item.get('txid')
                    ln_value = Decimal(tx_item['amount_msat']) / 1000
       -            if txid and txid in transactions_tmp:
       -                item = transactions_tmp[txid]
       -                item['label'] = tx_item['label']
       -                item['type'] = tx_item['type']
       -                item['channel_id'] = tx_item['channel_id']
       -                item['ln_value'] = Satoshis(ln_value)
       -            else:
       -                tx_item['lightning'] = True
       -                tx_item['ln_value'] = Satoshis(ln_value)
       -                key = tx_item.get('txid') or tx_item['payment_hash']
       -                transactions_tmp[key] = tx_item
       +            tx_item['lightning'] = True
       +            tx_item['ln_value'] = Satoshis(ln_value)
       +            key = tx_item.get('txid') or tx_item['payment_hash']
       +            transactions_tmp[key] = tx_item
                # sort on-chain and LN stuff into new dict, by timestamp
                # (we rely on this being a *stable* sort)
                transactions = OrderedDictWithIndex()