From d6febb5c1243f3f80d5a79af9aa39312c8166c91 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Thu, 19 Jan 2023 20:54:42 +0100 Subject: [PATCH] Display mined tx outputs as ShortIDs instead of full transaction outpoints. ShortIDs were originally designed for lightning channels, and are now understood by some block explorers. This allows to remove one column in the UTXO tab (height is redundant). In the transaction dialog, the space saving ensures that all inputs fit into one line (it was not the case previously with p2wsh addresses). For clarity and consistency, the ShortID is displayed for both inputs and outputs in the transaction dialog. --- electrum/address_synchronizer.py | 25 ++++++++---- electrum/gui/qt/transaction_dialog.py | 50 ++++++++++++----------- electrum/gui/qt/utxo_list.py | 8 +--- electrum/lnutil.py | 59 ++------------------------- electrum/transaction.py | 19 +++++++-- electrum/util.py | 59 +++++++++++++++++++++++++++ electrum/wallet.py | 2 +- 7 files changed, 124 insertions(+), 98 deletions(-) diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py index cc45c5826..9a8464c98 100644 --- a/electrum/address_synchronizer.py +++ b/electrum/address_synchronizer.py @@ -243,7 +243,14 @@ class AddressSynchronizer(Logger, EventListener): return conflicting_txns def get_transaction(self, txid: str) -> Transaction: - return self.db.get_transaction(txid) + tx = self.db.get_transaction(txid) + # add verified tx info + tx.deserialize() + for txin in tx._inputs: + tx_height, tx_pos = self.get_txpos(txin.prevout.txid.hex()) + txin.block_height = tx_height + txin.block_txpos = tx_pos + return tx def add_transaction(self, tx: Transaction, *, allow_unrelated=False, is_new=True) -> bool: """ @@ -768,9 +775,10 @@ class AddressSynchronizer(Logger, EventListener): received = {} sent = {} for tx_hash, height in h: + hh, pos = self.get_txpos(tx_hash) d = self.db.get_txo_addr(tx_hash, address) for n, (v, is_cb) in d.items(): - received[tx_hash + ':%d'%n] = (height, v, is_cb) + received[tx_hash + ':%d'%n] = (height, pos, v, is_cb) for tx_hash, height in h: l = self.db.get_txi_addr(tx_hash, address) for txi, v in l: @@ -778,17 +786,18 @@ class AddressSynchronizer(Logger, EventListener): return received, sent def get_addr_outputs(self, address: str) -> Dict[TxOutpoint, PartialTxInput]: - coins, spent = self.get_addr_io(address) + received, sent = self.get_addr_io(address) out = {} - for prevout_str, v in coins.items(): - tx_height, value, is_cb = v + for prevout_str, v in received.items(): + tx_height, tx_pos, value, is_cb = v prevout = TxOutpoint.from_str(prevout_str) utxo = PartialTxInput(prevout=prevout, is_coinbase_output=is_cb) utxo._trusted_address = address utxo._trusted_value_sats = value utxo.block_height = tx_height - if prevout_str in spent: - txid, height = spent[prevout_str] + utxo.block_txpos = tx_pos + if prevout_str in sent: + txid, height = sent[prevout_str] utxo.spent_txid = txid utxo.spent_height = height else: @@ -807,7 +816,7 @@ class AddressSynchronizer(Logger, EventListener): # return the total amount ever received by an address def get_addr_received(self, address): received, sent = self.get_addr_io(address) - return sum([v for height, v, is_cb in received.values()]) + return sum([value for height, pos, value, is_cb in received.values()]) @with_local_height_cached def get_balance(self, domain, *, excluded_addresses: Set[str] = None, diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index 0007b2212..0916af943 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -45,8 +45,9 @@ from electrum.bitcoin import base_encode, NLOCKTIME_BLOCKHEIGHT_MAX from electrum.i18n import _ from electrum.plugin import run_hook from electrum import simple_config -from electrum.transaction import SerializationError, Transaction, PartialTransaction, PartialTxInput +from electrum.transaction import SerializationError, Transaction, PartialTransaction, PartialTxInput, TxOutpoint from electrum.logging import get_logger +from electrum.util import ShortID from .util import (MessageBoxMixin, read_QIcon, Buttons, icon_path, MONOSPACE_FONT, ColorScheme, ButtonsLineEdit, ShowQRLineEdit, text_dialog, @@ -593,8 +594,17 @@ class BaseTxDialog(QDialog, MessageBoxMixin): return self.txo_color_2fa.text_char_format return ext - def format_amount(amt): - return self.main_window.format_amount(amt, whitespaces=True) + def insert_tx_io(cursor, is_coinbase, short_id, address, value): + if is_coinbase: + cursor.insertText('coinbase') + else: + address_str = address or '
' + value_str = self.main_window.format_amount(value, whitespaces=True) + cursor.insertText("%-15s\t"%str(short_id), ext) + cursor.insertText("%-62s"%address_str, text_format(address)) + cursor.insertText('\t', ext) + cursor.insertText(value_str, ext) + cursor.insertBlock() i_text = self.inputs_textedit i_text.clear() @@ -602,34 +612,26 @@ class BaseTxDialog(QDialog, MessageBoxMixin): i_text.setReadOnly(True) cursor = i_text.textCursor() for txin in self.tx.inputs(): - if txin.is_coinbase_input(): - cursor.insertText('coinbase') - else: - prevout_hash = txin.prevout.txid.hex() - prevout_n = txin.prevout.out_idx - cursor.insertText(prevout_hash + ":%-4d " % prevout_n, ext) - addr = self.wallet.adb.get_txin_address(txin) - if addr is None: - addr = '' - cursor.insertText(addr, text_format(addr)) - txin_value = self.wallet.adb.get_txin_value(txin) - if txin_value is not None: - cursor.insertText(format_amount(txin_value), ext) - cursor.insertBlock() + addr = self.wallet.adb.get_txin_address(txin) + txin_value = self.wallet.adb.get_txin_value(txin) + insert_tx_io(cursor, txin.is_coinbase_output(), txin.short_id, addr, txin_value) self.outputs_header.setText(_("Outputs") + ' (%d)'%len(self.tx.outputs())) o_text = self.outputs_textedit o_text.clear() o_text.setFont(QFont(MONOSPACE_FONT)) o_text.setReadOnly(True) + tx_height, tx_pos = self.wallet.adb.get_txpos(self.tx.txid()) + tx_hash = bytes.fromhex(self.tx.txid()) cursor = o_text.textCursor() - for o in self.tx.outputs(): - addr, v = o.get_ui_address_str(), o.value - cursor.insertText(addr, text_format(addr)) - if v is not None: - cursor.insertText('\t', ext) - cursor.insertText(format_amount(v), ext) - cursor.insertBlock() + for index, o in enumerate(self.tx.outputs()): + if tx_pos is not None and tx_pos >= 0: + short_id = ShortID.from_components(tx_height, tx_pos, index) + else: + short_id = TxOutpoint(tx_hash, index).short_name() + + addr, value = o.get_ui_address_str(), o.value + insert_tx_io(cursor, False, short_id, addr, value) self.txo_color_recv.legend_label.setVisible(tf_used_recv) self.txo_color_change.legend_label.setVisible(tf_used_change) diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py index 727e38d46..3c1d067e7 100644 --- a/electrum/gui/qt/utxo_list.py +++ b/electrum/gui/qt/utxo_list.py @@ -46,14 +46,12 @@ class UTXOList(MyTreeView): ADDRESS = 1 LABEL = 2 AMOUNT = 3 - HEIGHT = 4 headers = { + Columns.OUTPOINT: _('Output point'), Columns.ADDRESS: _('Address'), Columns.LABEL: _('Label'), Columns.AMOUNT: _('Amount'), - Columns.HEIGHT: _('Height'), - Columns.OUTPOINT: _('Output point'), } filter_columns = [Columns.ADDRESS, Columns.LABEL, Columns.OUTPOINT] stretch_column = Columns.LABEL @@ -86,10 +84,8 @@ class UTXOList(MyTreeView): name = utxo.prevout.to_str() self._utxo_dict[name] = utxo address = utxo.address - height = utxo.block_height - name_short = utxo.prevout.txid.hex()[:16] + '...' + ":%d" % utxo.prevout.out_idx amount = self.parent.format_amount(utxo.value_sats(), whitespaces=True) - labels = [name_short, address, '', amount, '%d'%height] + labels = [str(utxo.short_id), address, '', amount] utxo_item = [QStandardItem(x) for x in labels] self.set_editability(utxo_item) utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_CLIPBOARD_DATA) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 2b1f3a7ce..90c079aa9 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -13,6 +13,9 @@ from aiorpcx import NetAddress from .util import bfh, bh2u, inv_dict, UserFacingException from .util import list_enabled_bits +from .util import ShortID as ShortChannelID +from .util import format_short_id as format_short_channel_id + from .crypto import sha256 from .transaction import (Transaction, PartialTransaction, PartialTxInput, TxOutpoint, PartialTxOutput, opcodes, TxOutput) @@ -1487,63 +1490,7 @@ NUM_MAX_HOPS_IN_PAYMENT_PATH = 20 NUM_MAX_EDGES_IN_PAYMENT_PATH = NUM_MAX_HOPS_IN_PAYMENT_PATH -class ShortChannelID(bytes): - - def __repr__(self): - return f"" - - def __str__(self): - return format_short_channel_id(self) - - @classmethod - def from_components(cls, block_height: int, tx_pos_in_block: int, output_index: int) -> 'ShortChannelID': - bh = block_height.to_bytes(3, byteorder='big') - tpos = tx_pos_in_block.to_bytes(3, byteorder='big') - oi = output_index.to_bytes(2, byteorder='big') - return ShortChannelID(bh + tpos + oi) - - @classmethod - def from_str(cls, scid: str) -> 'ShortChannelID': - """Parses a formatted scid str, e.g. '643920x356x0'.""" - components = scid.split("x") - if len(components) != 3: - raise ValueError(f"failed to parse ShortChannelID: {scid!r}") - try: - components = [int(x) for x in components] - except ValueError: - raise ValueError(f"failed to parse ShortChannelID: {scid!r}") from None - return ShortChannelID.from_components(*components) - - @classmethod - def normalize(cls, data: Union[None, str, bytes, 'ShortChannelID']) -> Optional['ShortChannelID']: - if isinstance(data, ShortChannelID) or data is None: - return data - if isinstance(data, str): - assert len(data) == 16 - return ShortChannelID.fromhex(data) - if isinstance(data, (bytes, bytearray)): - assert len(data) == 8 - return ShortChannelID(data) - - @property - def block_height(self) -> int: - return int.from_bytes(self[:3], byteorder='big') - - @property - def txpos(self) -> int: - return int.from_bytes(self[3:6], byteorder='big') - - @property - def output_index(self) -> int: - return int.from_bytes(self[6:8], byteorder='big') - -def format_short_channel_id(short_channel_id: Optional[bytes]): - if not short_channel_id: - return _('Not yet available') - return str(int.from_bytes(short_channel_id[:3], 'big')) \ - + 'x' + str(int.from_bytes(short_channel_id[3:6], 'big')) \ - + 'x' + str(int.from_bytes(short_channel_id[6:], 'big')) @attr.s(frozen=True) diff --git a/electrum/transaction.py b/electrum/transaction.py index 0fc7d713a..763d003d0 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -51,6 +51,7 @@ from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160, base_encode, construct_witness, construct_script) from .crypto import sha256d from .logging import get_logger +from .util import ShortID if TYPE_CHECKING: from .wallet import Abstract_Wallet @@ -212,6 +213,9 @@ class TxOutpoint(NamedTuple): def is_coinbase(self) -> bool: return self.txid == bytes(32) + def short_name(self): + return f"{self.txid.hex()[0:10]}:{self.out_idx}" + class TxInput: prevout: TxOutpoint @@ -231,6 +235,18 @@ class TxInput: self.nsequence = nsequence self.witness = witness self._is_coinbase_output = is_coinbase_output + # blockchain fields + self.block_height = None # type: Optional[int] # height at which the TXO is mined; None means unknown + self.block_txpos = None + self.spent_height = None # type: Optional[int] # height at which the TXO got spent + self.spent_txid = None # type: Optional[str] # txid of the spender + + @property + def short_id(self): + if self.block_txpos is not None and self.block_txpos >= 0: + return ShortID.from_components(self.block_height, self.block_txpos, self.prevout.out_idx) + else: + return self.prevout.short_name() def is_coinbase_input(self) -> bool: """Whether this is the input of a coinbase tx.""" @@ -1227,9 +1243,6 @@ class PartialTxInput(TxInput, PSBTSection): self.pubkeys = [] # type: List[bytes] # note: order matters self._trusted_value_sats = None # type: Optional[int] self._trusted_address = None # type: Optional[str] - self.block_height = None # type: Optional[int] # height at which the TXO is mined; None means unknown - self.spent_height = None # type: Optional[int] # height at which the TXO got spent - self.spent_txid = None # type: Optional[str] # txid of the spender self._is_p2sh_segwit = None # type: Optional[bool] # None means unknown self._is_native_segwit = None # type: Optional[bool] # None means unknown self.witness_sizehint = None # type: Optional[int] # byte size of serialized complete witness, for tx size est diff --git a/electrum/util.py b/electrum/util.py index a4388fd28..ba3738e35 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -1286,6 +1286,65 @@ class TxMinedInfo(NamedTuple): header_hash: Optional[str] = None # hash of block that mined tx +class ShortID(bytes): + + def __repr__(self): + return f"" + + def __str__(self): + return format_short_id(self) + + @classmethod + def from_components(cls, block_height: int, tx_pos_in_block: int, output_index: int) -> 'ShortID': + bh = block_height.to_bytes(3, byteorder='big') + tpos = tx_pos_in_block.to_bytes(3, byteorder='big') + oi = output_index.to_bytes(2, byteorder='big') + return ShortID(bh + tpos + oi) + + @classmethod + def from_str(cls, scid: str) -> 'ShortID': + """Parses a formatted scid str, e.g. '643920x356x0'.""" + components = scid.split("x") + if len(components) != 3: + raise ValueError(f"failed to parse ShortID: {scid!r}") + try: + components = [int(x) for x in components] + except ValueError: + raise ValueError(f"failed to parse ShortID: {scid!r}") from None + return ShortID.from_components(*components) + + @classmethod + def normalize(cls, data: Union[None, str, bytes, 'ShortChannelID']) -> Optional['ShortChannelID']: + if isinstance(data, ShortID) or data is None: + return data + if isinstance(data, str): + assert len(data) == 16 + return ShortID.fromhex(data) + if isinstance(data, (bytes, bytearray)): + assert len(data) == 8 + return ShortID(data) + + @property + def block_height(self) -> int: + return int.from_bytes(self[:3], byteorder='big') + + @property + def txpos(self) -> int: + return int.from_bytes(self[3:6], byteorder='big') + + @property + def output_index(self) -> int: + return int.from_bytes(self[6:8], byteorder='big') + + +def format_short_id(short_channel_id: Optional[bytes]): + if not short_channel_id: + return _('Not yet available') + return str(int.from_bytes(short_channel_id[:3], 'big')) \ + + 'x' + str(int.from_bytes(short_channel_id[3:6], 'big')) \ + + 'x' + str(int.from_bytes(short_channel_id[6:], 'big')) + + def make_aiohttp_session(proxy: Optional[dict], headers=None, timeout=None): if headers is None: headers = {'User-Agent': 'Electrum'} diff --git a/electrum/wallet.py b/electrum/wallet.py index 34258778f..f6ef08580 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -2116,7 +2116,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): received, spent = self.adb.get_addr_io(address) item = received.get(txin.prevout.to_str()) if item: - txin_value = item[1] + txin_value = item[2] txin.witness_utxo = TxOutput.from_address_and_value(address, txin_value) if txin.utxo is None: txin.utxo = self.get_input_tx(txin.prevout.txid.hex(), ignore_network_issues=ignore_network_issues)