From b944371ffde90e66930ad92d8b60b87285d73b63 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Wed, 10 Sep 2025 15:24:28 +0000 Subject: [PATCH] adb: change API of util.TxMinedInfo: height() is now always SPV-ed --- electrum/address_synchronizer.py | 41 +++++++++++----------- electrum/commands.py | 2 +- electrum/gui/qml/qetransactionlistmodel.py | 8 ++--- electrum/gui/qml/qetxdetails.py | 6 ++-- electrum/gui/qt/history_list.py | 4 +-- electrum/gui/qt/transaction_dialog.py | 4 +-- electrum/gui/qt/utxo_dialog.py | 2 +- electrum/lnchannel.py | 14 ++++---- electrum/lnwatcher.py | 2 +- electrum/lnworker.py | 2 +- electrum/plugins/watchtower/watchtower.py | 2 +- electrum/submarine_swaps.py | 6 ++-- electrum/txbatcher.py | 12 +++---- electrum/util.py | 25 +++++++++---- electrum/verifier.py | 2 +- electrum/wallet.py | 24 ++++++------- electrum/wallet_db.py | 6 ++-- tests/test_invoices.py | 6 ++-- tests/test_txbatcher.py | 8 +++-- tests/test_wallet.py | 2 +- tests/test_wallet_vertical.py | 14 ++++---- 21 files changed, 105 insertions(+), 87 deletions(-) diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py index 1f28c28da..f501f6809 100644 --- a/electrum/address_synchronizer.py +++ b/electrum/address_synchronizer.py @@ -23,6 +23,7 @@ import asyncio import copy +import dataclasses import threading import itertools from collections import defaultdict @@ -146,7 +147,7 @@ class AddressSynchronizer(Logger, EventListener): h = {} related_txns = self._history_local.get(addr, set()) for tx_hash in related_txns: - tx_height = self.get_tx_height(tx_hash).height + tx_height = self.get_tx_height(tx_hash).height() h[tx_hash] = tx_height return h @@ -272,7 +273,7 @@ class AddressSynchronizer(Logger, EventListener): tx.deserialize() for txin in tx._inputs: tx_mined_info = self.get_tx_height(txin.prevout.txid.hex()) - txin.block_height = tx_mined_info.height # not SPV-ed + txin.block_height = tx_mined_info.height() txin.block_txpos = tx_mined_info.txpos return tx @@ -297,7 +298,7 @@ class AddressSynchronizer(Logger, EventListener): # of add_transaction tx, we might learn of more-and-more inputs of # being is_mine, as we roll the gap_limit forward is_coinbase = tx.inputs()[0].is_coinbase_input() - tx_height = self.get_tx_height(tx_hash, force_local_if_missing_tx=False).height + tx_height = self.get_tx_height(tx_hash, force_local_if_missing_tx=False).height() if not allow_unrelated: # note that during sync, if the transactions are not properly sorted, # it could happen that we think tx is unrelated but actually one of the inputs is is_mine. @@ -316,10 +317,10 @@ class AddressSynchronizer(Logger, EventListener): conflicting_txns = self.get_conflicting_transactions(tx) if conflicting_txns: existing_mempool_txn = any( - self.get_tx_height(tx_hash2).height in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT) + self.get_tx_height(tx_hash2).height() in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT) for tx_hash2 in conflicting_txns) existing_confirmed_txn = any( - self.get_tx_height(tx_hash2).height > 0 + self.get_tx_height(tx_hash2).height() > 0 for tx_hash2 in conflicting_txns) if existing_confirmed_txn and tx_height <= 0: # this is a non-confirmed tx that conflicts with confirmed txns; drop. @@ -502,7 +503,7 @@ class AddressSynchronizer(Logger, EventListener): @with_lock def remove_local_transactions_we_dont_have(self): for txid in itertools.chain(self.db.list_txi(), self.db.list_txo()): - tx_height = self.get_tx_height(txid).height + tx_height = self.get_tx_height(txid).height() if tx_height == TX_HEIGHT_LOCAL and not self.db.get_transaction(txid): self.remove_transaction(txid) @@ -516,7 +517,7 @@ class AddressSynchronizer(Logger, EventListener): def _get_tx_sort_key(self, tx_hash: str) -> Tuple[int, int]: """Returns a key to be used for sorting txs.""" tx_mined_info = self.get_tx_height(tx_hash) - height = self.tx_height_to_sort_height(tx_mined_info.height) + height = self.tx_height_to_sort_height(tx_mined_info.height()) txpos = tx_mined_info.txpos or -1 return height, txpos @@ -664,7 +665,7 @@ class AddressSynchronizer(Logger, EventListener): with self.lock: for tx_hash in self.db.list_verified_tx(): info = self.db.get_verified_tx(tx_hash) - tx_height = info.height + tx_height = info._height if tx_height > above_height: header = blockchain.read_header(tx_height) if not header or hash_header(header) != info.header_hash: @@ -711,25 +712,25 @@ class AddressSynchronizer(Logger, EventListener): force_local_if_missing_tx: bool = True, ) -> TxMinedInfo: if tx_hash is None: # ugly backwards compat... - return TxMinedInfo(height=TX_HEIGHT_LOCAL, conf=0) + return TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0) with self.lock: if verified_tx_mined_info := self.db.get_verified_tx(tx_hash): # mined and spv-ed - conf = max(self.get_local_height() - verified_tx_mined_info.height + 1, 0) - tx_mined_info = verified_tx_mined_info._replace(conf=conf) + conf = max(self.get_local_height() - verified_tx_mined_info._height + 1, 0) + tx_mined_info = dataclasses.replace(verified_tx_mined_info, conf=conf) elif tx_hash in self.unverified_tx: # mined, no spv height = self.unverified_tx[tx_hash] - tx_mined_info = TxMinedInfo(height=height, conf=0) + tx_mined_info = TxMinedInfo(_height=height, conf=0) elif tx_hash in self.unconfirmed_tx: # mempool height = self.unconfirmed_tx[tx_hash] - tx_mined_info = TxMinedInfo(height=height, conf=0) + tx_mined_info = TxMinedInfo(_height=height, conf=0) elif wanted_height := self.future_tx.get(tx_hash): # future if wanted_height > self.get_local_height(): - tx_mined_info = TxMinedInfo(height=TX_HEIGHT_FUTURE, conf=0, wanted_height=wanted_height) + tx_mined_info = TxMinedInfo(_height=TX_HEIGHT_FUTURE, conf=0, wanted_height=wanted_height) else: - tx_mined_info = TxMinedInfo(height=TX_HEIGHT_LOCAL, conf=0) + tx_mined_info = TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0) else: # local - tx_mined_info = TxMinedInfo(height=TX_HEIGHT_LOCAL, conf=0) - if tx_mined_info.height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE): + tx_mined_info = TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0) + if tx_mined_info.height() in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE): return tx_mined_info if force_local_if_missing_tx: # It can happen for a txid in any state (unconf/unverified/verified) that we @@ -739,7 +740,7 @@ class AddressSynchronizer(Logger, EventListener): # a different tx if only the witness differs. We should compare wtxids. tx = self.db.get_transaction(tx_hash) if tx is None or isinstance(tx, PartialTransaction): - return TxMinedInfo(height=TX_HEIGHT_LOCAL, conf=0) + return TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0) return tx_mined_info def up_to_date_changed(self) -> None: @@ -1047,7 +1048,7 @@ class AddressSynchronizer(Logger, EventListener): spender_txid = self.db.get_spent_outpoint(prev_txid, int(index)) # discard local spenders tx_mined_status = self.get_tx_height(spender_txid) - if tx_mined_status.height in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]: + if tx_mined_status.height() in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]: spender_txid = None if not spender_txid: return None @@ -1063,7 +1064,7 @@ class AddressSynchronizer(Logger, EventListener): if not txid: return TxMinedDepth.FREE tx_mined_depth = self.get_tx_height(txid) - height, conf = tx_mined_depth.height, tx_mined_depth.conf + height, conf = tx_mined_depth.height(), tx_mined_depth.conf if conf > 20: # FIXME unify with lnutil.REDEEM_AFTER_DOUBLE_SPENT_DELAY ? return TxMinedDepth.DEEP elif conf > 0: diff --git a/electrum/commands.py b/electrum/commands.py index f5c4887f1..6ee60d139 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1634,7 +1634,7 @@ class Commands(Logger): arg:txid:txid:Transaction ID """ - height = wallet.adb.get_tx_height(txid).height + height = wallet.adb.get_tx_height(txid).height() if height != TX_HEIGHT_LOCAL: raise UserFacingException( f'Only local transactions can be removed. ' diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index c693ec482..c5cfdafe6 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -196,7 +196,7 @@ class QETransactionListModel(QAbstractListModel, QtEventListener): def _tx_mined_info_from_tx_item(tx_item: Dict[str, Any]) -> TxMinedInfo: # FIXME a bit hackish to have to reconstruct the TxMinedInfo... same thing in qt-gui tx_mined_info = TxMinedInfo( - height=tx_item['height'], + _height=tx_item['height'], conf=tx_item['confirmations'], timestamp=tx_item['timestamp'], wanted_height=tx_item.get('wanted_height', None), @@ -229,10 +229,10 @@ class QETransactionListModel(QAbstractListModel, QtEventListener): self._dirty = False - def on_tx_verified(self, txid, info): + def on_tx_verified(self, txid: str, info: TxMinedInfo): for i, tx in enumerate(self.tx_history): if 'txid' in tx and tx['txid'] == txid: - tx['height'] = info.height + tx['height'] = info.height() tx['confirmations'] = info.conf tx['timestamp'] = info.timestamp tx['section'] = self.get_section_by_timestamp(info.timestamp) @@ -255,7 +255,7 @@ class QETransactionListModel(QAbstractListModel, QtEventListener): status, status_str = self.wallet.get_tx_status(txid, txinfo.tx_mined_status) tx_item['date'] = status_str # note: if the height changes, that might affect the history order, but we won't re-sort now. - tx_item['height'] = self.wallet.adb.get_tx_height(txid).height + tx_item['height'] = self.wallet.adb.get_tx_height(txid).height() index = self.index(tx_item_idx, 0) roles = [self._ROLE_RMAP[x] for x in ['height', 'date']] self.dataChanged.emit(index, index, roles) diff --git a/electrum/gui/qml/qetxdetails.py b/electrum/gui/qml/qetxdetails.py index 0952258d3..c557d8076 100644 --- a/electrum/gui/qml/qetxdetails.py +++ b/electrum/gui/qml/qetxdetails.py @@ -352,14 +352,14 @@ class QETxDetails(QObject, QtEventListener): self._lock_delay = 0 self._in_mempool = False - self._is_mined = False if not txinfo.tx_mined_status else txinfo.tx_mined_status.height > 0 + self._is_mined = False if not txinfo.tx_mined_status else txinfo.tx_mined_status.height() > 0 if self._is_mined: self.update_mined_status(txinfo.tx_mined_status) else: - if txinfo.tx_mined_status.height in [TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT]: + if txinfo.tx_mined_status.height() in [TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT]: self._mempool_depth = FeePolicy.depth_tooltip(txinfo.mempool_depth_bytes) self._in_mempool = True - elif txinfo.tx_mined_status.height == TX_HEIGHT_FUTURE: + elif txinfo.tx_mined_status.height() == TX_HEIGHT_FUTURE: self._lock_delay = txinfo.tx_mined_status.wanted_height - self._wallet.wallet.adb.get_local_height() if isinstance(self._tx, PartialTransaction): self._sighash_danger = self._wallet.wallet.check_sighash(self._tx) diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py index ee8474ef0..a18daa165 100644 --- a/electrum/gui/qt/history_list.py +++ b/electrum/gui/qt/history_list.py @@ -439,7 +439,7 @@ class HistoryModel(CustomModel, Logger): def _tx_mined_info_from_tx_item(tx_item: Dict[str, Any]) -> TxMinedInfo: # FIXME a bit hackish to have to reconstruct the TxMinedInfo... same thing in qml-gui tx_mined_info = TxMinedInfo( - height=tx_item['height'], + _height=tx_item['height'], conf=tx_item['confirmations'], timestamp=tx_item['timestamp'], wanted_height=tx_item.get('wanted_height', None), @@ -764,7 +764,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): return tx_URL = block_explorer_URL(self.config, 'tx', tx_hash) tx_details = self.wallet.get_tx_info(tx) - is_unconfirmed = tx_details.tx_mined_status.height <= 0 + is_unconfirmed = tx_details.tx_mined_status.height() <= 0 menu = QMenu() menu.addAction(_("Details"), lambda: self.main_window.show_transaction(tx)) if tx_details.can_remove: diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index d8e708a95..4f8345df4 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -282,7 +282,7 @@ class TxInOutWidget(QWidget): tx_hash = self.tx.txid() if tx_hash: tx_mined_info = self.wallet.adb.get_tx_height(tx_hash) - tx_height = tx_mined_info.height + tx_height = tx_mined_info.height() tx_pos = tx_mined_info.txpos cursor = o_text.textCursor() for txout_idx, o in enumerate(self.tx.outputs()): @@ -915,7 +915,7 @@ class TxDialog(QDialog, MessageBoxMixin): self.rbf_label.setText(_('Replace by fee: {}').format(_('True') if self.tx.is_rbf_enabled() else _('False'))) if tx_mined_status.header_hash: - self.block_height_label.setText(_("At block height: {}").format(tx_mined_status.height)) + self.block_height_label.setText(_("At block height: {}").format(tx_mined_status.height())) else: self.block_height_label.hide() if amount is None and ln_amount is None: diff --git a/electrum/gui/qt/utxo_dialog.py b/electrum/gui/qt/utxo_dialog.py index fa66c3cc9..f63837e3d 100644 --- a/electrum/gui/qt/utxo_dialog.py +++ b/electrum/gui/qt/utxo_dialog.py @@ -108,7 +108,7 @@ class UTXODialog(WindowModalDialog): if _txid not in parents: return tx_mined_info = self.wallet.adb.get_tx_height(_txid) - tx_height = tx_mined_info.height + tx_height = tx_mined_info.height() tx_pos = tx_mined_info.txpos key = "%dx%d"%(tx_height, tx_pos) if tx_pos is not None else _txid[0:8] label = self.wallet.get_label_for_txid(_txid) or "" diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index dbe907996..b6e19647d 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -336,9 +336,9 @@ class AbstractChannel(Logger, ABC): closing_txid: str, closing_height: TxMinedInfo, keep_watching: bool) -> None: # note: state transitions are irreversible, but # save_funding_height, save_closing_height are reversible - if funding_height.height == TX_HEIGHT_LOCAL: + if funding_height.height() == TX_HEIGHT_LOCAL: self.update_unfunded_state() - elif closing_height.height == TX_HEIGHT_LOCAL: + elif closing_height.height() == TX_HEIGHT_LOCAL: self.update_funded_state( funding_txid=funding_txid, funding_height=funding_height) @@ -401,11 +401,11 @@ class AbstractChannel(Logger, ABC): self.lnworker.remove_channel(self.channel_id) def update_funded_state(self, *, funding_txid: str, funding_height: TxMinedInfo) -> None: - self.save_funding_height(txid=funding_txid, height=funding_height.height, timestamp=funding_height.timestamp) + self.save_funding_height(txid=funding_txid, height=funding_height.height(), timestamp=funding_height.timestamp) self.delete_closing_height() if funding_height.conf>0: self.set_short_channel_id(ShortChannelID.from_components( - funding_height.height, funding_height.txpos, self.funding_outpoint.output_index)) + funding_height.height(), funding_height.txpos, self.funding_outpoint.output_index)) if self.get_state() == ChannelState.OPENING: if self.is_funding_tx_mined(funding_height): self.set_state(ChannelState.FUNDED) @@ -423,11 +423,11 @@ class AbstractChannel(Logger, ABC): def update_closed_state(self, *, funding_txid: str, funding_height: TxMinedInfo, closing_txid: str, closing_height: TxMinedInfo, keep_watching: bool) -> None: - self.save_funding_height(txid=funding_txid, height=funding_height.height, timestamp=funding_height.timestamp) - self.save_closing_height(txid=closing_txid, height=closing_height.height, timestamp=closing_height.timestamp) + self.save_funding_height(txid=funding_txid, height=funding_height.height(), timestamp=funding_height.timestamp) + self.save_closing_height(txid=closing_txid, height=closing_height.height(), timestamp=closing_height.timestamp) if funding_height.conf>0: self.set_short_channel_id(ShortChannelID.from_components( - funding_height.height, funding_height.txpos, self.funding_outpoint.output_index)) + funding_height.height(), funding_height.txpos, self.funding_outpoint.output_index)) if self.get_state() < ChannelState.CLOSED: conf = closing_height.conf if conf > 0: diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py index c86a28432..8041eac7e 100644 --- a/electrum/lnwatcher.py +++ b/electrum/lnwatcher.py @@ -279,5 +279,5 @@ class LNWatcher(Logger, EventListener): # We should not keep warning the user forever. return tx_mined_status = self.adb.get_tx_height(spender_txid) - if tx_mined_status.height == TX_HEIGHT_LOCAL: + if tx_mined_status.height() == TX_HEIGHT_LOCAL: self._pending_force_closes.add(chan) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index c4409cbc2..c66843ee0 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1264,7 +1264,7 @@ class LNWallet(LNWorker): elif chan.get_state() == ChannelState.FORCE_CLOSING: force_close_tx = chan.force_close_tx() txid = force_close_tx.txid() - height = self.lnwatcher.adb.get_tx_height(txid).height + height = self.lnwatcher.adb.get_tx_height(txid).height() if height == TX_HEIGHT_LOCAL: self.logger.info('REBROADCASTING CLOSING TX') await self.network.try_broadcasting(force_close_tx, 'force-close') diff --git a/electrum/plugins/watchtower/watchtower.py b/electrum/plugins/watchtower/watchtower.py index dd39ebd49..6bb08a801 100644 --- a/electrum/plugins/watchtower/watchtower.py +++ b/electrum/plugins/watchtower/watchtower.py @@ -218,7 +218,7 @@ class WatchTower(Logger, EventListener): return keep_watching async def broadcast_or_log(self, funding_outpoint: str, tx: Transaction): - height = self.adb.get_tx_height(tx.txid()).height + height = self.adb.get_tx_height(tx.txid()).height() if height != TX_HEIGHT_LOCAL: return try: diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 5112b18b4..99468eb2e 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -1440,7 +1440,7 @@ class SwapManager(Logger): delta = current_height - swap.locktime if self.wallet.adb.is_mine(swap.lockup_address): tx_height = self.wallet.adb.get_tx_height(swap.funding_txid) - if swap.is_reverse and tx_height.height <= 0: + if swap.is_reverse and tx_height.height() <= 0: label += ' (%s)' % _('waiting for funding tx confirmation') if not swap.is_reverse and not swap.is_redeemed and swap.spending_txid is None and delta < 0: label += f' (refundable in {-delta} blocks)' # fixme: only if unspent @@ -1481,8 +1481,8 @@ class SwapManager(Logger): # and adb will return TX_HEIGHT_LOCAL continue # note: adb.get_tx_height returns TX_HEIGHT_LOCAL if the txid is unknown - funding_height = self.lnworker.wallet.adb.get_tx_height(swap.funding_txid).height - spending_height = self.lnworker.wallet.adb.get_tx_height(swap.spending_txid).height + funding_height = self.lnworker.wallet.adb.get_tx_height(swap.funding_txid).height() + spending_height = self.lnworker.wallet.adb.get_tx_height(swap.spending_txid).height() if funding_height > TX_HEIGHT_LOCAL and spending_height <= TX_HEIGHT_LOCAL: pending_swaps.append(swap) return pending_swaps diff --git a/electrum/txbatcher.py b/electrum/txbatcher.py index 7a0782c05..3dcf159fd 100644 --- a/electrum/txbatcher.py +++ b/electrum/txbatcher.py @@ -174,9 +174,9 @@ class TxBatcher(Logger): prev_txid, index = outpoint.split(':') if spender_txid := self.wallet.adb.db.get_spent_outpoint(prev_txid, int(index)): tx_mined_status = self.wallet.adb.get_tx_height(spender_txid) - if tx_mined_status.height > 0: + if tx_mined_status.height() > 0: return - if tx_mined_status.height not in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]: + if tx_mined_status.height() not in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]: return self.logger.info(f'will broadcast standalone tx {sweep_info.name}') tx = PartialTransaction.from_io([sweep_info.txin], [sweep_info.txout], locktime=sweep_info.cltv_abs, version=2) @@ -293,7 +293,7 @@ class TxBatch(Logger): prev_txid, index = prevout.split(':') if spender_txid := self.wallet.adb.db.get_spent_outpoint(prev_txid, int(index)): tx_mined_status = self.wallet.adb.get_tx_height(spender_txid) - if tx_mined_status.height > 0: + if tx_mined_status.height() > 0: return self._unconfirmed_sweeps.add(txin.prevout) self.logger.info(f'add_sweep_info: {sweep_info.name} {sweep_info.txin.prevout.to_str()}') @@ -331,7 +331,7 @@ class TxBatch(Logger): continue if spender_txid := self.wallet.adb.db.get_spent_outpoint(prev_txid, int(index)): tx_mined_status = self.wallet.adb.get_tx_height(spender_txid) - if tx_mined_status.height not in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]: + if tx_mined_status.height() not in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]: continue if prevout in tx_prevouts: continue @@ -378,7 +378,7 @@ class TxBatch(Logger): self.logger.info(f'base tx confirmed {txid}') self._clear_unconfirmed_sweeps(tx) self._start_new_batch(tx) - if tx_mined_status.height in [TX_HEIGHT_LOCAL]: + if tx_mined_status.height() in [TX_HEIGHT_LOCAL]: # this may happen if our Electrum server is unresponsive # server could also be lying to us. Rebroadcasting might # help, if we have switched to another server. @@ -590,7 +590,7 @@ class TxBatch(Logger): wanted_height_cltv = sweep_info.cltv_abs if wanted_height_cltv - local_height > 0: can_broadcast = False - prev_height = self.wallet.adb.get_tx_height(prev_txid).height + prev_height = self.wallet.adb.get_tx_height(prev_txid).height() if sweep_info.csv_delay: if prev_height > 0: wanted_height_csv = prev_height + sweep_info.csv_delay - 1 diff --git a/electrum/util.py b/electrum/util.py index 748c1c8f5..b019b0868 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -1264,26 +1264,37 @@ def with_lock(func): return func_wrapper -class TxMinedInfo(NamedTuple): - height: int # height of block that mined tx +@dataclass(frozen=True, kw_only=True) +class TxMinedInfo: + _height: int # height of block that mined tx conf: Optional[int] = None # number of confirmations, SPV verified. >=0, or None (None means unknown) timestamp: Optional[int] = None # timestamp of block that mined tx txpos: Optional[int] = None # position of tx in serialized block header_hash: Optional[str] = None # hash of block that mined tx wanted_height: Optional[int] = None # in case of timelock, min abs block height + def height(self) -> int: + """Treat unverified heights as unconfirmed.""" + h = self._height + if h > 0: + if self.conf is not None and self.conf >= 1: + return h + return 0 # treat it as unconfirmed until SPV-ed + else: # h <= 0 + return h + def short_id(self) -> Optional[str]: if self.txpos is not None and self.txpos >= 0: - assert self.height > 0 - return f"{self.height}x{self.txpos}" + assert self.height() > 0 + return f"{self.height()}x{self.txpos}" return None def is_local_like(self) -> bool: """Returns whether the tx is local-like (LOCAL/FUTURE).""" from .address_synchronizer import TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT - if self.height > 0: + if self.height() > 0: return False - if self.height in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT): + if self.height() in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT): return False return True @@ -2360,7 +2371,7 @@ class OnchainHistoryItem(NamedTuple): 'txid': self.txid, 'amount_sat': self.amount_sat, 'fee_sat': self.fee_sat, - 'height': self.tx_mined_status.height, + 'height': self.tx_mined_status.height(), 'confirmations': self.tx_mined_status.conf, 'timestamp': self.tx_mined_status.timestamp, 'monotonic_timestamp': self.monotonic_timestamp, diff --git a/electrum/verifier.py b/electrum/verifier.py index 7205fce2e..ca74ec722 100644 --- a/electrum/verifier.py +++ b/electrum/verifier.py @@ -131,7 +131,7 @@ class SPV(NetworkJobOnDefaultServer): self.requested_merkle.discard(tx_hash) self.logger.info(f"verified {tx_hash}") header_hash = hash_header(header) - tx_info = TxMinedInfo(height=tx_height, + tx_info = TxMinedInfo(_height=tx_height, timestamp=header.get('timestamp'), txpos=pos, header_hash=header_hash) diff --git a/electrum/wallet.py b/electrum/wallet.py index e997e9bfb..3760a1dba 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -949,7 +949,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): and (tx_we_already_have_in_db is None or not tx_we_already_have_in_db.is_complete())) label = '' tx_mined_status = self.adb.get_tx_height(tx_hash) - can_remove = ((tx_mined_status.height in [TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL]) + can_remove = ((tx_mined_status.height() in [TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL]) # otherwise 'height' is unreliable (typically LOCAL): and is_relevant # don't offer during common signing flow, e.g. when watch-only wallet starts creating a tx: @@ -958,12 +958,12 @@ class Abstract_Wallet(ABC, Logger, EventListener): if tx.is_complete(): if tx_we_already_have_in_db: label = self.get_label_for_txid(tx_hash) - if tx_mined_status.height > 0: + if tx_mined_status.height() > 0: if tx_mined_status.conf: status = _("{} confirmations").format(tx_mined_status.conf) else: status = _('Not verified') - elif tx_mined_status.height in (TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED): + elif tx_mined_status.height() in (TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED): status = _('Unconfirmed') if fee is None: fee = self.adb.get_tx_fee(tx_hash) @@ -981,7 +981,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): can_cpfp = False else: status = _('Local') - if tx_mined_status.height == TX_HEIGHT_FUTURE: + if tx_mined_status.height() == TX_HEIGHT_FUTURE: num_blocks_remainining = tx_mined_status.wanted_height - self.adb.get_local_height() num_blocks_remainining = max(0, num_blocks_remainining) status = _('Local (future: {})').format(_('in {} blocks').format(num_blocks_remainining)) @@ -1043,7 +1043,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): # populate cache in chronological order (confirmed tx only) # todo: get_full_history should return unconfirmed tx topologically sorted for _txid, tx_item in self._last_full_history.items(): - if tx_item.tx_mined_status.height > 0: + if tx_item.tx_mined_status.height() > 0: self.get_tx_parents(_txid) result = self._tx_parents_cache.get(txid, None) @@ -1224,7 +1224,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): monotonic_timestamp = 0 for hist_item in self.adb.get_history(domain=domain): timestamp = (hist_item.tx_mined_status.timestamp or TX_TIMESTAMP_INF) - height = hist_item.tx_mined_status.height + height = hist_item.tx_mined_status.height() if from_timestamp and (timestamp or now) < from_timestamp: continue if to_timestamp and (timestamp or now) >= to_timestamp: @@ -1405,7 +1405,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): for prevout, v in prevouts_and_values: relevant_txs.add(prevout.txid.hex()) tx_height = self.adb.get_tx_height(prevout.txid.hex()) - if 0 < tx_height.height <= invoice.height: # exclude txs older than invoice + if 0 < tx_height.height() <= invoice.height: # exclude txs older than invoice continue confs_and_values.append((tx_height.conf or 0, v)) # check that there is at least one TXO, and that they pay enough. @@ -1748,7 +1748,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): def get_tx_status(self, tx_hash: str, tx_mined_info: TxMinedInfo): extra = [] - height = tx_mined_info.height + height = tx_mined_info.height() conf = tx_mined_info.conf timestamp = tx_mined_info.timestamp if height == TX_HEIGHT_FUTURE: @@ -1812,7 +1812,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): # tx should not be mined yet if hist_item.tx_mined_status.conf > 0: continue # conservative future proofing of code: only allow known unconfirmed types - if hist_item.tx_mined_status.height not in ( + if hist_item.tx_mined_status.height() not in ( TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_LOCAL): @@ -2016,7 +2016,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): if base_tx: # make sure we don't try to spend change from the tx-to-be-replaced: coins = [c for c in coins if c.prevout.txid.hex() != base_tx.txid()] - is_local = self.adb.get_tx_height(base_tx.txid()).height == TX_HEIGHT_LOCAL + is_local = self.adb.get_tx_height(base_tx.txid()).height() == TX_HEIGHT_LOCAL if not isinstance(base_tx, PartialTransaction): base_tx = PartialTransaction.from_tx(base_tx) base_tx.add_info_from_wallet(self) @@ -2149,7 +2149,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): return bool(frozen) # State not set. We implicitly mark certain coins as frozen: tx_mined_status = self.adb.get_tx_height(utxo.prevout.txid.hex()) - if tx_mined_status.height == TX_HEIGHT_FUTURE: + if tx_mined_status.height() == TX_HEIGHT_FUTURE: return True if self._is_coin_small_and_unconfirmed(utxo): return True @@ -2675,7 +2675,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): txin.script_descriptor = self.get_script_descriptor_for_address(address) txin.is_mine = True self._add_txinout_derivation_info(txin, address, only_der_suffix=only_der_suffix) - txin.block_height = self.adb.get_tx_height(txin.prevout.txid.hex()).height + txin.block_height = self.adb.get_tx_height(txin.prevout.txid.hex()).height() def has_support_for_slip_19_ownership_proofs(self) -> bool: return False diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index 8f37430dc..f527d5d47 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -1508,7 +1508,7 @@ class WalletDB(JsonDB): if txid not in self.verified_tx: return None height, timestamp, txpos, header_hash = self.verified_tx[txid] - return TxMinedInfo(height=height, + return TxMinedInfo(_height=height, conf=None, timestamp=timestamp, txpos=txpos, @@ -1518,7 +1518,9 @@ class WalletDB(JsonDB): def add_verified_tx(self, txid: str, info: TxMinedInfo): assert isinstance(txid, str) assert isinstance(info, TxMinedInfo) - self.verified_tx[txid] = (info.height, info.timestamp, info.txpos, info.header_hash) + height = info._height # number of conf is dynamic and might not be set here + assert height > 0, height + self.verified_tx[txid] = (height, info.timestamp, info.txpos, info.header_hash) @modifier def remove_verified_tx(self, txid: str): diff --git a/tests/test_invoices.py b/tests/test_invoices.py index d1f1a5c2f..267780242 100644 --- a/tests/test_invoices.py +++ b/tests/test_invoices.py @@ -80,7 +80,7 @@ class TestWalletPaymentRequests(ElectrumTestCase): self.assertEqual(PR_UNCONFIRMED, wallet1.get_invoice_status(pr)) # tx gets mined wallet1.db.put('stored_height', 1010) - tx_info = TxMinedInfo(height=1001, + tx_info = TxMinedInfo(_height=1001, timestamp=pr.get_time() + 100, txpos=1, header_hash="01"*32) @@ -111,7 +111,7 @@ class TestWalletPaymentRequests(ElectrumTestCase): self.assertEqual(PR_UNCONFIRMED, wallet1.get_invoice_status(pr)) # tx gets mined wallet1.db.put('stored_height', 1010) - tx_info = TxMinedInfo(height=1001, + tx_info = TxMinedInfo(_height=1001, timestamp=pr.get_time() + 100, txpos=1, header_hash="01"*32) @@ -141,7 +141,7 @@ class TestWalletPaymentRequests(ElectrumTestCase): wallet1.adb.receive_tx_callback(tx, tx_height=TX_HEIGHT_UNCONFIRMED) self.assertEqual(PR_UNCONFIRMED, wallet1.get_invoice_status(pr)) # tx mined in the past (before invoice creation) - tx_info = TxMinedInfo(height=990, + tx_info = TxMinedInfo(_height=990, timestamp=pr.get_time() + 100, txpos=1, header_hash="01" * 32) diff --git a/tests/test_txbatcher.py b/tests/test_txbatcher.py index c7a1b07ce..2afafe4ce 100644 --- a/tests/test_txbatcher.py +++ b/tests/test_txbatcher.py @@ -2,6 +2,8 @@ import unittest import logging from unittest import mock import asyncio +import dataclasses + from aiorpcx import timeout_after from electrum import storage, bitcoin, keystore, wallet @@ -152,7 +154,7 @@ class TestTxBatcher(ElectrumTestCase): # tx1 gets confirmed, tx2 gets removed wallet.adb.receive_tx_callback(tx1, tx_height=1) tx_mined_status = wallet.adb.get_tx_height(tx1.txid()) - wallet.adb.add_verified_tx(tx1.txid(), tx_mined_status._replace(conf=1)) + wallet.adb.add_verified_tx(tx1.txid(), dataclasses.replace(tx_mined_status, conf=1)) assert wallet.adb.get_transaction(tx1.txid()) is not None assert wallet.adb.get_transaction(tx1_prime.txid()) is None # txbatcher creates tx2 @@ -195,7 +197,7 @@ class TestTxBatcher(ElectrumTestCase): # tx1 gets confirmed wallet.adb.receive_tx_callback(tx1, tx_height=1) tx_mined_status = wallet.adb.get_tx_height(tx1.txid()) - wallet.adb.add_verified_tx(tx1.txid(), tx_mined_status._replace(conf=1)) + wallet.adb.add_verified_tx(tx1.txid(), dataclasses.replace(tx_mined_status, conf=1)) tx2 = await self.network.next_tx() assert len(tx2.outputs()) == 2 @@ -209,6 +211,8 @@ class TestTxBatcher(ElectrumTestCase): wallet = self._create_wallet() wallet.adb.db.transactions[SWAPDATA.funding_txid] = tx = Transaction(SWAP_FUNDING_TX) wallet.adb.receive_tx_callback(tx, tx_height=1) + tx_mined_status = wallet.adb.get_tx_height(tx.txid()) + wallet.adb.add_verified_tx(tx.txid(), dataclasses.replace(tx_mined_status, conf=1)) wallet.txbatcher.add_sweep_input('default', SWAP_SWEEP_INFO) tx = await self.network.next_tx() txid = tx.txid() diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 77ae7012a..b122506fb 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -164,7 +164,7 @@ class FakeADB: def get_tx_height(self, txid): # because we use a current timestamp, and history is empty, # FxThread.history_rate will use spot prices - return TxMinedInfo(height=10, conf=10, timestamp=int(time.time()), header_hash='def') + return TxMinedInfo(_height=10, conf=10, timestamp=int(time.time()), header_hash='def') class FakeWallet: def __init__(self, fiat_value): diff --git a/tests/test_wallet_vertical.py b/tests/test_wallet_vertical.py index c1c73dcfa..7edf0fe49 100644 --- a/tests/test_wallet_vertical.py +++ b/tests/test_wallet_vertical.py @@ -2924,7 +2924,7 @@ class TestWalletSending(ElectrumTestCase): assert payment_txid # save payment_tx as LOCAL and UNSIGNED wallet.adb.add_transaction(payment_tx) - self.assertEqual(TX_HEIGHT_LOCAL, wallet.adb.get_tx_height(payment_txid).height) + self.assertEqual(TX_HEIGHT_LOCAL, wallet.adb.get_tx_height(payment_txid).height()) self.assertEqual(1, len(wallet.get_spendable_coins(nonlocal_only=True))) self.assertEqual(2, len(wallet.get_spendable_coins(nonlocal_only=False))) # transition payment_tx to mempool (but it is still unsigned!) @@ -2937,13 +2937,13 @@ class TestWalletSending(ElectrumTestCase): # but the wallet db does not yet have the corresponding full tx. # In such cases, we instead want the txid to be considered LOCAL. wallet.adb.receive_tx_callback(payment_tx, tx_height=TX_HEIGHT_UNCONFIRMED) - self.assertEqual(TX_HEIGHT_LOCAL, wallet.adb.get_tx_height(payment_txid).height) + self.assertEqual(TX_HEIGHT_LOCAL, wallet.adb.get_tx_height(payment_txid).height()) self.assertEqual(1, len(wallet.get_spendable_coins(nonlocal_only=True))) self.assertEqual(2, len(wallet.get_spendable_coins(nonlocal_only=False))) # wallet gets signed tx (e.g. from network). payment_tx is now considered to be in mempool wallet.sign_transaction(payment_tx, password=None) wallet.adb.receive_tx_callback(payment_tx, tx_height=TX_HEIGHT_UNCONFIRMED) - self.assertEqual(TX_HEIGHT_UNCONFIRMED, wallet.adb.get_tx_height(payment_txid).height) + self.assertEqual(TX_HEIGHT_UNCONFIRMED, wallet.adb.get_tx_height(payment_txid).height()) self.assertEqual(2, len(wallet.get_spendable_coins(nonlocal_only=True))) self.assertEqual(2, len(wallet.get_spendable_coins(nonlocal_only=False))) @@ -2962,10 +2962,10 @@ class TestWalletSending(ElectrumTestCase): assert payment_txid # save payment_tx as LOCAL and UNSIGNED wallet.adb.add_transaction(payment_tx) - self.assertEqual(TX_HEIGHT_LOCAL, wallet.adb.get_tx_height(payment_txid).height) + self.assertEqual(TX_HEIGHT_LOCAL, wallet.adb.get_tx_height(payment_txid).height()) # mark payment_tx as future wallet.adb.set_future_tx(payment_txid, wanted_height=300) - self.assertEqual(TX_HEIGHT_FUTURE, wallet.adb.get_tx_height(payment_txid).height) + self.assertEqual(TX_HEIGHT_FUTURE, wallet.adb.get_tx_height(payment_txid).height()) @mock.patch.object(wallet.Abstract_Wallet, 'save_db') async def test_imported_wallet_usechange_off(self, mock_save_db): @@ -4458,7 +4458,7 @@ class TestWalletHistory_HelperFns(ElectrumTestCase): wallet1.adb.add_transaction(tx) # let's see if the calculated feerate correct: self.assertEqual((3, 'Local [26.3 sat/vB]'), - wallet1.get_tx_status(tx.txid(), TxMinedInfo(height=TX_HEIGHT_LOCAL, conf=0))) + wallet1.get_tx_status(tx.txid(), TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0))) @mock.patch.object(wallet.Abstract_Wallet, 'save_db') async def test_get_tx_status_feerate_for_local_2of3_multisig_signed_tx(self, mock_save_db): @@ -4481,7 +4481,7 @@ class TestWalletHistory_HelperFns(ElectrumTestCase): wallet1.adb.add_transaction(tx) # let's see if the calculated feerate correct: self.assertEqual((3, 'Local [26.3 sat/vB]'), - wallet1.get_tx_status(tx.txid(), TxMinedInfo(height=TX_HEIGHT_LOCAL, conf=0))) + wallet1.get_tx_status(tx.txid(), TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0))) class TestImportedWallet(ElectrumTestCase):