From bbc326db75118f32b3f4ea9dcaf930f77f0463d8 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Wed, 19 Aug 2026 12:14:49 +0200 Subject: [PATCH 1/9] Fix MoneroRpcConnection.send_path_request --- src/cpp/common/py_monero_common_bindings.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cpp/common/py_monero_common_bindings.cpp b/src/cpp/common/py_monero_common_bindings.cpp index 7a556b0..f79d9e7 100644 --- a/src/cpp/common/py_monero_common_bindings.cpp +++ b/src/cpp/common/py_monero_common_bindings.cpp @@ -322,7 +322,7 @@ void py_monero_bind_common(py::module_& m, PyMoneroTypes& t) { return res; }, py::arg("method"), py::arg("parameters") = py::none()) .def("send_path_request", [](monero_rpc_connection& self, const std::string &method, const boost::optional& parameters) { - monero_rpc_request request(method, std::make_shared(parameters)); + monero_rpc_request request(method, std::make_shared(parameters), false); auto response = self.send_path_request(request); boost::optional res; if (response.m_response != boost::none) res = PyGenUtils::ptree_to_pyobject(*response.m_response); From edac82021a94c638a583714cc724af0f37f83821 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Wed, 19 Aug 2026 12:16:28 +0200 Subject: [PATCH 2/9] Fix PyMoneroRequestParams::to_rapidjson_val --- src/cpp/common/py_monero_common.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cpp/common/py_monero_common.cpp b/src/cpp/common/py_monero_common.cpp index 6b64687..80b7468 100644 --- a/src/cpp/common/py_monero_common.cpp +++ b/src/cpp/common/py_monero_common.cpp @@ -127,7 +127,7 @@ rapidjson::Value PyMoneroRequestParams::to_rapidjson_val(rapidjson::Document::Al std::string json = PyGenUtils::serialize(m_py_params.get()); rapidjson::Document doc; doc.Parse(json.c_str()); - root.Swap(doc); + root.CopyFrom(doc, allocator); return root; } From 947ae9fc2560c230132f5cff879e94a10b4cc3a6 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Wed, 19 Aug 2026 15:29:25 +0200 Subject: [PATCH 3/9] Fix not supported conftest.py --- conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index 07d43e2..4d0987c 100644 --- a/conftest.py +++ b/conftest.py @@ -18,9 +18,9 @@ def pytest_runtest_call(item: pytest.Item): try: # run test item.runtest() - except RuntimeError as e: + except Exception as e: e_str = str(e).lower() - if "not supported" in e_str or "does not support" in e_str: + if "not supported" in e_str or "does not support" in e_str or "doesn't support" in e_str: # Ok pytest.xfail(str(e)) if not_implemented and "not implemented" in e_str: From 971c2cbd8e8d087d58f2d456c1ddb87c97aab814 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Wed, 19 Aug 2026 16:10:50 +0200 Subject: [PATCH 4/9] Improve file logging --- conftest.py | 14 ++++++++++++++ pytest.ini | 7 ++++++- tests/utils/gen_utils.py | 10 ++++++++++ tests/utils/test_utils.py | 2 +- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index 4d0987c..1a4f89b 100644 --- a/conftest.py +++ b/conftest.py @@ -1,5 +1,19 @@ import pytest +from os.path import splitext +from tests.utils.gen_utils import GenUtils + + +def pytest_configure(config: pytest.Config) -> None: + # inject current date/time into the configured log file name + log_file: str = config.getini("log_file") # type: ignore + + if not log_file: + return + + name, ext = splitext(log_file) + config.option.log_file = f"{name}_{GenUtils.current_date_time_str()}{ext}" + def pytest_runtest_call(item: pytest.Item): # get not_supported marker diff --git a/pytest.ini b/pytest.ini index 71b3681..752cff6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,10 +1,15 @@ [pytest] minversion = 6.0 -addopts = -v --reruns 5 --reruns-delay 10 --only-rerun "BUSY" +required_plugins = pytest-rerunfailures pytest-timeout pytest-cov +addopts = -s -v --reruns 5 --reruns-delay 10 --only-rerun "BUSY" log_level = INFO log_cli = True log_cli_level = INFO console_output_style = progress +log_file = monero_tests_python.log +log_file_level = DEBUG +log_file_format = %(asctime)s %(levelname)-8s %(name)s:%(lineno)s %(message)s +log_file_date_format = %Y-%m-%d %H:%M:%S testpaths = tests markers = diff --git a/tests/utils/gen_utils.py b/tests/utils/gen_utils.py index f78832e..f6537b5 100644 --- a/tests/utils/gen_utils.py +++ b/tests/utils/gen_utils.py @@ -1,6 +1,7 @@ from typing import Union, Any, Optional from abc import ABC from time import sleep, time +from datetime import datetime from os import makedirs from os.path import exists as path_exists @@ -62,6 +63,15 @@ def current_timestamp_str(cls) -> str: """ return f"{cls.current_timestamp()}" + @classmethod + def current_date_time_str(cls, fmt: str = "%Y-%m-%d_%H-%M-%S") -> str: + """Gets current date and time formatted as string, e.g. "2026-08-19_15-50-25". + + :param str fmt: strftime format to use. + :returns str: current date and time formatted as string. + """ + return datetime.now().strftime(fmt) + @classmethod def has_key(cls, key: Optional[str], dictionary: dict[str, Any]) -> bool: assert key is not None, "Key is None" diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index e17007b..91f29eb 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -258,7 +258,7 @@ def load(cls) -> None: @classmethod def configure_logging(cls) -> None: """Configure internal Monero core logging.""" - MoneroUtils.configure_logging(f"monero_tests_{GenUtils.current_timestamp_str()}.log", False) + MoneroUtils.configure_logging(f"monero_tests_cpp_{GenUtils.current_date_time_str()}.log", False) MoneroUtils.set_log_level(cls.LOG_LEVEL) @classmethod From e04e32ddbd6af7e9c9c9bcacc38793c61fa866cc Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Wed, 19 Aug 2026 16:19:49 +0200 Subject: [PATCH 5/9] Minor fixes * Add generate blocks util * Fund wallet also with a generated block * Fix ViewOnlyAndOfflineWalletTester.test() * Fix WalletTestUtils.fund() --- tests/test_monero_wallet_common.py | 66 ++++++++++--------- tests/utils/integration_test_utils.py | 33 ++++++---- tests/utils/mining_utils.py | 14 +++- .../view_only_and_offline_wallet_tester.py | 2 +- tests/utils/wallet_test_utils.py | 3 +- 5 files changed, 71 insertions(+), 47 deletions(-) diff --git a/tests/test_monero_wallet_common.py b/tests/test_monero_wallet_common.py index a113df7..928c2aa 100644 --- a/tests/test_monero_wallet_common.py +++ b/tests/test_monero_wallet_common.py @@ -51,6 +51,11 @@ def get_wallet_type(cls) -> WalletType: """Wallet type to test.""" return WalletType.UNDEFINED + @classmethod + def supports_save(cls) -> bool: + wallet_type: WalletType = cls.get_wallet_type() + return wallet_type == WalletType.FULL or wallet_type == WalletType.RPC + class Config: """Wallet test configuration.""" @@ -203,7 +208,7 @@ def after_all(self) -> None: # close wallet wallet = self.get_test_wallet() - wallet.close(True) + wallet.close(self.supports_save()) # Before each test @override @@ -462,13 +467,13 @@ def test_send_to_external(self, wallet: MoneroWallet) -> None: expected_balance = balance1 - tx.get_outgoing_amount() - tx.fee assert expected_balance == balance2, "Balance after send was not balance before - net tx amount - fee (5 - 1 != 4 test)" - # test recipient balance after - recipient.sync() tx_query: MoneroTxQuery = MoneroTxQuery() tx_query.is_confirmed = False txs = wallet.get_txs(tx_query) - assert len(txs) > 0 + + # test recipient balance after + recipient.sync() assert amount == recipient.get_balance() finally: @@ -3610,32 +3615,33 @@ def test_prove_unrelayed_txs(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet # create random wallet to verify transfers verifying_wallet: MoneroWallet = self._create_wallet(MoneroWalletConfig()) - # verify transfer 1 - check: MoneroCheckTx = verifying_wallet.check_tx_key(tx.hash, tx.key, address1) - assert check.is_good - assert check.in_tx_pool is True - assert check.num_confirmations == 0 - assert check.received_amount == TxWalletUtils.MAX_FEE - - # verify transfer 2 - check = verifying_wallet.check_tx_key(tx.hash, tx.key, address2) - assert check.is_good - assert check.in_tx_pool is True - assert check.num_confirmations == 0 - # + change amount - assert check.received_amount is not None - assert check.received_amount >= TxWalletUtils.MAX_FEE * 2 - - # verify transfer 3 - check = verifying_wallet.check_tx_key(tx.hash, tx.key, address3) - assert check.is_good - assert check.in_tx_pool is True - assert check.num_confirmations == 0 - assert TxWalletUtils.MAX_FEE * 3 == check.received_amount - - # cleanup - daemon.flush_tx_pool(tx.hash) - self._close_wallet(verifying_wallet) + try: + # verify transfer 1 + check: MoneroCheckTx = verifying_wallet.check_tx_key(tx.hash, tx.key, address1) + assert check.is_good + assert check.in_tx_pool is True + assert check.num_confirmations == 0 + assert check.received_amount == TxWalletUtils.MAX_FEE + + # verify transfer 2 + check = verifying_wallet.check_tx_key(tx.hash, tx.key, address2) + assert check.is_good + assert check.in_tx_pool is True + assert check.num_confirmations == 0 + # + change amount + assert check.received_amount is not None + assert check.received_amount >= TxWalletUtils.MAX_FEE * 2 + + # verify transfer 3 + check = verifying_wallet.check_tx_key(tx.hash, tx.key, address3) + assert check.is_good + assert check.in_tx_pool is True + assert check.num_confirmations == 0 + assert TxWalletUtils.MAX_FEE * 3 == check.received_amount + finally: + # cleanup, otherwise it permanently ties up the dest outputs for entire session + daemon.flush_tx_pool(tx.hash) + self._close_wallet(verifying_wallet) # Can get the default fee priority @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") diff --git a/tests/utils/integration_test_utils.py b/tests/utils/integration_test_utils.py index 9c4ad0a..e78314d 100644 --- a/tests/utils/integration_test_utils.py +++ b/tests/utils/integration_test_utils.py @@ -2,9 +2,10 @@ from abc import ABC from time import sleep -from monero import MoneroWallet, MoneroTxWallet, MoneroTxQuery +from monero import MoneroWallet, MoneroTxWallet, MoneroTxQuery, MoneroSyncResult from .wallet_test_utils import WalletTestUtils +from .mining_utils import MiningUtils from .blockchain_utils import BlockchainUtils from .wallet_type import WalletType from .test_utils import TestUtils @@ -41,23 +42,21 @@ def setup(cls, wallet_type: WalletType) -> None: wallet = TestUtils.get_wallet_rpc() type_str = "RPC" else: - logger.warning("Only RPC and FULL wallet are supported for integration tests") - return + raise ValueError("Only RPC and FULL wallet are supported for integration tests") wallet_txs: list[MoneroTxWallet] = wallet.get_txs() num_wallet_txs: int = len(wallet_txs) # fund wallet with mined coins and wait for unlocked balance txs = cls.fund_wallet_and_wait_for_unlocked(wallet) - # setup regtest first receive height - if TestUtils.REGTEST: - tx: MoneroTxWallet = txs[0] if num_wallet_txs == 0 else wallet_txs[0] - tx_height: int | None = tx.get_height() - assert tx_height is not None - TestUtils.FIRST_RECEIVE_HEIGHT = tx_height - logger.debug(f"Set FIRST_RECEIVE_HEIGHT = {tx_height}") + # setup first receive height + tx: MoneroTxWallet = txs[0] if num_wallet_txs == 0 else wallet_txs[0] + tx_height: int | None = tx.get_height() + assert tx_height is not None + TestUtils.FIRST_RECEIVE_HEIGHT = tx_height + logger.debug(f"Test wallet first receive height: {tx_height}") - if num_wallet_txs == 0: + if num_wallet_txs < len(txs): logger.info(f"Funded test wallet {type_str}") @classmethod @@ -70,12 +69,18 @@ def fund_wallet_and_wait_for_unlocked(cls, wallet: MoneroWallet) -> list[MoneroT # fund wallet txs: list[MoneroTxWallet] = WalletTestUtils.fund_wallet(wallet) if len(txs) > 0: + # mine an output to wallet primary address + MiningUtils.generate_blocks(wallet.get_primary_address(), 1) # mine blocks to confirm txs block_height: int = BlockchainUtils.wait_for_blocks(11) # sync wallet while wallet.get_height() < block_height: - wallet.sync() + sync_result: MoneroSyncResult = wallet.sync() + assert sync_result.num_blocks_fetched is not None + if sync_result.num_blocks_fetched > 0: + logger.debug(f"Sync result from funded wallet: {sync_result.serialize()}") + sleep(TestUtils.SYNC_PERIOD_IN_MS / 1000) # check for txs @@ -87,10 +92,10 @@ def fund_wallet_and_wait_for_unlocked(cls, wallet: MoneroWallet) -> list[MoneroT num_txs: int = len(txs) txs = wallet.get_txs(query) - assert len(txs) == num_txs + assert len(txs) == num_txs, f"Expected {num_txs} txs, but got {len(txs)}" # assert txs are unlocked for tx in txs: - assert tx.is_locked is False + assert tx.is_locked is False, f"Expected tx to be unlocked: {tx.serialize()}" return txs diff --git a/tests/utils/mining_utils.py b/tests/utils/mining_utils.py index 718752d..bfc53a6 100644 --- a/tests/utils/mining_utils.py +++ b/tests/utils/mining_utils.py @@ -1,6 +1,6 @@ import logging -from monero import MoneroDaemonRpc +from monero import MoneroDaemonRpc, MoneroGenerateBlocksResult from .test_utils import TestUtils as Utils @@ -21,6 +21,18 @@ def get_daemon(cls) -> MoneroDaemonRpc: """ return Utils.get_mining_daemon() + @classmethod + def generate_blocks(cls, address: str, num_blocks: int, d: MoneroDaemonRpc | None = None) -> MoneroGenerateBlocksResult: + """Generate blocks to a wallet address (regtest only). + + :param str address: is the address of the wallet to receive miner transactions if block is successfully mined. + :param int num_blocks: is the number of blocks to generate. + :returns MoneroGenerateBlocksResult: the result of generating blocks; height is the height of the last block generated. + """ + assert Utils.REGTEST, "Generating blocks is supported only on regtest." + daemon: MoneroDaemonRpc = cls.get_daemon() if d is None else d + return daemon.generate_blocks(address, num_blocks) + @classmethod def is_mining(cls, d: MoneroDaemonRpc | None = None) -> bool: """Check if mining is enabled. diff --git a/tests/utils/view_only_and_offline_wallet_tester.py b/tests/utils/view_only_and_offline_wallet_tester.py index 70dd3e8..21d3316 100644 --- a/tests/utils/view_only_and_offline_wallet_tester.py +++ b/tests/utils/view_only_and_offline_wallet_tester.py @@ -156,4 +156,4 @@ def test(self) -> None: assert len(tx_hashes) == 1 assert len(tx_hashes[0]) == 64 # wait for confirmation for other tests - TestUtils.WALLET_TX_TRACKER.wait_for_txs_to_clear_pool(self._view_only_wallet) + TestUtils.WALLET_TX_TRACKER.wait_for_txs_to_clear_pool([self._wallet, self._view_only_wallet]) diff --git a/tests/utils/wallet_test_utils.py b/tests/utils/wallet_test_utils.py index a2c0445..8688228 100644 --- a/tests/utils/wallet_test_utils.py +++ b/tests/utils/wallet_test_utils.py @@ -166,6 +166,7 @@ def fund_wallet( logger.debug(f"Funding wallet {primary_addr} with {amount_required_str}...") supports_get_accounts: bool = isinstance(wallet, MoneroWalletRpc) or isinstance(wallet, MoneroWalletFull) + supports_save: bool = isinstance(wallet, MoneroWalletRpc) or isinstance(wallet, MoneroWalletFull) tx_config: MoneroTxConfig = cls.build_tx_config(wallet, num_accounts, num_subaddresses, amount_per_address, supports_get_accounts) @@ -184,7 +185,7 @@ def fund_wallet( sent_amount_xmr_str: str = f"{MoneroUtils.atomic_units_to_xmr(txs_amount)} XMR" - if supports_get_accounts: + if supports_save: wallet.save() logger.debug(f"Funded test wallet {primary_addr} with {sent_amount_xmr_str} in {len(txs)} txs") From 38bff5a6ecff20e1ea4a98b1b77a85e6e6eb0e3d Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Wed, 19 Aug 2026 16:23:31 +0200 Subject: [PATCH 6/9] Debug and comestic improvements --- bin/cleanup_test_environment.sh | 1 + tests/test_monero_common.py | 2 +- tests/test_monero_daemon_rpc.py | 2 +- tests/test_monero_utils.py | 27 +++++++++-------- tests/test_monero_wallet_common.py | 35 +++++++++-------------- tests/test_monero_wallet_full.py | 8 +++--- tests/test_monero_wallet_rpc.py | 2 +- tests/utils/address_book.py | 1 + tests/utils/base_test_class.py | 6 ++-- tests/utils/send_and_update_txs_tester.py | 4 +-- tests/utils/test_utils.py | 2 +- tests/utils/tx_wallet_utils.py | 6 ++-- tests/utils/wallet_equality_utils.py | 4 ++- tests/utils/wallet_tx_tracker.py | 6 ++-- 14 files changed, 50 insertions(+), 56 deletions(-) diff --git a/bin/cleanup_test_environment.sh b/bin/cleanup_test_environment.sh index 335b577..dd45abb 100755 --- a/bin/cleanup_test_environment.sh +++ b/bin/cleanup_test_environment.sh @@ -3,3 +3,4 @@ # remove docker containers sudo docker compose -f tests/docker-compose.yml down -v rm -rf test_wallets +rm monero_tests_* \ No newline at end of file diff --git a/tests/test_monero_common.py b/tests/test_monero_common.py index 066451f..df8067e 100644 --- a/tests/test_monero_common.py +++ b/tests/test_monero_common.py @@ -44,7 +44,7 @@ def test_ssl_options(self) -> None: ssl_options.ssl_ca_file = "ca_file" ssl_options.ssl_certificate_path = "certificate_path" ssl_options.ssl_private_key_path = "private_key_path" - logger.info(f"Testing ssl options: {ssl_options.serialize()}") + logger.debug(f"Testing ssl options: {ssl_options.serialize()}") obj: dict[str, str] = loads(ssl_options.serialize()) assert obj['sslAllowAnyCert'] == ssl_options.ssl_allow_any_cert assert obj['sslCaFile'] == ssl_options.ssl_ca_file diff --git a/tests/test_monero_daemon_rpc.py b/tests/test_monero_daemon_rpc.py index dd6d220..c30b737 100644 --- a/tests/test_monero_daemon_rpc.py +++ b/tests/test_monero_daemon_rpc.py @@ -39,7 +39,7 @@ class TestMoneroDaemonRpc(BaseTestClass): #region Fixtures @override - def before_all(self): + def before_all(self) -> None: # setup wallet rpc for tests IntegrationTestUtils.setup(WalletType.RPC) diff --git a/tests/test_monero_utils.py b/tests/test_monero_utils.py index 488f93a..6e80809 100644 --- a/tests/test_monero_utils.py +++ b/tests/test_monero_utils.py @@ -53,7 +53,7 @@ def parse(cls, parser: ConfigParser) -> TestMoneroUtils.Config: @pytest.fixture(scope="class") def config(self) -> TestMoneroUtils.Config: - parser = ConfigParser() + parser: ConfigParser = ConfigParser() parser.read('tests/config/test_monero_utils.ini') return TestMoneroUtils.Config.parse(parser) @@ -100,11 +100,9 @@ def test_serialize_heights_small(self) -> None: } binary: bytes = MoneroUtils.dict_to_binary(json_map) - assert len(binary) > 0 json_map2: dict[Any, Any] = MoneroUtils.binary_to_dict(binary) - assert json_map == json_map2 # Can serialize heights with big numbers @@ -115,8 +113,8 @@ def test_serialize_heights_big(self) -> None: binary: bytes = MoneroUtils.dict_to_binary(json_map) assert len(binary) > 0 - json_map2: dict[Any, Any] = MoneroUtils.binary_to_dict(binary) + json_map2: dict[Any, Any] = MoneroUtils.binary_to_dict(binary) assert json_map == json_map2 # can serialize height with large unsigned values @@ -127,6 +125,7 @@ def test_serialize_large_unsigned_values(self) -> None: } binary: bytes = MoneroUtils.dict_to_binary(json_map) assert len(binary) > 0 + json_map2: dict[Any, Any] = MoneroUtils.binary_to_dict(binary) assert json_map == json_map2 assert json_map2["heights"][0] > 0, "uint64 > INT64_MAX must not serialize as negative" @@ -140,8 +139,8 @@ def test_serialize_text_short(self, config: TestMoneroUtils.Config) -> None: binary: bytes = MoneroUtils.dict_to_binary(json_map) assert len(binary) > 0 - json_map2: dict[Any, Any] = MoneroUtils.binary_to_dict(binary) + json_map2: dict[Any, Any] = MoneroUtils.binary_to_dict(binary) assert json_map == json_map2 # Can serialize json with long text @@ -166,8 +165,8 @@ def test_serialize_text_long(self, config: TestMoneroUtils.Config) -> None: binary: bytes = MoneroUtils.dict_to_binary(json_map) assert len(binary) > 0 - json_map2: dict[Any, Any] = MoneroUtils.binary_to_dict(binary) + json_map2: dict[Any, Any] = MoneroUtils.binary_to_dict(binary) assert json_map == json_map2 # Can validate addresses @@ -349,16 +348,16 @@ def test_atomic_unit_conversion(self) -> None: # Can get payment uri def test_get_payment_uri(self, config: TestMoneroUtils.Config) -> None: - address = config.mainnet.primary_address_1 + address: str = config.mainnet.primary_address_1 tx_config: MoneroTxConfig = WalletUtils.build_payment_uri_config(address) - payment_uri = MoneroUtils.get_payment_uri(tx_config) + payment_uri: str = MoneroUtils.get_payment_uri(tx_config) + query: str = "tx_amount=0.250000000000&recipient_name=John%20Doe&tx_description=My%20transfer%20to%20wallet" logger.debug(f"Testing payment uri: {payment_uri}") - query = "tx_amount=0.250000000000&recipient_name=John%20Doe&tx_description=My%20transfer%20to%20wallet" assert payment_uri == f"monero:{address}?{query}" # Test invalid payment uri address network type def test_payment_uri_invalid_network_type(self, config: TestMoneroUtils.Config) -> None: - address = config.testnet.primary_address_1 + address: str = config.testnet.primary_address_1 tx_config: MoneroTxConfig = WalletUtils.build_payment_uri_config(address) try: MoneroUtils.get_payment_uri(tx_config) @@ -368,7 +367,7 @@ def test_payment_uri_invalid_network_type(self, config: TestMoneroUtils.Config) # Test deprecated standalone payment id def test_payment_uri_deprecated_payment_uri(self, config: TestMoneroUtils.Config) -> None: - address = config.testnet.primary_address_1 + address: str = config.testnet.primary_address_1 tx_config: MoneroTxConfig = WalletUtils.build_payment_uri_config(address) tx_config.payment_id = "03284e41c342f03603284e41c342f03603284e41c342f03603284e41c342f036" try: @@ -379,14 +378,14 @@ def test_payment_uri_deprecated_payment_uri(self, config: TestMoneroUtils.Config # Can get version def test_get_version(self) -> None: - version = MoneroUtils.get_version() + version: str = MoneroUtils.get_version() logger.debug(f"Testing monero-python version: {version}") assert version != "", "Version is empty" # Can get ring size def test_get_ring_size(self) -> None: - size = MoneroUtils.get_ring_size() - # TODO why 12? + size: int = MoneroUtils.get_ring_size() + # TODO monero-cpp update ring size to 16 assert size == 12 #endregion diff --git a/tests/test_monero_wallet_common.py b/tests/test_monero_wallet_common.py index 928c2aa..c6529f7 100644 --- a/tests/test_monero_wallet_common.py +++ b/tests/test_monero_wallet_common.py @@ -18,22 +18,16 @@ MoneroOutputQuery, MoneroTransfer, MoneroIncomingTransfer, MoneroOutgoingTransfer, MoneroTxWallet, MoneroOutputWallet, MoneroTx, MoneroAccount, MoneroSubaddress, MoneroMessageSignatureType, MoneroTxPriority, MoneroFeeEstimate, - MoneroIntegratedAddress, MoneroCheckTx, MoneroCheckReserve, - MoneroAddressBookEntry, MoneroSubmitTxResult, MoneroAccountTag, - MoneroKeyImageExportResult + MoneroIntegratedAddress, MoneroCheckTx, MoneroCheckReserve, MoneroAddressBookEntry, + MoneroSubmitTxResult, MoneroAccountTag, MoneroKeyImageExportResult ) from utils import ( - MultisigSampleCodeTester, - TestUtils, WalletEqualityUtils, - StringUtils, AssertUtils, - TxContext, GenUtils, WalletUtils, - WalletType, IntegrationTestUtils, - ViewOnlyAndOfflineWalletTester, - WalletNotificationCollector, - MiningUtils, BaseTestClass, - OutputUtils, TxWalletUtils, TransferUtils, - WalletTxsUtils, WalletTransfersUtils, - WalletErrorUtils, WalletSendUtils, WalletTestUtils + MultisigSampleCodeTester, TestUtils, WalletEqualityUtils, + StringUtils, AssertUtils, TxContext, GenUtils, WalletUtils, + WalletType, IntegrationTestUtils, ViewOnlyAndOfflineWalletTester, + WalletNotificationCollector, MiningUtils, BaseTestClass, + OutputUtils, TxWalletUtils, TransferUtils, WalletTxsUtils, + WalletTransfersUtils, WalletErrorUtils, WalletSendUtils, WalletTestUtils ) logger: logging.Logger = logging.getLogger("TestMoneroWalletCommon") @@ -177,7 +171,7 @@ def is_random_wallet_config(cls, config: Optional[MoneroWalletConfig]) -> bool: @pytest.fixture(scope="class") def test_config(self) -> BaseTestMoneroWallet.Config: """Test configuration.""" - parser = ConfigParser() + parser: ConfigParser = ConfigParser() parser.read('tests/config/test_monero_wallet_common.ini') return BaseTestMoneroWallet.Config.parse(parser) @@ -265,11 +259,10 @@ def test_sync_with_pool_same_accounts(self, daemon: MoneroDaemonRpc, wallet: Mon WalletSendUtils.test_sync_with_pool_submit(daemon, wallet, config) # Can sync with txs submitted and flushed from the pool - # This test takes at least 500 seconds (~8 minutes) to catchup failed txs - # (see wallet2::process_unconfirmed_transfer) @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_RELAYS disabled") @pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled") def test_sync_with_pool_submit_and_flush(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + logger.warning("This test takes at least 500 seconds (~8 minutes) to catchup failed txs (see wallet2::process_unconfirmed_transfer).") config: MoneroTxConfig = MoneroTxConfig() config.account_index = 2 config.address = wallet.get_primary_address() @@ -2894,8 +2887,6 @@ def test_get_reserve_proof_account(self, wallet: MoneroWallet) -> None: assert account.balance is not None amount: int = account.balance + TxWalletUtils.MAX_FEE proof: str = wallet.get_reserve_proof_account(0, amount, "Test message") - logger.info(f"Account balance: {wallet.get_balance(0)}") - logger.info(f"First account balance {account.balance}") reserve: MoneroCheckReserve = wallet.check_reserve_proof(wallet.get_primary_address(), "Test message", proof) try: wallet.get_reserve_proof_account(0, amount, "Test message") @@ -2904,11 +2895,11 @@ def test_get_reserve_proof_account(self, wallet: MoneroWallet) -> None: err_msg: str = str(e) assert "expecting this to succeed" == err_msg, err_msg - logger.info(f"Check reserve proof: {reserve.serialize()}") + logger.warning(f"Got reserve proof: {reserve.serialize()}") raise Exception("Should have thrown exception but got reserve proof: https://github.com/monero-project/monero/issues/6595") except Exception as e: err_msg: str = str(e) - logger.debug(err_msg) + logger.warning(err_msg) #assert "Should have thrown exception" not in err_msg, err_msg # test different wallet address @@ -3452,7 +3443,7 @@ def test_freeze_outputs(self, wallet: MoneroWallet) -> None: wallet.freeze_output("123") raise Exception("Should have thrown error") except Exception as e: - logger.debug(e) + logger.warning(e) #if "Bad key image" != str(e): # raise diff --git a/tests/test_monero_wallet_full.py b/tests/test_monero_wallet_full.py index 19cd02a..f7a64ce 100644 --- a/tests/test_monero_wallet_full.py +++ b/tests/test_monero_wallet_full.py @@ -46,7 +46,7 @@ def after_all(self) -> None: Utils.WALLET_FULL_TESTS_RUN = True @override - def _create_wallet(self, config: Optional[MoneroWalletConfig], start_syncing: bool = True): + def _create_wallet(self, config: Optional[MoneroWalletConfig], start_syncing: bool = True) -> MoneroWalletFull: # assign defaults if config is None: config = MoneroWalletConfig() @@ -661,13 +661,13 @@ def test_multisig_sample(self) -> None: @pytest.mark.skipif(Utils.REGTEST, reason="Cannot retrieve accurate height by date from regtest fakechain") @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @override - def test_get_height_by_date(self, wallet: MoneroWallet): + def test_get_height_by_date(self, wallet: MoneroWallet) -> None: return super().test_get_height_by_date(wallet) @pytest.mark.skipif(Utils.REGTEST is False, reason="REGTEST disabled") @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @pytest.mark.xfail(raises=RuntimeError, reason="Month or day out of range") - def test_get_height_by_date_regtest(self, wallet: MoneroWallet): + def test_get_height_by_date_regtest(self, wallet: MoneroWallet) -> None: return super().test_get_height_by_date(wallet) #endregion @@ -676,7 +676,7 @@ def test_get_height_by_date_regtest(self, wallet: MoneroWallet): @pytest.mark.skip(reason="TODO disabled because importing key images deletes corresponding incoming transfers: #5812") @override - def test_import_key_images(self, wallet: MoneroWallet): + def test_import_key_images(self, wallet: MoneroWallet) -> None: return super().test_import_key_images(wallet) #endregion diff --git a/tests/test_monero_wallet_rpc.py b/tests/test_monero_wallet_rpc.py index 17a70d6..b090365 100644 --- a/tests/test_monero_wallet_rpc.py +++ b/tests/test_monero_wallet_rpc.py @@ -345,7 +345,7 @@ def test_get_public_spend_key(self, wallet: MoneroWallet) -> None: #region Disabled Tests - @pytest.mark.skip(reason="TODO (monero-project): https://github.com/monero-project/monero/issues/5812") + @pytest.mark.skip(reason="TODO https://github.com/monero-project/monero/issues/5812") @override def test_import_key_images(self, wallet: MoneroWallet) -> None: return super().test_import_key_images(wallet) diff --git a/tests/utils/address_book.py b/tests/utils/address_book.py index 707f1be..a40f834 100644 --- a/tests/utils/address_book.py +++ b/tests/utils/address_book.py @@ -46,6 +46,7 @@ def parse(cls, parser: ConfigParser, section: str) -> AddressBook: """ if not parser.has_section(section): raise Exception(f"Cannot parse address book entry, invalid section '{section}'") + entry = cls() entry.primary_address_1 = parser.get(section, 'primary_address_1') entry.primary_address_2 = parser.get(section, 'primary_address_2') diff --git a/tests/utils/base_test_class.py b/tests/utils/base_test_class.py index 69df8d2..12e0862 100644 --- a/tests/utils/base_test_class.py +++ b/tests/utils/base_test_class.py @@ -2,7 +2,7 @@ import pytest from abc import ABC - +from typing import Any, Generator from monero import MoneroUtils logger: logging.Logger = logging.getLogger("BaseTestClass") @@ -13,7 +13,7 @@ class BaseTestClass(ABC): # Setup and teardown of test class @pytest.fixture(scope="class", autouse=True) - def global_setup_and_teardown(self): + def global_setup_and_teardown(self) -> Generator[None, Any, None]: """Executed once before all tests.""" self.before_all() yield @@ -21,7 +21,7 @@ def global_setup_and_teardown(self): # Setup and teardown of each test @pytest.fixture(autouse=True) - def setup_and_teardown(self, request: pytest.FixtureRequest): + def setup_and_teardown(self, request: pytest.FixtureRequest) -> Generator[None, Any, None]: """Executed before each test.""" self.before_each(request) yield diff --git a/tests/utils/send_and_update_txs_tester.py b/tests/utils/send_and_update_txs_tester.py index 10699af..dbd5da1 100644 --- a/tests/utils/send_and_update_txs_tester.py +++ b/tests/utils/send_and_update_txs_tester.py @@ -155,9 +155,9 @@ def wait_for_confirmations(self, sent_txs: list[MoneroTxWallet], num_confirmatio :param int num_confirmations_total: number of confirmed txs required. """ # track resulting outgoing and incoming txs as blocks are added to the chain - logger.info(f"{self.num_confirmations} < {num_confirmations_total} needed confirmations") + logger.debug(f"{self.num_confirmations} < {num_confirmations_total} needed confirmations") header: MoneroBlockHeader = self.daemon.wait_for_next_block_header() - logger.info(f"*** Block {header.height} added to chain ***") + logger.debug(f"*** Block {header.height} added to chain ***") # give wallet time to catch up, otherwise incoming tx may not appear # TODO: this lets new block slip, okay? diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 91f29eb..ff40a00 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -164,7 +164,7 @@ def load_config(cls) -> None: if cls._LOADED: return - parser = ConfigParser() + parser: ConfigParser = ConfigParser() parser.read('tests/config/config.ini') # validate config diff --git a/tests/utils/tx_wallet_utils.py b/tests/utils/tx_wallet_utils.py index 0f659bf..d2aa04f 100644 --- a/tests/utils/tx_wallet_utils.py +++ b/tests/utils/tx_wallet_utils.py @@ -60,9 +60,9 @@ def test_described_tx_set(cls, described_tx_set: MoneroTxSet, network_type: Mone :param MoneroTxSet described_tx_set: described tx set to test. :param MoneroNetworkType network_type: tx set network type. """ - assert len(described_tx_set.txs) > 0 - assert described_tx_set.signed_tx_hex is None - assert described_tx_set.unsigned_tx_hex is None + assert len(described_tx_set.txs) > 0, "Described tx set has no txs to test" + assert described_tx_set.signed_tx_hex is None, "Expected no signed tx hex to be defined in described tx set" + assert described_tx_set.unsigned_tx_hex is None, "Expected no unsigned tx hex to be defined in described tx set" # test each transaction # TODO use common tx wallet test? diff --git a/tests/utils/wallet_equality_utils.py b/tests/utils/wallet_equality_utils.py index 2fd57b5..1adc12c 100644 --- a/tests/utils/wallet_equality_utils.py +++ b/tests/utils/wallet_equality_utils.py @@ -192,7 +192,9 @@ def test_txs_wallet_equality(cls, txs1: list[MoneroTxWallet], txs2: list[MoneroT cls.transfer_cached_info(tx2, tx1) # test tx equality - assert TxWalletUtils.txs_mergeable(tx1, tx2), "Txs are not mergeable" + tx1_str: str = tx1.serialize() + tx2_str: str = tx2.serialize() + assert TxWalletUtils.txs_mergeable(tx1, tx2), f"Txs are not mergeable: tx1: {tx1_str}, tx2: {tx2_str}" AssertUtils.assert_equals(tx1, tx2) found = True diff --git a/tests/utils/wallet_tx_tracker.py b/tests/utils/wallet_tx_tracker.py index 838355f..8c05835 100644 --- a/tests/utils/wallet_tx_tracker.py +++ b/tests/utils/wallet_tx_tracker.py @@ -120,10 +120,10 @@ def _wait_for_txs_to_clear(self, clear_from_wallet: bool, wallets: list[MoneroWa self._daemon.start_mining(self._mining_address, 1, False, False) mining_started = True except Exception as e: - logger.debug(f"An error occured while starting mining: {e}") + logger.warning(f"An error occured while starting mining: {e}") # no problem else: - logger.debug("Mining already active") + logger.warning("Mining already active") # sleep for sync period logger.debug(f"Waiting for {num_txs_in_pool} tx(s) to confirm (it={num_it})...") @@ -204,7 +204,7 @@ def wait_for_unlocked_balance( self._daemon.start_mining(self._mining_address, 1, False, False) mining_started = True except Exception as e: - logger.debug(f"An error occurred while starting mining: {str(e)}") + logger.warning(f"An error occurred while starting mining: {str(e)}") # no problem # wait for unlocked balance // TODO: promote to MoneroWallet interface? From da0fbde66473e1b852301b89c04e1e72613e8d71 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Wed, 19 Aug 2026 17:21:51 +0200 Subject: [PATCH 7/9] Update docker container to monero v0.18.5.1 * Minor fixes --- .github/workflows/test.yml | 8 ++++---- pyproject.toml | 4 +--- tests/config/config.ini | 4 +++- tests/docker-compose.yml | 42 +++++++++++++++++++++++--------------- tests/utils/test_utils.py | 6 ++++++ 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b316c63..d519f4b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,8 +27,8 @@ jobs: - name: Install dependencies run: | sudo apt update - sudo apt install -y build-essential cmake pkg-config libssl-dev libzmq3-dev libunbound-dev libsodium-dev libunwind8-dev liblzma-dev libreadline6-dev libexpat1-dev libpgm-dev qttools5-dev-tools libhidapi-dev libusb-1.0-0-dev libprotobuf-dev protobuf-compiler libudev-dev libboost-chrono-dev libboost-date-time-dev libboost-filesystem-dev libboost-locale-dev libboost-program-options-dev libboost-regex-dev libboost-serialization-dev libboost-system-dev libboost-thread-dev python3 ccache doxygen graphviz git curl autoconf libtool gperf nettle-dev libevent-dev debhelper python3-all python3-pip python3-pybind11 python3-pytest python3-pytest-rerunfailures python3-pytest-cov lcov python3-scikit-build-core - pip3 install pybind11-stubgen pytest pyproject-metadata --break-system-packages + sudo apt install -y build-essential cmake pkg-config libssl-dev libzmq3-dev libunbound-dev libsodium-dev libunwind8-dev liblzma-dev libreadline6-dev libexpat1-dev libpgm-dev qttools5-dev-tools libhidapi-dev libusb-1.0-0-dev libprotobuf-dev protobuf-compiler libudev-dev libboost-chrono-dev libboost-date-time-dev libboost-filesystem-dev libboost-locale-dev libboost-program-options-dev libboost-regex-dev libboost-serialization-dev libboost-system-dev libboost-thread-dev python3 ccache doxygen graphviz git curl autoconf libtool gperf nettle-dev libevent-dev debhelper python3-all python3-pip python3-pybind11 lcov python3-scikit-build-core + pip3 install pybind11-stubgen pytest pytest-rerunfailures pytest-cov pytest-timeout pyproject-metadata --break-system-packages - name: Install expat run: | @@ -170,7 +170,7 @@ jobs: - name: Install pytest and dependencies shell: bash run: | - python -m pip install pytest pytest-rerunfailures typing_extensions scikit-build-core + python -m pip install pytest pytest-rerunfailures pytest-timeout pytest-cov typing_extensions scikit-build-core - name: Setup MSYS2 MINGW64 uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 @@ -282,7 +282,7 @@ jobs: HOMEBREW_NO_AUTO_UPDATE=1 brew install python boost@1.85 hidapi openssl zmq libpgm miniupnpc expat libunwind-headers protobuf unbound brew unlink boost || true brew link boost@1.85 --force - pip3 install pytest pytest-rerunfailures pytest-cov setuptools wheel scikit-build-core --break-system-packages + pip3 install pytest pytest-rerunfailures pytest-timeout pytest-cov setuptools wheel scikit-build-core --break-system-packages - name: Install pybind11 v2.13.6 run: | diff --git a/pyproject.toml b/pyproject.toml index c8cd071..5853821 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,7 @@ authors = [ license = { text = "MIT" } readme = "README.md" requires-python = ">=3.8" -dependencies = [ - "pybind11>=2.12" -] +dependencies = [] [build-system] requires = [ diff --git a/tests/config/config.ini b/tests/config/config.ini index aa870f4..a68ff4b 100644 --- a/tests/config/config.ini +++ b/tests/config/config.ini @@ -1,7 +1,7 @@ [general] test_relays=True test_non_relays=True -lite_mode=False +lite_mode=True test_notifications=True test_resets=True network_type=regtest @@ -11,6 +11,8 @@ auto_connect_timeout_ms=3000 rpc_uri=http://127.0.0.1:18081 rpc_username=rpc_daemon_user rpc_password=abc123 +zmq_uri=tcp://127.0.0.1:18085 +zmq_pub_uri=tcp://127.0.0.1:18086 [wallet] name=test_wallet_1 diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index da5dc10..fe2536d 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -12,27 +12,27 @@ services: - xmr_wallet_3 node_1: - image: lalanza808/monero:v0.18.5.0 + image: lalanza808/monero:v0.18.5.1 container_name: node_1 command: [ "monerod", "--fixed-difficulty=500", - "--log-level=2", + "--log-level=3", "--p2p-bind-ip=0.0.0.0", "--p2p-bind-port=48080", "--rpc-bind-port=18089", "--rpc-bind-ip=0.0.0.0", - "--confirm-external-bind", "--rpc-access-control-origins=*", + "--rpc-ssl=disabled", + "--rpc-login=rpc_daemon_user:abc123", + "--rpc-max-connections-per-private-ip=100", + "--disable-rpc-ban", + "--confirm-external-bind", "--add-exclusive-node=node_2:18080", "--regtest", - "--no-igd", "--hide-my-port", "--no-zmq", "--max-connections-per-ip=100", - "--rpc-max-connections-per-private-ip=100", - "--mining-threads=1", - "--rpc-login=rpc_daemon_user:abc123", "--non-interactive" ] volumes: @@ -40,28 +40,33 @@ services: ports: - "48080:48080" - "18089:18089" + restart: on-failure node_2: - image: lalanza808/monero:v0.18.5.0 + image: lalanza808/monero:v0.18.5.1 container_name: node_2 command: [ "monerod", "--fixed-difficulty=500", - "--log-level=2", + "--log-level=3", "--p2p-bind-ip=0.0.0.0", "--p2p-bind-port=18080", "--rpc-bind-ip=0.0.0.0", - "--confirm-external-bind", "--rpc-bind-port=18081", "--rpc-access-control-origins=*", + "--rpc-ssl=disabled", + "--rpc-max-connections-per-private-ip=100", + "--rpc-login=rpc_daemon_user:abc123", + "--zmq-rpc-bind-ip=0.0.0.0", + "--zmq-rpc-bind-port=18085", + "--zmq-pub=tcp://0.0.0.0:18086", + "--disable-rpc-ban", + "--confirm-external-bind", + "--confirm-zmq-rpc-external-bind", "--add-exclusive-node=node_1:48080", "--regtest", - "--no-igd", "--hide-my-port", - "--no-zmq", "--max-connections-per-ip=100", - "--rpc-max-connections-per-private-ip=100", - "--rpc-login=rpc_daemon_user:abc123", "--non-interactive" ] volumes: @@ -69,11 +74,14 @@ services: ports: - "18080:18080" - "18081:18081" + - "18085:18085" + - "18086:18086" depends_on: - node_1 + restart: on-failure xmr_wallet_1: - image: lalanza808/monero:v0.18.5.0 + image: lalanza808/monero:v0.18.5.1 container_name: xmr_wallet_1 command: [ "monero-wallet-rpc", @@ -100,7 +108,7 @@ services: - node_2 xmr_wallet_2: - image: lalanza808/monero:v0.18.5.0 + image: lalanza808/monero:v0.18.5.1 container_name: xmr_wallet_2 command: [ "monero-wallet-rpc", @@ -127,7 +135,7 @@ services: - node_2 xmr_wallet_3: - image: lalanza808/monero:v0.18.5.0 + image: lalanza808/monero:v0.18.5.1 container_name: xmr_wallet_3 command: [ "monero-wallet-rpc", diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index ff40a00..1fc4ae5 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -58,6 +58,10 @@ class TestUtils(ABC): """Monero daemon rpc username.""" DAEMON_RPC_PASSWORD: str = "" """Monero daemon rpc password.""" + DAEMON_RPC_ZMQ_URI: str = "" + """Monero daemon rpc zmq uri.""" + DAEMON_RPC_ZMQ_PUB_URI: str = "" + """Monero daemon rpc zmq pub uri.""" TEST_NON_RELAYS: bool = True """Indicates if non-relays tests are enabled.""" TEST_RELAYS: bool = True @@ -191,6 +195,8 @@ def load_config(cls) -> None: cls.CONTAINER_DAEMON_RPC_URI = cls.DAEMON_RPC_URI.replace("127.0.0.1", "node_2") cls.DAEMON_RPC_USERNAME = parser.get('daemon', 'rpc_username') cls.DAEMON_RPC_PASSWORD = parser.get('daemon', 'rpc_password') + cls.DAEMON_RPC_ZMQ_URI = parser.get('daemon', 'zmq_uri') + cls.DAEMON_RPC_ZMQ_PUB_URI = parser.get('daemon', 'zmq_pub_uri') # parse wallet config cls.WALLET_NAME = parser.get('wallet', 'name') From e0c2537feff9b3bd3661dde347f15086faa2b2c1 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Mon, 24 Aug 2026 16:46:37 +0200 Subject: [PATCH 8/9] Expose derialization methods and add data model unit tests --- .gitignore | 2 +- src/cpp/common/py_monero_common_bindings.cpp | 6 + src/cpp/daemon/py_monero_daemon_bindings.cpp | 69 ++ src/cpp/py_monero_types.h | 18 + src/cpp/utils/py_monero_utils.h | 1 + src/cpp/utils/py_monero_utils_bindings.cpp | 42 + src/cpp/wallet/py_monero_wallet_bindings.cpp | 54 ++ src/python/__init__.pyi | 8 + src/python/gen_utils.pyi | 140 ++++ src/python/incoming_transfer_comparator.pyi | 20 + src/python/monero_account.pyi | 10 + src/python/monero_account_tag.pyi | 10 + src/python/monero_address_book_entry.pyi | 10 + src/python/monero_alt_chain.pyi | 10 + src/python/monero_ban.pyi | 10 + src/python/monero_block_template.pyi | 10 + src/python/monero_check_reserve.pyi | 10 + src/python/monero_check_tx.pyi | 10 + src/python/monero_connection_span.pyi | 10 + src/python/monero_daemon_info.pyi | 10 + src/python/monero_daemon_sync_info.pyi | 10 + .../monero_daemon_update_check_result.pyi | 10 + .../monero_daemon_update_download_result.pyi | 10 + src/python/monero_destination.pyi | 10 + src/python/monero_fee_estimate.pyi | 10 + src/python/monero_generate_blocks_result.pyi | 10 + src/python/monero_hard_fork_info.pyi | 10 + src/python/monero_incoming_transfer.pyi | 11 + src/python/monero_integrated_address.pyi | 10 + src/python/monero_key_image.pyi | 10 + src/python/monero_key_image_import_result.pyi | 10 + .../monero_message_signature_result.pyi | 10 + src/python/monero_miner_tx_sum.pyi | 10 + src/python/monero_mining_status.pyi | 10 + src/python/monero_multisig_info.pyi | 10 + src/python/monero_multisig_init_result.pyi | 10 + src/python/monero_multisig_sign_result.pyi | 10 + src/python/monero_output.pyi | 10 + .../monero_output_distribution_entry.pyi | 10 + src/python/monero_output_histogram_entry.pyi | 10 + src/python/monero_output_query.pyi | 10 + src/python/monero_output_wallet.pyi | 22 + src/python/monero_peer.pyi | 10 + src/python/monero_prune_result.pyi | 10 + src/python/monero_rpc_connection.pyi | 10 + src/python/monero_rpc_payment_info.pyi | 10 + src/python/monero_subaddress.pyi | 10 + src/python/monero_submit_tx_result.pyi | 10 + src/python/monero_transfer_query.pyi | 10 + src/python/monero_tx.pyi | 19 + src/python/monero_tx_pool_stats.pyi | 10 + src/python/monero_tx_query.pyi | 10 + src/python/monero_tx_wallet.pyi | 10 + src/python/monero_utils.pyi | 65 ++ src/python/monero_version.pyi | 10 + src/python/output_comparator.pyi | 21 + src/python/tx_height_comparator.pyi | 21 + tests/config/config.ini | 2 +- tests/test_gen_utils.py | 152 ++++ tests/test_monero_daemon_model.py | 743 ++++++++++++++++++ tests/test_monero_utils.py | 440 ++++++++++- tests/test_monero_wallet_common.py | 5 +- tests/test_monero_wallet_keys.py | 63 ++ tests/test_monero_wallet_model.py | 556 ++++++++++++- tests/utils/assert_utils.py | 16 + tests/utils/wallet_utils.py | 1 + 66 files changed, 2899 insertions(+), 8 deletions(-) create mode 100644 src/python/gen_utils.pyi create mode 100644 src/python/incoming_transfer_comparator.pyi create mode 100644 src/python/output_comparator.pyi create mode 100644 src/python/tx_height_comparator.pyi create mode 100644 tests/test_gen_utils.py create mode 100644 tests/test_monero_daemon_model.py diff --git a/.gitignore b/.gitignore index 00c3a73..b5ce865 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ test_wallets .github/instructions/codacy.instructions.md .idea .codacy -coverage/ +coverage* .coverage .cache monero.log diff --git a/src/cpp/common/py_monero_common_bindings.cpp b/src/cpp/common/py_monero_common_bindings.cpp index f79d9e7..2ebb110 100644 --- a/src/cpp/common/py_monero_common_bindings.cpp +++ b/src/cpp/common/py_monero_common_bindings.cpp @@ -234,6 +234,9 @@ void py_monero_bind_common(py::module_& m, PyMoneroTypes& t) { // monero_rpc_payment_info t.py_monero_rpc_payment_info .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("credits", &monero_rpc_payment_info::m_credits) .def_readwrite("top_block_hash", &monero_rpc_payment_info::m_top_block_hash); @@ -253,6 +256,9 @@ void py_monero_bind_common(py::module_& m, PyMoneroTypes& t) { .def_static("compare", [](int p1, int p2) { MONERO_CATCH_AND_RETHROW(monero_rpc_connection::compare(p1, p2)); }, py::arg("p1"), py::arg("p2")) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize_rpc_connection(json)); + }, py::arg("json")) .def_property("uri", [](const monero_rpc_connection& self) { return self.m_uri; }, [](monero_rpc_connection& self, const boost::optional& val) { diff --git a/src/cpp/daemon/py_monero_daemon_bindings.cpp b/src/cpp/daemon/py_monero_daemon_bindings.cpp index 5a04d60..26ec16e 100644 --- a/src/cpp/daemon/py_monero_daemon_bindings.cpp +++ b/src/cpp/daemon/py_monero_daemon_bindings.cpp @@ -57,6 +57,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_fee_estimate py::class_>(m, "MoneroFeeEstimate") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("fee", &monero_fee_estimate::m_fee) .def_readwrite("fees", &monero_fee_estimate::m_fees) .def_readwrite("quantization_mask", &monero_fee_estimate::m_quantization_mask); @@ -68,6 +71,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_version t.py_monero_version .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("number", &monero_version::m_number) .def_readwrite("is_release", &monero_version::m_is_release); @@ -122,6 +128,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_block_template py::class_>(m, "MoneroBlockTemplate") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("block_template_blob", &monero_block_template::m_block_template_blob) .def_readwrite("block_hashing_blob", &monero_block_template::m_block_hashing_blob) .def_readwrite("difficulty_low", &monero_block_template::m_difficulty_low) @@ -137,6 +146,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_connection_span py::class_>(m, "MoneroConnectionSpan") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("connection_id", &monero_connection_span::m_connection_id) .def_readwrite("num_blocks", &monero_connection_span::m_num_blocks) .def_readwrite("remote_address", &monero_connection_span::m_remote_address) @@ -148,6 +160,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_peer py::class_>(m, "MoneroPeer") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("id", &monero_peer::m_id) .def_readwrite("address", &monero_peer::m_address) .def_readwrite("host", &monero_peer::m_host) @@ -178,6 +193,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_alt_chain py::class_>(m, "MoneroAltChain") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("block_hashes", &monero_alt_chain::m_block_hashes) .def_readwrite("difficulty_low", &monero_alt_chain::m_difficulty_low) .def_readwrite("difficulty_high", &monero_alt_chain::m_difficulty_high) @@ -188,6 +206,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_ban py::class_>(m, "MoneroBan") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("host", &monero_ban::m_host) .def_readwrite("ip", &monero_ban::m_ip) .def_readwrite("is_banned", &monero_ban::m_is_banned) @@ -196,6 +217,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_output_distribution_entry py::class_>(m, "MoneroOutputDistributionEntry") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("amount", &monero_output_distribution_entry::m_amount) .def_readwrite("base", &monero_output_distribution_entry::m_base) .def_readwrite("distribution", &monero_output_distribution_entry::m_distribution) @@ -204,6 +228,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_output_histogram_entry py::class_>(m, "MoneroOutputHistogramEntry") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("amount", &monero_output_histogram_entry::m_amount) .def_readwrite("num_instances", &monero_output_histogram_entry::m_num_instances) .def_readwrite("unlocked_instances", &monero_output_histogram_entry::m_unlocked_instances) @@ -212,6 +239,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_hard_fork_info py::class_>(m, "MoneroHardForkInfo") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("earliest_height", &monero_hard_fork_info::m_earliest_height) .def_readwrite("is_enabled", &monero_hard_fork_info::m_is_enabled) .def_readwrite("state", &monero_hard_fork_info::m_state) @@ -224,12 +254,18 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_prune_result py::class_>(m, "MoneroPruneResult") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("is_pruned", &monero_prune_result::m_is_pruned) .def_readwrite("pruning_seed", &monero_prune_result::m_pruning_seed); // monero_daemon_sync_info py::class_>(m, "MoneroDaemonSyncInfo") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("height", &monero_daemon_sync_info::m_height) .def_readwrite("peers", &monero_daemon_sync_info::m_peers) .def_readwrite("spans", &monero_daemon_sync_info::m_spans) @@ -240,6 +276,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_daemon_info py::class_>(m, "MoneroDaemonInfo") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("version", &monero_daemon_info::m_version) .def_readwrite("num_alt_blocks", &monero_daemon_info::m_num_alt_blocks) .def_readwrite("block_size_limit", &monero_daemon_info::m_block_size_limit) @@ -277,6 +316,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_daemon_update_check_result py::class_>(m, "MoneroDaemonUpdateCheckResult") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("is_update_available", &monero_daemon_update_check_result::m_is_update_available) .def_readwrite("version", &monero_daemon_update_check_result::m_version) .def_readwrite("hash", &monero_daemon_update_check_result::m_hash) @@ -286,11 +328,17 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_daemon_update_check_result py::class_>(m, "MoneroDaemonUpdateDownloadResult") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("download_path", &monero_daemon_update_download_result::m_download_path); // monero_submit_tx_result py::class_>(m, "MoneroSubmitTxResult") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("is_good", &monero_submit_tx_result::m_is_good) .def_readwrite("is_relayed", &monero_submit_tx_result::m_is_relayed) .def_readwrite("is_double_spend", &monero_submit_tx_result::m_is_double_spend) @@ -309,12 +357,18 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_generate_blocks_result py::class_>(m, "MoneroGenerateBlocksResult") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("block_hashes", &monero_generate_blocks_result::m_block_hashes) .def_readwrite("height", &monero_generate_blocks_result::m_height); // monero_tx_pool_stats py::class_>(m, "MoneroTxPoolStats") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("num_txs", &monero_tx_pool_stats::m_num_txs) .def_readwrite("num_not_relayed", &monero_tx_pool_stats::m_num_not_relayed) .def_readwrite("num_failing", &monero_tx_pool_stats::m_num_failing) @@ -332,6 +386,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_mining_status py::class_>(m, "MoneroMiningStatus") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("is_active", &monero_mining_status::m_is_active) .def_readwrite("is_background", &monero_mining_status::m_is_background) .def_readwrite("address", &monero_mining_status::m_address) @@ -341,6 +398,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_miner_tx_sum py::class_>(m, "MoneroMinerTxSum") .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("emission_sum_low", &monero_miner_tx_sum::m_emission_sum_low) .def_readwrite("emission_sum_high", &monero_miner_tx_sum::m_emission_sum_high) .def_readwrite("fee_sum_low", &monero_miner_tx_sum::m_fee_sum_low) @@ -349,6 +409,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_tx t.py_monero_tx .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_property_readonly_static("DEFAULT_PAYMENT_ID", [](py::object /* self */) { return monero_tx::DEFAULT_PAYMENT_ID; }) .def_readwrite("block", &monero_tx::m_block) .def_readwrite("hash", &monero_tx::m_hash) @@ -406,6 +469,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_key_image t.py_monero_key_image .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_static("deserialize_key_images", [](const std::string& key_images_json) { MONERO_CATCH_AND_RETHROW(monero_key_image::deserialize_key_images(key_images_json)); }, py::arg("key_images_json")) @@ -422,6 +488,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { // monero_output t.py_monero_output .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("tx", &monero_output::m_tx) .def_readwrite("key_image", &monero_output::m_key_image) .def_readwrite("amount", &monero_output::m_amount) diff --git a/src/cpp/py_monero_types.h b/src/cpp/py_monero_types.h index 4361876..e748894 100644 --- a/src/cpp/py_monero_types.h +++ b/src/cpp/py_monero_types.h @@ -61,6 +61,22 @@ #include "wallet/monero_wallet_keys.h" #include "wallet/monero_wallet_full.h" #include "utils/py_monero_utils.h" +#include "utils/gen_utils.h" + +template +std::shared_ptr py_monero_deserialize(const std::string& json) { + boost::property_tree::ptree root; + gen_utils::deserialize(json, root); + std::shared_ptr obj = std::make_shared(); + T::from_property_tree(root, obj); + return obj; +} + +inline std::shared_ptr py_monero_deserialize_rpc_connection(const std::string& json) { + boost::property_tree::ptree root; + gen_utils::deserialize(json, root); + return monero_rpc_connection::from_property_tree(root); +} #define MONERO_CATCH_AND_RETHROW(expr) \ try { \ @@ -149,6 +165,7 @@ struct PyMoneroTypes { py::class_> py_monero_wallet_full; py::class_> py_monero_wallet_rpc; py::class_ py_monero_utils; + py::class_ py_gen_utils; py::class_> py_tx_height_comparator; py::class_> py_incoming_transfer_comparator; @@ -202,6 +219,7 @@ struct PyMoneroTypes { py_monero_wallet_full(m, "MoneroWalletFull"), py_monero_wallet_rpc(m, "MoneroWalletRpc"), py_monero_utils(m, "MoneroUtils"), + py_gen_utils(m, "GenUtils"), py_tx_height_comparator(m, "TxHeightComparator"), py_incoming_transfer_comparator(m, "IncomingTransferComparator"), py_output_comparator(m, "OutputComparator") diff --git a/src/cpp/utils/py_monero_utils.h b/src/cpp/utils/py_monero_utils.h index ee0d3b9..d9f6518 100644 --- a/src/cpp/utils/py_monero_utils.h +++ b/src/cpp/utils/py_monero_utils.h @@ -55,6 +55,7 @@ #include "common/py_monero_common.h" #include "utils/monero_utils.h" +#include "utils/gen_utils.h" #include "wallet/monero_wallet.h" diff --git a/src/cpp/utils/py_monero_utils_bindings.cpp b/src/cpp/utils/py_monero_utils_bindings.cpp index 420a482..1bef2ca 100644 --- a/src/cpp/utils/py_monero_utils_bindings.cpp +++ b/src/cpp/utils/py_monero_utils_bindings.cpp @@ -128,6 +128,24 @@ void py_monero_bind_utils(py::module_& m, PyMoneroTypes& t) { .def_static("get_blocks_from_outputs", [](const std::vector>& outputs) { MONERO_CATCH_AND_RETHROW(monero_utils::get_blocks_from_outputs(outputs)); }, py::arg("outputs")) + .def_static("free", [](const std::shared_ptr& block) { + MONERO_CATCH_AND_RETHROW(monero_utils::free(block)); + }, py::arg("block")) + .def_static("free", [](const std::vector>& blocks) { + MONERO_CATCH_AND_RETHROW(monero_utils::free(blocks)); + }, py::arg("blocks")) + .def_static("free", [](const std::shared_ptr& tx) { + MONERO_CATCH_AND_RETHROW(monero_utils::free(tx)); + }, py::arg("tx")) + .def_static("free", [](const std::vector>& txs) { + MONERO_CATCH_AND_RETHROW(monero_utils::free(txs)); + }, py::arg("txs")) + .def_static("free", [](const std::vector>& transfers) { + MONERO_CATCH_AND_RETHROW(monero_utils::free(transfers)); + }, py::arg("transfers")) + .def_static("free", [](const std::vector>& outputs) { + MONERO_CATCH_AND_RETHROW(monero_utils::free(outputs)); + }, py::arg("outputs")) .def_static("get_payment_uri", [](const monero_tx_config &config, monero_network_type network_type) { MONERO_CATCH_AND_RETHROW(monero_utils::get_payment_uri(config, network_type)); }, py::arg("config"), py::arg("network_type") = monero_network_type::MAINNET) @@ -171,4 +189,28 @@ void py_monero_bind_utils(py::module_& m, PyMoneroTypes& t) { MERROR(message); }, py::arg("message")); + // gen_utils + t.py_gen_utils + .def_static("get_uuid", []() { + MONERO_CATCH_AND_RETHROW(gen_utils::get_uuid()); + }) + .def_static("wait_for", [](uint64_t duration_ms) { + MONERO_CATCH_AND_RETHROW(gen_utils::wait_for(duration_ms)); + }, py::arg("duration_ms"), py::call_guard()) + .def_static("bool_equals", [](bool val, const boost::optional& opt_val) { + MONERO_CATCH_AND_RETHROW(gen_utils::bool_equals(val, opt_val)); + }, py::arg("val"), py::arg("opt_val")) + .def_static("reconcile_bool", [](const boost::optional& val1, const boost::optional& val2, const boost::optional& resolve_defined, const boost::optional& resolve_true, const boost::optional& resolve_max, const std::string& err_msg) { + MONERO_CATCH_AND_RETHROW(gen_utils::reconcile(val1, val2, resolve_defined, resolve_true, resolve_max, err_msg)); + }, py::arg("val1"), py::arg("val2"), py::arg("resolve_defined") = py::none(), py::arg("resolve_true") = py::none(), py::arg("resolve_max") = py::none(), py::arg("err_msg") = "") + .def_static("reconcile_uint64", [](const boost::optional& val1, const boost::optional& val2, const boost::optional& resolve_defined, const boost::optional& resolve_true, const boost::optional& resolve_max, const std::string& err_msg) { + MONERO_CATCH_AND_RETHROW(gen_utils::reconcile(val1, val2, resolve_defined, resolve_true, resolve_max, err_msg)); + }, py::arg("val1"), py::arg("val2"), py::arg("resolve_defined") = py::none(), py::arg("resolve_true") = py::none(), py::arg("resolve_max") = py::none(), py::arg("err_msg") = "") + .def_static("reconcile_string", [](const boost::optional& val1, const boost::optional& val2, const boost::optional& resolve_defined, const boost::optional& resolve_true, const boost::optional& resolve_max, const std::string& err_msg) { + MONERO_CATCH_AND_RETHROW(gen_utils::reconcile(val1, val2, resolve_defined, resolve_true, resolve_max, err_msg)); + }, py::arg("val1"), py::arg("val2"), py::arg("resolve_defined") = py::none(), py::arg("resolve_true") = py::none(), py::arg("resolve_max") = py::none(), py::arg("err_msg") = "") + .def_static("reconcile_string_list", [](const std::vector& v1, const std::vector& v2, const std::string& err_msg) { + MONERO_CATCH_AND_RETHROW(gen_utils::reconcile(v1, v2, err_msg)); + }, py::arg("v1"), py::arg("v2"), py::arg("err_msg") = ""); + } diff --git a/src/cpp/wallet/py_monero_wallet_bindings.cpp b/src/cpp/wallet/py_monero_wallet_bindings.cpp index 329d80f..c0bc481 100644 --- a/src/cpp/wallet/py_monero_wallet_bindings.cpp +++ b/src/cpp/wallet/py_monero_wallet_bindings.cpp @@ -84,6 +84,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_subaddress t.py_monero_subaddress .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("account_index", &monero_subaddress::m_account_index) .def_readwrite("index", &monero_subaddress::m_index) .def_readwrite("address", &monero_subaddress::m_address) @@ -104,6 +107,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_account t.py_monero_account .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("index", &monero_account::m_index) .def_readwrite("primary_address", &monero_account::m_primary_address) .def_readwrite("balance", &monero_account::m_balance) @@ -114,6 +120,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_account_tag t.py_monero_account_tag .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def(py::init(), py::arg("tag"), py::arg("label")) .def(py::init>(), py::arg("tag"), py::arg("label"), py::arg("account_indices")) .def_readwrite("tag", &monero_account_tag::m_tag) @@ -123,6 +132,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_destination t.py_monero_destination .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def(py::init(), py::arg("address")) .def(py::init(), py::arg("address"), py::arg("amount")) .def_readwrite("address", &monero_destination::m_address) @@ -187,6 +199,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_transfer_query t.py_monero_transfer_query .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_static("deserialize_from_block", [](const std::string& transfer_query_json) { MONERO_CATCH_AND_RETHROW(monero_transfer_query::deserialize_from_block(transfer_query_json)); }, py::arg("transfer_query_json")) @@ -228,6 +243,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_output_wallet t.py_monero_output_wallet .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("account_index", &monero_output_wallet::m_account_index) .def_readwrite("subaddress_index", &monero_output_wallet::m_subaddress_index) .def_readwrite("is_spent", &monero_output_wallet::m_is_spent) @@ -247,6 +265,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_output_query t.py_monero_output_query .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_static("deserialize_from_block", [](const std::string& output_query_json) { MONERO_CATCH_AND_RETHROW(monero_output_query::deserialize_from_block(output_query_json)); }, py::arg("output_query_json")) @@ -279,6 +300,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_tx_wallet t.py_monero_tx_wallet .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("tx_set", &monero_tx_wallet::m_tx_set) .def_readwrite("is_incoming", &monero_tx_wallet::m_is_incoming) .def_readwrite("is_outgoing", &monero_tx_wallet::m_is_outgoing) @@ -345,6 +369,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_tx_query t.py_monero_tx_query .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_static("deserialize_from_block", [](const std::string& tx_query_json) { MONERO_CATCH_AND_RETHROW(monero_tx_query::deserialize_from_block(tx_query_json)); }, py::arg("tx_query_json")) @@ -394,6 +421,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_integrated_address t.py_monero_integrated_address .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("standard_address", &monero_integrated_address::m_standard_address) .def_readwrite("payment_id", &monero_integrated_address::m_payment_id) .def_readwrite("integrated_address", &monero_integrated_address::m_integrated_address); @@ -454,6 +484,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_key_image_import_result t.py_monero_key_image_import_result .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("height", &monero_key_image_import_result::m_height) .def_readwrite("spent_amount", &monero_key_image_import_result::m_spent_amount) .def_readwrite("unspent_amount", &monero_key_image_import_result::m_unspent_amount); @@ -461,6 +494,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_message_signature_result t.py_monero_message_signature_result .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("is_good", &monero_message_signature_result::m_is_good) .def_readwrite("version", &monero_message_signature_result::m_version) .def_readwrite("is_old", &monero_message_signature_result::m_is_old) @@ -474,6 +510,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_check_tx t.py_monero_check_tx .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("in_tx_pool", &monero_check_tx::m_in_tx_pool) .def_readwrite("num_confirmations", &monero_check_tx::m_num_confirmations) .def_readwrite("received_amount", &monero_check_tx::m_received_amount); @@ -481,12 +520,18 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_check_reserve t.py_monero_check_reserve .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("total_amount", &monero_check_reserve::m_total_amount) .def_readwrite("unconfirmed_spent_amount", &monero_check_reserve::m_unconfirmed_spent_amount); // monero_multisig_info t.py_monero_multisig_info .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("is_multisig", &monero_multisig_info::m_is_multisig) .def_readwrite("is_ready", &monero_multisig_info::m_is_ready) .def_readwrite("threshold", &monero_multisig_info::m_threshold) @@ -495,18 +540,27 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { // monero_multisig_init_result t.py_monero_multisig_init_result .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("address", &monero_multisig_init_result::m_address) .def_readwrite("multisig_hex", &monero_multisig_init_result::m_multisig_hex); // monero_multisig_sign_result t.py_monero_multisig_sign_result .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def_readwrite("signed_multisig_tx_hex", &monero_multisig_sign_result::m_signed_multisig_tx_hex) .def_readwrite("tx_hashes", &monero_multisig_sign_result::m_tx_hashes); // monero_address_book_entry t.py_monero_address_book_entry .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) .def(py::init(), py::arg("index"), py::arg("address"), py::arg("description")) .def(py::init(), py::arg("index"), py::arg("address"), py::arg("description"), py::arg("payment_id")) .def_readwrite("index", &monero_address_book_entry::m_index) diff --git a/src/python/__init__.pyi b/src/python/__init__.pyi index c277084..cbd172e 100644 --- a/src/python/__init__.pyi +++ b/src/python/__init__.pyi @@ -80,9 +80,11 @@ from .monero_daemon_update_download_result import MoneroDaemonUpdateDownloadResu from .monero_decoded_address import MoneroDecodedAddress from .monero_destination import MoneroDestination from .monero_error import MoneroError +from .gen_utils import GenUtils from .monero_fee_estimate import MoneroFeeEstimate from .monero_generate_blocks_result import MoneroGenerateBlocksResult from .monero_hard_fork_info import MoneroHardForkInfo +from .incoming_transfer_comparator import IncomingTransferComparator from .monero_incoming_transfer import MoneroIncomingTransfer from .monero_integrated_address import MoneroIntegratedAddress from .monero_key_image import MoneroKeyImage @@ -105,6 +107,7 @@ from .monero_output_distribution_entry import MoneroOutputDistributionEntry from .monero_output_histogram_entry import MoneroOutputHistogramEntry from .monero_output_query import MoneroOutputQuery from .monero_output_wallet import MoneroOutputWallet +from .output_comparator import OutputComparator from .monero_peer import MoneroPeer from .monero_prune_result import MoneroPruneResult from .monero_rpc_connection import MoneroRpcConnection @@ -115,6 +118,7 @@ from .monero_submit_tx_result import MoneroSubmitTxResult from .monero_sync_result import MoneroSyncResult from .monero_transfer_query import MoneroTransferQuery from .monero_tx import MoneroTx +from .tx_height_comparator import TxHeightComparator from .monero_tx_backlog_entry import MoneroTxBacklogEntry from .monero_tx_config import MoneroTxConfig from .monero_tx_pool_stats import MoneroTxPoolStats @@ -158,9 +162,11 @@ __all__ = [ 'MoneroDecodedAddress', 'MoneroDestination', 'MoneroError', + 'GenUtils', 'MoneroFeeEstimate', 'MoneroGenerateBlocksResult', 'MoneroHardForkInfo', + 'IncomingTransferComparator', 'MoneroIncomingTransfer', 'MoneroIntegratedAddress', 'MoneroKeyImage', @@ -181,6 +187,7 @@ __all__ = [ 'MoneroOutputHistogramEntry', 'MoneroOutputQuery', 'MoneroOutputWallet', + 'OutputComparator', 'MoneroPeer', 'MoneroPruneResult', 'MoneroRpcConnection', @@ -191,6 +198,7 @@ __all__ = [ 'MoneroTransfer', 'MoneroTransferQuery', 'MoneroTx', + 'TxHeightComparator', 'MoneroTxBacklogEntry', 'MoneroTxConfig', 'MoneroTxPoolStats', diff --git a/src/python/gen_utils.pyi b/src/python/gen_utils.pyi new file mode 100644 index 0000000..417ece2 --- /dev/null +++ b/src/python/gen_utils.pyi @@ -0,0 +1,140 @@ +from abc import ABC + + +class GenUtils(ABC): + """Collection of generic utilities.""" + + @staticmethod + def get_uuid() -> str: + """ + Return a random unique identifier. + + :returns str: a unique id. + """ + ... + + @staticmethod + def wait_for(duration_ms: int) -> None: + """ + Block the calling thread for the given duration. Releases the GIL + while sleeping. + + :param int duration_ms: duration to wait, in milliseconds. + :raises TypeError: Must be a non-negative number that fits in a valid `uint64_t` range. + """ + ... + + @staticmethod + def bool_equals(val: bool, opt_val: bool | None) -> bool: + """ + Compare a bool to an optional bool. + + :param bool val: value to compare. + :param bool | None opt_val: optional value to compare against; `False` if `None`. + + :returns bool: `True` if `opt_val` is set and equals `val`, `False` otherwise. + """ + ... + + @staticmethod + def reconcile_bool( + val1: bool | None, + val2: bool | None, + resolve_defined: bool | None = None, + resolve_true: bool | None = None, + resolve_max: bool | None = None, + err_msg: str = "", + ) -> bool | None: + """ + Reconcile two optional bools to a single value, the same logic used + internally to merge model fields (e.g. `MoneroTx.merge()`). + + - If both are equal (including both `None`), returns that value. + - If exactly one is `None`, returns the other, unless `resolve_defined` + is `False`, in which case `None` is returned. + - Otherwise, if both are set and differ: `resolve_true` picks whichever + operand equals `resolve_true`; else `resolve_max` picks the greater + (`True`) or lesser (`False`) of the two, treating `True` as 1 and + `False` as 0. + + :param bool | None val1: first value. + :param bool | None val2: second value. + :param bool | None resolve_defined: when only one side is set and this + is `False`, return `None` instead of the set side. + :param bool | None resolve_true: when both sides are set and differ, + prefer whichever operand equals this value. + :param bool | None resolve_max: when both sides are set and differ + (and `resolve_true` didn't resolve it), prefer the greater (`True`) + or lesser (`False`) value. + :param str err_msg: extra context appended to the error message on conflict. + + :returns bool | None: the reconciled value. + :raises RuntimeError: If none of the above resolves. + """ + ... + + @staticmethod + def reconcile_uint64( + val1: int | None, + val2: int | None, + resolve_defined: bool | None = None, + resolve_true: bool | None = None, + resolve_max: bool | None = None, + err_msg: str = "", + ) -> int | None: + """ + Reconcile two optional unsigned 64-bit integers. See `reconcile_bool` + for the resolution rules (`resolve_max` here picks the numeric max/min). + + :param int | None val1: first value. + :param int | None val2: second value. + :param bool | None resolve_defined: see `reconcile_bool`. + :param bool | None resolve_true: see `reconcile_bool`. + :param bool | None resolve_max: prefer the larger (`True`) or smaller (`False`) value. + :param str err_msg: extra context appended to the error message on conflict. + + :returns int | None: the reconciled value. + :raises RuntimeError: If none of the above resolves. + """ + ... + + @staticmethod + def reconcile_string( + val1: str | None, + val2: str | None, + resolve_defined: bool | None = None, + resolve_true: bool | None = None, + resolve_max: bool | None = None, + err_msg: str = "", + ) -> str | None: + """ + Reconcile two optional strings. Unlike the bool/int overloads, + `resolve_true`/`resolve_max` are accepted for signature symmetry but + are not used. + + :param str | None val1: first value. + :param str | None val2: second value. + :param bool | None resolve_defined: see `reconcile_bool`. + :param bool | None resolve_true: accepted but ignored. + :param bool | None resolve_max: accepted but ignored. + :param str err_msg: extra context appended to the error message on conflict. + + :returns str | None: the reconciled value. + :raises RuntimeError: on different strings always regardless of `resolve_true`/`resolve_max` flags. + """ + ... + + @staticmethod + def reconcile_string_list(v1: list[str], v2: list[str], err_msg: str = "") -> list[str]: + """ + Reconcile two string lists: equal lists are returned as-is, an empty + list yields the other. + + :param list[str] v1: first list. + :param list[str] v2: second list. + :param str err_msg: extra context appended to the error message on conflict. + + :returns list[str]: the reconciled list. + :raises RuntimeError: on two different non-empty lists. + """ + ... diff --git a/src/python/incoming_transfer_comparator.pyi b/src/python/incoming_transfer_comparator.pyi new file mode 100644 index 0000000..4f2b993 --- /dev/null +++ b/src/python/incoming_transfer_comparator.pyi @@ -0,0 +1,20 @@ +from .monero_incoming_transfer import MoneroIncomingTransfer + + +class IncomingTransferComparator: + """Compares two incoming transfers by ascending account and subaddress indices.""" + + @staticmethod + def compare(transfer1: MoneroIncomingTransfer, transfer2: MoneroIncomingTransfer) -> bool: + """ + Compare two incoming transfers. + + Compares by transaction height first (see `TxHeightComparator`), then + by account index, then by subaddress index. + + :param MoneroIncomingTransfer transfer1: first transfer to compare. + :param MoneroIncomingTransfer transfer2: second transfer to compare. + + :returns bool: `True` if transfer1 sorts before transfer2, `False` otherwise. + """ + ... diff --git a/src/python/monero_account.pyi b/src/python/monero_account.pyi index 7952c91..455a619 100644 --- a/src/python/monero_account.pyi +++ b/src/python/monero_account.pyi @@ -18,6 +18,16 @@ class MoneroAccount(SerializableStruct): unlocked_balance: int | None """The account unlocked balance.""" + @staticmethod + def deserialize(json: str) -> MoneroAccount: + """ + Deserialize a MoneroAccount from a JSON string. + + :param str json: MoneroAccount in JSON format. + :returns MoneroAccount: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero account.""" ... diff --git a/src/python/monero_account_tag.pyi b/src/python/monero_account_tag.pyi index 9761f49..13d6915 100644 --- a/src/python/monero_account_tag.pyi +++ b/src/python/monero_account_tag.pyi @@ -13,6 +13,16 @@ class MoneroAccountTag(SerializableStruct): tag: str | None """The account tag.""" + @staticmethod + def deserialize(json: str) -> MoneroAccountTag: + """ + Deserialize a MoneroAccountTag from a JSON string. + + :param str json: MoneroAccountTag in JSON format. + :returns MoneroAccountTag: deserialized instance. + """ + ... + @typing.overload def __init__(self) -> None: """ diff --git a/src/python/monero_address_book_entry.pyi b/src/python/monero_address_book_entry.pyi index 9fb4445..bc6eb64 100644 --- a/src/python/monero_address_book_entry.pyi +++ b/src/python/monero_address_book_entry.pyi @@ -15,6 +15,16 @@ class MoneroAddressBookEntry(SerializableStruct): payment_id: str | None """The book entry payment id.""" + @staticmethod + def deserialize(json: str) -> MoneroAddressBookEntry: + """ + Deserialize a MoneroAddressBookEntry from a JSON string. + + :param str json: MoneroAddressBookEntry in JSON format. + :returns MoneroAddressBookEntry: deserialized instance. + """ + ... + @typing.overload def __init__(self) -> None: """Initialize an empty Monero address book entry.""" diff --git a/src/python/monero_alt_chain.pyi b/src/python/monero_alt_chain.pyi index 8b3f080..5cc651e 100644 --- a/src/python/monero_alt_chain.pyi +++ b/src/python/monero_alt_chain.pyi @@ -17,6 +17,16 @@ class MoneroAltChain(SerializableStruct): main_chain_parent_block_hash: str | None """The hash of the greatest height block that is shared between the alternative chain and the main chain.""" + @staticmethod + def deserialize(json: str) -> MoneroAltChain: + """ + Deserialize a MoneroAltChain from a JSON string. + + :param str json: MoneroAltChain in JSON format. + :returns MoneroAltChain: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero alt chain info.""" ... diff --git a/src/python/monero_ban.pyi b/src/python/monero_ban.pyi index 31d0f71..71afa93 100644 --- a/src/python/monero_ban.pyi +++ b/src/python/monero_ban.pyi @@ -13,6 +13,16 @@ class MoneroBan(SerializableStruct): seconds: int | None """Indicates the duration of the ban in seconds.""" + @staticmethod + def deserialize(json: str) -> MoneroBan: + """ + Deserialize a MoneroBan from a JSON string. + + :param str json: MoneroBan in JSON format. + :returns MoneroBan: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero banhammer.""" ... diff --git a/src/python/monero_block_template.pyi b/src/python/monero_block_template.pyi index 8eb5a61..cba5f3d 100644 --- a/src/python/monero_block_template.pyi +++ b/src/python/monero_block_template.pyi @@ -27,6 +27,16 @@ class MoneroBlockTemplate(SerializableStruct): seed_height: int | None """Height of block to use as seed for Random-X proof-of-work.""" + @staticmethod + def deserialize(json: str) -> MoneroBlockTemplate: + """ + Deserialize a MoneroBlockTemplate from a JSON string. + + :param str json: MoneroBlockTemplate in JSON format. + :returns MoneroBlockTemplate: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero block template.""" ... diff --git a/src/python/monero_check_reserve.pyi b/src/python/monero_check_reserve.pyi index ec58f58..482cb0e 100644 --- a/src/python/monero_check_reserve.pyi +++ b/src/python/monero_check_reserve.pyi @@ -9,6 +9,16 @@ class MoneroCheckReserve(MoneroCheck): unconfirmed_spent_amount: int | None """The reserve unconfirmed spent amount.""" + @staticmethod + def deserialize(json: str) -> MoneroCheckReserve: + """ + Deserialize a MoneroCheckReserve from a JSON string. + + :param str json: MoneroCheckReserve in JSON format. + :returns MoneroCheckReserve: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero reserve check.""" ... diff --git a/src/python/monero_check_tx.pyi b/src/python/monero_check_tx.pyi index 260e53a..2e742bf 100644 --- a/src/python/monero_check_tx.pyi +++ b/src/python/monero_check_tx.pyi @@ -11,6 +11,16 @@ class MoneroCheckTx(MoneroCheck): received_amount: int | None """Amount received in the transaction.""" + @staticmethod + def deserialize(json: str) -> MoneroCheckTx: + """ + Deserialize a MoneroCheckTx from a JSON string. + + :param str json: MoneroCheckTx in JSON format. + :returns MoneroCheckTx: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero transaction check.""" ... diff --git a/src/python/monero_connection_span.pyi b/src/python/monero_connection_span.pyi index 8943e87..e34b59a 100644 --- a/src/python/monero_connection_span.pyi +++ b/src/python/monero_connection_span.pyi @@ -19,6 +19,16 @@ class MoneroConnectionSpan(SerializableStruct): start_height: int | None """Block height of the first block in that span.""" + @staticmethod + def deserialize(json: str) -> MoneroConnectionSpan: + """ + Deserialize a MoneroConnectionSpan from a JSON string. + + :param str json: MoneroConnectionSpan in JSON format. + :returns MoneroConnectionSpan: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero connection span.""" ... diff --git a/src/python/monero_daemon_info.pyi b/src/python/monero_daemon_info.pyi index 9c10dba..7bf47c9 100644 --- a/src/python/monero_daemon_info.pyi +++ b/src/python/monero_daemon_info.pyi @@ -72,6 +72,16 @@ class MoneroDaemonInfo(MoneroRpcPaymentInfo): was_bootstrap_ever_used: bool | None """States if a bootstrap node has ever been used since the daemon started.""" + @staticmethod + def deserialize(json: str) -> MoneroDaemonInfo: + """ + Deserialize a MoneroDaemonInfo from a JSON string. + + :param str json: MoneroDaemonInfo in JSON format. + :returns MoneroDaemonInfo: deserialized instance. + """ + ... + def __init__(self) -> None: """Initiliaze a Monero daemon info.""" ... diff --git a/src/python/monero_daemon_sync_info.pyi b/src/python/monero_daemon_sync_info.pyi index 1f512ca..55dccc0 100644 --- a/src/python/monero_daemon_sync_info.pyi +++ b/src/python/monero_daemon_sync_info.pyi @@ -22,6 +22,16 @@ class MoneroDaemonSyncInfo(MoneroRpcPaymentInfo): target_height: int | None """Target height the node is syncing from (will be 0 if node is fully synced).""" + @staticmethod + def deserialize(json: str) -> MoneroDaemonSyncInfo: + """ + Deserialize a MoneroDaemonSyncInfo from a JSON string. + + :param str json: MoneroDaemonSyncInfo in JSON format. + :returns MoneroDaemonSyncInfo: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero daemon sync info.""" ... diff --git a/src/python/monero_daemon_update_check_result.pyi b/src/python/monero_daemon_update_check_result.pyi index b32ddcb..bcba511 100644 --- a/src/python/monero_daemon_update_check_result.pyi +++ b/src/python/monero_daemon_update_check_result.pyi @@ -15,6 +15,16 @@ class MoneroDaemonUpdateCheckResult(SerializableStruct): version: str | None """Version available for download.""" + @staticmethod + def deserialize(json: str) -> MoneroDaemonUpdateCheckResult: + """ + Deserialize a MoneroDaemonUpdateCheckResult from a JSON string. + + :param str json: MoneroDaemonUpdateCheckResult in JSON format. + :returns MoneroDaemonUpdateCheckResult: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero update check result.""" ... diff --git a/src/python/monero_daemon_update_download_result.pyi b/src/python/monero_daemon_update_download_result.pyi index 5eeb399..cb60c29 100644 --- a/src/python/monero_daemon_update_download_result.pyi +++ b/src/python/monero_daemon_update_download_result.pyi @@ -7,6 +7,16 @@ class MoneroDaemonUpdateDownloadResult(MoneroDaemonUpdateCheckResult): download_path: str | None """Path to download the update.""" + @staticmethod + def deserialize(json: str) -> MoneroDaemonUpdateDownloadResult: + """ + Deserialize a MoneroDaemonUpdateDownloadResult from a JSON string. + + :param str json: MoneroDaemonUpdateDownloadResult in JSON format. + :returns MoneroDaemonUpdateDownloadResult: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero update download result.""" ... diff --git a/src/python/monero_destination.pyi b/src/python/monero_destination.pyi index cc34598..057c9f0 100644 --- a/src/python/monero_destination.pyi +++ b/src/python/monero_destination.pyi @@ -10,6 +10,16 @@ class MoneroDestination(SerializableStruct): amount: int | None """Amount sent to this destination.""" + @staticmethod + def deserialize(json: str) -> MoneroDestination: + """ + Deserialize a MoneroDestination from a JSON string. + + :param str json: MoneroDestination in JSON format. + :returns MoneroDestination: deserialized instance. + """ + ... + @typing.overload def __init__(self) -> None: """Initialize a Monero outgoing transfer destination.""" diff --git a/src/python/monero_fee_estimate.pyi b/src/python/monero_fee_estimate.pyi index c7d1d5e..45b6bff 100644 --- a/src/python/monero_fee_estimate.pyi +++ b/src/python/monero_fee_estimate.pyi @@ -11,6 +11,16 @@ class MoneroFeeEstimate(SerializableStruct): quantization_mask: int | None """Final fee should be rounded up to an even multiple of this value.""" + @staticmethod + def deserialize(json: str) -> MoneroFeeEstimate: + """ + Deserialize a MoneroFeeEstimate from a JSON string. + + :param str json: MoneroFeeEstimate in JSON format. + :returns MoneroFeeEstimate: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero fee estimate.""" ... diff --git a/src/python/monero_generate_blocks_result.pyi b/src/python/monero_generate_blocks_result.pyi index 68456da..7b424ba 100644 --- a/src/python/monero_generate_blocks_result.pyi +++ b/src/python/monero_generate_blocks_result.pyi @@ -8,3 +8,13 @@ class MoneroGenerateBlocksResult(SerializableStruct): """Generated block hashes.""" height: int | None """New chain height.""" + + @staticmethod + def deserialize(json: str) -> MoneroGenerateBlocksResult: + """ + Deserialize a MoneroGenerateBlocksResult from a JSON string. + + :param str json: MoneroGenerateBlocksResult in JSON format. + :returns MoneroGenerateBlocksResult: deserialized instance. + """ + ... diff --git a/src/python/monero_hard_fork_info.pyi b/src/python/monero_hard_fork_info.pyi index 86301f1..376c94e 100644 --- a/src/python/monero_hard_fork_info.pyi +++ b/src/python/monero_hard_fork_info.pyi @@ -24,6 +24,16 @@ class MoneroHardForkInfo(MoneroRpcPaymentInfo): window: int | None """Number of blocks over which current votes are cast. Default is `10080` blocks.""" + @staticmethod + def deserialize(json: str) -> MoneroHardForkInfo: + """ + Deserialize a MoneroHardForkInfo from a JSON string. + + :param str json: MoneroHardForkInfo in JSON format. + :returns MoneroHardForkInfo: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero hard fork info.""" ... diff --git a/src/python/monero_incoming_transfer.pyi b/src/python/monero_incoming_transfer.pyi index 5b6b0b7..7fb23e7 100644 --- a/src/python/monero_incoming_transfer.pyi +++ b/src/python/monero_incoming_transfer.pyi @@ -43,3 +43,14 @@ class MoneroIncomingTransfer(MoneroTransfer): :param MoneroTransfer other: other transfer to merge with. """ ... + + def __lt__(self, other: MoneroIncomingTransfer) -> bool: + """ + Compare this incoming transfer to another by ascending tx height, + then account index, then subaddress index (see `IncomingTransferComparator`). + + :param MoneroIncomingTransfer other: transfer to compare against. + + :returns bool: `True` if this transfer sorts before `other`. + """ + ... diff --git a/src/python/monero_integrated_address.pyi b/src/python/monero_integrated_address.pyi index ca36960..e248d08 100644 --- a/src/python/monero_integrated_address.pyi +++ b/src/python/monero_integrated_address.pyi @@ -11,6 +11,16 @@ class MoneroIntegratedAddress(SerializableStruct): standard_address: str """The standard address related to this integrated address.""" + @staticmethod + def deserialize(json: str) -> MoneroIntegratedAddress: + """ + Deserialize a MoneroIntegratedAddress from a JSON string. + + :param str json: MoneroIntegratedAddress in JSON format. + :returns MoneroIntegratedAddress: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero integrated address.""" ... diff --git a/src/python/monero_key_image.pyi b/src/python/monero_key_image.pyi index 80f7112..9c2d881 100644 --- a/src/python/monero_key_image.pyi +++ b/src/python/monero_key_image.pyi @@ -19,6 +19,16 @@ class MoneroKeyImage(SerializableStruct): """ ... + @staticmethod + def deserialize(json: str) -> MoneroKeyImage: + """ + Deserialize a MoneroKeyImage from a JSON string. + + :param str json: MoneroKeyImage in JSON format. + :returns MoneroKeyImage: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero key image.""" ... diff --git a/src/python/monero_key_image_import_result.pyi b/src/python/monero_key_image_import_result.pyi index 4493228..498da10 100644 --- a/src/python/monero_key_image_import_result.pyi +++ b/src/python/monero_key_image_import_result.pyi @@ -11,6 +11,16 @@ class MoneroKeyImageImportResult(SerializableStruct): unspent_amount: int | None """Amount (in atomic-units) still available from those key images.""" + @staticmethod + def deserialize(json: str) -> MoneroKeyImageImportResult: + """ + Deserialize a MoneroKeyImageImportResult from a JSON string. + + :param str json: MoneroKeyImageImportResult in JSON format. + :returns MoneroKeyImageImportResult: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero key image import result.""" ... diff --git a/src/python/monero_message_signature_result.pyi b/src/python/monero_message_signature_result.pyi index 6c57228..a4028bf 100644 --- a/src/python/monero_message_signature_result.pyi +++ b/src/python/monero_message_signature_result.pyi @@ -14,6 +14,16 @@ class MoneroMessageSignatureResult(SerializableStruct): version: int """Message signature version.""" + @staticmethod + def deserialize(json: str) -> MoneroMessageSignatureResult: + """ + Deserialize a MoneroMessageSignatureResult from a JSON string. + + :param str json: MoneroMessageSignatureResult in JSON format. + :returns MoneroMessageSignatureResult: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero message signature result.""" ... diff --git a/src/python/monero_miner_tx_sum.pyi b/src/python/monero_miner_tx_sum.pyi index 7250009..de2fcaa 100644 --- a/src/python/monero_miner_tx_sum.pyi +++ b/src/python/monero_miner_tx_sum.pyi @@ -13,6 +13,16 @@ class MoneroMinerTxSum(SerializableStruct): fee_sum_high: int | None """The sum of fees in atomic-units. (Most significant 64 bits for 128 bit integer)""" + @staticmethod + def deserialize(json: str) -> MoneroMinerTxSum: + """ + Deserialize a MoneroMinerTxSum from a JSON string. + + :param str json: MoneroMinerTxSum in JSON format. + :returns MoneroMinerTxSum: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero miner transaction sum.""" ... diff --git a/src/python/monero_mining_status.pyi b/src/python/monero_mining_status.pyi index ee17844..a295f2c 100644 --- a/src/python/monero_mining_status.pyi +++ b/src/python/monero_mining_status.pyi @@ -15,6 +15,16 @@ class MoneroMiningStatus(SerializableStruct): speed: int | None """Mining power in hashes per seconds.""" + @staticmethod + def deserialize(json: str) -> MoneroMiningStatus: + """ + Deserialize a MoneroMiningStatus from a JSON string. + + :param str json: MoneroMiningStatus in JSON format. + :returns MoneroMiningStatus: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero daemon mining status.""" ... diff --git a/src/python/monero_multisig_info.pyi b/src/python/monero_multisig_info.pyi index 230ebfe..012a269 100644 --- a/src/python/monero_multisig_info.pyi +++ b/src/python/monero_multisig_info.pyi @@ -13,6 +13,16 @@ class MoneroMultisigInfo(SerializableStruct): threshold: int """Number of participants need in order to sign a transaction.""" + @staticmethod + def deserialize(json: str) -> MoneroMultisigInfo: + """ + Deserialize a MoneroMultisigInfo from a JSON string. + + :param str json: MoneroMultisigInfo in JSON format. + :returns MoneroMultisigInfo: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero multisignature info.""" ... diff --git a/src/python/monero_multisig_init_result.pyi b/src/python/monero_multisig_init_result.pyi index 14dd245..7f7febf 100644 --- a/src/python/monero_multisig_init_result.pyi +++ b/src/python/monero_multisig_init_result.pyi @@ -13,6 +13,16 @@ class MoneroMultisigInitResult(SerializableStruct): multisig_hex: str | None """The multisignature hex to share with other participants.""" + @staticmethod + def deserialize(json: str) -> MoneroMultisigInitResult: + """ + Deserialize a MoneroMultisigInitResult from a JSON string. + + :param str json: MoneroMultisigInitResult in JSON format. + :returns MoneroMultisigInitResult: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero multisignature initializing result.""" ... diff --git a/src/python/monero_multisig_sign_result.pyi b/src/python/monero_multisig_sign_result.pyi index 6c219ea..86c04a0 100644 --- a/src/python/monero_multisig_sign_result.pyi +++ b/src/python/monero_multisig_sign_result.pyi @@ -10,6 +10,16 @@ class MoneroMultisigSignResult(SerializableStruct): tx_hashes: list[str] """List of transaction hash.""" + @staticmethod + def deserialize(json: str) -> MoneroMultisigSignResult: + """ + Deserialize a MoneroMultisigSignResult from a JSON string. + + :param str json: MoneroMultisigSignResult in JSON format. + :returns MoneroMultisigSignResult: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero multisignature signature result.""" ... diff --git a/src/python/monero_output.pyi b/src/python/monero_output.pyi index 83d028c..3bef591 100644 --- a/src/python/monero_output.pyi +++ b/src/python/monero_output.pyi @@ -19,6 +19,16 @@ class MoneroOutput(SerializableStruct): tx: MoneroTx """The transaction related to this output.""" + @staticmethod + def deserialize(json: str) -> MoneroOutput: + """ + Deserialize a MoneroOutput from a JSON string. + + :param str json: MoneroOutput in JSON format. + :returns MoneroOutput: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero output.""" ... diff --git a/src/python/monero_output_distribution_entry.pyi b/src/python/monero_output_distribution_entry.pyi index a745489..0af40db 100644 --- a/src/python/monero_output_distribution_entry.pyi +++ b/src/python/monero_output_distribution_entry.pyi @@ -13,6 +13,16 @@ class MoneroOutputDistributionEntry(SerializableStruct): start_height: int | None """Not necessarily equal to `start_height` parameter especially for `amount = 0` where `start_height` will be no less than the height of the v4 hardfork.""" + @staticmethod + def deserialize(json: str) -> MoneroOutputDistributionEntry: + """ + Deserialize a MoneroOutputDistributionEntry from a JSON string. + + :param str json: MoneroOutputDistributionEntry in JSON format. + :returns MoneroOutputDistributionEntry: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero output distribution entry.""" ... diff --git a/src/python/monero_output_histogram_entry.pyi b/src/python/monero_output_histogram_entry.pyi index 211b435..a08a3db 100644 --- a/src/python/monero_output_histogram_entry.pyi +++ b/src/python/monero_output_histogram_entry.pyi @@ -13,6 +13,16 @@ class MoneroOutputHistogramEntry(SerializableStruct): unlocked_instances: int | None """Number of unlocked outputs.""" + @staticmethod + def deserialize(json: str) -> MoneroOutputHistogramEntry: + """ + Deserialize a MoneroOutputHistogramEntry from a JSON string. + + :param str json: MoneroOutputHistogramEntry in JSON format. + :returns MoneroOutputHistogramEntry: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero output histogram entry.""" ... diff --git a/src/python/monero_output_query.pyi b/src/python/monero_output_query.pyi index 4d2d807..1214bc2 100644 --- a/src/python/monero_output_query.pyi +++ b/src/python/monero_output_query.pyi @@ -34,6 +34,16 @@ class MoneroOutputQuery(MoneroOutputWallet): """ ... + @staticmethod + def deserialize(json: str) -> MoneroOutputQuery: + """ + Deserialize a MoneroOutputQuery from a JSON string. + + :param str json: MoneroOutputQuery in JSON format. + :returns MoneroOutputQuery: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero output query.""" ... diff --git a/src/python/monero_output_wallet.pyi b/src/python/monero_output_wallet.pyi index dbc3d1d..c8ce943 100644 --- a/src/python/monero_output_wallet.pyi +++ b/src/python/monero_output_wallet.pyi @@ -15,6 +15,16 @@ class MoneroOutputWallet(MoneroOutput): subaddress_index: int | None """The index of the subaddress that owns this output.""" + @staticmethod + def deserialize(json: str) -> MoneroOutputWallet: + """ + Deserialize a MoneroOutputWallet from a JSON string. + + :param str json: MoneroOutputWallet in JSON format. + :returns MoneroOutputWallet: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero wallet output.""" ... @@ -45,3 +55,15 @@ class MoneroOutputWallet(MoneroOutput): :param MoneroOutput other: other output to merge with. """ ... + + def __lt__(self, other: MoneroOutputWallet) -> bool: + """ + Compare this output to another by ascending tx height, then account + index, subaddress index, output index and key image hex (see + `OutputComparator`). + + :param MoneroOutputWallet other: output to compare against. + + :returns bool: `True` if this output sorts before `other`. + """ + ... diff --git a/src/python/monero_peer.pyi b/src/python/monero_peer.pyi index 70e2d4d..bf213ea 100644 --- a/src/python/monero_peer.pyi +++ b/src/python/monero_peer.pyi @@ -58,6 +58,16 @@ class MoneroPeer(SerializableStruct): state: str | None """Peer state.""" + @staticmethod + def deserialize(json: str) -> MoneroPeer: + """ + Deserialize a MoneroPeer from a JSON string. + + :param str json: MoneroPeer in JSON format. + :returns MoneroPeer: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a new Monero peer.""" ... diff --git a/src/python/monero_prune_result.pyi b/src/python/monero_prune_result.pyi index 93cd4cd..4e8aa30 100644 --- a/src/python/monero_prune_result.pyi +++ b/src/python/monero_prune_result.pyi @@ -9,6 +9,16 @@ class MoneroPruneResult(SerializableStruct): pruning_seed: int | None """Blockheight at which pruning began.""" + @staticmethod + def deserialize(json: str) -> MoneroPruneResult: + """ + Deserialize a MoneroPruneResult from a JSON string. + + :param str json: MoneroPruneResult in JSON format. + :returns MoneroPruneResult: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero prune result.""" ... diff --git a/src/python/monero_rpc_connection.pyi b/src/python/monero_rpc_connection.pyi index 8396ce7..93d919c 100644 --- a/src/python/monero_rpc_connection.pyi +++ b/src/python/monero_rpc_connection.pyi @@ -44,6 +44,16 @@ class MoneroRpcConnection(SerializableStruct): """ ... + @staticmethod + def deserialize(json: str) -> MoneroRpcConnection: + """ + Deserialize a MoneroRpcConnection from a JSON string. + + :param str json: MoneroRpcConnection in JSON format. + :returns MoneroRpcConnection: deserialized instance. + """ + ... + @typing.overload def __init__(self, uri: str = '', username: str = '', password: str = '', proxy_uri: str = '', zmq_uri: str = '', priority: int = 0, timeout_ms: int | None = None) -> None: """ diff --git a/src/python/monero_rpc_payment_info.pyi b/src/python/monero_rpc_payment_info.pyi index 4376682..1b331c8 100644 --- a/src/python/monero_rpc_payment_info.pyi +++ b/src/python/monero_rpc_payment_info.pyi @@ -9,3 +9,13 @@ class MoneroRpcPaymentInfo(SerializableStruct): top_block_hash: str | None """If payment for RPC is enabled, the hash of the highest block in the chain. Otherwise, `None`.""" + + @staticmethod + def deserialize(json: str) -> MoneroRpcPaymentInfo: + """ + Deserialize a MoneroRpcPaymentInfo from a JSON string. + + :param str json: MoneroRpcPaymentInfo in JSON format. + :returns MoneroRpcPaymentInfo: deserialized instance. + """ + ... diff --git a/src/python/monero_subaddress.pyi b/src/python/monero_subaddress.pyi index 2b066f2..332df12 100644 --- a/src/python/monero_subaddress.pyi +++ b/src/python/monero_subaddress.pyi @@ -23,6 +23,16 @@ class MoneroSubaddress(SerializableStruct): unlocked_balance: int | None """The subaddress unlocked balance.""" + @staticmethod + def deserialize(json: str) -> MoneroSubaddress: + """ + Deserialize a MoneroSubaddress from a JSON string. + + :param str json: MoneroSubaddress in JSON format. + :returns MoneroSubaddress: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero subaddress.""" ... diff --git a/src/python/monero_submit_tx_result.pyi b/src/python/monero_submit_tx_result.pyi index 0e93c6b..9bfbf3e 100644 --- a/src/python/monero_submit_tx_result.pyi +++ b/src/python/monero_submit_tx_result.pyi @@ -31,6 +31,16 @@ class MoneroSubmitTxResult(MoneroRpcPaymentInfo): sanity_check_failed: bool | None """Indicates if the transaction sanity check has failed.""" + @staticmethod + def deserialize(json: str) -> MoneroSubmitTxResult: + """ + Deserialize a MoneroSubmitTxResult from a JSON string. + + :param str json: MoneroSubmitTxResult in JSON format. + :returns MoneroSubmitTxResult: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a new submit transaction result.""" ... diff --git a/src/python/monero_transfer_query.pyi b/src/python/monero_transfer_query.pyi index 5197516..a5b5fa6 100644 --- a/src/python/monero_transfer_query.pyi +++ b/src/python/monero_transfer_query.pyi @@ -41,6 +41,16 @@ class MoneroTransferQuery(MoneroTransfer): """ ... + @staticmethod + def deserialize(json: str) -> MoneroTransferQuery: + """ + Deserialize a MoneroTransferQuery from a JSON string. + + :param str json: MoneroTransferQuery in JSON format. + :returns MoneroTransferQuery: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero transfer query.""" ... diff --git a/src/python/monero_tx.pyi b/src/python/monero_tx.pyi index 4062265..c11da01 100644 --- a/src/python/monero_tx.pyi +++ b/src/python/monero_tx.pyi @@ -84,6 +84,16 @@ class MoneroTx(SerializableStruct): """Transaction version.""" weight: int | None """The weight of this transaction in bytes.""" + @staticmethod + def deserialize(json: str) -> MoneroTx: + """ + Deserialize a MoneroTx from a JSON string. + + :param str json: MoneroTx in JSON format. + :returns MoneroTx: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a new Monero transaction.""" ... @@ -108,3 +118,12 @@ class MoneroTx(SerializableStruct): :param MoneroTx other: other tx to merge with. """ ... + def __lt__(self, other: MoneroTx) -> bool: + """ + Compare this tx to another by height (see `TxHeightComparator`). + + :param MoneroTx other: tx to compare against. + + :returns bool: `True` if this tx sorts before `other`. + """ + ... diff --git a/src/python/monero_tx_pool_stats.pyi b/src/python/monero_tx_pool_stats.pyi index 2310a3c..a65848d 100644 --- a/src/python/monero_tx_pool_stats.pyi +++ b/src/python/monero_tx_pool_stats.pyi @@ -31,6 +31,16 @@ class MoneroTxPoolStats(SerializableStruct): histo: dict[int, int] """Txs histogram (key for bytes, value for txs).""" + @staticmethod + def deserialize(json: str) -> MoneroTxPoolStats: + """ + Deserialize a MoneroTxPoolStats from a JSON string. + + :param str json: MoneroTxPoolStats in JSON format. + :returns MoneroTxPoolStats: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a Monero transaction pool statistics.""" ... diff --git a/src/python/monero_tx_query.pyi b/src/python/monero_tx_query.pyi index 1e1abbc..1660646 100644 --- a/src/python/monero_tx_query.pyi +++ b/src/python/monero_tx_query.pyi @@ -44,6 +44,16 @@ class MoneroTxQuery(MoneroTxWallet): :returns MoneroTxQuery: deserialized tx query. """ ... + @staticmethod + def deserialize(json: str) -> MoneroTxQuery: + """ + Deserialize a MoneroTxQuery from a JSON string. + + :param str json: MoneroTxQuery in JSON format. + :returns MoneroTxQuery: deserialized instance. + """ + ... + def __init__(self) -> None: """Initiliaze a new Monero transaction query.""" ... diff --git a/src/python/monero_tx_wallet.pyi b/src/python/monero_tx_wallet.pyi index 1f9a1e1..88e8416 100644 --- a/src/python/monero_tx_wallet.pyi +++ b/src/python/monero_tx_wallet.pyi @@ -39,6 +39,16 @@ class MoneroTxWallet(MoneroTx): """The total output amount sum originated from this transaction.""" tx_set: MoneroTxSet | None """Set of transactions related to current tx.""" + @staticmethod + def deserialize(json: str) -> MoneroTxWallet: + """ + Deserialize a MoneroTxWallet from a JSON string. + + :param str json: MoneroTxWallet in JSON format. + :returns MoneroTxWallet: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a new Monero tx wallet.""" ... diff --git a/src/python/monero_utils.pyi b/src/python/monero_utils.pyi index fb53892..556530f 100644 --- a/src/python/monero_utils.pyi +++ b/src/python/monero_utils.pyi @@ -1,7 +1,10 @@ +import typing + from typing import Any from .monero_output_wallet import MoneroOutputWallet from .monero_block import MoneroBlock from .monero_transfer import MoneroTransfer +from .monero_tx import MoneroTx from .monero_tx_wallet import MoneroTxWallet from .monero_tx_config import MoneroTxConfig from .monero_network_type import MoneroNetworkType @@ -101,6 +104,68 @@ class MoneroUtils: """ ... + @staticmethod + @typing.overload + def free(block: MoneroBlock) -> None: + """ + Break the internal parent/child shared_ptr cycles (tx <-> block, + transfer <-> tx, output <-> tx, ...) that would otherwise keep this + block's data alive forever, since Python's cyclic garbage collector + cannot see reference cycles hidden inside C++ shared_ptr members. + + :param MoneroBlock block: block to free. + """ + ... + @staticmethod + @typing.overload + def free(blocks: list[MoneroBlock]) -> None: + """ + Free each of the given blocks. See `free(block)`. + + :param list[MoneroBlock] blocks: blocks to free. + """ + ... + @staticmethod + @typing.overload + def free(tx: MoneroTx) -> None: + """ + Free the block that owns this transaction (creating one first if the + transaction is unconfirmed). See `free(block)`. + + :param MoneroTx tx: transaction whose block should be freed. + """ + ... + @staticmethod + @typing.overload + def free(txs: list[MoneroTxWallet]) -> None: + """ + Free the distinct blocks referenced by the given transactions. See + `get_blocks_from_txs` and `free(block)`. + + :param list[MoneroTxWallet] txs: transactions whose blocks should be freed. + """ + ... + @staticmethod + @typing.overload + def free(transfers: list[MoneroTransfer]) -> None: + """ + Free the distinct blocks referenced by the given transfers. See + `get_blocks_from_transfers` and `free(block)`. + + :param list[MoneroTransfer] transfers: transfers whose blocks should be freed. + """ + ... + @staticmethod + @typing.overload + def free(outputs: list[MoneroOutputWallet]) -> None: + """ + Free the distinct blocks referenced by the given outputs. See + `get_blocks_from_outputs` and `free(block)`. + + :param list[MoneroOutputWallet] outputs: outputs whose blocks should be freed. + """ + ... + @staticmethod def get_integrated_address(network_type: MoneroNetworkType, standard_address: str, payment_id: str = '') -> MoneroIntegratedAddress: """ diff --git a/src/python/monero_version.pyi b/src/python/monero_version.pyi index 2bbf942..6375a1c 100644 --- a/src/python/monero_version.pyi +++ b/src/python/monero_version.pyi @@ -9,6 +9,16 @@ class MoneroVersion(SerializableStruct): number: int | None """Number of the monero software version.""" + @staticmethod + def deserialize(json: str) -> MoneroVersion: + """ + Deserialize a MoneroVersion from a JSON string. + + :param str json: MoneroVersion in JSON format. + :returns MoneroVersion: deserialized instance. + """ + ... + def __init__(self) -> None: """Initialize a new Monero version.""" ... diff --git a/src/python/output_comparator.pyi b/src/python/output_comparator.pyi new file mode 100644 index 0000000..53ee998 --- /dev/null +++ b/src/python/output_comparator.pyi @@ -0,0 +1,21 @@ +from .monero_output_wallet import MoneroOutputWallet + + +class OutputComparator: + """Compares two wallet outputs by ascending account, subaddress and output index.""" + + @staticmethod + def compare(output1: MoneroOutputWallet, output2: MoneroOutputWallet) -> bool: + """ + Compare two wallet outputs. + + Compares by transaction height first (see `TxHeightComparator`), then + by account index, subaddress index, output index and finally key + image hex. + + :param MoneroOutputWallet output1: first output to compare. + :param MoneroOutputWallet output2: second output to compare. + + :returns bool: `True` if output1 sorts before output2, `False` otherwise. + """ + ... diff --git a/src/python/tx_height_comparator.pyi b/src/python/tx_height_comparator.pyi new file mode 100644 index 0000000..755d978 --- /dev/null +++ b/src/python/tx_height_comparator.pyi @@ -0,0 +1,21 @@ +from .monero_tx import MoneroTx + + +class TxHeightComparator: + """Compares two transactions by their height.""" + + @staticmethod + def compare(tx1: MoneroTx, tx2: MoneroTx) -> bool: + """ + Compare two transactions by height. + + Unconfirmed transactions (no block) sort after confirmed ones; when + both are unconfirmed or share the same height and block, their + original order within the block's tx list is preserved. + + :param MoneroTx tx1: first transaction to compare. + :param MoneroTx tx2: second transaction to compare. + + :returns bool: `True` if tx1 sorts before tx2, `False` otherwise. + """ + ... diff --git a/tests/config/config.ini b/tests/config/config.ini index a68ff4b..8174c16 100644 --- a/tests/config/config.ini +++ b/tests/config/config.ini @@ -1,7 +1,7 @@ [general] test_relays=True test_non_relays=True -lite_mode=True +lite_mode=False test_notifications=True test_resets=True network_type=regtest diff --git a/tests/test_gen_utils.py b/tests/test_gen_utils.py new file mode 100644 index 0000000..bed03a7 --- /dev/null +++ b/tests/test_gen_utils.py @@ -0,0 +1,152 @@ +import pytest +import logging +import re +import time + +from monero import GenUtils +from utils import BaseTestClass + +logger: logging.Logger = logging.getLogger("TestGenUtils") + +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + +@pytest.mark.unit +class TestGenUtils(BaseTestClass): + """Unit tests for GenUtils.""" + + #region uuid / wait_for / bool_equals + + def test_get_uuid_format_and_uniqueness(self) -> None: + uuid1 = GenUtils.get_uuid() + uuid2 = GenUtils.get_uuid() + logger.debug(f"get_uuid(): {uuid1}, {uuid2}") + assert _UUID_RE.match(uuid1), f"not a UUID: {uuid1}" + assert _UUID_RE.match(uuid2), f"not a UUID: {uuid2}" + assert uuid1 != uuid2 + + def test_wait_for_blocks_for_at_least_duration(self) -> None: + start = time.monotonic() + GenUtils.wait_for(50) + elapsed_ms = (time.monotonic() - start) * 1000 + logger.debug(f"wait_for(50) actually took {elapsed_ms:.1f} ms") + assert elapsed_ms >= 50 + + def test_wait_for_zero_does_not_block(self) -> None: + start = time.monotonic() + GenUtils.wait_for(0) + elapsed_ms = (time.monotonic() - start) * 1000 + assert elapsed_ms < 50 + + def test_wait_for_negative_raises(self) -> None: + with pytest.raises(TypeError): + GenUtils.wait_for(-1) # type: ignore + + def test_bool_equals(self) -> None: + assert GenUtils.bool_equals(True, True) is True + assert GenUtils.bool_equals(False, False) is True + assert GenUtils.bool_equals(True, False) is False + assert GenUtils.bool_equals(False, True) is False + # opt_val=None is documented to compare as False, even against val=False + assert GenUtils.bool_equals(True, None) is False + assert GenUtils.bool_equals(False, None) is False + + #endregion + + #region reconcile: equality and gap filling + + def test_reconcile_uint64_equal_returns_value(self) -> None: + assert GenUtils.reconcile_uint64(5, 5) == 5 + assert GenUtils.reconcile_uint64(None, None) is None + + def test_reconcile_uint64_gap_fill(self) -> None: + assert GenUtils.reconcile_uint64(None, 5) == 5 + assert GenUtils.reconcile_uint64(5, None) == 5 + + def test_reconcile_uint64_resolve_defined_false_returns_none_on_gap(self) -> None: + # resolve_defined=False overrides the default "fill the gap" behavior + assert GenUtils.reconcile_uint64(None, 5, resolve_defined=False) is None + assert GenUtils.reconcile_uint64(5, None, resolve_defined=False) is None + + def test_reconcile_bool_equal_and_gap_fill(self) -> None: + assert GenUtils.reconcile_bool(True, True) is True + assert GenUtils.reconcile_bool(None, True) is True + assert GenUtils.reconcile_bool(False, None) is False + + def test_reconcile_string_equal_and_gap_fill(self) -> None: + assert GenUtils.reconcile_string("x", "x") == "x" + assert GenUtils.reconcile_string(None, "x") == "x" + assert GenUtils.reconcile_string("x", None) == "x" + + def test_reconcile_string_list_equal_and_gap_fill(self) -> None: + assert GenUtils.reconcile_string_list(["a", "b"], ["a", "b"]) == ["a", "b"] + assert GenUtils.reconcile_string_list([], ["a", "b"]) == ["a", "b"] + assert GenUtils.reconcile_string_list(["a", "b"], []) == ["a", "b"] + + #endregion + + #region reconcile: conflicts raise by design + + def test_reconcile_uint64_conflict_raises(self) -> None: + with pytest.raises(RuntimeError, match="Cannot reconcile integrals"): + GenUtils.reconcile_uint64(3, 7) + + def test_reconcile_bool_conflict_without_resolver_raises(self) -> None: + with pytest.raises(RuntimeError, match="Cannot reconcile integrals"): + GenUtils.reconcile_bool(True, False) + + def test_reconcile_string_conflict_raises(self) -> None: + with pytest.raises(RuntimeError, match="Cannot reconcile strings"): + GenUtils.reconcile_string("a", "b") + + def test_reconcile_string_list_conflict_raises(self) -> None: + with pytest.raises(RuntimeError, match="Cannot reconcile vectors"): + GenUtils.reconcile_string_list(["a"], ["b"]) + + def test_reconcile_string_resolve_true_is_ignored(self) -> None: + # unlike the bool/int overloads, the string overload accepts + # resolve_true/resolve_max for signature symmetry but never reads them + with pytest.raises(RuntimeError, match="Cannot reconcile strings"): + GenUtils.reconcile_string("a", "b", resolve_true=True) + + #endregion + + #region reconcile: resolve_max picks the numeric extreme + + def test_reconcile_uint64_resolve_max_true_picks_greater(self) -> None: + assert GenUtils.reconcile_uint64(3, 7, resolve_max=True) == 7 + assert GenUtils.reconcile_uint64(7, 3, resolve_max=True) == 7 + + def test_reconcile_uint64_resolve_max_false_picks_lesser(self) -> None: + assert GenUtils.reconcile_uint64(3, 7, resolve_max=False) == 3 + assert GenUtils.reconcile_uint64(7, 3, resolve_max=False) == 3 + + #endregion + + #region reconcile values + + @pytest.mark.xfail(reason="gen_utils::reconcile()'s resolve_true branch casts the boost::optional wrapper to bool instead of its value, so it always returns val1 (ignoring which operand is actually true)", strict=True) + def test_reconcile_bool_resolve_true_prefers_the_true_operand(self) -> None: + # val1=False, val2=True, resolve_true=True -> should prefer the + # operand that IS true, i.e. val2 + result = GenUtils.reconcile_bool(False, True, resolve_true=True) + logger.debug(f"reconcile_bool(False, True, resolve_true=True) = {result}") + assert result is True + + @pytest.mark.xfail(reason="gen_utils::reconcile()'s resolve_true branch casts the boost::optional wrapper to bool instead of its value, so it always returns val2 for resolve_true=False (ignoring which operand is actually false)", strict=True) + def test_reconcile_bool_resolve_true_false_prefers_the_false_operand(self) -> None: + # val1=False, val2=True, resolve_true=False -> should prefer the + # operand that IS false, i.e. val1 + result = GenUtils.reconcile_bool(False, True, resolve_true=False) + logger.debug(f"reconcile_bool(False, True, resolve_true=False) = {result}") + assert result is False + + @pytest.mark.xfail(reason="same resolve_true bug as reconcile_bool, reproduced with the uint64 overload to show it isn't bool-specific", strict=True) + def test_reconcile_uint64_resolve_true_prefers_the_true_operand(self) -> None: + # val1=0 (falsy), val2=1 (truthy), resolve_true=True -> should prefer + # val2 since it's the operand whose bool cast is True + result = GenUtils.reconcile_uint64(0, 1, resolve_true=True) + logger.debug(f"reconcile_uint64(0, 1, resolve_true=True) = {result}") + assert result == 1 + + #endregion diff --git a/tests/test_monero_daemon_model.py b/tests/test_monero_daemon_model.py new file mode 100644 index 0000000..096e991 --- /dev/null +++ b/tests/test_monero_daemon_model.py @@ -0,0 +1,743 @@ +import pytest +import logging + +from monero import ( + MoneroVersion, MoneroRpcPaymentInfo, MoneroRpcConnection, MoneroAltChain, + MoneroBan, MoneroPruneResult, MoneroMiningStatus, MoneroMinerTxSum, + MoneroBlockTemplate, MoneroConnectionSpan, MoneroPeer, MoneroConnectionType, + MoneroSubmitTxResult, MoneroOutputDistributionEntry, MoneroOutputHistogramEntry, + MoneroTxPoolStats, MoneroDaemonUpdateCheckResult, MoneroDaemonUpdateDownloadResult, + MoneroFeeEstimate, MoneroDaemonInfo, MoneroNetworkType, MoneroDaemonSyncInfo, + MoneroHardForkInfo, MoneroGenerateBlocksResult, MoneroTx, MoneroKeyImage, + MoneroOutput, MoneroBlockHeader, MoneroBlock, TxHeightComparator +) +from utils import BaseTestClass, AssertUtils + +logger: logging.Logger = logging.getLogger("TestMoneroDaemonModel") + + +@pytest.mark.unit +class TestMoneroDaemonModel(BaseTestClass): + """Test monero daemon data models' deserialize() (from_property_tree) round trips.""" + + #region Common / rpc models + + def test_version_deserialize(self) -> None: + version = MoneroVersion() + version.number = 65552 + version.is_release = True + AssertUtils.assert_serialization_integrity(version) + + def test_rpc_payment_info_deserialize(self) -> None: + info = MoneroRpcPaymentInfo() + info.credits = 42 + info.top_block_hash = "a" * 64 + AssertUtils.assert_serialization_integrity(info) + + def test_rpc_connection_deserialize(self) -> None: + connection = MoneroRpcConnection("http://127.0.0.1:18081", "user", "pass", "127.0.0.1:9050", "tcp://127.0.0.1:18083", 2, 5000) + json_str = connection.serialize() + logger.debug(f"Serialized rpc connection: {json_str}") + restored = MoneroRpcConnection.deserialize(json_str) + assert restored.uri == connection.uri + assert restored.username == connection.username + assert restored.password == connection.password + assert restored.proxy_uri == connection.proxy_uri + assert restored.zmq_uri == connection.zmq_uri + + @pytest.mark.xfail(reason="monero_rpc_connection::from_property_tree() doesn't read back priority/timeoutMs; fixed upstream in the local everoddandeven/monero-cpp checkout, pending a submodule bump", strict=True) + def test_rpc_connection_priority_and_timeout_deserialize(self) -> None: + # to_rapidjson_val() emits "priority" and "timeoutMs" but + # from_property_tree() never read either back + connection = MoneroRpcConnection("http://127.0.0.1:18081", priority=2, timeout_ms=5000) + json_str = connection.serialize() + logger.debug(f"Serialized rpc connection: {json_str}") + assert '"priority"' in json_str and '"timeoutMs"' in json_str + restored = MoneroRpcConnection.deserialize(json_str) + logger.debug(f"Deserialized rpc connection re-serialized: {restored.serialize()}") + assert restored.priority == connection.priority + assert restored.timeout_ms == connection.timeout_ms + + #endregion + + #region Blockchain / mining models + + def test_alt_chain_deserialize(self) -> None: + alt_chain = MoneroAltChain() + alt_chain.block_hashes = ["a" * 64, "b" * 64] + alt_chain.difficulty_low = 100 + alt_chain.difficulty_high = 0 + alt_chain.height = 12345 + alt_chain.length = 3 + alt_chain.main_chain_parent_block_hash = "c" * 64 + AssertUtils.assert_serialization_integrity(alt_chain) + + def test_ban_deserialize(self) -> None: + ban = MoneroBan() + ban.host = "127.0.0.1" + ban.ip = 2130706433 + ban.is_banned = True + ban.seconds = 3600 + AssertUtils.assert_serialization_integrity(ban) + + def test_prune_result_deserialize(self) -> None: + result = MoneroPruneResult() + result.pruning_seed = 387 + # is_pruned is deliberately not set here: to_rapidjson_val() serializes it + # under "isPruned" but from_property_tree() looks for "pruned" instead, so + # it never round trips (see test below) + AssertUtils.assert_serialization_integrity(result) + + @pytest.mark.xfail(reason="monero_prune_result::from_property_tree() bug", strict=True) + def test_prune_result_is_pruned_deserialize(self) -> None: + result = MoneroPruneResult() + result.is_pruned = True + json_str = result.serialize() + logger.debug(f"Serialized prune result: {json_str}") + assert '"isPruned"' in json_str + restored = MoneroPruneResult.deserialize(json_str) + logger.debug(f"Deserialized prune result re-serialized: {restored.serialize()}") + assert restored.is_pruned == result.is_pruned + + def test_mining_status_deserialize(self) -> None: + status = MoneroMiningStatus() + status.is_active = True + status.is_background = False + status.address = "9" + "a" * 94 + status.speed = 500 + status.num_threads = 4 + AssertUtils.assert_serialization_integrity(status) + + def test_miner_tx_sum_deserialize(self) -> None: + summ = MoneroMinerTxSum() + summ.emission_sum_low = 1000 + summ.emission_sum_high = 0 + summ.fee_sum_low = 10 + summ.fee_sum_high = 0 + AssertUtils.assert_serialization_integrity(summ) + + def test_block_template_deserialize(self) -> None: + template = MoneroBlockTemplate() + template.block_template_blob = "abcd" + template.block_hashing_blob = "ef01" + template.prev_hash = "a" * 64 + template.seed_hash = "b" * 64 + template.next_seed_hash = "c" * 64 + template.difficulty_low = 5000 + template.difficulty_high = 0 + template.expected_reward = 600000000000 + template.height = 12345 + template.reserved_offset = 130 + template.seed_height = 0 + AssertUtils.assert_serialization_integrity(template) + + def test_connection_span_deserialize(self) -> None: + span = MoneroConnectionSpan() + span.connection_id = "deadbeef" + span.remote_address = "127.0.0.1:18080" + span.num_blocks = 10 + span.rate = 100 + span.speed = 200 + span.size = 1024 + span.start_height = 1000 + AssertUtils.assert_serialization_integrity(span) + + def test_peer_deserialize(self) -> None: + peer = MoneroPeer() + peer.id = "1122334455667788" + peer.address = "127.0.0.1:18080" + peer.host = "127.0.0.1" + peer.port = 18080 + peer.last_seen_timestamp = 1700000000 + peer.pruning_seed = 0 + peer.rpc_port = 18081 + peer.rpc_credits_per_hash = 0 + peer.hash = "a" * 64 + peer.avg_download = 10 + peer.avg_upload = 20 + peer.current_download = 30 + peer.current_upload = 40 + peer.height = 12345 + peer.is_incoming = True + peer.live_time = 3600 + peer.is_local_ip = False + peer.is_local_host = False + peer.num_receives = 5 + peer.num_sends = 6 + peer.receive_idle_time = 7 + peer.send_idle_time = 8 + peer.state = "normal" + peer.num_support_flags = 1 + # is_online and connection_type are serialized (isOnline/addressType) but + # monero_peer::from_property_tree() never reads them back, so they are + # deliberately excluded from this round trip (see test below). + AssertUtils.assert_serialization_integrity(peer) + + @pytest.mark.xfail(reason="monero_peer::from_property_tree() bug", strict=True) + def test_peer_is_online_deserialize(self) -> None: + peer = MoneroPeer() + peer.is_online = True + json_str = peer.serialize() + logger.debug(f"Serialized peer: {json_str}") + assert "isOnline" in json_str + restored = MoneroPeer.deserialize(json_str) + logger.debug(f"Deserialized peer re-serialized: {restored.serialize()}") + assert restored.is_online == peer.is_online + + @pytest.mark.xfail(reason="monero_peer::to_rapidjson_val() bug/monero-cpp checkout", strict=True) + def test_peer_connection_serialization_integrity(self) -> None: + peer = MoneroPeer() + peer.connection_type = MoneroConnectionType.IPV6 + json_str = peer.serialize() + logger.debug(f"Serialized peer: {json_str}") + assert "addressType" in json_str + restored = MoneroPeer.deserialize(json_str) + logger.debug(f"Deserialized peer re-serialized: {restored.serialize()}") + assert restored.connection_type == peer.connection_type + + def test_peer_connection_type_deserialize(self) -> None: + # connection_type is likewise never serialized by to_rapidjson_val(), so it + # can only be exercised by handing from_property_tree a raw "addressType" field + for value, expected in [ + (0, MoneroConnectionType.INVALID), + (1, MoneroConnectionType.IPV4), + (2, MoneroConnectionType.IPV6), + (3, MoneroConnectionType.TOR), + (4, MoneroConnectionType.I2P), + ]: + peer = MoneroPeer.deserialize(f'{{"addressType":{value}}}') + assert peer.connection_type == expected + + def test_peer_connection_type_invalid(self) -> None: + # TODO throws RuntimeError rather than MoneroError + with pytest.raises(RuntimeError, match="Invalid RPC peer type"): + MoneroPeer.deserialize('{"addressType":5}') + + def test_submit_tx_result_deserialize(self) -> None: + result = MoneroSubmitTxResult() + result.credits = 1 + result.top_block_hash = "a" * 64 + result.is_relayed = True + result.is_double_spend = False + result.is_fee_too_low = False + result.has_invalid_input = False + result.has_invalid_output = False + result.has_too_few_outputs = False + result.is_mixin_too_low = False + result.is_overspend = False + result.reason = "ok" + result.is_too_big = False + result.sanity_check_failed = False + result.is_tx_extra_too_big = False + result.is_nonzero_unlock_time = False + # is_good is serialized but never read back by from_property_tree() + AssertUtils.assert_serialization_integrity(result) + + @pytest.mark.xfail(reason="monero_submit_tx_result::from_property_tree() bug", strict=True) + def test_submit_tx_result_is_good_deserialize(self) -> None: + result = MoneroSubmitTxResult() + result.is_good = True + json_str = result.serialize() + logger.debug(f"Serialized submit tx result: {json_str}") + assert "isGood" in json_str + restored = MoneroSubmitTxResult.deserialize(json_str) + logger.debug(f"Deserialized submit tx result re-serialized: {restored.serialize()}") + assert restored.is_good == result.is_good + + def test_output_distribution_entry_deserialize(self) -> None: + entry = MoneroOutputDistributionEntry() + entry.amount = 0 + entry.base = 100 + entry.distribution = [1, 2, 3, 4] + entry.start_height = 0 + AssertUtils.assert_serialization_integrity(entry) + + def test_output_histogram_entry_deserialize(self) -> None: + entry = MoneroOutputHistogramEntry() + entry.amount = 0 + entry.num_instances = 10 + entry.unlocked_instances = 8 + entry.recent_instances = 2 + AssertUtils.assert_serialization_integrity(entry) + + def test_tx_pool_stats_deserialize(self) -> None: + stats = MoneroTxPoolStats() + stats.num_txs = 5 + stats.num_not_relayed = 1 + stats.num_failing = 0 + stats.num_double_spends = 0 + stats.num10m = 2 + stats.fee_total = 1000 + stats.bytes_max = 2000 + stats.bytes_med = 1500 + stats.bytes_min = 1000 + stats.bytes_total = 5000 + stats.histo98pc = 1800 + stats.oldest_timestamp = 1700000000 + # histo is serialized as an object but from_property_tree() has a TODO + # and never reads it back into the map + AssertUtils.assert_serialization_integrity(stats) + + @pytest.mark.xfail(reason="monero_tx_pool_stats::from_property_tree() bug", strict=True) + def test_tx_pool_stats_histo_deserialize(self) -> None: + stats = MoneroTxPoolStats() + stats.histo = {100: 1, 200: 2} + json_str = stats.serialize() + logger.debug(f"Serialized tx pool stats: {json_str}") + assert "histo" in json_str + restored = MoneroTxPoolStats.deserialize(json_str) + logger.debug(f"Deserialized tx pool stats re-serialized: {restored.serialize()}") + assert dict(restored.histo) == dict(stats.histo) + + def test_daemon_update_check_result_deserialize(self) -> None: + result = MoneroDaemonUpdateCheckResult() + result.is_update_available = True + result.version = "0.18.5.1" + result.hash = "a" * 64 + result.auto_uri = "https://example.com/auto" + result.user_uri = "https://example.com/user" + AssertUtils.assert_serialization_integrity(result) + + def test_daemon_update_download_result_deserialize(self) -> None: + result = MoneroDaemonUpdateDownloadResult() + result.is_update_available = True + result.version = "0.18.5.1" + result.hash = "a" * 64 + result.auto_uri = "https://example.com/auto" + result.user_uri = "https://example.com/user" + result.download_path = "/tmp/update.bin" + AssertUtils.assert_serialization_integrity(result) + + def test_fee_estimate_deserialize(self) -> None: + estimate = MoneroFeeEstimate() + estimate.fee = 20000 + estimate.quantization_mask = 10000 + estimate.fees = [10000, 20000, 30000, 40000] + AssertUtils.assert_serialization_integrity(estimate) + + def test_daemon_info_deserialize(self) -> None: + info = MoneroDaemonInfo() + info.credits = 0 + info.top_block_hash = "a" * 64 + info.version = "0.18.5.1" + info.num_alt_blocks = 1 + info.block_size_limit = 600000 + info.block_size_median = 300000 + info.block_weight_limit = 600000 + info.block_weight_median = 300000 + info.bootstrap_daemon_address = "node.example.com" + info.difficulty_low = 123456 + info.difficulty_high = 0 + info.cumulative_difficulty_low = 987654 + info.cumulative_difficulty_high = 0 + info.free_space = 1000000000 + info.num_offline_peers = 1 + info.num_online_peers = 10 + info.height = 3000000 + info.height_without_bootstrap = 3000000 + info.network_type = MoneroNetworkType.TESTNET + info.is_offline = False + info.num_incoming_connections = 5 + info.num_outgoing_connections = 8 + info.num_rpc_connections = 2 + info.start_timestamp = 1600000000 + info.adjusted_timestamp = 1700000000 + info.target = 120 + info.target_height = 0 + info.num_txs = 100 + info.num_txs_pool = 3 + info.was_bootstrap_ever_used = False + info.database_size = 100000000 + info.update_available = False + info.is_busy_syncing = False + info.is_synchronized = True + info.is_restricted = False + AssertUtils.assert_serialization_integrity(info) + + def test_daemon_info_invalid_network_type(self) -> None: + with pytest.raises(RuntimeError, match="invalid network type"): + MoneroDaemonInfo.deserialize('{"networkType":9}') + + def test_daemon_sync_info_deserialize(self) -> None: + info = MoneroDaemonSyncInfo() + info.credits = 0 + info.top_block_hash = "a" * 64 + info.height = 3000000 + info.target_height = 3000010 + info.next_needed_pruning_seed = 0 + info.overview = "syncing" + # peers/spans are serialized as sub-arrays but from_property_tree() never + # reads them back (see test below) + AssertUtils.assert_serialization_integrity(info) + + @pytest.mark.xfail(reason="monero_daemon_sync_info::from_property_tree() bug", strict=True) + def test_daemon_sync_info_peers_and_spans_deserialize(self) -> None: + info = MoneroDaemonSyncInfo() + info.peers = [MoneroPeer()] + info.spans = [MoneroConnectionSpan()] + json_str = info.serialize() + logger.debug(f"Serialized daemon sync info: {json_str}") + assert "peers" in json_str and "spans" in json_str + restored = MoneroDaemonSyncInfo.deserialize(json_str) + logger.debug(f"Deserialized daemon sync info re-serialized: {restored.serialize()}") + assert len(restored.peers) == len(info.peers) + assert len(restored.spans) == len(info.spans) + + def test_hard_fork_info_deserialize(self) -> None: + info = MoneroHardForkInfo() + info.credits = 0 + info.top_block_hash = "a" * 64 + info.earliest_height = 100000 + info.is_enabled = True + info.state = 0 + info.threshold = 0 + info.version = 16 + info.num_votes = 5000 + info.window = 10080 + info.voting = 16 + AssertUtils.assert_serialization_integrity(info) + + def test_generate_blocks_result_deserialize(self) -> None: + result = MoneroGenerateBlocksResult() + result.block_hashes = ["a" * 64, "b" * 64] + result.height = 12345 + AssertUtils.assert_serialization_integrity(result) + + #endregion + + #region Tx / output / key image + + def test_key_image_deserialize(self) -> None: + key_image = MoneroKeyImage() + key_image.hex = "a" * 64 + key_image.signature = "b" * 128 + AssertUtils.assert_serialization_integrity(key_image) + + def test_output_deserialize(self) -> None: + output = MoneroOutput() + output.amount = 1000000 + output.index = 5 + key_image = MoneroKeyImage() + key_image.hex = "a" * 64 + key_image.signature = "b" * 128 + output.key_image = key_image + # ring_output_indices / stealth_public_key raise "not implemented" (see below) + AssertUtils.assert_serialization_integrity(output) + + def test_output_ring_output_indices_not_implemented(self) -> None: + with pytest.raises(Exception, match="not implemented"): + MoneroOutput.deserialize('{"ringOutputIndices":[1,2,3]}') + + def test_output_stealth_public_key_not_implemented(self) -> None: + with pytest.raises(Exception, match="not implemented"): + MoneroOutput.deserialize('{"stealthPublicKey":"' + "a" * 64 + '"}') + + @pytest.mark.xfail(reason="monero_output::from_property_tree() bug", strict=True) + def test_output_ring_output_indices_and_stealth_public_key_deserialize(self) -> None: + output = MoneroOutput() + output.amount = 1000000 + output.index = 5 + output.ring_output_indices = [10, 20, 30] + output.stealth_public_key = "a" * 64 + AssertUtils.assert_serialization_integrity(output) + + def test_tx_deserialize(self) -> None: + tx = MoneroTx() + tx.hash = "a" * 64 + tx.is_miner_tx = False + tx.payment_id = "b" * 16 + tx.fee = 7500000 + tx.relay = True + tx.is_relayed = True + tx.is_confirmed = True + tx.in_tx_pool = False + tx.num_confirmations = 10 + tx.unlock_time = 0 + tx.last_relayed_timestamp = 1700000000 + tx.received_timestamp = 1700000000 + tx.is_double_spend_seen = False + tx.key = "c" * 64 + tx.full_hex = "deadbeef" + tx.pruned_hex = "deadbeef" + tx.prunable_hex = "deadbeef" + tx.prunable_hash = "d" * 64 + tx.size = 1500 + tx.weight = 1500 + tx.metadata = "meta" + tx.is_kept_by_block = False + tx.is_failed = False + tx.last_failed_hash = "e" * 64 + tx.max_used_block_hash = "f" * 64 + # version, inputs, outputs, outputIndices, commonTxSets, extra, + # rctSignatures, rctSigPrunable, lastFailedHeight, maxUsedBlockHeight and + # signatures are all left unimplemented in from_property_tree() (see below) + AssertUtils.assert_serialization_integrity(tx) + + @pytest.mark.parametrize("json_fragment", [ + '{"version":1}', + '{"mixin":5}', + '{"inputs":[]}', + '{"outputs":[]}', + '{"outputIndices":[1]}', + '{"commonTxSets":"x"}', + '{"extra":[1,2,3]}', + '{"rctSignatures":"x"}', + '{"rctSigPrunable":"x"}', + '{"lastFailedHeight":1}', + '{"maxUsedBlockHeight":1}', + '{"signatures":["x"]}', + ]) + def test_tx_unimplemented_fields(self, json_fragment: str) -> None: + with pytest.raises(Exception, match="not implemented"): + MoneroTx.deserialize(json_fragment) + + @pytest.mark.xfail(reason="monero_tx::from_property_tree() bug", strict=True) + def test_tx_version_common_tx_sets_last_failed_and_max_used_block_height_deserialize(self) -> None: + tx = MoneroTx() + tx.version = 2 + tx.common_tx_sets = "sets" + tx.last_failed_height = 100 + tx.max_used_block_height = 200 + AssertUtils.assert_serialization_integrity(tx) + + @pytest.mark.xfail(reason="monero_tx::from_property_tree() bug", strict=True) + def test_tx_ring_size_deserialize(self) -> None: + tx = MoneroTx() + tx.ring_size = 16 + AssertUtils.assert_serialization_integrity(tx) + + @pytest.mark.xfail(reason="monero_tx::from_property_tree() bug", strict=True) + def test_tx_extra_deserialize(self) -> None: + tx = MoneroTx() + tx.extra = [1, 2, 3, 255] + AssertUtils.assert_serialization_integrity(tx) + + @pytest.mark.xfail(reason="monero_tx::from_property_tree() bug", strict=True) + def test_tx_inputs_outputs_and_output_indices_deserialize(self) -> None: + tx = MoneroTx() + tx.output_indices = [100, 101] + vin = MoneroOutput() + vin.amount = 1 + vin.key_image = MoneroKeyImage() + vin.key_image.hex = "a" * 64 + tx.inputs = [vin] + vout = MoneroOutput() + vout.amount = 2 + vout.index = 0 + tx.outputs = [vout] + AssertUtils.assert_serialization_integrity(tx) + + #endregion + + #region Copy / merge / comparators + + def test_block_header_copy(self) -> None: + header = MoneroBlockHeader() + header.hash = "a" * 64 + header.height = 100 + header.timestamp = 1700000000 + header.size = 1000 + header.weight = 1000 + header.major_version = 16 + header.minor_version = 16 + header.nonce = 12345 + header.reward = 600000000000 + + copy = header.copy() + assert copy is not header + assert copy.serialize() == header.serialize() + + # copy is independent of the original + copy.height = 999 + assert header.height == 100 + + def test_block_header_merge(self) -> None: + a = MoneroBlockHeader() + a.hash = "a" * 64 + a.height = 100 + a.timestamp = 1700000000 + + b = a.copy() + b.height = 200 # height can increase -> resolves to the higher value + b.timestamp = 1800000000 # timestamp can increase -> resolves to the higher value + b.size = 2000 # a.size is unset -> merge fills the gap + + a.merge(b) + assert a.height == 200 + assert a.timestamp == 1800000000 + assert a.size == 2000 + assert a.hash == "a" * 64 + + def test_block_header_merge_conflict_raises(self) -> None: + # fields without special reconciliation (e.g. hash) must match on both + # sides, or merge() raises rather than silently picking one + a = MoneroBlockHeader() + a.hash = "a" * 64 + b = MoneroBlockHeader() + b.hash = "b" * 64 + with pytest.raises(Exception, match="[Cc]annot reconcile"): + a.merge(b) + + def test_block_copy(self) -> None: + block = MoneroBlock() + block.hash = "a" * 64 + block.height = 100 + block.hex = "deadbeef" + block.tx_hashes = ["b" * 64, "c" * 64] + + copy = block.copy() + assert copy is not block + assert copy.serialize() == block.serialize() + + def test_block_merge(self) -> None: + a = MoneroBlock() + a.hash = "a" * 64 + a.height = 100 + b = a.copy() + b.hex = "deadbeef" # a.hex is unset -> merge fills the gap + a.merge(b) + assert a.hex == "deadbeef" + + def test_tx_copy(self) -> None: + tx = MoneroTx() + tx.hash = "a" * 64 + tx.is_confirmed = True + tx.fee = 7500000 + + copy = tx.copy() + assert copy is not tx + assert copy.serialize() == tx.serialize() + + def test_tx_merge(self) -> None: + a = MoneroTx() + a.hash = "a" * 64 + a.is_confirmed = True # required: merge() dereferences is_confirmed directly + a.fee = 7500000 + + b = a.copy() + b.num_confirmations = 5 # a.num_confirmations is unset -> merge fills the gap + a.merge(b) + assert a.num_confirmations == 5 + + @pytest.mark.xfail(reason="gen_utils::reconcile() bug", strict=True) + def test_tx_merge_is_confirmed_can_become_true(self) -> None: + a = MoneroTx() + a.hash = "a" * 64 + a.is_confirmed = False + b = a.copy() + b.is_confirmed = True + a.merge(b) + assert a.is_confirmed is True + + @pytest.mark.xfail(reason="same gen_utils::reconcile() bug", strict=True) + def test_tx_merge_is_double_spend_seen_can_become_true(self) -> None: + a = MoneroTx() + a.hash = "a" * 64 + a.is_confirmed = True + a.is_double_spend_seen = False + b = a.copy() + b.is_double_spend_seen = True + a.merge(b) + assert a.is_double_spend_seen is True + + @pytest.mark.xfail(reason="same gen_utils::reconcile() bug", strict=True) + def test_tx_merge_in_tx_pool_can_become_true(self) -> None: + a = MoneroTx() + a.hash = "a" * 64 + a.is_confirmed = False + a.in_tx_pool = False + b = a.copy() + b.in_tx_pool = True + a.merge(b) + assert a.in_tx_pool is True + + def test_key_image_copy(self) -> None: + key_image = MoneroKeyImage() + key_image.hex = "a" * 64 + key_image.signature = "b" * 128 + + copy = key_image.copy() + assert copy is not key_image + assert copy.serialize() == key_image.serialize() + + def test_key_image_merge(self) -> None: + a = MoneroKeyImage() + a.hex = "a" * 64 + b = a.copy() + b.signature = "b" * 128 # a.signature is unset -> merge fills the gap + a.merge(b) + assert a.signature == "b" * 128 + + def test_output_copy(self) -> None: + output = MoneroOutput() + output.amount = 1000000 + output.index = 5 + key_image = MoneroKeyImage() + key_image.hex = "a" * 64 + output.key_image = key_image + + copy = output.copy() + assert copy is not output + assert copy.key_image is not output.key_image # key_image is deep copied + assert copy.serialize() == output.serialize() + + def test_output_merge(self) -> None: + a = MoneroOutput() + a.amount = 1000000 + a.index = 5 + b = a.copy() # preserves the (unset) tx reference, so merge won't recurse into tx merge + b.key_image = MoneroKeyImage() + b.key_image.hex = "a" * 64 + a.merge(b) # a.key_image is unset -> merge adopts b's key_image + assert a.key_image is not None + assert a.key_image.hex == "a" * 64 + + @pytest.mark.xfail(reason="monero_tx::merge() bug", strict=True) + def test_tx_merge_extra_and_output_indices(self) -> None: + a = MoneroTx() + a.hash = "a" * 64 + a.is_confirmed = True # required: merge() dereferences is_confirmed directly + b = a.copy() + b.extra = [1, 2, 3, 255] + b.output_indices = [100, 101] + a.merge(b) # a.extra/output_indices are unset -> merge should adopt b's + assert a.extra == [1, 2, 3, 255] + assert a.output_indices == [100, 101] + + @pytest.mark.xfail(reason="monero_output::merge() bug", strict=True) + def test_output_merge_ring_output_indices_and_stealth_public_key(self) -> None: + a = MoneroOutput() + a.amount = 1000000 + a.index = 5 + b = a.copy() # preserves the (unset) tx reference, so merge won't recurse into tx merge + b.ring_output_indices = [10, 20, 30] + b.stealth_public_key = "a" * 64 + a.merge(b) # a.ring_output_indices/stealth_public_key are unset -> merge should adopt b's + assert a.ring_output_indices == [10, 20, 30] + assert a.stealth_public_key == "a" * 64 + + def test_tx_lt_height_comparator(self) -> None: + tx_a = MoneroTx() + tx_a.block = MoneroBlock() + tx_a.block.height = 100 + + tx_b = MoneroTx() + tx_b.block = MoneroBlock() + tx_b.block.height = 200 + + assert tx_a < tx_b + assert not (tx_b < tx_a) + assert TxHeightComparator.compare(tx_a, tx_b) + assert not TxHeightComparator.compare(tx_b, tx_a) + + txs = [tx_b, tx_a] + txs.sort() + assert txs[0] is tx_a + assert txs[1] is tx_b + + # unconfirmed (no block) transactions sort after confirmed ones + tx_unconfirmed = MoneroTx() + assert tx_a < tx_unconfirmed + assert not (tx_unconfirmed < tx_a) + + #endregion diff --git a/tests/test_monero_utils.py b/tests/test_monero_utils.py index 6e80809..6822655 100644 --- a/tests/test_monero_utils.py +++ b/tests/test_monero_utils.py @@ -2,11 +2,17 @@ import pytest import logging +import subprocess +import sys +import gc +import resource from typing import Any from configparser import ConfigParser from monero import ( MoneroNetworkType, MoneroIntegratedAddress, MoneroUtils, MoneroTxConfig, + MoneroBlock, MoneroTxWallet, MoneroIncomingTransfer, MoneroOutputWallet, + MoneroTx ) from utils import AddressBook, KeysBook, WalletUtils, BaseTestClass, WalletErrorUtils @@ -323,8 +329,8 @@ def test_payment_id_validation(self) -> None: try: MoneroUtils.validate_payment_id(payment_id) except Exception as e: - expected = "payment id expected to be 64 or 16 hex characters" - e_str = str(e) + expected: str = "payment id expected to be 64 or 16 hex characters" + e_str: str = str(e) assert expected == e_str, f"Expected error '{expected}', got {e_str}" # Can convert between XMR and atomic units @@ -346,6 +352,72 @@ def test_atomic_unit_conversion(self) -> None: assert 2796726180000 == MoneroUtils.xmr_to_atomic_units(2.79672618) assert 2.79672618 == MoneroUtils.atomic_units_to_xmr(2796726180000) + # xmr_to_atomic_units(0.0) and negative zero are valid, not errors + def test_xmr_to_atomic_units_zero(self) -> None: + assert 0 == MoneroUtils.xmr_to_atomic_units(0.0) + # -0.0 < 0 is False in IEEE 754, so it must not be rejected by the + # "amount must be non-negative" check + assert 0 == MoneroUtils.xmr_to_atomic_units(-0.0) + + # Amounts smaller than half an atomic unit (1e-12 XMR) round down to 0 + def test_xmr_to_atomic_units_rounds_down_to_zero(self) -> None: + assert 0 == MoneroUtils.xmr_to_atomic_units(1e-13) + assert 0 == MoneroUtils.xmr_to_atomic_units(4e-13) + + # xmr_to_atomic_units() rounds the underlying long double * 1e12 product to + # the nearest atomic unit (away from zero on an exact .5). Decimal literals + # ending in .5e-12 are not necessarily exact halves once represented as a + # double, so which way they round depends on whether the closest double is + # a hair above or below the nominal decimal value, and that itself can + # depend on the platform's "long double" precision (80-bit extended on + # x86_64 Linux/glibc, but identical to a plain 64-bit double on Apple + # Silicon/macOS), so the same literal can round differently on different + # platforms. This is inherent to IEEE 754, not an inconsistency in + # xmr_to_atomic_units() itself, assert the result lands on one of the + # two atomic units the value sits between, not a specific platform's tie-break. + def test_xmr_to_atomic_units_half_atomic_unit_rounding(self) -> None: + cases = [ + (0.5e-12, 0, 1), + (1.5e-12, 1, 2), + (2.5e-12, 2, 3), + (3.5e-12, 3, 4), + ] + for amount_xmr, floor_atomic, ceil_atomic in cases: + actual = MoneroUtils.xmr_to_atomic_units(amount_xmr) + logger.debug(f"xmr_to_atomic_units({amount_xmr!r}) = {actual} (expected {floor_atomic} or {ceil_atomic})") + assert actual in (floor_atomic, ceil_atomic), f"xmr_to_atomic_units({amount_xmr!r}) == {actual}, expected {floor_atomic} or {ceil_atomic}" + + # amount_xmr must be finite and non-negative + @pytest.mark.parametrize("amount_xmr", [-1.0, -0.0000000001, float("nan"), float("inf"), float("-inf")]) + def test_xmr_to_atomic_units_invalid_amount(self, amount_xmr: float) -> None: + with pytest.raises(RuntimeError, match="amount must be a finite, non-negative number"): + MoneroUtils.xmr_to_atomic_units(amount_xmr) + + # amounts whose rounded atomic-unit value would overflow uint64_t are rejected + # rather than silently wrapping or invoking undefined behavior on the cast + def test_xmr_to_atomic_units_overflow(self) -> None: + # UINT64_MAX atomic units is ~18446744.0737... XMR; comfortably over that + # (with margin for float imprecision) must raise + with pytest.raises(RuntimeError, match="amount exceeds maximum representable atomic units"): + MoneroUtils.xmr_to_atomic_units(18446745.0) + with pytest.raises(RuntimeError, match="amount exceeds maximum representable atomic units"): + MoneroUtils.xmr_to_atomic_units(2e22) + + # values comfortably below the uint64_t boundary succeed + def test_xmr_to_atomic_units_near_uint64_max_boundary(self) -> None: + uint64_max = 2 ** 64 - 1 + boundary_xmr = uint64_max / 1e12 # ~18446744.073709551615 XMR + margin_xmr = 1.0 + + safely_below = boundary_xmr - margin_xmr + below = MoneroUtils.xmr_to_atomic_units(safely_below) + logger.debug(f"xmr_to_atomic_units({safely_below!r}) = {below} (uint64_max = {uint64_max})") + assert below <= uint64_max + + safely_above = boundary_xmr + margin_xmr + with pytest.raises(RuntimeError, match="amount exceeds maximum representable atomic units"): + MoneroUtils.xmr_to_atomic_units(safely_above) + # Can get payment uri def test_get_payment_uri(self, config: TestMoneroUtils.Config) -> None: address: str = config.mainnet.primary_address_1 @@ -389,3 +461,367 @@ def test_get_ring_size(self) -> None: assert size == 12 #endregion + + #region Gather blocks + + def test_get_blocks_from_txs_dedup_and_order(self) -> None: + block1 = MoneroBlock() + block1.height = 100 + block2 = MoneroBlock() + block2.height = 200 + + tx1 = MoneroTxWallet() + tx1.hash = "a" * 64 + tx1.block = block1 + + tx2 = MoneroTxWallet() + tx2.hash = "b" * 64 + tx2.block = block2 + + tx3 = MoneroTxWallet() # shares block1 with tx1 + tx3.hash = "c" * 64 + tx3.block = block1 + + blocks = MoneroUtils.get_blocks_from_txs([tx1, tx2, tx3]) + assert len(blocks) == 2 # block1 deduplicated despite appearing twice + assert blocks[0] is block1 # blocks are returned in first-seen order + assert blocks[1] is block2 + + def test_get_blocks_from_txs_unconfirmed_placeholder(self) -> None: + tx1 = MoneroTxWallet() + tx1.hash = "a" * 64 + tx2 = MoneroTxWallet() + tx2.hash = "b" * 64 + assert tx1.block is None and tx2.block is None + + blocks = MoneroUtils.get_blocks_from_txs([tx1, tx2]) + + # unconfirmed (blockless) txs are grouped under one shared placeholder block + assert len(blocks) == 1 + placeholder = blocks[0] + assert placeholder.height is None + assert len(placeholder.txs) == 2 + + # side effect: get_blocks_from_txs() mutates its inputs, attaching each + # unconfirmed tx to the placeholder block it creates + assert tx1.block is placeholder + assert tx2.block is placeholder + + def test_get_blocks_from_txs_mixed_confirmed_and_unconfirmed(self) -> None: + block = MoneroBlock() + block.height = 100 + confirmed = MoneroTxWallet() + confirmed.hash = "a" * 64 + confirmed.block = block + + unconfirmed1 = MoneroTxWallet() + unconfirmed1.hash = "b" * 64 + unconfirmed2 = MoneroTxWallet() + unconfirmed2.hash = "c" * 64 + + blocks = MoneroUtils.get_blocks_from_txs([confirmed, unconfirmed1, unconfirmed2]) + assert len(blocks) == 2 # the real block, plus one shared unconfirmed placeholder + assert blocks[0] is block + assert blocks[1].height is None + assert unconfirmed1.block is unconfirmed2.block is blocks[1] + + def test_get_blocks_from_transfers_dedup_and_order(self) -> None: + block1 = MoneroBlock() + block1.height = 100 + block2 = MoneroBlock() + block2.height = 200 + + t1 = MoneroIncomingTransfer() + t1.tx = MoneroTxWallet() + t1.tx.hash = "a" * 64 + t1.tx.block = block1 + + t2 = MoneroIncomingTransfer() + t2.tx = MoneroTxWallet() + t2.tx.hash = "b" * 64 + t2.tx.block = block2 + + t3 = MoneroIncomingTransfer() # tx shares block1 with t1 + t3.tx = MoneroTxWallet() + t3.tx.hash = "c" * 64 + t3.tx.block = block1 + + blocks = MoneroUtils.get_blocks_from_transfers([t1, t2, t3]) + assert len(blocks) == 2 + assert blocks[0] is block1 + assert blocks[1] is block2 + + def test_get_blocks_from_transfers_unconfirmed_placeholder(self) -> None: + t1 = MoneroIncomingTransfer() + t1.tx = MoneroTxWallet() + t1.tx.hash = "a" * 64 + t2 = MoneroIncomingTransfer() + t2.tx = MoneroTxWallet() + t2.tx.hash = "b" * 64 + assert t1.tx.block is None and t2.tx.block is None + + blocks = MoneroUtils.get_blocks_from_transfers([t1, t2]) + assert len(blocks) == 1 + placeholder = blocks[0] + assert placeholder.height is None + + # side effect: mutates transfer.tx.block, same as get_blocks_from_txs() + assert t1.tx.block is placeholder + assert t2.tx.block is placeholder + + @pytest.mark.xfail(reason="get_blocks_from_transfers() dereferences transfer.tx without a null check and segfaults the interpreter when it's unset; fixed upstream in the local everoddandeven/monero-cpp checkout, pending a submodule bump", strict=True) + def test_get_blocks_from_transfers_missing_tx_does_not_crash(self) -> None: + # a transfer with no tx set is a legitimate, reachable state (it's just + # never assigned), but get_blocks_from_transfers() used to dereference + # transfer.tx unconditionally, causing a native segfault (SIGSEGV) + # instead of raising a catchable Python exception. Run in an isolated + # subprocess so a regression here only kills a throwaway process + # instead of the whole test run; fixed upstream in the local + # everoddandeven/monero-cpp checkout, pending a submodule bump. + script = ( + "import monero\n" + "t = monero.MoneroIncomingTransfer()\n" + "t.amount = 500000\n" + "monero.MoneroUtils.get_blocks_from_transfers([t])\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"get_blocks_from_transfers() crashed the interpreter (exit code {result.returncode}) " + "instead of raising a Python exception for a transfer with no tx set" + ) + + def test_get_blocks_from_outputs_dedup_and_order(self) -> None: + block1 = MoneroBlock() + block1.height = 100 + block2 = MoneroBlock() + block2.height = 200 + + tx1 = MoneroTxWallet() + tx1.hash = "a" * 64 + tx1.block = block1 + tx2 = MoneroTxWallet() + tx2.hash = "b" * 64 + tx2.block = block2 + + o1 = MoneroOutputWallet() + o1.tx = tx1 + o2 = MoneroOutputWallet() + o2.tx = tx2 + o3 = MoneroOutputWallet() # tx shares block1 with o1 + o3.tx = tx1 + + blocks = MoneroUtils.get_blocks_from_outputs([o1, o2, o3]) + assert len(blocks) == 2 + assert blocks[0] is block1 + assert blocks[1] is block2 + + def test_get_blocks_from_outputs_unconfirmed_raises(self) -> None: + # unlike get_blocks_from_txs()/get_blocks_from_transfers(), an + # unconfirmed (blockless) output's tx does not get a placeholder + # block -- it raises instead + output = MoneroOutputWallet() + output.tx = MoneroTxWallet() + output.tx.hash = "a" * 64 + assert output.tx.block is None + + with pytest.raises(RuntimeError, match="Need to handle unconfirmed output"): + MoneroUtils.get_blocks_from_outputs([output]) + + @pytest.mark.xfail(reason="get_blocks_from_outputs() bug", strict=True) + def test_get_blocks_from_outputs_missing_tx_does_not_crash(self) -> None: + # same crash as get_blocks_from_transfers(), for the same reason: + # output.tx is a legitimate but unchecked null before the cast/dereference. + script = ( + "import monero\n" + "o = monero.MoneroOutputWallet()\n" + "o.amount = 1000000\n" + "monero.MoneroUtils.get_blocks_from_outputs([o])\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"get_blocks_from_outputs() crashed the interpreter (exit code {result.returncode}) " + "instead of raising a Python exception for an output with no tx set" + ) + + #endregion + + #region Free memory + + def test_free_block_breaks_tx_backlink(self) -> None: + block = MoneroBlock() + block.height = 100 + tx = MoneroTxWallet() + tx.hash = "a" * 64 + tx.block = block + block.txs = [tx] + + MoneroUtils.free(block) + assert tx.block is None + + def test_free_blocks_list(self) -> None: + block1 = MoneroBlock() + block1.height = 1 + tx1 = MoneroTxWallet() + tx1.hash = "a" * 64 + tx1.block = block1 + block1.txs = [tx1] + + block2 = MoneroBlock() + block2.height = 2 + tx2 = MoneroTxWallet() + tx2.hash = "b" * 64 + tx2.block = block2 + block2.txs = [tx2] + + MoneroUtils.free([block1, block2]) + assert tx1.block is None + assert tx2.block is None + + def test_free_tx_without_block_does_not_crash(self) -> None: + # free(tx) creates a throwaway placeholder block for an unconfirmed + # tx, then immediately frees it. Net no-op on tx.block, but exercises + # that code path safely + tx = MoneroTxWallet() + tx.hash = "a" * 64 + assert tx.block is None + MoneroUtils.free(tx) + assert tx.block is None + + def test_free_tx_with_block(self) -> None: + block = MoneroBlock() + block.height = 100 + tx = MoneroTxWallet() + tx.hash = "a" * 64 + tx.block = block + block.txs = [tx] + + MoneroUtils.free(tx) + assert tx.block is None + + def test_free_txs_list_confirmed_and_unconfirmed(self) -> None: + block = MoneroBlock() + block.height = 100 + confirmed = MoneroTxWallet() + confirmed.hash = "a" * 64 + confirmed.block = block + block.txs = [confirmed] + + unconfirmed = MoneroTxWallet() + unconfirmed.hash = "b" * 64 + + MoneroUtils.free([confirmed, unconfirmed]) + assert confirmed.block is None + assert unconfirmed.block is None + + def test_free_transfers_list(self) -> None: + block = MoneroBlock() + block.height = 100 + tx = MoneroTxWallet() + tx.hash = "a" * 64 + tx.block = block + block.txs = [tx] + + transfer = MoneroIncomingTransfer() + transfer.tx = tx + + MoneroUtils.free([transfer]) + assert transfer.tx.block is None + + def test_free_outputs_list(self) -> None: + block = MoneroBlock() + block.height = 100 + tx = MoneroTxWallet() + tx.hash = "a" * 64 + tx.block = block + block.txs = [tx] + + output = MoneroOutputWallet() + output.tx = tx + + MoneroUtils.free([output]) + assert output.tx.block is None + + def test_free_breaks_reference_cycle_avoids_leak(self) -> None: + # regression guard for the leak demonstrated manually: building + # block<->tx cycles and dropping every Python reference without + # calling free() leaves the C++ shared_ptr cycle permanently + # unreachable-but-alive (confirmed: 2,000,000 such tx objects grew + # RSS by ~2.4GB with gc.collect() unable to reclaim any of it). This + # test builds a much smaller-but-still-telling batch, always calling + # free() before dropping references, and asserts memory stays roughly flat. + def rss_mb() -> float: + return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 + + def run_batch(rounds: int) -> None: + for _ in range(rounds): + for _ in range(50): + block = MoneroBlock() + block.height = 100 + txs: list[MoneroTx] = [] + for i in range(20): + tx = MoneroTxWallet() + tx.hash = "a" * 63 + str(i % 10) + tx.block = block + txs.append(tx) + block.txs = txs + MoneroUtils.free(block) + + gc.disable() + try: + run_batch(20) # unmeasured warmup: absorb one-time allocator growth + baseline = rss_mb() + run_batch(200) # measured: 200 * 50 * 20 = 200,000 tx objects + after = rss_mb() + finally: + gc.enable() + + growth_mb = after - baseline + logger.debug(f"RSS growth after freeing 200,000 tx objects (post-warmup): {growth_mb:.1f} MB") + # generous bound: a real leak of this shape grows ~1.2KB/tx (~240MB for + # 200,000 tx); this only needs to rule out that magnitude of leak, not + # pin down normal allocator noise + assert growth_mb < 100, f"RSS grew {growth_mb:.1f} MB after freeing 200,000 tx objects -- possible leak" + + @pytest.mark.xfail(reason="monero_utils::free(block)/free(tx) dereference their argument without a null check and segfault the interpreter when it's None; fixed upstream in the local everoddandeven/monero-cpp checkout, pending a submodule bump", strict=True) + def test_free_none_does_not_crash(self) -> None: + script = "import monero\nmonero.MoneroUtils.free(None)\n" + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"free(None) crashed the interpreter (exit code {result.returncode}) " + "instead of raising a Python exception (or being a documented no-op)" + ) + + @pytest.mark.xfail(reason="free(transfers) delegates to get_blocks_from_transfers(), which segfaults on a transfer with no tx set; fixed upstream in the local everoddandeven/monero-cpp checkout, pending a submodule bump", strict=True) + def test_free_transfers_missing_tx_does_not_crash(self) -> None: + script = ( + "import monero\n" + "t = monero.MoneroIncomingTransfer()\n" + "t.amount = 500000\n" + "monero.MoneroUtils.free([t])\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"free([transfer]) crashed the interpreter (exit code {result.returncode}) " + "instead of raising a Python exception for a transfer with no tx set" + ) + + @pytest.mark.xfail(reason="free(outputs) delegates to get_blocks_from_outputs(), which segfaults on an output with no tx set; fixed upstream in the local everoddandeven/monero-cpp checkout, pending a submodule bump", strict=True) + def test_free_outputs_missing_tx_does_not_crash(self) -> None: + script = ( + "import monero\n" + "o = monero.MoneroOutputWallet()\n" + "o.amount = 1000000\n" + "monero.MoneroUtils.free([o])\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"free([output]) crashed the interpreter (exit code {result.returncode}) " + "instead of raising a Python exception for an output with no tx set" + ) + + #endregion diff --git a/tests/test_monero_wallet_common.py b/tests/test_monero_wallet_common.py index c6529f7..fc7f208 100644 --- a/tests/test_monero_wallet_common.py +++ b/tests/test_monero_wallet_common.py @@ -3443,9 +3443,8 @@ def test_freeze_outputs(self, wallet: MoneroWallet) -> None: wallet.freeze_output("123") raise Exception("Should have thrown error") except Exception as e: - logger.warning(e) - #if "Bad key image" != str(e): - # raise + if "failed to parse key image" != str(e): + raise # thaw output by key image wallet.thaw_output(output.key_image.hex) diff --git a/tests/test_monero_wallet_keys.py b/tests/test_monero_wallet_keys.py index 3985267..bf0d5ed 100644 --- a/tests/test_monero_wallet_keys.py +++ b/tests/test_monero_wallet_keys.py @@ -648,6 +648,7 @@ def test_create_wallet_from_seed_with_offset(self) -> None: assert MoneroWallet.DEFAULT_LANGUAGE == wallet.get_seed_language() @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.xfail(reason="TODO update to new monero-cpp") @override def test_create_wallet_from_keys(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: # save for comparison @@ -662,6 +663,9 @@ def test_create_wallet_from_keys(self, daemon: MoneroDaemonRpc, wallet: MoneroWa config.private_spend_key = private_spend_key w: MoneroWallet = self._create_wallet(config) WalletUtils.test_wallet_keys(primary_address, private_view_key, private_spend_key, w) + assert w.get_network_type() == Utils.NETWORK_TYPE + assert not w.is_closed() + w.close() # recreate test wallet from spend key config = MoneroWalletConfig() @@ -669,6 +673,65 @@ def test_create_wallet_from_keys(self, daemon: MoneroDaemonRpc, wallet: MoneroWa config.private_spend_key = private_spend_key w = self._create_wallet(config) WalletUtils.test_wallet_keys(primary_address, private_view_key, private_spend_key, w) + assert w.get_network_type() == Utils.NETWORK_TYPE + assert not w.is_closed() + w.close() + + # recreate test wallet from view keys + config = MoneroWalletConfig() + config.primary_address = primary_address + config.private_view_key = private_view_key + w = self._create_wallet(config) + logger.info(f"Created wallet with config: {config.serialize()}") + logger.info(f"Wallet seed: {w.get_seed()}") + assert w.get_network_type() == Utils.NETWORK_TYPE + assert w.is_view_only() + assert not w.is_closed() + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.xfail(raises=RuntimeError, reason="Neither a private spend key nor a private view key was supplied") + def test_create_wallet_from_keys_no_keys(self) -> None: + """ + create_wallet_from_keys() must require at least one of the private spend/view keys. + """ + config = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + MoneroWalletKeys.create_wallet_from_keys(config) + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.xfail(raises=RuntimeError, reason="Malformed private spend key hex cannot be parsed") + def test_create_wallet_from_keys_invalid_spend_key(self) -> None: + """ + create_wallet_from_keys() must fail to parse a malformed private spend key. + """ + config = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + config.private_spend_key = "not-a-valid-hex-secret-key" + MoneroWalletKeys.create_wallet_from_keys(config) + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.xfail(raises=RuntimeError, reason="Malformed private view key hex cannot be parsed") + def test_create_wallet_from_keys_invalid_view_key(self) -> None: + """ + create_wallet_from_keys() must fail to parse a malformed private view key. + """ + config = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + config.primary_address = Utils.ADDRESS + config.private_view_key = "not-a-valid-hex-secret-key" + MoneroWalletKeys.create_wallet_from_keys(config) + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.xfail(raises=RuntimeError, reason="A primary address is required when a private view key is provided") + def test_create_wallet_from_keys_view_key_without_address(self) -> None: + """ + create_wallet_from_keys() must require a primary address when a private view key is given. + """ + config = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + config.private_view_key = Utils.PRIVATE_VIEW_KEY + MoneroWalletKeys.create_wallet_from_keys(config) @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @override diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index dcb822c..4348a36 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -4,7 +4,14 @@ from monero import ( MoneroTxQuery, MoneroTransferQuery, MoneroOutputQuery, MoneroWalletConfig, MoneroDestination, MoneroUtils, - MoneroTxConfig + MoneroTxConfig, MoneroSubaddress, MoneroAccount, MoneroTxWallet, + MoneroOutputWallet, MoneroKeyImage, MoneroIntegratedAddress, + MoneroKeyImageImportResult, MoneroMessageSignatureResult, + MoneroMessageSignatureType, MoneroCheckTx, MoneroCheckReserve, + MoneroMultisigInfo, MoneroMultisigInitResult, MoneroMultisigSignResult, + MoneroAddressBookEntry, MoneroAccountTag, MoneroIncomingTransfer, + MoneroOutgoingTransfer, IncomingTransferComparator, OutputComparator, MoneroTx, + MoneroTxSet ) from utils import BaseTestClass, TestUtils, AssertUtils @@ -201,3 +208,550 @@ def test_tx_config(self) -> None: AssertUtils.assert_equals(config, deserialized_config) #endregion + + #region Serialize/deserialize integrity + + def test_subaddress_deserialize(self) -> None: + subaddress = MoneroSubaddress() + subaddress.account_index = 0 + subaddress.index = 1 + subaddress.address = TestUtils.ADDRESS + subaddress.label = "primary" + subaddress.balance = 1000000 + subaddress.unlocked_balance = 900000 + subaddress.is_used = True + subaddress.num_unspent_outputs = 3 + subaddress.num_blocks_to_unlock = 0 + AssertUtils.assert_serialization_integrity(subaddress) + + def test_account_deserialize(self) -> None: + account = MoneroAccount() + account.index = 0 + account.balance = 1000000 + account.unlocked_balance = 900000 + account.primary_address = TestUtils.ADDRESS + account.tag = "savings" + # subaddresses is serialized as a sub-array but from_property_tree() never + # reads it back (see test below) + AssertUtils.assert_serialization_integrity(account) + + @pytest.mark.xfail(reason="monero_account::from_property_tree() never reads back \"subaddresses\" even though to_rapidjson_val() emits it; fixed upstream in the local everoddandeven/monero-cpp checkout, pending a submodule bump", strict=True) + def test_account_subaddresses_deserialize(self) -> None: + account = MoneroAccount() + account.subaddresses = [MoneroSubaddress()] + json_str = account.serialize() + logger.debug(f"Serialized account: {json_str}") + assert "subaddresses" in json_str + restored = MoneroAccount.deserialize(json_str) + logger.debug(f"Deserialized account re-serialized: {restored.serialize()}") + assert len(restored.subaddresses) == len(account.subaddresses) + + def test_transfer_query_deserialize(self) -> None: + query = MoneroTransferQuery() + query.amount = 500000 + query.account_index = 0 + query.incoming = True + query.address = TestUtils.ADDRESS + query.subaddress_index = 1 + query.subaddress_indices = [0, 1, 2] + query.has_destinations = False + # addresses, destinations and tx_query all raise "not implemented" (see below) + AssertUtils.assert_serialization_integrity(query) + + @pytest.mark.parametrize("json_fragment", [ + '{"addresses":["' + TestUtils.ADDRESS + '"]}', + '{"destinations":[{"address":"' + TestUtils.ADDRESS + '","amount":1}]}', + '{"txQuery":{}}', + ]) + def test_transfer_query_unimplemented_fields(self, json_fragment: str) -> None: + with pytest.raises(Exception, match="not implemented"): + MoneroTransferQuery.deserialize(json_fragment) + + def test_output_wallet_deserialize(self) -> None: + output_wallet = MoneroOutputWallet() + output_wallet.amount = 1000000 + output_wallet.index = 2 + key_image = MoneroKeyImage() + key_image.hex = "a" * 64 + key_image.signature = "b" * 128 + output_wallet.key_image = key_image + output_wallet.account_index = 0 + output_wallet.subaddress_index = 1 + output_wallet.is_spent = False + output_wallet.is_frozen = False + AssertUtils.assert_serialization_integrity(output_wallet) + + def test_output_query_deserialize(self) -> None: + query = MoneroOutputQuery() + query.amount = 1000000 + query.index = 2 + query.account_index = 0 + query.subaddress_index = 1 + query.is_spent = False + query.is_frozen = False + query.subaddress_indices = [0, 1] + query.min_amount = 100000 + query.max_amount = 2000000 + AssertUtils.assert_serialization_integrity(query) + + def test_tx_wallet_deserialize(self) -> None: + tx_wallet = MoneroTxWallet() + tx_wallet.hash = "a" * 64 + tx_wallet.is_miner_tx = False + tx_wallet.fee = 7500000 + tx_wallet.relay = True + tx_wallet.is_relayed = True + tx_wallet.is_confirmed = True + tx_wallet.in_tx_pool = False + tx_wallet.num_confirmations = 10 + tx_wallet.unlock_time = 0 + tx_wallet.is_incoming = True + tx_wallet.is_outgoing = False + tx_wallet.note = "thanks" + tx_wallet.is_locked = False + tx_wallet.input_sum = 2000000 + tx_wallet.output_sum = 1900000 + # tx_set, incoming_transfers, outgoing_transfer, change_address, + # change_amount, num_dummy_outputs and extra_hex all raise "not + # implemented" (see below), same as their monero_tx base counterparts + # (version, inputs, outputs, ...) tested in test_monero_daemon_model.py + AssertUtils.assert_serialization_integrity(tx_wallet) + + @pytest.mark.parametrize("json_fragment", [ + '{"txSet":{}}', + '{"incomingTransfers":[]}', + '{"outgoingTransfer":{}}', + '{"changeAddress":"' + TestUtils.ADDRESS + '"}', + '{"changeAmount":1}', + '{"numDummyOutputs":1}', + '{"extraHex":"deadbeef"}', + ]) + def test_tx_wallet_unimplemented_fields(self, json_fragment: str) -> None: + with pytest.raises(Exception, match="not implemented"): + MoneroTxWallet.deserialize(json_fragment) + + def test_tx_query_deserialize(self) -> None: + tx_query = MoneroTxQuery() + tx_query.hash = "a" * 64 + tx_query.is_confirmed = True + tx_query.hashes = ["a" * 64, "b" * 64] + tx_query.has_payment_id = False + tx_query.payment_ids = ["c" * 16] + tx_query.height = 3000000 + tx_query.min_height = 2999990 + tx_query.max_height = 3000010 + tx_query.include_outputs = False + # is_outgoing/is_incoming are deliberately not set here: monero_tx_query + # redeclares them as its own fields shadowing monero_tx_wallet's, and + # deserializing populates both copies at once (see test below) + AssertUtils.assert_serialization_integrity(tx_query) + + @pytest.mark.xfail(reason="monero_tx_query declares its own m_is_incoming/m_is_outgoing shadowing monero_tx_wallet's fields of the same name, so from_property_tree() double-populates them and a re-serialize duplicates the JSON keys", strict=True) + def test_tx_query_is_incoming_deserialize_not_duplicated(self) -> None: + tx_query = MoneroTxQuery() + tx_query.is_incoming = True + tx_query.is_outgoing = False + json_str = tx_query.serialize() + logger.debug(f"Serialized tx query: {json_str}") + assert json_str.count("isIncoming") == 1 + assert json_str.count("isOutgoing") == 1 + + restored: MoneroTxQuery = MoneroTxQuery.deserialize(json_str) + assert restored.is_incoming == tx_query.is_incoming + assert restored.is_outgoing == tx_query.is_outgoing + + restored_json = restored.serialize() + incoming_count = restored_json.count("isIncoming") + outgoing_count = restored_json.count("isOutgoing") + logger.debug(f"Deserialized tx query re-serialized: {restored_json}") + logger.debug(f"'isIncoming' occurs {incoming_count} time(s), 'isOutgoing' occurs {outgoing_count} time(s) (expected 1 each -- >1 means duplicated, i.e. malformed)") + assert incoming_count == 1 + assert outgoing_count == 1 + + def test_tx_query_nested_transfer_query_deserialize(self) -> None: + # transfer_query is a nested sub-object that to_rapidjson_val() and + # from_property_tree() both handle recursively + tx_query = MoneroTxQuery() + tx_query.height = 3000000 + transfer_query = MoneroTransferQuery() + transfer_query.incoming = True + transfer_query.amount = 500000 + transfer_query.account_index = 0 + tx_query.transfer_query = transfer_query + + json_str = tx_query.serialize() + logger.debug(f"Serialized nested tx query: {json_str}") + assert "transferQuery" in json_str + + restored: MoneroTxQuery = MoneroTxQuery.deserialize(json_str) + assert restored.height == tx_query.height + assert restored.transfer_query is not None + assert restored.transfer_query.incoming == transfer_query.incoming + assert restored.transfer_query.amount == transfer_query.amount + assert restored.transfer_query.account_index == transfer_query.account_index + + def test_tx_query_input_and_output_query_deserialize(self) -> None: + # TODO input_query/output_query are read by from_property_tree() but never written by to_rapidjson_val() + tx_query: MoneroTxQuery = MoneroTxQuery.deserialize( + '{"inputQuery":{"amount":5,"index":1},"outputQuery":{"amount":7,"index":2}}' + ) + assert tx_query.input_query is not None + assert tx_query.input_query.amount == 5 + assert tx_query.input_query.index == 1 + assert tx_query.output_query is not None + assert tx_query.output_query.amount == 7 + assert tx_query.output_query.index == 2 + + @pytest.mark.xfail(reason="monero_tx_query::to_rapidjson_val() never serialized input_query/output_query even though from_property_tree() can read them back (see test above), so a plain serialize()+deserialize() round trip lost them; fixed upstream in the local everoddandeven/monero-cpp checkout, pending a submodule bump", strict=True) + def test_tx_query_input_and_output_query_serialize_round_trip(self) -> None: + tx_query = MoneroTxQuery() + tx_query.input_query = MoneroOutputQuery() + tx_query.input_query.amount = 5 + tx_query.input_query.index = 1 + tx_query.output_query = MoneroOutputQuery() + tx_query.output_query.amount = 7 + tx_query.output_query.index = 2 + + json_str = tx_query.serialize() + logger.debug(f"Serialized tx query with input/output query: {json_str}") + assert "inputQuery" in json_str + assert "outputQuery" in json_str + + restored: MoneroTxQuery = MoneroTxQuery.deserialize(json_str) + assert restored.input_query is not None + assert restored.input_query.amount == 5 + assert restored.input_query.index == 1 + assert restored.output_query is not None + assert restored.output_query.amount == 7 + assert restored.output_query.index == 2 + + def test_integrated_address_deserialize(self) -> None: + address = MoneroIntegratedAddress() + address.standard_address = TestUtils.ADDRESS + address.payment_id = "d" * 16 + address.integrated_address = TestUtils.ADDRESS + AssertUtils.assert_serialization_integrity(address) + + def test_key_image_import_result_deserialize(self) -> None: + result = MoneroKeyImageImportResult() + result.height = 3000000 + result.spent_amount = 500000 + result.unspent_amount = 1500000 + AssertUtils.assert_serialization_integrity(result) + + def test_message_signature_result_deserialize(self) -> None: + result = MoneroMessageSignatureResult() + result.is_good = True + result.is_old = False + result.version = 2 + result.signature_type = MoneroMessageSignatureType.SIGN_WITH_SPEND_KEY + AssertUtils.assert_serialization_integrity(result) + + def test_check_tx_deserialize(self) -> None: + check = MoneroCheckTx() + check.is_good = True + check.in_tx_pool = False + check.num_confirmations = 10 + check.received_amount = 500000 + AssertUtils.assert_serialization_integrity(check) + + def test_check_reserve_deserialize(self) -> None: + check = MoneroCheckReserve() + check.is_good = True + check.total_amount = 1000000 + check.unconfirmed_spent_amount = 0 + AssertUtils.assert_serialization_integrity(check) + + def test_multisig_info_deserialize(self) -> None: + info = MoneroMultisigInfo() + info.is_multisig = True + info.is_ready = True + info.threshold = 2 + info.num_participants = 3 + AssertUtils.assert_serialization_integrity(info) + + def test_multisig_init_result_deserialize(self) -> None: + result = MoneroMultisigInitResult() + result.address = TestUtils.ADDRESS + result.multisig_hex = "deadbeef" + AssertUtils.assert_serialization_integrity(result) + + def test_multisig_sign_result_deserialize(self) -> None: + result = MoneroMultisigSignResult() + result.signed_multisig_tx_hex = "deadbeef" + result.tx_hashes = ["a" * 64, "b" * 64] + AssertUtils.assert_serialization_integrity(result) + + def test_address_book_entry_deserialize(self) -> None: + entry = MoneroAddressBookEntry() + entry.index = 0 + entry.address = TestUtils.ADDRESS + entry.description = "friend" + entry.payment_id = "e" * 16 + AssertUtils.assert_serialization_integrity(entry) + + def test_account_tag_deserialize(self) -> None: + tag = MoneroAccountTag() + tag.tag = "savings" + tag.label = "Savings accounts" + tag.account_indices = [0, 1, 2] + AssertUtils.assert_serialization_integrity(tag) + + def test_tx_set_deserialize(self) -> None: + tx_set = MoneroTxSet() + tx_set.unsigned_tx_hex = "deadbeef" + tx_set.multisig_tx_hex = "beefdead" + AssertUtils.assert_serialization_integrity(tx_set) + + @pytest.mark.xfail(reason="monero_tx_set::deserialize() -- the static JSON-string entry point MoneroWalletFull.describeTxSet() uses to send a tx set to native code -- never handled \"signedTxHex\" and rejects any unrecognized key outright, so describing an already-signed tx set always raised \"field 'signedTxHex' not supported\" even though monero_tx_set::to_rapidjson_val() always wrote it; fixed upstream in the local everoddandeven/monero-cpp checkout, pending a submodule bump", strict=True) + def test_tx_set_signed_tx_hex_deserialize(self) -> None: + tx_set = MoneroTxSet() + tx_set.signed_tx_hex = "deadbeef" + AssertUtils.assert_serialization_integrity(tx_set) + + #endregion + + #region Copy / merge / comparators + + def test_incoming_transfer_copy(self) -> None: + transfer = MoneroIncomingTransfer() + transfer.amount = 500000 + transfer.account_index = 0 + transfer.subaddress_index = 1 + transfer.address = TestUtils.ADDRESS + transfer.num_suggested_confirmations = 10 + + copy = transfer.copy() + assert copy is not transfer + assert copy.serialize() == transfer.serialize() + + def test_incoming_transfer_merge(self) -> None: + a = MoneroIncomingTransfer() + a.amount = 500000 + a.account_index = 0 + a.subaddress_index = 1 + # a.tx is left unset on both sides so merge() won't recurse into tx merge + + b = a.copy() + b.address = TestUtils.ADDRESS # a.address is unset -> merge fills the gap + a.merge(b) + assert a.address == TestUtils.ADDRESS + + def test_incoming_transfer_lt_comparator(self) -> None: + t1 = MoneroIncomingTransfer() + t1.tx = MoneroTxWallet() + t1.account_index = 0 + t1.subaddress_index = 0 + + t2 = MoneroIncomingTransfer() + t2.tx = MoneroTxWallet() + t2.account_index = 0 + t2.subaddress_index = 1 + + assert t1 < t2 + assert not (t2 < t1) + assert IncomingTransferComparator.compare(t1, t2) + assert not IncomingTransferComparator.compare(t2, t1) + + transfers = [t2, t1] + transfers.sort() + assert transfers[0] is t1 + assert transfers[1] is t2 + + def test_outgoing_transfer_copy(self) -> None: + transfer = MoneroOutgoingTransfer() + transfer.amount = 500000 + transfer.account_index = 0 + transfer.addresses = [TestUtils.ADDRESS] + transfer.subaddress_indices = [0] + transfer.destinations = [MoneroDestination(TestUtils.ADDRESS, 500000)] + + copy = transfer.copy() + assert copy is not transfer + assert copy.serialize() == transfer.serialize() + + def test_outgoing_transfer_merge(self) -> None: + a = MoneroOutgoingTransfer() + a.amount = 500000 + a.account_index = 0 + # a.addresses/subaddress_indices/destinations left empty on both sides so far + + b = a.copy() + b.addresses = [TestUtils.ADDRESS] + b.subaddress_indices = [0] + b.destinations = [MoneroDestination(TestUtils.ADDRESS, 500000)] + a.merge(b) # a's lists are empty -> merge adopts b's + assert a.addresses == [TestUtils.ADDRESS] + assert a.subaddress_indices == [0] + assert len(a.destinations) == 1 + + def test_output_wallet_copy(self) -> None: + output = MoneroOutputWallet() + output.amount = 1000000 + output.index = 2 + output.account_index = 0 + output.subaddress_index = 1 + output.is_spent = False + output.is_frozen = False + + copy = output.copy() + assert copy is not output + assert copy.serialize() == output.serialize() + + def test_output_wallet_merge(self) -> None: + a = MoneroOutputWallet() + a.amount = 1000000 + a.index = 2 + a.account_index = 0 + a.subaddress_index = 1 + # a.tx is left unset on both sides so merge() won't recurse into tx merge + + b = a.copy() + b.is_spent = True # a.is_spent is unset -> merge fills the gap + a.merge(b) + assert a.is_spent is True + + def test_output_wallet_lt_comparator(self) -> None: + o1 = MoneroOutputWallet() + o1.tx = MoneroTx() + o1.account_index = 0 + o1.subaddress_index = 0 + o1.index = 0 + o1.key_image = MoneroKeyImage() + o1.key_image.hex = "a" * 64 + + o2 = MoneroOutputWallet() + o2.tx = MoneroTx() + o2.account_index = 0 + o2.subaddress_index = 0 + o2.index = 1 + o2.key_image = MoneroKeyImage() + o2.key_image.hex = "b" * 64 + + assert o1 < o2 + assert not (o2 < o1) + assert OutputComparator.compare(o1, o2) + assert not OutputComparator.compare(o2, o1) + + outputs = [o2, o1] + outputs.sort() + assert outputs[0] is o1 + assert outputs[1] is o2 + + def test_tx_wallet_copy(self) -> None: + tx = MoneroTxWallet() + tx.hash = "a" * 64 + tx.is_confirmed = True + tx.note = "hello" + + copy = tx.copy() + assert copy is not tx + assert copy.serialize() == tx.serialize() + + def test_tx_wallet_merge(self) -> None: + a = MoneroTxWallet() + a.hash = "a" * 64 + a.is_confirmed = True # required: base monero_tx::merge() dereferences is_confirmed directly + + b = a.copy() + b.note = "hello" # a.note is unset -> merge fills the gap + a.merge(b) + assert a.note == "hello" + + @pytest.mark.xfail(reason="gen_utils::reconcile()'s bug", strict=True) + def test_tx_wallet_merge_is_locked_can_become_false(self) -> None: + a = MoneroTxWallet() + a.hash = "a" * 64 + a.is_confirmed = True + a.is_locked = False # self: already unlocked + b = a.copy() + b.is_locked = True # other: still locked + a.merge(b) + assert a.is_locked is False + + @pytest.mark.xfail(reason="TODO monero-cpp bug", strict=True) + def test_tx_wallet_outputs_deserialize_as_output_wallet(self) -> None: + tx = MoneroTxWallet() + tx.hash = "a" * 64 + output = MoneroOutputWallet() + output.amount = 500000 + output.index = 3 + output.account_index = 2 + output.subaddress_index = 1 + output.is_spent = True + output.is_frozen = False + tx.outputs = [output] + + json_str = tx.serialize() + assert "accountIndex" in json_str + assert "isSpent" in json_str + + restored = MoneroTxWallet.deserialize(json_str) + assert len(restored.outputs) == 1 + assert isinstance(restored.outputs[0], MoneroOutputWallet) + assert restored.outputs[0].account_index == 2 + assert restored.outputs[0].subaddress_index == 1 + assert restored.outputs[0].is_spent is True + assert restored.outputs[0].is_frozen is False + + @pytest.mark.xfail(reason="TODO monero-cpp bug", strict=True) + def test_tx_wallet_get_outputs_wallet_after_deserialize(self) -> None: + tx = MoneroTxWallet() + tx.hash = "a" * 64 + output = MoneroOutputWallet() + output.amount = 500000 + output.index = 3 + tx.outputs = [output] + + restored = MoneroTxWallet.deserialize(tx.serialize()) + outputs_wallet = restored.get_outputs_wallet() # once deserialize works: raises "nullptr given to monero_output_query::meets_criteria()" + assert len(outputs_wallet) == 1 + assert outputs_wallet[0].amount == 500000 + + @pytest.mark.xfail(reason="monero_output::copy() is not virtual, so monero_tx::copy()'s inputs/outputs loop always copies through it regardless of the actual dynamic type, silently downgrading a MoneroOutputWallet to a plain MoneroOutput and dropping its wallet-only fields (this is independent of deserialize -- it reproduces on an in-memory tx too); fixed upstream in the local everoddandeven/monero-cpp checkout, pending a submodule bump", strict=True) + def test_tx_wallet_copy_preserves_output_wallet_type(self) -> None: + tx = MoneroTxWallet() + tx.hash = "a" * 64 + output = MoneroOutputWallet() + output.amount = 500000 + output.account_index = 2 + output.is_spent = True + tx.outputs = [output] + + copy = tx.copy() + assert len(copy.outputs) == 1 + assert isinstance(copy.outputs[0], MoneroOutputWallet) + assert copy.outputs[0].account_index == 2 + assert copy.outputs[0].is_spent is True + + def test_transfer_query_copy(self) -> None: + query = MoneroTransferQuery() + query.amount = 500000 + query.incoming = True + query.address = TestUtils.ADDRESS + + copy = query.copy() + assert copy is not query + assert copy.serialize() == query.serialize() + + def test_output_query_copy(self) -> None: + query = MoneroOutputQuery() + query.amount = 1000000 + query.min_amount = 100000 + query.max_amount = 2000000 + + copy = query.copy() + assert copy is not query + assert copy.serialize() == query.serialize() + + def test_tx_query_copy(self) -> None: + query = MoneroTxQuery() + query.hash = "a" * 64 + query.height = 3000000 + query.is_confirmed = True + + copy = query.copy() + assert copy is not query + assert copy.serialize() == query.serialize() + + #endregion diff --git a/tests/utils/assert_utils.py b/tests/utils/assert_utils.py index 34632ce..eff6eab 100644 --- a/tests/utils/assert_utils.py +++ b/tests/utils/assert_utils.py @@ -40,3 +40,19 @@ def assert_list_equals(cls, expr1: list[Any], expr2: list[Any], message: str = " for i, elem1 in enumerate(expr1): elem2: Any = expr2[i] cls.assert_equals(elem1, elem2, message) + + @classmethod + def assert_serialization_integrity(cls, obj: Any) -> Any: + """Serialize obj, deserialize it back through the model's own from_property_tree + binding, and assert the result matches the original field for field. + + :param Any obj: object to verity serialization integrity. + :return Any: new deserialized object. + """ + cls = type(obj) # type: ignore + json_str: str = obj.serialize() + logger.debug(f"Serialized {cls.__name__}: {json_str}") + restored: Any = cls.deserialize(json_str) # type: ignore + AssertUtils.assert_equals(obj, restored) + return restored # type: ignore + diff --git a/tests/utils/wallet_utils.py b/tests/utils/wallet_utils.py index a5209ba..1d49221 100644 --- a/tests/utils/wallet_utils.py +++ b/tests/utils/wallet_utils.py @@ -231,6 +231,7 @@ def test_wallet_keys(cls, address: str, view_key: str, spend_key: str, w: Monero assert view_key == w.get_private_view_key() assert spend_key == w.get_private_spend_key() MoneroUtils.validate_mnemonic(w.get_seed()) + assert not w.is_view_only() assert MoneroWallet.DEFAULT_LANGUAGE == w.get_seed_language() @classmethod From e22492d0934903f9f10b0d60ae081ccde9e62bbb Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Tue, 25 Aug 2026 14:14:51 +0200 Subject: [PATCH 9/9] add unit tests for monero-cpp bugs --- tests/test_monero_daemon_model.py | 25 +++++++++++ tests/test_monero_daemon_rpc.py | 59 ++++++++++-------------- tests/test_monero_utils.py | 5 ++- tests/test_monero_wallet_full.py | 53 ++++++++++++++++++++++ tests/test_monero_wallet_keys.py | 29 +++++++++--- tests/test_monero_wallet_model.py | 74 +++++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 42 deletions(-) diff --git a/tests/test_monero_daemon_model.py b/tests/test_monero_daemon_model.py index 096e991..9ae7747 100644 --- a/tests/test_monero_daemon_model.py +++ b/tests/test_monero_daemon_model.py @@ -1,5 +1,7 @@ import pytest import logging +import subprocess +import sys from monero import ( MoneroVersion, MoneroRpcPaymentInfo, MoneroRpcConnection, MoneroAltChain, @@ -598,6 +600,29 @@ def test_block_merge(self) -> None: a.merge(b) assert a.hex == "deadbeef" + @pytest.mark.xfail(reason="merge_tx() dereferences m_hash unconditionally (boost::optional UB when unset); locally this just dedups wrongly, but the same NDEBUG/ODR-ambiguity root cause aborts the process in CI", strict=True) + def test_block_merge_txs_with_unset_hash_are_kept_distinct(self) -> None: + script = ( + "import monero, sys\n" + "a = monero.MoneroBlock()\n" + "a.height = 100\n" + "tx_a = monero.MoneroTx()\n" # hash intentionally left unset + "a.txs = [tx_a]\n" + "b = monero.MoneroBlock()\n" + "b.height = 100\n" + "tx_b = monero.MoneroTx()\n" # hash intentionally left unset + "b.txs = [tx_b]\n" + "a.merge(b)\n" + "n = len(a.txs) if a.txs else 0\n" + "sys.exit(0 if n == 2 else f'txs not kept distinct: len={n}')\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"Block.merge() did not keep unset-hash txs distinct (exit code {result.returncode}): " + f"{result.stderr.strip()[-300:]}" + ) + def test_tx_copy(self) -> None: tx = MoneroTx() tx.hash = "a" * 64 diff --git a/tests/test_monero_daemon_rpc.py b/tests/test_monero_daemon_rpc.py index c30b737..1417cb4 100644 --- a/tests/test_monero_daemon_rpc.py +++ b/tests/test_monero_daemon_rpc.py @@ -6,24 +6,20 @@ from monero import ( MoneroDaemonRpc, MoneroVersion, MoneroBlockHeader, MoneroBlockTemplate, - MoneroBlock, MoneroMiningStatus, MoneroPruneResult, + MoneroBlock, MoneroMiningStatus, MoneroPruneResult, MoneroMinerTxSum, MoneroDaemonUpdateCheckResult, MoneroDaemonUpdateDownloadResult, MoneroDaemonListener, MoneroPeer, MoneroDaemonInfo, MoneroDaemonSyncInfo, MoneroHardForkInfo, MoneroAltChain, MoneroTx, MoneroSubmitTxResult, MoneroTxPoolStats, MoneroBan, MoneroTxConfig, MoneroDestination, - MoneroWalletRpc, MoneroKeyImageSpentStatus, - MoneroOutputHistogramEntry, MoneroOutputDistributionEntry, - MoneroRpcConnection + MoneroWalletRpc, MoneroKeyImageSpentStatus, MoneroRpcConnection, + MoneroOutputHistogramEntry, MoneroOutputDistributionEntry ) from utils import ( - TestUtils as Utils, TestContext, - BinaryBlockContext, RpcConnectionUtils, - AssertUtils, TxUtils, OutputUtils, - BlockUtils, GenUtils, BlockchainUtils, - DaemonUtils, WalletType, - IntegrationTestUtils, - SubmitThenRelayTxTester, BaseTestClass, - TxWalletUtils, WalletTxsUtils, DaemonNotificationCollector + TestUtils as Utils, TestContext, BinaryBlockContext, RpcConnectionUtils, + AssertUtils, TxUtils, OutputUtils, BlockUtils, GenUtils, BlockchainUtils, + DaemonUtils, WalletType, IntegrationTestUtils, SubmitThenRelayTxTester, + BaseTestClass, TxWalletUtils, WalletTxsUtils, DaemonNotificationCollector, + MiningUtils ) logger: logging.Logger = logging.getLogger("TestMoneroDaemonRpc") @@ -185,7 +181,7 @@ def test_get_block_headers_by_range(self, daemon: MoneroDaemonRpc) -> None: @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_get_block_by_hash(self, daemon: MoneroDaemonRpc) -> None: # test config - ctx = TestContext() + ctx: TestContext = TestContext() ctx.has_hex = True ctx.has_txs = False ctx.header_is_full = True @@ -217,7 +213,7 @@ def test_get_blocks_by_hash_binary(self) -> None: @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_get_block_by_height(self, daemon: MoneroDaemonRpc) -> None: # config for testing blocks - ctx = TestContext() + ctx: TestContext = TestContext() ctx.has_hex = True ctx.header_is_full = True ctx.has_txs = False @@ -337,7 +333,7 @@ def test_get_tx_by_hash(self, daemon: MoneroDaemonRpc) -> None: tx_hashes: list[str] = DaemonUtils.get_confirmed_tx_hashes(daemon) # context for creating txs - ctx = TestContext() + ctx: TestContext = TestContext() ctx.is_pruned = False ctx.is_confirmed = True ctx.from_get_tx_pool = False @@ -370,7 +366,7 @@ def test_get_txs_by_hashes(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletRp assert len(tx_hashes) > 0, "No tx hashes found" # context for creating txs - ctx = TestContext() + ctx: TestContext = TestContext() ctx.is_pruned = False ctx.is_confirmed = True ctx.from_get_tx_pool = False @@ -517,7 +513,7 @@ def test_get_tx_hexes_by_hashes(self, daemon: MoneroDaemonRpc) -> None: # Can get the miner tx sum @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_get_miner_tx_sum(self, daemon: MoneroDaemonRpc) -> None: - tx_sum = daemon.get_miner_tx_sum(0, min(5000, daemon.get_height())) + tx_sum: MoneroMinerTxSum = daemon.get_miner_tx_sum(0, min(5000, daemon.get_height())) DaemonUtils.test_miner_tx_sum(tx_sum) # Can get fee estimate @@ -918,15 +914,15 @@ def test_block_listener(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletRpc) @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_ban_peer(self, daemon: MoneroDaemonRpc) -> None: # set ban - host = "192.168.1.51" - ban = MoneroBan() + host: str = "192.168.1.51" + ban: MoneroBan = MoneroBan() ban.host = host ban.is_banned = True ban.seconds = 60 daemon.set_peer_ban(ban) # test ban - bans = daemon.get_peer_bans() + bans: list[MoneroBan] = daemon.get_peer_bans() found: bool = False for peer_ban in bans: DaemonUtils.test_ban(peer_ban) @@ -939,13 +935,13 @@ def test_ban_peer(self, daemon: MoneroDaemonRpc) -> None: @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_ban_peers(self, daemon: MoneroDaemonRpc) -> None: # set bans - addr1 = "192.168.1.52" - addr2 = "192.168.1.53" - ban1 = MoneroBan() + addr1: str = "192.168.1.52" + addr2: str = "192.168.1.53" + ban1: MoneroBan = MoneroBan() ban1.host = addr1 ban1.is_banned = True ban1.seconds = 60 - ban2 = MoneroBan() + ban2: MoneroBan = MoneroBan() ban2.host = addr2 ban2.is_banned = True ban2.seconds = 60 @@ -972,10 +968,7 @@ def test_ban_peers(self, daemon: MoneroDaemonRpc) -> None: @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_mining(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletRpc) -> None: # stop mining at beginning of test - try: - daemon.stop_mining() - except Exception as e: - logger.warning(f"[!]: {str(e)}") + MiningUtils.try_stop_mining(daemon) # generate address to mine to address: str = wallet.get_primary_address() @@ -991,10 +984,7 @@ def test_mining(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletRpc) -> None: def test_get_mining_status(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletRpc) -> None: try: # stop mining at beginning of test - try: - daemon.stop_mining() - except Exception as e: - logger.warning(f"[!]: {str(e)}") + MiningUtils.try_stop_mining(daemon) # test status without mining status: MoneroMiningStatus = daemon.get_mining_status() @@ -1020,10 +1010,7 @@ def test_get_mining_status(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletRp assert is_background == status.is_background finally: # stop mining at end of test - try: - daemon.stop_mining() - except Exception as e: - logger.warning(f"Could not stop mining: {str(e)}") + MiningUtils.try_stop_mining(daemon) # Can submit a mined block to the network @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") diff --git a/tests/test_monero_utils.py b/tests/test_monero_utils.py index 6822655..8255a62 100644 --- a/tests/test_monero_utils.py +++ b/tests/test_monero_utils.py @@ -5,7 +5,9 @@ import subprocess import sys import gc -import resource + +if sys.platform != "win32": + import resource from typing import Any from configparser import ConfigParser @@ -743,6 +745,7 @@ def test_free_outputs_list(self) -> None: MoneroUtils.free([output]) assert output.tx.block is None + @pytest.mark.skipif(sys.platform == "win32", reason="resource module (RSS measurement) is not available on Windows") def test_free_breaks_reference_cycle_avoids_leak(self) -> None: # regression guard for the leak demonstrated manually: building # block<->tx cycles and dropping every Python reference without diff --git a/tests/test_monero_wallet_full.py b/tests/test_monero_wallet_full.py index f7a64ce..46b6a60 100644 --- a/tests/test_monero_wallet_full.py +++ b/tests/test_monero_wallet_full.py @@ -1,5 +1,7 @@ import pytest import logging +import subprocess +import sys from typing import Optional from typing_extensions import override @@ -670,6 +672,57 @@ def test_get_height_by_date(self, wallet: MoneroWallet) -> None: def test_get_height_by_date_regtest(self, wallet: MoneroWallet) -> None: return super().test_get_height_by_date(wallet) + @pytest.mark.unit + @pytest.mark.xfail(reason="import_key_images() dereferences m_hex unconditionally (boost::optional UB when unset)", strict=True) + def test_import_key_images_hex_not_defined(self) -> None: + script = ( + "import monero, sys, tempfile, os\n" + "d = tempfile.mkdtemp()\n" + "cfg = monero.MoneroWalletConfig()\n" + "cfg.path = os.path.join(d, 'w')\n" + "cfg.password = 'testpass123'\n" + "cfg.network_type = monero.MoneroNetworkType.STAGENET\n" + "w = monero.MoneroWalletFull.create_wallet(cfg)\n" + "ki = monero.MoneroKeyImage()\n" + "try:\n" + " w.import_key_images([ki])\n" + " sys.exit('import_key_images() did not raise')\n" + "except RuntimeError as e:\n" + " sys.exit(0 if str(e) == 'key image hex is not defined' else f'wrong message: {e}')\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"import_key_images() did not cleanly raise 'key image hex is not defined' " + f"(exit code {result.returncode}): {result.stderr.strip()[-300:]}" + ) + + @pytest.mark.unit + @pytest.mark.xfail(reason="import_key_images() dereferences m_signature unconditionally (boost::optional UB when unset)", strict=True) + def test_import_key_images_signature_not_defined(self) -> None: + script = ( + "import monero, sys, tempfile, os\n" + "d = tempfile.mkdtemp()\n" + "cfg = monero.MoneroWalletConfig()\n" + "cfg.path = os.path.join(d, 'w')\n" + "cfg.password = 'testpass123'\n" + "cfg.network_type = monero.MoneroNetworkType.STAGENET\n" + "w = monero.MoneroWalletFull.create_wallet(cfg)\n" + "ki = monero.MoneroKeyImage()\n" + "ki.hex = 'a' * 64\n" + "try:\n" + " w.import_key_images([ki])\n" + " sys.exit('import_key_images() did not raise')\n" + "except RuntimeError as e:\n" + " sys.exit(0 if str(e) == 'key image signature is not defined' else f'wrong message: {e}')\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"import_key_images() did not cleanly raise 'key image signature is not defined' " + f"(exit code {result.returncode}): {result.stderr.strip()[-300:]}" + ) + #endregion #region Disabled Tests diff --git a/tests/test_monero_wallet_keys.py b/tests/test_monero_wallet_keys.py index bf0d5ed..43d5e48 100644 --- a/tests/test_monero_wallet_keys.py +++ b/tests/test_monero_wallet_keys.py @@ -1,5 +1,7 @@ import pytest import logging +import subprocess +import sys from typing import Optional from typing_extensions import override @@ -722,16 +724,33 @@ def test_create_wallet_from_keys_invalid_view_key(self) -> None: config.private_view_key = "not-a-valid-hex-secret-key" MoneroWalletKeys.create_wallet_from_keys(config) + # Test invalid wallet configuration @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - @pytest.mark.xfail(raises=RuntimeError, reason="A primary address is required when a private view key is provided") + #@pytest.mark.xfail(reason="create_wallet_from_keys() dereferences m_primary_address unconditionally (boost::optional UB when unset)", strict=True) + @pytest.mark.skip("UB when m_primary_adress is unset") def test_create_wallet_from_keys_view_key_without_address(self) -> None: """ create_wallet_from_keys() must require a primary address when a private view key is given. + Non-deterministic in-process (observed locally as RuntimeError with varying messages + 'std::bad_alloc' or 'failed to parse address'). """ - config = MoneroWalletConfig() - config.network_type = Utils.NETWORK_TYPE - config.private_view_key = Utils.PRIVATE_VIEW_KEY - MoneroWalletKeys.create_wallet_from_keys(config) + script = ( + "import monero, sys\n" + "config = monero.MoneroWalletConfig()\n" + f"config.network_type = monero.MoneroNetworkType.{Utils.NETWORK_TYPE.name}\n" + "config.private_view_key = 'a' * 64\n" + "try:\n" + " monero.MoneroWalletKeys.create_wallet_from_keys(config)\n" + " sys.exit('create_wallet_from_keys() did not raise')\n" + "except RuntimeError as e:\n" + " sys.exit(0 if str(e) == 'must provide address if providing private view key' else f'wrong message: {e}')\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"create_wallet_from_keys() did not cleanly raise 'must provide address if providing " + f"private view key' (exit code {result.returncode}): {result.stderr.strip()[-300:]}" + ) @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @override diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 4348a36..2eeacac 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -1,5 +1,7 @@ import pytest import logging +import subprocess +import sys from monero import ( MoneroTxQuery, MoneroTransferQuery, MoneroOutputQuery, @@ -537,6 +539,45 @@ def test_incoming_transfer_merge(self) -> None: a.merge(b) assert a.address == TestUtils.ADDRESS + @pytest.mark.xfail(reason="merge_incoming_transfer() dereferences account/subaddress index unconditionally (boost::optional UB when unset); locally this just dedups wrongly, but the same NDEBUG/ODR-ambiguity root cause aborts the process in CI", strict=True) + def test_tx_wallet_merge_incoming_transfers_with_unset_indices_are_kept_distinct(self) -> None: + """ + merge_incoming_transfer() dedups incoming transfers by (account_index, subaddress_index) + when reconciling two txs' transfer lists. Previously it dereferenced both indices + unconditionally (boost::optional UB when unset), reachable via TxWallet.merge() with + user-constructed MoneroIncomingTransfer objects that never had an index assigned. Since + identity can't be verified without both indices, unset-index transfers must be kept as + distinct entries rather than crashing or being silently coalesced. Run in an isolated + subprocess: locally this UB just gives a wrong (deduped) result, but the same + optional::get() assertion has been observed to abort the whole process in CI's build + (NDEBUG/ODR ambiguity between monero-cpp and monero-python's own compiled units), which a + plain in-process assertion can't survive. + """ + script = ( + "import monero, sys\n" + "tx_a = monero.MoneroTxWallet()\n" + "tx_a.hash = 'a' * 64\n" + "tx_a.is_confirmed = True\n" + "transfer_a = monero.MoneroIncomingTransfer()\n" # account_index/subaddress_index intentionally unset + "transfer_a.tx = tx_a\n" + "tx_a.incoming_transfers = [transfer_a]\n" + "tx_b = monero.MoneroTxWallet()\n" + "tx_b.hash = 'a' * 64\n" + "tx_b.is_confirmed = True\n" + "transfer_b = monero.MoneroIncomingTransfer()\n" # account_index/subaddress_index intentionally unset + "transfer_b.tx = tx_b\n" + "tx_b.incoming_transfers = [transfer_b]\n" + "tx_a.merge(tx_b)\n" + "n = len(tx_a.incoming_transfers) if tx_a.incoming_transfers else 0\n" + "sys.exit(0 if n == 2 else f'incoming_transfers not kept distinct: len={n}')\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"TxWallet.merge() did not keep unset-index incoming transfers distinct " + f"(exit code {result.returncode}): {result.stderr.strip()[-300:]}" + ) + def test_incoming_transfer_lt_comparator(self) -> None: t1 = MoneroIncomingTransfer() t1.tx = MoneroTxWallet() @@ -585,6 +626,39 @@ def test_outgoing_transfer_merge(self) -> None: assert a.subaddress_indices == [0] assert len(a.destinations) == 1 + @pytest.mark.xfail(reason="monero_outgoing_transfer::merge() dereferences destination address/amount unconditionally (boost::optional UB when unset) and segfaults the interpreter", strict=True) + def test_outgoing_transfer_merge_destinations_with_unset_fields(self) -> None: + script = ( + "import monero, sys\n" + # dirty the heap first: a clean freshly-started interpreter doesn't reliably + # reproduce the crash, but a heap with realistic allocation churn (much closer to + # a real test run or application) does, consistently + "garbage = []\n" + "for i in range(500):\n" + " tx = monero.MoneroTxWallet()\n" + " tx.hash = 'b' * 64 + str(i)\n" + " tx.note = 'x' * (i % 200)\n" + " garbage.append(tx)\n" + "del garbage\n" + "a = monero.MoneroOutgoingTransfer()\n" + "a.amount = 500000\n" + "a.account_index = 0\n" + "a.destinations = [monero.MoneroDestination()]\n" + "b = a.copy()\n" + "b.destinations = [monero.MoneroDestination()]\n" + "try:\n" + " a.merge(b)\n" + " sys.exit('merge() did not raise')\n" + "except RuntimeError as e:\n" + " sys.exit(0 if str(e) == 'Destination vectors are different' else f'wrong message: {e}')\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") + assert result.returncode == 0, ( + f"outgoing_transfer.merge() did not cleanly raise 'Destination vectors are different' " + f"(exit code {result.returncode}): {result.stderr.strip()[-300:]}" + ) + def test_output_wallet_copy(self) -> None: output = MoneroOutputWallet() output.amount = 1000000