electrum

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

commit 4bda8826957ed84927cf8399e62d9abbf1ee5eb0
parent f3c4b8698d83239de1985e2b480722b25e4fd171
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:
Aelectrum/gui/qt/custom_model.py | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Melectrum/gui/qt/history_list.py | 139+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Melectrum/lnworker.py | 35++++++++++++++++++++++++++++++-----
Melectrum/submarine_swaps.py | 4++++
Melectrum/util.py | 7+++++++
Melectrum/wallet.py | 33++++++++++++++++-----------------
6 files changed, 237 insertions(+), 75 deletions(-)

diff --git a/electrum/gui/qt/custom_model.py b/electrum/gui/qt/custom_model.py @@ -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) diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py @@ -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) @@ -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: @@ -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 @@ -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() @@ -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() @@ -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 @@ -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: @@ -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 @@ -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 @@ -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)) @@ -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) diff --git a/electrum/lnworker.py b/electrum/lnworker.py @@ -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 @@ -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): diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py @@ -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) @@ -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) diff --git a/electrum/util.py b/electrum/util.py @@ -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): @@ -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): diff --git a/electrum/wallet.py b/electrum/wallet.py @@ -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()