adb: change API of util.TxMinedInfo: height() is now always SPV-ed
What changed, and why it matters
This commit changes how Electrum decides whether a Bitcoin transaction is truly confirmed. Previously, parts of the code could treat a transaction as confirmed just because an Electrum server claimed it was mined at a certain block height, even before the wallet had independently verified that block header through SPV (Simple Payment Verification). Now, the wallet's central `TxMinedInfo.height()` method returns a real block height only if the transaction has also passed SPV verification; otherwise it reports the transaction as unconfirmed. This closes a class of bugs where a malicious or buggy Electrum server could trick the wallet into thinking a transaction was confirmed when it was not, which could affect spending decisions, Lightning channel state, swap refunds, and user-visible confirmation counts.
Treat this as a security-hardening fix. Review that all new `.height()` callers correctly handle the `0` (unconfirmed) return value for previously-mined-but-not-yet-SPV-ed transactions, especially in automated paths such as Lightning channel state transitions, submarine swap refunds, and tx batching. Ensure downstream release notes mention that unverified heights are no longer treated as confirmed. No immediate emergency response is warranted absent a disclosed exploit, but users running older versions should upgrade to avoid trusting a single Electrum server's height claims.
Security signals we found
Centralizes trust decision: unverified block heights are downgraded to unconfirmed unless SPV `conf >= 1` exists
Touches consensus-critical wallet logic: coin selection, channel state, swap refunds, and transaction replacement all depend on height
Removes direct `.height` reads in favor of `.height()`, reducing risk of accidental use of unverified data
Adds test changes showing the new behavior is expected (e.g., `add_verified_tx` with `conf=1` is now required for a tx to be considered confirmed)
No explicit CVE, advisory, or vendor security disclosure is present in the supplied materials
Evidence from the diff
The patch refactors util.TxMinedInfo from a NamedTuple to a frozen @dataclass and renames the raw height field to _height. It adds a height() method whose contract is: if _height > 0 but conf is None or < 1, return 0 (unconfirmed); otherwise return _height. This makes every caller that previously read .height now read .height(), effectively forcing SPV verification before a transaction is considered confirmed. Call sites across address_synchronizer.py, wallet.py, Lightning modules, submarine swaps, tx batching, GUI code, and tests are updated. The change is defensive: it prevents unverified server-reported heights from being treated as confirmed in history sorting, coin selection, channel funding/closing state, swap refund logic, RBF, and UI status strings.
Changed components
electrum/util.py (TxMinedInfo dataclass and height() method)electrum/address_synchronizer.py (get_tx_height, verification, conflict handling)electrum/wallet.py (tx status, coin selection, RBF, history)electrum/lnchannel.py (funding/closing height handling)electrum/lnwatcher.py / lnworker.py (force-close broadcast conditions)electrum/submarine_swaps.py (swap funding/spending height checks)electrum/txbatcher.py (sweep input eligibility)electrum/plugins/watchtower/watchtower.py (broadcast conditions)electrum/commands.py (remove transaction command)electrum/gui/qt and qml (history, tx dialog, UTXO dialog, tx details)electrum/wallet_db.py and verifier.py (verified tx storage and SPV verification)Inspect captured patch +105 / −87
diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py
index 1f28c28..f501f68 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 f5c4887..6ee60d1 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 c693ec4..c5cfdaf 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 0952258..c557d80 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 ee8474e..a18daa1 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 d8e708a..4f8345d 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 fa66c3c..f63837e 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 dbe9079..b6e1964 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 c86a284..8041eac 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 c4409cb..c66843e 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 dd39ebd..6bb08a8 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 5112b18..99468eb 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 7a0782c..3dcf159 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 748c1c8..b019b08 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 7205fce..ca74ec7 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 e997e9b..3760a1d 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 8f37430..f527d5d 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 d1f1a5c..2677802 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 c7a1b07..2afafe4 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 77ae701..b122506 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 c1c73dc..7edf0fe 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):
Why this scored 63/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.