commands: add back from_height/to_height params to onchain_history
What changed, and why it matters
This commit restores two command-line options, from_height and to_height, to Electrum's onchain_history command. It also fixes a small bug where the wallet's internal history filtering was reading the wrong variable for block height, and tightens how dates are formatted to JSON. There is no direct security vulnerability here; it is mostly a bug-fix and feature-restoration change.
No immediate security action required. Reviewers may want to confirm that from_height/to_height are documented consistently and that the restored parameters behave as users expect, but the change appears to be a straightforward bug fix and feature restoration.
Security signals we found
Bug fix: wallet.get_onchain_history previously compared a TxMinedStatus object against integer heights, which would have silently broken height-based filtering
No input validation changes for from_height/to_height beyond existing sanity check that blocks mixing timestamp and height filters
Date serialization change removes brittle string slicing and makes timezone offset behavior explicit, though commit message notes timezone ambiguity remains
Evidence from the diff
The patch re-exposes from_height/to_height parameters in Commands.onchain_history, passes them to wallet.get_onchain_history, and fixes a typo in wallet.py where height was assigned the full TxMinedStatus object instead of .height. It also changes MyEncoder to use datetime.isoformat(timespec=’minutes’) and introduces a DEFAULT_TIMEZONE constant used by timestamp_to_datetime. Tests are added covering the restored parameters and date formatting.
Changed components
electrum/commands.pyelectrum/wallet.pyelectrum/util.pytests/test_commands.pyInspect captured patch +105 / −11
diff --git a/electrum/commands.py b/electrum/commands.py
index 40da49d..2a004db 100644
--- a/electrum/commands.py
+++ b/electrum/commands.py
@@ -36,7 +36,7 @@ import inspect
from collections import defaultdict
from functools import wraps
from decimal import Decimal, InvalidOperation
-from typing import Optional, TYPE_CHECKING, Dict, List
+from typing import Optional, TYPE_CHECKING, Dict, List, Any
import os
import re
@@ -1011,7 +1011,7 @@ class Commands(Logger):
await self.addtransaction(result, wallet=wallet)
return result
- def get_year_timestamps(self, year:int):
+ def get_year_timestamps(self, year: int) -> dict[str, Any]:
kwargs = {}
if year:
start_date = datetime.datetime(year, 1, 1)
@@ -1071,21 +1071,26 @@ class Commands(Logger):
return new_tx.serialize()
@command('w')
- async def onchain_history(self, show_fiat=False, year=None, show_addresses=False, wallet: Abstract_Wallet = None):
+ async def onchain_history(
+ self, show_fiat=False, year=None, show_addresses=False,
+ from_height=None, to_height=None,
+ wallet: Abstract_Wallet = None,
+ ):
"""Wallet onchain history. Returns the transaction history of your wallet.
arg:bool:show_addresses:Show input and output addresses
arg:bool:show_fiat:Show fiat value of transactions
- arg:bool:show_fees:Show miner fees paid by transactions
arg:int:year:Show history for a given year
+ arg:int:from_height:Only show transactions that confirmed after(inclusive) given block height
+ arg:int:to_height:Only show transactions that confirmed before(exclusive) given block height
"""
# trigger lnwatcher callbacks for their side effects: setting labels and accounting_addresses
if not self.network and wallet.lnworker:
await wallet.lnworker.lnwatcher.trigger_callbacks(requires_synchronizer=False)
- #'from_height': (None, "Only show transactions that confirmed after given block height"),
- #'to_height': (None, "Only show transactions that confirmed before given block height"),
kwargs = self.get_year_timestamps(year)
+ kwargs['from_height'] = from_height
+ kwargs['to_height'] = to_height
onchain_history = wallet.get_onchain_history(**kwargs)
out = [x.to_dict() for x in onchain_history.values()]
if show_fiat:
diff --git a/electrum/util.py b/electrum/util.py
index d266a6a..0105ef3 100644
--- a/electrum/util.py
+++ b/electrum/util.py
@@ -325,7 +325,8 @@ class MyEncoder(json.JSONEncoder):
if isinstance(obj, Decimal):
return str(obj)
if isinstance(obj, datetime):
- return obj.isoformat(' ')[:-3]
+ # note: if there is a timezone specified, this will include the offset
+ return obj.isoformat(' ', timespec="minutes")
if isinstance(obj, set):
return list(obj)
if isinstance(obj, bytes): # for nametuples in lnchannel
@@ -893,10 +894,11 @@ def quantize_feerate(fee) -> Union[None, Decimal, int]:
return Decimal(fee).quantize(_feerate_quanta, rounding=decimal.ROUND_HALF_DOWN)
+DEFAULT_TIMEZONE = None # type: timezone | None # None means local OS timezone
def timestamp_to_datetime(timestamp: Union[int, float, None], *, utc: bool = False) -> Optional[datetime]:
if timestamp is None:
return None
- tz = None
+ tz = DEFAULT_TIMEZONE
if utc:
tz = timezone.utc
return datetime.fromtimestamp(timestamp, tz=tz)
diff --git a/electrum/wallet.py b/electrum/wallet.py
index cf68667..d203c24 100644
--- a/electrum/wallet.py
+++ b/electrum/wallet.py
@@ -1202,8 +1202,8 @@ class Abstract_Wallet(ABC, Logger, EventListener):
domain=None,
from_timestamp=None,
to_timestamp=None,
- from_height=None,
- to_height=None
+ from_height=None, # [from_height, to_height[
+ to_height=None,
) -> Dict[str, OnchainHistoryItem]:
# sanity check
if (from_timestamp is not None or to_timestamp is not None) \
@@ -1220,7 +1220,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 = 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:
diff --git a/tests/test_commands.py b/tests/test_commands.py
index c312daa..ac7b515 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -1,9 +1,12 @@
import binascii
+import datetime
+import os.path
import unittest
from unittest import mock
from decimal import Decimal
from os import urandom
+import electrum
from electrum.commands import Commands, eval_bool
from electrum import storage, wallet
from electrum.lnworker import RecvMPPResolution
@@ -14,6 +17,8 @@ from electrum.transaction import Transaction, TxOutput, tx_from_any
from electrum.util import UserFacingException, NotEnoughFunds
from electrum.crypto import sha256
from electrum.lnaddr import lndecode
+from electrum.daemon import Daemon
+from electrum import json_db
from . import ElectrumTestCase
from . import restore_wallet_from_text__for_unittest
@@ -171,6 +176,23 @@ class TestCommandsTestnet(ElectrumTestCase):
def setUp(self):
super().setUp()
self.config = SimpleConfig({'electrum_path': self.electrum_path})
+ self.config.NETWORK_OFFLINE = True
+ self._default_default_timezone = electrum.util.DEFAULT_TIMEZONE
+ electrum.util.DEFAULT_TIMEZONE = datetime.timezone.utc
+
+ def tearDown(self):
+ electrum.util.DEFAULT_TIMEZONE = self._default_default_timezone
+ super().tearDown()
+
+ async def asyncSetUp(self):
+ await super().asyncSetUp()
+ self.daemon = Daemon(config=self.config, listen_jsonrpc=False)
+ assert self.daemon.network is None
+
+ async def asyncTearDown(self):
+ with mock.patch.object(wallet.Abstract_Wallet, 'save_db'):
+ await self.daemon.stop()
+ await super().asyncTearDown()
async def test_convert_xkey(self):
cmds = Commands(config=self.config)
@@ -533,3 +555,68 @@ class TestCommandsTestnet(ElectrumTestCase):
with self.assertRaises(AssertionError):
# cancelling a settled invoice should raise
await cmds.cancel_hold_invoice(payment_hash=payment_hash, wallet=wallet)
+
+ @mock.patch.object(storage.WalletStorage, 'write')
+ @mock.patch.object(storage.WalletStorage, 'append')
+ async def test_onchain_history(self, *mock_args):
+ cmds = Commands(config=self.config, daemon=self.daemon)
+ WALLET_FILES_DIR = os.path.join(os.path.dirname(__file__), "test_storage_upgrade")
+ wallet_path = os.path.join(WALLET_FILES_DIR, "client_3_3_8_xpub_with_realistic_history")
+ await cmds.load_wallet(wallet_path=wallet_path)
+
+ expected_last_history_item = {
+ "amount_sat": -500200,
+ "bc_balance": "0.75136687",
+ "bc_value": "-0.005002",
+ "confirmations": 968,
+ "date": "2020-07-02 11:57+00:00", # kind of a hack. normally, there is no timezone offset here
+ "fee_sat": 200,
+ "group_id": None,
+ "height": 1774910,
+ "incoming": False,
+ "label": "",
+ "monotonic_timestamp": 1593691025,
+ "timestamp": 1593691025,
+ "txid": "6db8ee1bf57bb6ff1c4447749079ba1bd5e47a948bf5700b114b37af3437b5fc",
+ "txpos_in_block": 44,
+ "wanted_height": None,
+ }
+
+ hist = await cmds.onchain_history(wallet_path=wallet_path)
+ self.assertEqual(len(hist), 89)
+ self.assertEqual(hist[-1], expected_last_history_item)
+
+ with self.subTest(msg="'show_addresses' param"):
+ hist = await cmds.onchain_history(wallet_path=wallet_path, show_addresses=True)
+ self.assertEqual(len(hist), 89)
+ self.assertEqual(
+ hist[-1],
+ expected_last_history_item | {
+ 'inputs': [
+ {
+ 'coinbase': False,
+ 'nsequence': 4294967293,
+ 'prevout_hash': 'd42f6de015d93e6cd573ec8ae5ef6f87c4deb3763b0310e006d26c30d8800c67',
+ 'prevout_n': 0,
+ 'scriptSig': '',
+ 'witness': [
+ '3044022056e0a02c45b5e4f93dc533c7f3fa95296684b0f41019ae91b5b7b083a5b651c202200a0e0c56bdfa299f4af8c604d359033863c9ce0a7fdd35acfbda5cff4a6ffa3301',
+ '02eba8ba71542a884f2eec1f40594192be2628268f9fa141c9b12b026008dbb274'
+ ]
+ }
+ ],
+ 'outputs': [
+ {'address': 'tb1qr5mf6sumdlhjrq9t6wlyvdm960zu0n0t5d60ug', 'value_sat': 500000},
+ {'address': 'tb1qp3p2d72gj2l7r6za056tgu4ezsurjphper4swh', 'value_sat': 762100}
+ ],
+ }
+ )
+ with self.subTest(msg="'from_height' / 'to_height' params"):
+ hist = await cmds.onchain_history(wallet_path=wallet_path, from_height=1638866, to_height=1665815)
+ self.assertEqual(len(hist), 8)
+ with self.subTest(msg="'year' param"):
+ hist = await cmds.onchain_history(wallet_path=wallet_path, year=2019)
+ self.assertEqual(len(hist), 23)
+ with self.subTest(msg="timestamp and block height based filtering cannot be used together"):
+ with self.assertRaises(UserFacingException):
+ hist = await cmds.onchain_history(wallet_path=wallet_path, year=2019, from_height=1638866, to_height=1665815)
Why this scored 17/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.