1
0

Merge pull request #10131 from SomberNight/202508_commands_onchain_history

commands: add back from_height/to_height params to onchain_history
This commit is contained in:
ghost43
2025-08-15 13:36:25 +00:00
committed by GitHub
5 changed files with 3127 additions and 11 deletions

View File

@@ -36,7 +36,7 @@ import inspect
from collections import defaultdict from collections import defaultdict
from functools import wraps from functools import wraps
from decimal import Decimal, InvalidOperation 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 os
import re import re
@@ -1011,7 +1011,7 @@ class Commands(Logger):
await self.addtransaction(result, wallet=wallet) await self.addtransaction(result, wallet=wallet)
return result return result
def get_year_timestamps(self, year:int): def get_year_timestamps(self, year: int) -> dict[str, Any]:
kwargs = {} kwargs = {}
if year: if year:
start_date = datetime.datetime(year, 1, 1) start_date = datetime.datetime(year, 1, 1)
@@ -1071,21 +1071,26 @@ class Commands(Logger):
return new_tx.serialize() return new_tx.serialize()
@command('w') @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. """Wallet onchain history. Returns the transaction history of your wallet.
arg:bool:show_addresses:Show input and output addresses arg:bool:show_addresses:Show input and output addresses
arg:bool:show_fiat:Show fiat value of transactions 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: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 # trigger lnwatcher callbacks for their side effects: setting labels and accounting_addresses
if not self.network and wallet.lnworker: if not self.network and wallet.lnworker:
await wallet.lnworker.lnwatcher.trigger_callbacks(requires_synchronizer=False) 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 = self.get_year_timestamps(year)
kwargs['from_height'] = from_height
kwargs['to_height'] = to_height
onchain_history = wallet.get_onchain_history(**kwargs) onchain_history = wallet.get_onchain_history(**kwargs)
out = [x.to_dict() for x in onchain_history.values()] out = [x.to_dict() for x in onchain_history.values()]
if show_fiat: if show_fiat:

View File

@@ -325,7 +325,8 @@ class MyEncoder(json.JSONEncoder):
if isinstance(obj, Decimal): if isinstance(obj, Decimal):
return str(obj) return str(obj)
if isinstance(obj, datetime): 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): if isinstance(obj, set):
return list(obj) return list(obj)
if isinstance(obj, bytes): # for nametuples in lnchannel 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) 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]: def timestamp_to_datetime(timestamp: Union[int, float, None], *, utc: bool = False) -> Optional[datetime]:
if timestamp is None: if timestamp is None:
return None return None
tz = None tz = DEFAULT_TIMEZONE
if utc: if utc:
tz = timezone.utc tz = timezone.utc
return datetime.fromtimestamp(timestamp, tz=tz) return datetime.fromtimestamp(timestamp, tz=tz)

View File

@@ -1202,8 +1202,8 @@ class Abstract_Wallet(ABC, Logger, EventListener):
domain=None, domain=None,
from_timestamp=None, from_timestamp=None,
to_timestamp=None, to_timestamp=None,
from_height=None, from_height=None, # [from_height, to_height[
to_height=None to_height=None,
) -> Dict[str, OnchainHistoryItem]: ) -> Dict[str, OnchainHistoryItem]:
# sanity check # sanity check
if (from_timestamp is not None or to_timestamp is not None) \ 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 monotonic_timestamp = 0
for hist_item in self.adb.get_history(domain=domain): for hist_item in self.adb.get_history(domain=domain):
timestamp = (hist_item.tx_mined_status.timestamp or TX_TIMESTAMP_INF) 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: if from_timestamp and (timestamp or now) < from_timestamp:
continue continue
if to_timestamp and (timestamp or now) >= to_timestamp: if to_timestamp and (timestamp or now) >= to_timestamp:

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
import binascii import binascii
import datetime
import os.path
import unittest import unittest
from unittest import mock from unittest import mock
from decimal import Decimal from decimal import Decimal
from os import urandom from os import urandom
import shutil
import electrum
from electrum.commands import Commands, eval_bool from electrum.commands import Commands, eval_bool
from electrum import storage, wallet from electrum import storage, wallet
from electrum.lnworker import RecvMPPResolution from electrum.lnworker import RecvMPPResolution
@@ -14,6 +18,8 @@ from electrum.transaction import Transaction, TxOutput, tx_from_any
from electrum.util import UserFacingException, NotEnoughFunds from electrum.util import UserFacingException, NotEnoughFunds
from electrum.crypto import sha256 from electrum.crypto import sha256
from electrum.lnaddr import lndecode from electrum.lnaddr import lndecode
from electrum.daemon import Daemon
from electrum import json_db
from . import ElectrumTestCase from . import ElectrumTestCase
from . import restore_wallet_from_text__for_unittest from . import restore_wallet_from_text__for_unittest
@@ -171,6 +177,26 @@ class TestCommandsTestnet(ElectrumTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.config = SimpleConfig({'electrum_path': self.electrum_path}) self.config = SimpleConfig({'electrum_path': self.electrum_path})
self.config.NETWORK_OFFLINE = True
shutil.copytree(os.path.join(os.path.dirname(__file__), "fiat_fx_data"), os.path.join(self.electrum_path, "cache"))
self.config.FX_EXCHANGE = "BitFinex"
self.config.FX_CURRENCY = "EUR"
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): async def test_convert_xkey(self):
cmds = Commands(config=self.config) cmds = Commands(config=self.config)
@@ -533,3 +559,84 @@ class TestCommandsTestnet(ElectrumTestCase):
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
# cancelling a settled invoice should raise # cancelling a settled invoice should raise
await cmds.cancel_hold_invoice(payment_hash=payment_hash, wallet=wallet) 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)
with self.subTest(msg="'show_fiat' param"):
self.config.FX_USE_EXCHANGE_RATE = True
hist = await cmds.onchain_history(wallet_path=wallet_path, show_fiat=True)
self.assertEqual(len(hist), 89)
self.assertEqual(
hist[-1],
expected_last_history_item | {
"acquisition_price": "41.67",
"capital_gain": "-1.16",
"fiat_currency": "EUR",
"fiat_default": True,
"fiat_fee": "0.02",
"fiat_rate": "8097.91",
"fiat_value": "-40.51",
}
)