From b576c6031da24d90028897b6c27fe96b09b6b909 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Thu, 10 Sep 2026 23:58:05 +0200 Subject: [PATCH 01/20] daemon rpc: add test for get_outputs method --- tests/test_monero_daemon_rpc.py | 37 ++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/test_monero_daemon_rpc.py b/tests/test_monero_daemon_rpc.py index c66ee50..2398d5e 100644 --- a/tests/test_monero_daemon_rpc.py +++ b/tests/test_monero_daemon_rpc.py @@ -14,7 +14,8 @@ MoneroWalletRpc, MoneroKeyImageSpentStatus, MoneroRpcConnection, MoneroOutputHistogramEntry, MoneroOutputDistributionEntry, MoneroFeeEstimate, MoneroMinerData, MoneroDaemonNetworkStats, MoneroAuxiliaryPow, - MoneroAddAuxiliaryPowResult, MoneroGetBlocksByHashResult, MoneroGetBlockHashesResult + MoneroAddAuxiliaryPowResult, MoneroGetBlocksByHashResult, MoneroGetBlockHashesResult, + MoneroOutput ) from utils import ( TestUtils as Utils, TestContext, BinaryBlockContext, RpcConnectionUtils, @@ -1159,6 +1160,40 @@ def test_get_output_indices(self, daemon: MoneroDaemonRpc) -> None: daemon.get_output_indices("") assert str(exc_info.value) == "Must provide a transaction hash" + # Can get outputs given a list of output amounts and indices (binary) + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_outputs(self, daemon: MoneroDaemonRpc) -> None: + tx_hashes: list[str] = DaemonUtils.get_confirmed_tx_hashes(daemon) + assert len(tx_hashes) > 0, "No confirmed txs found" + + # identify each output by amount 0 (RingCT) and its global index + requested: list[MoneroOutput] = [] + for tx_hash in tx_hashes: + for index in daemon.get_output_indices(tx_hash): + output: MoneroOutput = MoneroOutput() + output.amount = 0 + output.index = index + requested.append(output) + + num_requested: int = len(requested) + assert num_requested > 0 + + fetched: list[MoneroOutput] = daemon.get_outputs(requested) + num_fetched: int = len(fetched) + assert num_fetched == num_requested + + for i, output in enumerate(fetched): + logger.debug(f"Testing fetched output: {output.serialize()}") + assert output.index == requested[i].index + assert output.stealth_public_key is not None and len(output.stealth_public_key) == 64 + assert output.mask is not None and len(output.mask) == 64 + assert output.tx is not None + assert output.tx.block is not None and output.tx.block.height is not None + + with pytest.raises(Exception) as exc_info: + daemon.get_outputs([]) + assert str(exc_info.value) == "Must provide outputs to fetch" + # Can get network (bandwidth) statistics @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_get_network_stats(self, daemon: MoneroDaemonRpc) -> None: From 1e72a81c53d209bc9e53be6209af1dce47d2012f Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 00:12:03 +0200 Subject: [PATCH 02/20] daemon rpc: add test for set_poll_period --- src/cpp/daemon/py_monero_daemon_bindings.cpp | 3 +++ src/python/monero_daemon_rpc.pyi | 8 ++++++++ tests/config/config.ini | 1 + tests/test_monero_daemon_rpc.py | 9 +++++++++ tests/utils/test_utils.py | 3 +++ 5 files changed, 24 insertions(+) diff --git a/src/cpp/daemon/py_monero_daemon_bindings.cpp b/src/cpp/daemon/py_monero_daemon_bindings.cpp index 3c37224..2648e6f 100644 --- a/src/cpp/daemon/py_monero_daemon_bindings.cpp +++ b/src/cpp/daemon/py_monero_daemon_bindings.cpp @@ -856,6 +856,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("get_rpc_connection", [](const monero_daemon_rpc& self) { MONERO_CATCH_AND_RETHROW(self.get_rpc_connection()); }) + .def("set_poll_period_in_ms", [](monero_daemon_rpc& self, uint64_t period_ms) { + MONERO_CATCH_AND_RETHROW(self.set_poll_period_in_ms(period_ms)); + }, py::arg("period_ms"), py::call_guard()) .def("is_connected", [](monero_daemon_rpc& self) { MONERO_CATCH_AND_RETHROW(self.is_connected()); }, py::call_guard()); diff --git a/src/python/monero_daemon_rpc.pyi b/src/python/monero_daemon_rpc.pyi index 0feb772..be8a648 100644 --- a/src/python/monero_daemon_rpc.pyi +++ b/src/python/monero_daemon_rpc.pyi @@ -46,6 +46,14 @@ class MoneroDaemonRpc(MoneroDaemon): """ ... + def set_poll_period_in_ms(self, period_ms: int) -> None: + """ + Set the period between polls of the daemon for block notifications. + + :param int period_ms: the poll period in milliseconds. + """ + ... + def is_connected(self) -> bool: """ Indicates if the client is connected to the daemon via RPC. diff --git a/tests/config/config.ini b/tests/config/config.ini index f2ee27c..00ec0c4 100644 --- a/tests/config/config.ini +++ b/tests/config/config.ini @@ -15,6 +15,7 @@ rpc_password=abc123 zmq_uri=tcp://127.0.0.1:18085 zmq_pub_uri=tcp://127.0.0.1:18086 log_level=3 +poll_period_in_ms=10000 [wallet] name=test_wallet_1 diff --git a/tests/test_monero_daemon_rpc.py b/tests/test_monero_daemon_rpc.py index 2398d5e..7535ae9 100644 --- a/tests/test_monero_daemon_rpc.py +++ b/tests/test_monero_daemon_rpc.py @@ -1256,6 +1256,15 @@ def test_flush_cache(self, daemon: MoneroDaemonRpc) -> None: def test_remove_bootstrap_daemon(self, daemon: MoneroDaemonRpc) -> None: daemon.remove_bootstrap_daemon() + # Can set the poll period for block notifications + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_set_poll_period_in_ms(self, daemon: MoneroDaemonRpc) -> None: + try: + daemon.set_poll_period_in_ms(1000) + finally: + # restore the configured period so later tests poll at the expected rate + daemon.set_poll_period_in_ms(Utils.DAEMON_POLL_PERIOD_IN_MS) + # Can set the daemon's log level @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_set_log_level(self, daemon: MoneroDaemonRpc) -> None: diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index fd425b7..9f2d031 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -165,6 +165,8 @@ class TestUtils(ABC): """Monero core internal log level.""" DAEMON_LOG_LEVEL: int = 3 """Daemon rpc log level.""" + DAEMON_POLL_PERIOD_IN_MS: int = 10000 + """period between daemon polls for block notifications in milliseconds.""" @classmethod def load_config(cls) -> None: @@ -197,6 +199,7 @@ def load_config(cls) -> None: # parse daemon config cls.DAEMON_LOG_LEVEL = parser.getint('daemon', 'log_level', fallback=cls.DAEMON_LOG_LEVEL) + cls.DAEMON_POLL_PERIOD_IN_MS = parser.getint('daemon', 'poll_period_in_ms', fallback=cls.DAEMON_POLL_PERIOD_IN_MS) cls.DAEMON_RPC_URI = parser.get('daemon', 'rpc_uri') 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') From 834693e66de980676b57c5c2f1d96b2b09673375 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 00:17:43 +0200 Subject: [PATCH 03/20] daemon model: add unit test for MoneroKeyImage.deserialize_key_images --- tests/test_monero_daemon_model.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_monero_daemon_model.py b/tests/test_monero_daemon_model.py index 13a8fd9..20ec763 100644 --- a/tests/test_monero_daemon_model.py +++ b/tests/test_monero_daemon_model.py @@ -516,6 +516,28 @@ def test_key_image_deserialize(self) -> None: key_image.signature = "b" * 128 AssertUtils.assert_serialization_integrity(key_image) + def test_key_image_deserialize_key_images(self) -> None: + json_str: str = ( + '{"keyImages": [' + '{"hex": "' + "a" * 64 + '", "signature": "' + "b" * 128 + '"}, ' + '{"hex": "' + "c" * 64 + '"}' + ']}' + ) + key_images: list[MoneroKeyImage] = MoneroKeyImage.deserialize_key_images(json_str) + assert len(key_images) == 2 + assert key_images[0].hex == "a" * 64 + assert key_images[0].signature == "b" * 128 + assert key_images[1].hex == "c" * 64 + assert key_images[1].signature is None + + # a missing or empty keyImages array yields no key images + assert MoneroKeyImage.deserialize_key_images("{}") == [] + assert MoneroKeyImage.deserialize_key_images('{"keyImages": []}') == [] + + # an empty string is not valid json + with pytest.raises(Exception): + MoneroKeyImage.deserialize_key_images("") + def test_output_deserialize(self) -> None: output: MoneroOutput = MoneroOutput() output.amount = 1000000 From 3bbef19657de38cc7d0e13426a0735dab6de2ca2 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 00:21:04 +0200 Subject: [PATCH 04/20] daemon model: improve test_block_deserialize --- tests/test_monero_daemon_model.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_monero_daemon_model.py b/tests/test_monero_daemon_model.py index 20ec763..4322dba 100644 --- a/tests/test_monero_daemon_model.py +++ b/tests/test_monero_daemon_model.py @@ -165,7 +165,26 @@ def test_block_deserialize(self) -> None: block.height = 12345 block.hex = "deadbeef" block.tx_hashes = ["b" * 64, "c" * 64] - AssertUtils.assert_serialization_integrity(block) + + miner_tx: MoneroTx = MoneroTx() + miner_tx.hash = "d" * 64 + miner_tx.is_miner_tx = True + block.miner_tx = miner_tx + + tx: MoneroTx = MoneroTx() + tx.hash = "e" * 64 + block.txs = [tx] + + restored: MoneroBlock = AssertUtils.assert_serialization_integrity(block) + + # nested txs are parsed and back-linked to the block + assert restored.miner_tx is not None + assert restored.miner_tx.hash == "d" * 64 + assert restored.miner_tx.is_miner_tx is True + assert restored.miner_tx.block is not None and restored.miner_tx.block.height == 12345 + assert len(restored.txs) == 1 + assert restored.txs[0].hash == "e" * 64 + assert restored.txs[0].block is not None and restored.txs[0].block.height == 12345 def test_connection_span_deserialize(self) -> None: span: MoneroConnectionSpan = MoneroConnectionSpan() From 4ee011f0b0f4c174e8553def347342a26f565c7c Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 00:28:11 +0200 Subject: [PATCH 05/20] daemon model: add and improve test for blocks --- tests/test_monero_daemon_model.py | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_monero_daemon_model.py b/tests/test_monero_daemon_model.py index 4322dba..6201391 100644 --- a/tests/test_monero_daemon_model.py +++ b/tests/test_monero_daemon_model.py @@ -708,10 +708,28 @@ def test_block_copy(self) -> None: block.hex = "deadbeef" block.tx_hashes = ["b" * 64, "c" * 64] + miner_tx: MoneroTx = MoneroTx() + miner_tx.hash = "d" * 64 + miner_tx.is_miner_tx = True + block.miner_tx = miner_tx + + tx: MoneroTx = MoneroTx() + tx.hash = "e" * 64 + block.txs = [tx] + copy: MoneroBlock = block.copy() assert copy is not block assert copy.serialize() == block.serialize() + # miner tx and non-wallet txs are deep copied and back-linked to the copy + assert copy.miner_tx is not None and copy.miner_tx is not block.miner_tx + assert copy.miner_tx.hash == "d" * 64 + assert copy.miner_tx.block is copy + assert len(copy.txs) == 1 + assert copy.txs[0] is not block.txs[0] + assert copy.txs[0].hash == "e" * 64 + assert copy.txs[0].block is copy + def test_block_merge(self) -> None: a: MoneroBlock = MoneroBlock() a.hash = "a" * 64 @@ -764,6 +782,24 @@ def test_tx_merge(self) -> None: a.merge(b) assert a.num_confirmations == 5 + def test_tx_merge_adopts_other_block(self) -> None: + block: MoneroBlock = MoneroBlock() + block.height = 200 + + other: MoneroTx = MoneroTx() + other.hash = "a" * 64 + other.is_confirmed = True + other.block = block + block.txs = [other] + + tx: MoneroTx = MoneroTx() + tx.hash = "a" * 64 + tx.is_confirmed = True + # tx.block is unset -> merge adopts other's block and repoints the block at tx + tx.merge(other) + assert tx.block is block + assert block.txs[0] is tx + def test_tx_merge_is_confirmed_can_become_true(self) -> None: a: MoneroTx = MoneroTx() a.hash = "a" * 64 From 40776f31952f289a702e0abacee8569ea1dbdf25 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 00:29:26 +0200 Subject: [PATCH 06/20] daemon model: test tx inputs merge --- tests/test_monero_daemon_model.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_monero_daemon_model.py b/tests/test_monero_daemon_model.py index 6201391..9da59ea 100644 --- a/tests/test_monero_daemon_model.py +++ b/tests/test_monero_daemon_model.py @@ -891,6 +891,34 @@ def test_tx_merge_extra_and_output_indices(self) -> None: assert a.extra == [1, 2, 3, 255] assert a.output_indices == [100, 101] + def test_tx_merge_inputs(self) -> None: + def make_input(tx: MoneroTx, key_image_hex: str) -> MoneroOutput: + key_image: MoneroKeyImage = MoneroKeyImage() + key_image.hex = key_image_hex + tx_input: MoneroOutput = MoneroOutput() + tx_input.tx = tx + tx_input.key_image = key_image + return tx_input + + a: MoneroTx = MoneroTx() + a.hash = "a" * 64 + a.is_confirmed = True # required: merge() dereferences is_confirmed directly + a.inputs = [make_input(a, "1" * 64)] + + b: MoneroTx = MoneroTx() + b.hash = "a" * 64 + b.is_confirmed = True + shared_input: MoneroOutput = make_input(b, "1" * 64) # same key image -> merged into a's input + shared_input.amount = 5000 + b.inputs = [shared_input, make_input(b, "2" * 64)] # new key image -> appended + + a.merge(b) + assert len(a.inputs) == 2 + assert a.inputs[0].key_image.hex == "1" * 64 # type: ignore + assert a.inputs[0].amount == 5000 # filled from b's matching input + assert a.inputs[1].key_image.hex == "2" * 64 # type: ignore + assert a.inputs[1].tx is a # appended input is repointed at a + def test_output_merge_ring_output_indices_and_stealth_public_key(self) -> None: a: MoneroOutput = MoneroOutput() a.amount = 1000000 From 3eb04012a63766b6dcf99a08e47d1b9ffdb7fc43 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 00:33:57 +0200 Subject: [PATCH 07/20] daemon model: add test_output_merge_recurses_into_tx_merge --- tests/test_monero_daemon_model.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_monero_daemon_model.py b/tests/test_monero_daemon_model.py index 9da59ea..4a40178 100644 --- a/tests/test_monero_daemon_model.py +++ b/tests/test_monero_daemon_model.py @@ -880,6 +880,30 @@ def test_output_merge(self) -> None: assert a.key_image is not None assert a.key_image.hex == "a" * 64 + def test_output_merge_recurses_into_tx_merge(self) -> None: + tx_a: MoneroTx = MoneroTx() + tx_a.hash = "a" * 64 + tx_a.is_confirmed = True + + tx_b: MoneroTx = MoneroTx() + tx_b.hash = "a" * 64 + tx_b.is_confirmed = True + tx_b.num_confirmations = 9 # only tx merge reconciles this + + out_a: MoneroOutput = MoneroOutput() + out_a.tx = tx_a + out_a.stealth_public_key = "s" * 64 + tx_a.outputs = [out_a] + + out_b: MoneroOutput = MoneroOutput() + out_b.tx = tx_b + out_b.stealth_public_key = "s" * 64 + tx_b.outputs = [out_b] + + # outputs on different txs -> merge delegates to tx merge (which comes back to merging outputs) + out_a.merge(out_b) + assert tx_a.num_confirmations == 9 + def test_tx_merge_extra_and_output_indices(self) -> None: a: MoneroTx = MoneroTx() a.hash = "a" * 64 From 43262b32c2ed1a6e8146c378a1d286ce9924000b Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 00:37:25 +0200 Subject: [PATCH 08/20] daemon model: add test for mining status deserialization consinstency --- tests/test_monero_daemon_model.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_monero_daemon_model.py b/tests/test_monero_daemon_model.py index 4a40178..fbdb872 100644 --- a/tests/test_monero_daemon_model.py +++ b/tests/test_monero_daemon_model.py @@ -112,6 +112,20 @@ def test_mining_status_deserialize(self) -> None: status.num_threads = 4 AssertUtils.assert_serialization_integrity(status) + def test_mining_status_inactive_clears_background_and_address_deserialize(self) -> None: + # when isActive is false from_property_tree drops isBackground and address + # even if present in the json + json_str: str = ( + '{"isActive": false, "isBackground": true, ' + '"address": "' + "9" + "a" * 94 + '", "speed": 500, "numThreads": 4}' + ) + status: MoneroMiningStatus = MoneroMiningStatus.deserialize(json_str) + assert status.is_active is False + assert status.is_background is None + assert status.address is None + assert status.speed == 500 + assert status.num_threads == 4 + def test_miner_tx_sum_deserialize(self) -> None: summ: MoneroMinerTxSum = MoneroMinerTxSum() summ.emission_sum_low = 1000 From 4068052658d65c29f5fa82271b15c80cf0bdc771 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 00:46:31 +0200 Subject: [PATCH 09/20] wallet model: test tx wallet input deserialization --- tests/test_monero_wallet_model.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 350cc56..4764ae7 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -787,6 +787,37 @@ def test_tx_wallet_merge_is_locked_can_become_false(self) -> None: a.merge(b) assert a.is_locked is False + def test_tx_wallet_inputs_deserialize_as_output_wallet(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + tx_input: MoneroOutputWallet = MoneroOutputWallet() + tx_input.amount = 500000 + tx_input.index = 7 + tx_input.account_index = 1 + tx_input.subaddress_index = 2 + tx_input.is_spent = True + tx_input.is_frozen = False + tx_input.key_image = MoneroKeyImage() + tx_input.key_image.hex = "k" * 64 + tx.inputs = [tx_input] + + json_str: str = tx.serialize() + assert "accountIndex" in json_str + assert "isSpent" in json_str + + # monero_tx::from_property_tree parses inputs as MoneroOutput; the wallet + # deserializer re-parses them as MoneroOutputWallet in place + restored: MoneroTxWallet = MoneroTxWallet.deserialize(json_str) + assert len(restored.inputs) == 1 + assert isinstance(restored.inputs[0], MoneroOutputWallet) + assert restored.inputs[0].account_index == 1 + assert restored.inputs[0].subaddress_index == 2 + assert restored.inputs[0].is_spent is True + assert restored.inputs[0].is_frozen is False + assert restored.inputs[0].key_image is not None + assert restored.inputs[0].key_image.hex == "k" * 64 + assert restored.inputs[0].tx is restored + def test_tx_wallet_outputs_deserialize_as_output_wallet(self) -> None: tx: MoneroTxWallet = MoneroTxWallet() tx.hash = "a" * 64 From 1a5b10d690fcec76a2c9d626f6575ea68c6a2088 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 00:50:52 +0200 Subject: [PATCH 10/20] wallet model: test tx filter inputs/outputs wallet --- tests/test_monero_wallet_model.py | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 4764ae7..5df65b7 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -856,6 +856,52 @@ def test_tx_wallet_get_outputs_wallet_after_deserialize(self) -> None: assert len(outputs_wallet) == 1 assert outputs_wallet[0].amount == 500000 + def test_tx_wallet_filter_inputs_wallet(self) -> None: + def make_input(account_index: int, amount: int) -> MoneroOutputWallet: + tx_input: MoneroOutputWallet = MoneroOutputWallet() + tx_input.account_index = account_index + tx_input.amount = amount + return tx_input + + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + tx.inputs = [make_input(0, 100), make_input(1, 200), make_input(0, 300)] + + query: MoneroOutputQuery = MoneroOutputQuery() + query.account_index = 0 + + # filter returns the matches and drops the rest from tx.inputs in place + matched: list[MoneroOutputWallet] = tx.filter_inputs_wallet(query) + assert [i.amount for i in matched] == [100, 300] + assert all(isinstance(i, MoneroOutputWallet) for i in matched) + assert [i.amount for i in tx.inputs] == [100, 300] + + # an unconstrained query keeps everything + assert len(tx.filter_inputs_wallet(MoneroOutputQuery())) == 2 + + def test_tx_wallet_filter_outputs_wallet(self) -> None: + def make_output(account_index: int, amount: int) -> MoneroOutputWallet: + output: MoneroOutputWallet = MoneroOutputWallet() + output.account_index = account_index + output.amount = amount + return output + + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + tx.outputs = [make_output(0, 100), make_output(1, 200), make_output(0, 300)] + + query: MoneroOutputQuery = MoneroOutputQuery() + query.account_index = 0 + + # filter returns the matches and drops the rest from tx.outputs in place + matched: list[MoneroOutputWallet] = tx.filter_outputs_wallet(query) + assert [o.amount for o in matched] == [100, 300] + assert all(isinstance(o, MoneroOutputWallet) for o in matched) + assert [o.amount for o in tx.outputs] == [100, 300] + + # an unconstrained query keeps everything + assert len(tx.filter_outputs_wallet(MoneroOutputQuery())) == 2 + def test_tx_wallet_copy_preserves_output_wallet_type(self) -> None: tx: MoneroTxWallet = MoneroTxWallet() tx.hash = "a" * 64 From 5d62df94c16d3dd80eeb318f2f10ed0e741f88ae Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 13:44:04 +0200 Subject: [PATCH 11/20] wallet model: test query deserialize from block --- tests/test_monero_wallet_model.py | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 5df65b7..2ea92e1 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -430,6 +430,41 @@ def test_tx_query_input_and_output_query_serialize_round_trip(self) -> None: assert restored.output_query.amount == 7 assert restored.output_query.index == 2 + def test_tx_query_deserialize_from_block(self) -> None: + tx_query: MoneroTxQuery = MoneroTxQuery() + tx_query.hash = "a" * 64 + tx_query.is_confirmed = True + tx_query.hashes = ["a" * 64, "b" * 64] + tx_query.min_height = 100 + tx_query.max_height = 200 + + # deserialize_from_block expects a block node wrapping the query under "txs" + block_json: str = '{"txs":[' + tx_query.serialize() + ']}' + restored: MoneroTxQuery = MoneroTxQuery.deserialize_from_block(block_json) + assert restored.hash == "a" * 64 + assert restored.is_confirmed is True + assert list(restored.hashes) == ["a" * 64, "b" * 64] + assert restored.min_height == 100 + assert restored.max_height == 200 + + # the query is linked back to its parent block + assert restored.block is not None + assert restored.block.txs[0] is restored + + @pytest.mark.xfail(reason="monero_tx_query::deserialize_from_block() segfaults when array txs is empty", strict=True) + def test_tx_query_deserialize_from_block_no_txs_does_not_crash(self) -> None: + # a block node with an empty "txs" array causes a native segfault (SIGSEGV) + script: str = ( + "import monero\n" + "monero.MoneroTxQuery.deserialize_from_block('{\"txs\": []}')\n" + ) + result: subprocess.CompletedProcess[str] = 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"deserialize_from_block() crashed the interpreter (exit code {result.returncode}) " + "instead of raising a Python exception for a block with no txs" + ) + def test_integrated_address_deserialize(self) -> None: address: MoneroIntegratedAddress = MoneroIntegratedAddress() address.standard_address = TestUtils.ADDRESS From fb1456c6776675c74e9551e1b7a6b3dab3c067d0 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 13:47:22 +0200 Subject: [PATCH 12/20] wallet model: test invalid message signature type deserialization --- tests/test_monero_wallet_model.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 2ea92e1..6421549 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -487,6 +487,11 @@ def test_message_signature_result_deserialize(self) -> None: result.signature_type = MoneroMessageSignatureType.SIGN_WITH_SPEND_KEY AssertUtils.assert_serialization_integrity(result) + def test_message_signature_result_invalid_signature_type_deserialize(self) -> None: + with pytest.raises(RuntimeError) as exc_info: + MoneroMessageSignatureResult.deserialize('{"signatureType": "bogus"}') + assert str(exc_info.value) == "Invalid message signature type: bogus" + def test_check_tx_deserialize(self) -> None: check: MoneroCheckTx = MoneroCheckTx() check.is_good = True From f1ec2781f200499001e951cda80b940dd708177f Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 13:51:10 +0200 Subject: [PATCH 13/20] wallet model: add tests for output comparator --- tests/test_monero_wallet_model.py | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 6421549..bf3bf9c 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -792,6 +792,55 @@ def test_output_wallet_lt_comparator(self) -> None: assert OutputComparator.compare(o1, o2) assert not OutputComparator.compare(o2, o1) + def test_output_wallet_lt_comparator_account_index(self) -> None: + tx: MoneroTx = MoneroTx() + o1: MoneroOutputWallet = MoneroOutputWallet() + o1.tx = tx + o1.account_index = 0 + o1.subaddress_index = 0 + o1.index = 0 + o1.key_image = MoneroKeyImage() + o1.key_image.hex = "a" * 64 + + o2: MoneroOutputWallet = o1.copy() + o2.account_index = 1 # only the account index differs + + assert OutputComparator.compare(o1, o2) + assert not OutputComparator.compare(o2, o1) + + def test_output_wallet_lt_comparator_subaddress_index(self) -> None: + tx: MoneroTx = MoneroTx() + o1: MoneroOutputWallet = MoneroOutputWallet() + o1.tx = tx + o1.account_index = 0 + o1.subaddress_index = 0 + o1.index = 0 + o1.key_image = MoneroKeyImage() + o1.key_image.hex = "a" * 64 + + o2: MoneroOutputWallet = o1.copy() + o2.subaddress_index = 1 # same account index, only the subaddress index differs + + assert OutputComparator.compare(o1, o2) + assert not OutputComparator.compare(o2, o1) + + def test_output_wallet_lt_comparator_key_image(self) -> None: + tx: MoneroTx = MoneroTx() + o1: MoneroOutputWallet = MoneroOutputWallet() + o1.tx = tx + o1.account_index = 0 + o1.subaddress_index = 0 + o1.index = 0 + o1.key_image = MoneroKeyImage() + o1.key_image.hex = "a" * 64 + + o2: MoneroOutputWallet = o1.copy() + o2.key_image = MoneroKeyImage() + o2.key_image.hex = "b" * 64 # account/subaddress/index tied -> falls back to key image hex + + assert OutputComparator.compare(o1, o2) + assert not OutputComparator.compare(o2, o1) + outputs: list[MoneroOutputWallet] = [o2, o1] outputs.sort() assert outputs[0] is o1 From 7a09ceca96247900d861a5f208f9dfc79fe18281 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 13:59:47 +0200 Subject: [PATCH 14/20] wallet model: add more unit tests for tx config --- tests/test_monero_wallet_model.py | 69 ++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index bf3bf9c..3181457 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -14,7 +14,7 @@ MoneroAddressBookEntry, MoneroAccountTag, MoneroIncomingTransfer, MoneroOutgoingTransfer, IncomingTransferComparator, OutputComparator, MoneroTx, MoneroTxSet, MoneroSyncResult, MoneroDecodedAddress, - MoneroAddressType, MoneroNetworkType + MoneroAddressType, MoneroNetworkType, MoneroTxPriority ) from utils import BaseTestClass, TestUtils, AssertUtils @@ -205,6 +205,8 @@ def test_tx_config(self) -> None: config.can_split = True config.fee = MoneroUtils.xmr_to_atomic_units(0.00075) config.sweep_each_subaddress = False + config.priority = MoneroTxPriority.ELEVATED + config.key_image = "a" * 64 copy: MoneroTxConfig = config.copy() AssertUtils.assert_equals(config, copy) @@ -215,6 +217,71 @@ def test_tx_config(self) -> None: deserialized_config: MoneroTxConfig = MoneroTxConfig.deserialize(config_str) AssertUtils.assert_equals(config, deserialized_config) + @pytest.mark.parametrize("priority,priority_num", [ + (MoneroTxPriority.DEFAULT, 0), + (MoneroTxPriority.UNIMPORTANT, 1), + (MoneroTxPriority.NORMAL, 2), + (MoneroTxPriority.ELEVATED, 3), + ]) + def test_tx_config_priority_deserialize(self, priority: MoneroTxPriority, priority_num: int) -> None: + config: MoneroTxConfig = MoneroTxConfig.deserialize('{"priority": %d}' % priority_num) + assert config.priority == priority + + def test_tx_config_invalid_priority_deserialize(self) -> None: + with pytest.raises(RuntimeError) as exc_info: + MoneroTxConfig.deserialize('{"priority": 4}') + assert str(exc_info.value) == "Invalid priority number: 4" + + def test_get_normalized_destinations_uses_destinations_when_address_and_amount_unset(self) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.destinations = [MoneroDestination(TestUtils.ADDRESS, 100), MoneroDestination(TestUtils.MINING_ADDRESS, 200)] + assert config.get_normalized_destinations() == config.destinations + + def test_get_normalized_destinations_builds_single_destination(self) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.address = TestUtils.ADDRESS + config.amount = 500000 + assert len(config.destinations) == 0 # address/amount alone don't populate destinations + + normalized: list[MoneroDestination] = config.get_normalized_destinations() + assert len(normalized) == 1 + assert normalized[0].address == TestUtils.ADDRESS + assert normalized[0].amount == 500000 + + def test_get_normalized_destinations_conflicts_with_multiple_destinations(self) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.address = TestUtils.ADDRESS + config.amount = 500000 + config.destinations = [MoneroDestination(TestUtils.ADDRESS, 500000), MoneroDestination(TestUtils.MINING_ADDRESS, 1)] + with pytest.raises(RuntimeError) as exc_info: + config.get_normalized_destinations() + assert str(exc_info.value) == "Invalid tx configuration: single destination address/amount incompatible with multiple destinations" + + def test_get_normalized_destinations_address_mismatch(self) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.address = TestUtils.ADDRESS + config.amount = 500000 + config.destinations = [MoneroDestination(TestUtils.MINING_ADDRESS, 500000)] + with pytest.raises(RuntimeError) as exc_info: + config.get_normalized_destinations() + assert str(exc_info.value) == "Invalid tx configuration: single destination address does not match first destination address" + + def test_get_normalized_destinations_amount_mismatch(self) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.address = TestUtils.ADDRESS + config.amount = 500000 + config.destinations = [MoneroDestination(TestUtils.ADDRESS, 1)] + with pytest.raises(RuntimeError) as exc_info: + config.get_normalized_destinations() + assert str(exc_info.value) == "Invalid tx configuration: single destination amount does not match first destination amount" + + def test_get_normalized_destinations_matches_single_destination(self) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.address = TestUtils.ADDRESS + config.amount = 500000 + config.destinations = [MoneroDestination(TestUtils.ADDRESS, 500000)] + assert config.get_normalized_destinations() == config.destinations + #endregion #region Serialize/deserialize integrity From a22ced3db0cc55d37068ddd48870ab4e5f21f62d Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 14:03:15 +0200 Subject: [PATCH 15/20] wallet model: test destination deserialization --- tests/test_monero_wallet_model.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 3181457..29e2138 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -320,6 +320,10 @@ def test_account_subaddresses_deserialize(self) -> None: logger.debug(f"Deserialized account re-serialized: {restored.serialize()}") assert len(restored.subaddresses) == len(account.subaddresses) + def test_destination_deserialize(self) -> None: + destination: MoneroDestination = MoneroDestination(TestUtils.ADDRESS, 500000) + AssertUtils.assert_serialization_integrity(destination) + def test_transfer_query_deserialize(self) -> None: query: MoneroTransferQuery = MoneroTransferQuery() query.amount = 500000 From 06c05a48644de289864f3d0a0e5264b53bb36582 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 14:16:10 +0200 Subject: [PATCH 16/20] wallet model: improve deserialize from block unit tests --- tests/test_monero_wallet_model.py | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 29e2138..5c9c1b9 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -345,6 +345,37 @@ def test_transfer_query_unimplemented_fields(self, json_fragment: str) -> None: with pytest.raises(Exception, match="not implemented"): MoneroTransferQuery.deserialize(json_fragment) + def test_transfer_query_deserialize_from_block(self) -> None: + tx_query: MoneroTxQuery = MoneroTxQuery() + tx_query.hash = "a" * 64 + transfer_query: MoneroTransferQuery = MoneroTransferQuery() + transfer_query.amount = 500000 + transfer_query.account_index = 0 + tx_query.transfer_query = transfer_query + + # deserialize_from_block expects a block node wrapping the tx query under "txs" + block_json: str = '{"txs":[' + tx_query.serialize() + ']}' + restored: MoneroTransferQuery = MoneroTransferQuery.deserialize_from_block(block_json) + assert restored.amount == 500000 + assert restored.account_index == 0 + assert restored.tx_query is not None + assert restored.tx_query.hash == "a" * 64 + + def test_transfer_query_deserialize_from_block_no_nested_query(self) -> None: + # a tx with no nested transferQuery gets an empty one, linked to the tx query + tx_query: MoneroTxQuery = MoneroTxQuery() + tx_query.hash = "a" * 64 + block_json: str = '{"txs":[' + tx_query.serialize() + ']}' + restored: MoneroTransferQuery = MoneroTransferQuery.deserialize_from_block(block_json) + assert restored.amount is None + assert restored.tx_query is not None + assert restored.tx_query.hash == "a" * 64 + + def test_transfer_query_deserialize_from_block_no_txs(self) -> None: + restored: MoneroTransferQuery = MoneroTransferQuery.deserialize_from_block('{"txs": []}') + assert restored.amount is None + assert restored.tx_query is None + def test_output_wallet_deserialize(self) -> None: output_wallet: MoneroOutputWallet = MoneroOutputWallet() output_wallet.amount = 1000000 @@ -372,6 +403,32 @@ def test_output_query_deserialize(self) -> None: query.max_amount = 2000000 AssertUtils.assert_serialization_integrity(query) + def test_output_query_deserialize_from_block(self) -> None: + tx_query: MoneroTxQuery = MoneroTxQuery() + tx_query.hash = "a" * 64 + 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 + + # deserialize_from_block expects a block node wrapping the tx query under "txs" + block_json: str = '{"txs":[' + tx_query.serialize() + ']}' + restored: MoneroOutputQuery = MoneroOutputQuery.deserialize_from_block(block_json) + assert restored.amount == 7 + assert restored.index == 2 + assert restored.tx_query is not None + assert restored.tx_query.hash == "a" * 64 + # the input query is built alongside the output query and linked back too + assert restored.tx_query.input_query is not None + assert restored.tx_query.input_query.amount == 5 + + def test_output_query_deserialize_from_block_no_txs(self) -> None: + restored: MoneroOutputQuery = MoneroOutputQuery.deserialize_from_block('{"txs": []}') + assert restored.amount is None + assert restored.tx_query is None + def test_tx_wallet_deserialize(self) -> None: tx_wallet: MoneroTxWallet = MoneroTxWallet() tx_wallet.hash = "a" * 64 From bac42473587059c5e27edcefde55bbc76a04d7f4 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 14:21:45 +0200 Subject: [PATCH 17/20] wallet model: test MoneroTxWallet.get_transfers --- tests/test_monero_wallet_model.py | 42 ++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 5c9c1b9..40051b0 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -12,7 +12,7 @@ MoneroMessageSignatureType, MoneroCheck, MoneroCheckTx, MoneroCheckReserve, MoneroMultisigInfo, MoneroMultisigInitResult, MoneroMultisigSignResult, MoneroAddressBookEntry, MoneroAccountTag, MoneroIncomingTransfer, - MoneroOutgoingTransfer, IncomingTransferComparator, OutputComparator, MoneroTx, + MoneroOutgoingTransfer, IncomingTransferComparator, OutputComparator, MoneroTx, MoneroTransfer, MoneroTxSet, MoneroSyncResult, MoneroDecodedAddress, MoneroAddressType, MoneroNetworkType, MoneroTxPriority ) @@ -1119,6 +1119,46 @@ def make_output(account_index: int, amount: int) -> MoneroOutputWallet: # an unconstrained query keeps everything assert len(tx.filter_outputs_wallet(MoneroOutputQuery())) == 2 + def test_tx_wallet_get_transfers(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + tx.outgoing_transfer = MoneroOutgoingTransfer() + tx.outgoing_transfer.amount = 1000 + tx.incoming_transfers = [MoneroIncomingTransfer(), MoneroIncomingTransfer()] + tx.incoming_transfers[0].amount = 100 + tx.incoming_transfers[1].amount = 200 + + # no-arg overload delegates to an empty query -> outgoing then incoming, in order + all_transfers: list[MoneroTransfer] = tx.get_transfers() + assert [t.amount for t in all_transfers] == [1000, 100, 200] + assert isinstance(all_transfers[0], MoneroOutgoingTransfer) + assert all(isinstance(t, MoneroIncomingTransfer) for t in all_transfers[1:]) + + # get_transfers() does not mutate the tx + assert tx.outgoing_transfer is not None + assert len(tx.incoming_transfers) == 2 + + def test_tx_wallet_get_transfers_with_query(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + tx.outgoing_transfer = MoneroOutgoingTransfer() + tx.outgoing_transfer.amount = 1000 + tx.incoming_transfers = [MoneroIncomingTransfer(), MoneroIncomingTransfer()] + tx.incoming_transfers[0].amount = 100 + tx.incoming_transfers[1].amount = 200 + + incoming_query: MoneroTransferQuery = MoneroTransferQuery() + incoming_query.incoming = True + assert [t.amount for t in tx.get_transfers(incoming_query)] == [100, 200] + + outgoing_query: MoneroTransferQuery = MoneroTransferQuery() + outgoing_query.outgoing = True + assert [t.amount for t in tx.get_transfers(outgoing_query)] == [1000] + + no_match_query: MoneroTransferQuery = MoneroTransferQuery() + no_match_query.amount = 999999 + assert tx.get_transfers(no_match_query) == [] + def test_tx_wallet_copy_preserves_output_wallet_type(self) -> None: tx: MoneroTxWallet = MoneroTxWallet() tx.hash = "a" * 64 From 45a92ae2de2962fe92efe29a0dec467f1ca1d8b1 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 14:30:17 +0200 Subject: [PATCH 18/20] wallet model: add unit test for tx query decontextualize --- src/cpp/wallet/py_monero_wallet_bindings.cpp | 5 ++++- src/python/monero_tx_query.pyi | 13 ++++++++++++ tests/test_monero_wallet_model.py | 21 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/cpp/wallet/py_monero_wallet_bindings.cpp b/src/cpp/wallet/py_monero_wallet_bindings.cpp index 3c0e8e1..54d4bb3 100644 --- a/src/cpp/wallet/py_monero_wallet_bindings.cpp +++ b/src/cpp/wallet/py_monero_wallet_bindings.cpp @@ -405,7 +405,10 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { }) .def("meets_criteria", [](monero_tx_query& self, monero_tx_wallet* tx, bool query_children) { MONERO_CATCH_AND_RETHROW(self.meets_criteria(tx, query_children)); - }, py::arg("tx"), py::arg("query_children") = false); + }, py::arg("tx"), py::arg("query_children") = false) + .def_static("decontextualize", [](const std::shared_ptr& query) { + MONERO_CATCH_AND_RETHROW(monero_tx_query::decontextualize(query)); + }, py::arg("query")); // monero_tx_set t.py_monero_tx_set diff --git a/src/python/monero_tx_query.pyi b/src/python/monero_tx_query.pyi index 1660646..158642f 100644 --- a/src/python/monero_tx_query.pyi +++ b/src/python/monero_tx_query.pyi @@ -74,3 +74,16 @@ class MoneroTxQuery(MoneroTxWallet): :returns bool: `True` if `tx` meets all criteria defined in this query, `False` otherwise. """ ... + + @staticmethod + def decontextualize(query: MoneroTxQuery) -> MoneroTxQuery: + """ + Remove query criteria which require looking up other transfers/outputs to fulfill the query. + + Clears `is_incoming`, `is_outgoing`, `transfer_query`, `input_query` and `output_query`. + Mutates `query` in place. + + :param MoneroTxQuery query: the query to decontextualize. + :returns MoneroTxQuery: a reference to `query`, for convenience. + """ + ... diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 40051b0..dd85628 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -1204,4 +1204,25 @@ def test_tx_query_copy(self) -> None: assert copy is not query assert copy.serialize() == query.serialize() + def test_tx_query_decontextualize(self) -> None: + query: MoneroTxQuery = MoneroTxQuery() + query.hash = "a" * 64 + query.is_incoming = True + query.is_outgoing = False + query.transfer_query = MoneroTransferQuery() + query.input_query = MoneroOutputQuery() + query.output_query = MoneroOutputQuery() + + # mutates and returns the same query, for convenience + decontextualized: MoneroTxQuery = MoneroTxQuery.decontextualize(query) + assert decontextualized is query + assert query.is_incoming is None + assert query.is_outgoing is None + assert query.transfer_query is None + assert query.input_query is None + assert query.output_query is None + + # criteria that don't require looking up other transfers/outputs are untouched + assert query.hash == "a" * 64 + #endregion From a3f037cc4f6c4a62d292be0e6c8a005515b828a6 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Fri, 11 Sep 2026 15:27:25 +0200 Subject: [PATCH 19/20] wallet model: add unit tests for query meets_criteria --- src/cpp/wallet/py_monero_wallet_bindings.cpp | 2 +- src/python/monero_tx_query.pyi | 2 +- tests/test_monero_wallet_model.py | 488 ++++++++++++++++++- 3 files changed, 489 insertions(+), 3 deletions(-) diff --git a/src/cpp/wallet/py_monero_wallet_bindings.cpp b/src/cpp/wallet/py_monero_wallet_bindings.cpp index 54d4bb3..15b2394 100644 --- a/src/cpp/wallet/py_monero_wallet_bindings.cpp +++ b/src/cpp/wallet/py_monero_wallet_bindings.cpp @@ -405,7 +405,7 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { }) .def("meets_criteria", [](monero_tx_query& self, monero_tx_wallet* tx, bool query_children) { MONERO_CATCH_AND_RETHROW(self.meets_criteria(tx, query_children)); - }, py::arg("tx"), py::arg("query_children") = false) + }, py::arg("tx"), py::arg("query_children") = true) .def_static("decontextualize", [](const std::shared_ptr& query) { MONERO_CATCH_AND_RETHROW(monero_tx_query::decontextualize(query)); }, py::arg("query")); diff --git a/src/python/monero_tx_query.pyi b/src/python/monero_tx_query.pyi index 158642f..8e00691 100644 --- a/src/python/monero_tx_query.pyi +++ b/src/python/monero_tx_query.pyi @@ -65,7 +65,7 @@ class MoneroTxQuery(MoneroTxWallet): :returns MoneroTxQuery: tx query copy. """ ... - def meets_criteria(self, tx: MoneroTxWallet, query_children: bool = False) -> bool: + def meets_criteria(self, tx: MoneroTxWallet, query_children: bool = True) -> bool: """ Check if transaction wallet meets all criteria defined in this query. diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index dd85628..33841ba 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -14,7 +14,7 @@ MoneroAddressBookEntry, MoneroAccountTag, MoneroIncomingTransfer, MoneroOutgoingTransfer, IncomingTransferComparator, OutputComparator, MoneroTx, MoneroTransfer, MoneroTxSet, MoneroSyncResult, MoneroDecodedAddress, - MoneroAddressType, MoneroNetworkType, MoneroTxPriority + MoneroAddressType, MoneroNetworkType, MoneroTxPriority, MoneroBlock ) from utils import BaseTestClass, TestUtils, AssertUtils @@ -284,6 +284,492 @@ def test_get_normalized_destinations_matches_single_destination(self) -> None: #endregion + #region meets_criteria + + def test_tx_query_meets_criteria_matches_unconstrained_tx(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + assert MoneroTxQuery().meets_criteria(tx) is True + + @pytest.mark.parametrize("field,value,other_value", [ + ("hash", "a" * 64, "b" * 64), + ("payment_id", "c" * 16, "d" * 16), + ("is_confirmed", True, False), + ("in_tx_pool", True, False), + ("relay", True, False), + ("is_failed", True, False), + ("is_miner_tx", True, False), + ("is_locked", True, False), + ("is_incoming", True, False), + ("is_outgoing", True, False), + ]) + def test_tx_query_meets_criteria_field_filters(self, field: str, value: object, other_value: object) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + setattr(tx, field, value) + + query: MoneroTxQuery = MoneroTxQuery() + setattr(query, field, value) + assert query.meets_criteria(tx) is True + + setattr(query, field, other_value) + assert query.meets_criteria(tx) is False + + def test_tx_query_meets_criteria_has_payment_id(self) -> None: + with_payment_id: MoneroTxWallet = MoneroTxWallet() + with_payment_id.hash = "a" * 64 + with_payment_id.payment_id = "b" * 16 + without_payment_id: MoneroTxWallet = MoneroTxWallet() + without_payment_id.hash = "c" * 64 + + query_true: MoneroTxQuery = MoneroTxQuery() + query_true.has_payment_id = True + assert query_true.meets_criteria(with_payment_id) is True + assert query_true.meets_criteria(without_payment_id) is False + + query_false: MoneroTxQuery = MoneroTxQuery() + query_false.has_payment_id = False + assert query_false.meets_criteria(without_payment_id) is True + assert query_false.meets_criteria(with_payment_id) is False + + def test_tx_query_meets_criteria_hashes(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + + query: MoneroTxQuery = MoneroTxQuery() + query.hashes = ["a" * 64, "b" * 64] + assert query.meets_criteria(tx) is True + + query.hashes = ["c" * 64] + assert query.meets_criteria(tx) is False + + def test_tx_query_meets_criteria_payment_ids(self) -> None: + with_payment_id: MoneroTxWallet = MoneroTxWallet() + with_payment_id.hash = "a" * 64 + with_payment_id.payment_id = "b" * 16 + without_payment_id: MoneroTxWallet = MoneroTxWallet() + without_payment_id.hash = "c" * 64 + + query: MoneroTxQuery = MoneroTxQuery() + query.payment_ids = ["b" * 16, "d" * 16] + assert query.meets_criteria(with_payment_id) is True + assert query.meets_criteria(without_payment_id) is False # tx has no payment id at all + + query.payment_ids = ["d" * 16] + assert query.meets_criteria(with_payment_id) is False # tx's payment id isn't in the list + + def test_tx_query_meets_criteria_height_filters(self) -> None: + def confirmed_tx(height: int) -> MoneroTxWallet: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + block: MoneroBlock = MoneroBlock() + block.height = height + tx.block = block + return tx + + tx: MoneroTxWallet = confirmed_tx(100) + + height_query: MoneroTxQuery = MoneroTxQuery() + height_query.height = 100 + assert height_query.meets_criteria(tx) is True + height_query.height = 200 + assert height_query.meets_criteria(tx) is False + + min_query: MoneroTxQuery = MoneroTxQuery() + min_query.min_height = 50 + assert min_query.meets_criteria(tx) is True + min_query.min_height = 150 + assert min_query.meets_criteria(tx) is False + + max_query: MoneroTxQuery = MoneroTxQuery() + max_query.max_height = 150 + assert max_query.meets_criteria(tx) is True + max_query.max_height = 50 + assert max_query.meets_criteria(tx) is False + + # an unconfirmed tx (no height) never satisfies a height-bound query + unconfirmed: MoneroTxWallet = MoneroTxWallet() + unconfirmed.hash = "b" * 64 + assert min_query.meets_criteria(unconfirmed) is False + + def test_tx_query_meets_criteria_query_children_default_and_false(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 # no inputs -> fails an enforced input_query + + query: MoneroTxQuery = MoneroTxQuery() + query.input_query = MoneroOutputQuery() + + # query_children defaults to True, matching monero-cpp's default + assert query.meets_criteria(tx) is False + assert query.meets_criteria(tx, True) is False + assert query.meets_criteria(tx, False) is True + + def test_tx_query_meets_criteria_transfer_query(self) -> None: + def tx_with_outgoing(amount: int) -> MoneroTxWallet: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + tx.outgoing_transfer = MoneroOutgoingTransfer() + tx.outgoing_transfer.amount = amount + tx.outgoing_transfer.tx = tx + return tx + + def tx_with_incoming(amount: int) -> MoneroTxWallet: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + transfer: MoneroIncomingTransfer = MoneroIncomingTransfer() + transfer.amount = amount + transfer.tx = tx + tx.incoming_transfers = [transfer] + return tx + + query: MoneroTxQuery = MoneroTxQuery() + query.transfer_query = MoneroTransferQuery() + query.transfer_query.amount = 500 + + assert query.meets_criteria(tx_with_outgoing(500), True) is True # matches via outgoing + assert query.meets_criteria(tx_with_incoming(500), True) is True # matches via incoming + assert query.meets_criteria(tx_with_outgoing(1), True) is False # no transfer matches + + # outgoing fails the query but an incoming transfer still matches as a fallback + tx: MoneroTxWallet = tx_with_outgoing(1) + incoming: MoneroIncomingTransfer = MoneroIncomingTransfer() + incoming.amount = 500 + incoming.tx = tx + tx.incoming_transfers = [incoming] + assert query.meets_criteria(tx, True) is True + + def test_tx_query_meets_criteria_input_query(self) -> None: + tx_no_inputs: MoneroTxWallet = MoneroTxWallet() + tx_no_inputs.hash = "a" * 64 + + tx_with_input: MoneroTxWallet = MoneroTxWallet() + tx_with_input.hash = "b" * 64 + tx_input: MoneroOutputWallet = MoneroOutputWallet() + tx_input.amount = 100 + tx_with_input.inputs = [tx_input] + + query: MoneroTxQuery = MoneroTxQuery() + query.input_query = MoneroOutputQuery() + assert query.meets_criteria(tx_no_inputs, True) is False # no inputs at all + + query.input_query.amount = 999 + assert query.meets_criteria(tx_with_input, True) is False # no input matches + + query.input_query.amount = 100 + assert query.meets_criteria(tx_with_input, True) is True # one input matches + + def test_tx_query_meets_criteria_output_query(self) -> None: + tx_no_outputs: MoneroTxWallet = MoneroTxWallet() + tx_no_outputs.hash = "a" * 64 + + tx_with_output: MoneroTxWallet = MoneroTxWallet() + tx_with_output.hash = "b" * 64 + output: MoneroOutputWallet = MoneroOutputWallet() + output.amount = 100 + tx_with_output.outputs = [output] + + query: MoneroTxQuery = MoneroTxQuery() + query.output_query = MoneroOutputQuery() + assert query.meets_criteria(tx_no_outputs, True) is False # no outputs at all + + query.output_query.amount = 999 + assert query.meets_criteria(tx_with_output, True) is False # no output matches + + query.output_query.amount = 100 + assert query.meets_criteria(tx_with_output, True) is True # one output matches + + def test_tx_query_meets_criteria_none_tx_raises(self) -> None: + with pytest.raises(RuntimeError) as exc_info: + MoneroTxQuery().meets_criteria(None) # type: ignore + assert str(exc_info.value) == "nullptr given to monero_tx_query::meets_criteria()" + + def test_transfer_query_meets_criteria_matches_unconstrained_transfer(self) -> None: + transfer: MoneroIncomingTransfer = MoneroIncomingTransfer() + transfer.amount = 100 + assert MoneroTransferQuery().meets_criteria(transfer, False) is True + + @pytest.mark.parametrize("field,value,other_value", [ + ("amount", 100, 999), + ("account_index", 2, 5), + ]) + def test_transfer_query_meets_criteria_common_field_filters(self, field: str, value: object, other_value: object) -> None: + transfer: MoneroIncomingTransfer = MoneroIncomingTransfer() + transfer.amount = 1 + setattr(transfer, field, value) + + query: MoneroTransferQuery = MoneroTransferQuery() + setattr(query, field, value) + assert query.meets_criteria(transfer, False) is True + + setattr(query, field, other_value) + assert query.meets_criteria(transfer, False) is False + + def test_transfer_query_meets_criteria_incoming_outgoing_flags(self) -> None: + incoming: MoneroIncomingTransfer = MoneroIncomingTransfer() + incoming.amount = 1 + outgoing: MoneroOutgoingTransfer = MoneroOutgoingTransfer() + outgoing.amount = 1 + + incoming_query: MoneroTransferQuery = MoneroTransferQuery() + incoming_query.incoming = True + assert incoming_query.meets_criteria(incoming, False) is True + assert incoming_query.meets_criteria(outgoing, False) is False + + outgoing_query: MoneroTransferQuery = MoneroTransferQuery() + outgoing_query.outgoing = True + assert outgoing_query.meets_criteria(outgoing, False) is True + assert outgoing_query.meets_criteria(incoming, False) is False + + def test_transfer_query_meets_criteria_incoming_has_destinations_always_false(self) -> None: + # has_destinations is an outgoing-only concept; setting it on the query + # rejects an incoming transfer regardless of True/False + incoming: MoneroIncomingTransfer = MoneroIncomingTransfer() + incoming.amount = 1 + + query_true: MoneroTransferQuery = MoneroTransferQuery() + query_true.has_destinations = True + assert query_true.meets_criteria(incoming, False) is False + + query_false: MoneroTransferQuery = MoneroTransferQuery() + query_false.has_destinations = False + assert query_false.meets_criteria(incoming, False) is False + + def test_transfer_query_meets_criteria_incoming_address_fields(self) -> None: + incoming: MoneroIncomingTransfer = MoneroIncomingTransfer() + incoming.amount = 1 + incoming.address = TestUtils.ADDRESS + incoming.subaddress_index = 3 + + address_query: MoneroTransferQuery = MoneroTransferQuery() + address_query.address = TestUtils.ADDRESS + assert address_query.meets_criteria(incoming, False) is True + address_query.address = TestUtils.MINING_ADDRESS + assert address_query.meets_criteria(incoming, False) is False + + addresses_query: MoneroTransferQuery = MoneroTransferQuery() + addresses_query.addresses = [TestUtils.ADDRESS, TestUtils.MINING_ADDRESS] + assert addresses_query.meets_criteria(incoming, False) is True + addresses_query.addresses = [TestUtils.MINING_ADDRESS] + assert addresses_query.meets_criteria(incoming, False) is False + + subaddress_index_query: MoneroTransferQuery = MoneroTransferQuery() + subaddress_index_query.subaddress_index = 3 + assert subaddress_index_query.meets_criteria(incoming, False) is True + subaddress_index_query.subaddress_index = 9 + assert subaddress_index_query.meets_criteria(incoming, False) is False + + subaddress_indices_query: MoneroTransferQuery = MoneroTransferQuery() + subaddress_indices_query.subaddress_indices = [3, 4] + assert subaddress_indices_query.meets_criteria(incoming, False) is True + subaddress_indices_query.subaddress_indices = [9] + assert subaddress_indices_query.meets_criteria(incoming, False) is False + + def test_transfer_query_meets_criteria_outgoing_address_fields(self) -> None: + outgoing: MoneroOutgoingTransfer = MoneroOutgoingTransfer() + outgoing.amount = 1 + outgoing.addresses = [TestUtils.ADDRESS, TestUtils.MINING_ADDRESS] + + # a single address must be a member of the transfer's address list + address_query: MoneroTransferQuery = MoneroTransferQuery() + address_query.address = TestUtils.ADDRESS + assert address_query.meets_criteria(outgoing, False) is True + address_query.address = "9" + "z" * 94 + assert address_query.meets_criteria(outgoing, False) is False + assert address_query.meets_criteria(MoneroOutgoingTransfer(), False) is False # no addresses at all + + # a list of addresses only needs to intersect + addresses_query: MoneroTransferQuery = MoneroTransferQuery() + addresses_query.addresses = [TestUtils.MINING_ADDRESS, "9" + "z" * 94] + assert addresses_query.meets_criteria(outgoing, False) is True + addresses_query.addresses = ["9" + "z" * 94] + assert addresses_query.meets_criteria(outgoing, False) is False + + def test_transfer_query_meets_criteria_outgoing_subaddress_fields(self) -> None: + outgoing: MoneroOutgoingTransfer = MoneroOutgoingTransfer() + outgoing.amount = 1 + outgoing.subaddress_indices = [3, 4] + + subaddress_index_query: MoneroTransferQuery = MoneroTransferQuery() + subaddress_index_query.subaddress_index = 3 + assert subaddress_index_query.meets_criteria(outgoing, False) is True + subaddress_index_query.subaddress_index = 9 + assert subaddress_index_query.meets_criteria(outgoing, False) is False + assert subaddress_index_query.meets_criteria(MoneroOutgoingTransfer(), False) is False # no indices at all + + subaddress_indices_query: MoneroTransferQuery = MoneroTransferQuery() + subaddress_indices_query.subaddress_indices = [4, 100] + assert subaddress_indices_query.meets_criteria(outgoing, False) is True + subaddress_indices_query.subaddress_indices = [100, 200] + assert subaddress_indices_query.meets_criteria(outgoing, False) is False + + def test_transfer_query_meets_criteria_outgoing_has_destinations(self) -> None: + with_destinations: MoneroOutgoingTransfer = MoneroOutgoingTransfer() + with_destinations.amount = 1 + with_destinations.destinations = [MoneroDestination(TestUtils.ADDRESS, 1)] + without_destinations: MoneroOutgoingTransfer = MoneroOutgoingTransfer() + without_destinations.amount = 1 + + query_true: MoneroTransferQuery = MoneroTransferQuery() + query_true.has_destinations = True + assert query_true.meets_criteria(with_destinations, False) is True + assert query_true.meets_criteria(without_destinations, False) is False + + query_false: MoneroTransferQuery = MoneroTransferQuery() + query_false.has_destinations = False + assert query_false.meets_criteria(without_destinations, False) is True + assert query_false.meets_criteria(with_destinations, False) is False + + def test_transfer_query_meets_criteria_invalid_transfer_type_raises(self) -> None: + transfer: MoneroTransfer = MoneroTransfer() + transfer.amount = 1 + with pytest.raises(RuntimeError) as exc_info: + MoneroTransferQuery().meets_criteria(transfer, False) + assert str(exc_info.value) == "Transfer must be monero_incoming_transfer or monero_outgoing_transfer" + + def test_transfer_query_meets_criteria_query_parent_links_tx_query(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + transfer: MoneroIncomingTransfer = MoneroIncomingTransfer() + transfer.amount = 100 + transfer.tx = tx + + query: MoneroTransferQuery = MoneroTransferQuery() + query.amount = 100 + query.tx_query = MoneroTxQuery() + query.tx_query.hash = "b" * 64 # does not match the transfer's tx + + # query_parent defaults to True: the mismatching tx_query rejects the transfer + assert query.meets_criteria(transfer) is False + assert query.meets_criteria(transfer, False) is True # bypassed when not querying the parent + + query.tx_query.hash = "a" * 64 + assert query.meets_criteria(transfer) is True + + def test_transfer_query_meets_criteria_none_transfer_raises(self) -> None: + with pytest.raises(RuntimeError) as exc_info: + MoneroTransferQuery().meets_criteria(None, False) # type: ignore + assert str(exc_info.value) == "nullptr given to monero_transfer_query::meets_criteria()" + + def test_output_query_meets_criteria_matches_unconstrained_output(self) -> None: + output: MoneroOutputWallet = MoneroOutputWallet() + output.amount = 1 + assert MoneroOutputQuery().meets_criteria(output, False) is True + + @pytest.mark.parametrize("field,value,other_value", [ + ("account_index", 2, 9), + ("subaddress_index", 5, 1), + ("amount", 100, 1), + ("is_spent", True, False), + ("is_frozen", False, True), + ]) + def test_output_query_meets_criteria_field_filters(self, field: str, value: object, other_value: object) -> None: + output: MoneroOutputWallet = MoneroOutputWallet() + output.amount = 1 + setattr(output, field, value) + + query: MoneroOutputQuery = MoneroOutputQuery() + setattr(query, field, value) + assert query.meets_criteria(output, False) is True + + setattr(query, field, other_value) + assert query.meets_criteria(output, False) is False + + @pytest.mark.parametrize("field,value", [ + ("account_index", 2), + ("subaddress_index", 5), + ("amount", 100), + ("is_spent", True), + ("is_frozen", False), + ]) + def test_output_query_meets_criteria_field_filters_require_output_value(self, field: str, value: object) -> None: + # a query constraint never matches an output that leaves the field unset + output: MoneroOutputWallet = MoneroOutputWallet() + query: MoneroOutputQuery = MoneroOutputQuery() + setattr(query, field, value) + assert query.meets_criteria(output, False) is False + + def test_output_query_meets_criteria_key_image(self) -> None: + output: MoneroOutputWallet = MoneroOutputWallet() + output.amount = 1 + output.key_image = MoneroKeyImage() + output.key_image.hex = "a" * 64 + output.key_image.signature = "b" * 128 + + hex_query: MoneroOutputQuery = MoneroOutputQuery() + hex_query.key_image = MoneroKeyImage() + hex_query.key_image.hex = "a" * 64 + assert hex_query.meets_criteria(output, False) is True + hex_query.key_image.hex = "c" * 64 + assert hex_query.meets_criteria(output, False) is False + + signature_query: MoneroOutputQuery = MoneroOutputQuery() + signature_query.key_image = MoneroKeyImage() + signature_query.key_image.signature = "b" * 128 + assert signature_query.meets_criteria(output, False) is True + signature_query.key_image.signature = "d" * 128 + assert signature_query.meets_criteria(output, False) is False + + # never matches an output with no key image at all + no_key_image_query: MoneroOutputQuery = MoneroOutputQuery() + no_key_image_query.key_image = MoneroKeyImage() + no_key_image_query.key_image.hex = "a" * 64 + assert no_key_image_query.meets_criteria(MoneroOutputWallet(), False) is False + + def test_output_query_meets_criteria_subaddress_indices(self) -> None: + output: MoneroOutputWallet = MoneroOutputWallet() + output.amount = 1 + output.subaddress_index = 7 + + query: MoneroOutputQuery = MoneroOutputQuery() + query.subaddress_indices = [7, 8] + assert query.meets_criteria(output, False) is True + + query.subaddress_indices = [1, 2] + assert query.meets_criteria(output, False) is False + + def test_output_query_meets_criteria_min_max_amount(self) -> None: + output: MoneroOutputWallet = MoneroOutputWallet() + output.amount = 50 + + min_query: MoneroOutputQuery = MoneroOutputQuery() + min_query.min_amount = 10 + assert min_query.meets_criteria(output, False) is True + min_query.min_amount = 100 + assert min_query.meets_criteria(output, False) is False + + max_query: MoneroOutputQuery = MoneroOutputQuery() + max_query.max_amount = 100 + assert max_query.meets_criteria(output, False) is True + max_query.max_amount = 10 + assert max_query.meets_criteria(output, False) is False + + def test_output_query_meets_criteria_query_parent_links_tx_query(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + output: MoneroOutputWallet = MoneroOutputWallet() + output.amount = 1 + output.tx = tx + + query: MoneroOutputQuery = MoneroOutputQuery() + query.amount = 1 + tx_query: MoneroTxQuery = MoneroTxQuery() + tx_query.hash = "b" * 64 + query.set_tx_query(tx_query, True) + + # query_parent defaults to True: the mismatching tx_query rejects the output + assert query.meets_criteria(output) is False + assert query.meets_criteria(output, False) is True # bypassed when not querying the parent + + tx_query.hash = "a" * 64 + assert query.meets_criteria(output) is True + + def test_output_query_meets_criteria_none_output_raises(self) -> None: + with pytest.raises(RuntimeError) as exc_info: + MoneroOutputQuery().meets_criteria(None, False) # type: ignore + assert str(exc_info.value) == "nullptr given to monero_output_query::meets_criteria()" + + #endregion + #region Serialize/deserialize integrity def test_subaddress_deserialize(self) -> None: From 28e4768b55d96d2474c320ea14bf0f57a270e481 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Sat, 12 Sep 2026 10:59:32 +0200 Subject: [PATCH 20/20] utils: test MoneroUtils.get_vlocks_from --- tests/test_monero_utils.py | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_monero_utils.py b/tests/test_monero_utils.py index 8cbd4cf..dd7fde8 100644 --- a/tests/test_monero_utils.py +++ b/tests/test_monero_utils.py @@ -522,6 +522,9 @@ def test_get_blocks_from_txs_mixed_confirmed_and_unconfirmed(self) -> None: assert blocks[1].height is None assert unconfirmed1.block is unconfirmed2.block is blocks[1] + def test_get_blocks_from_txs_empty(self) -> None: + assert MoneroUtils.get_blocks_from_txs([]) == [] + def test_get_blocks_from_transfers_dedup_and_order(self) -> None: block1: MoneroBlock = MoneroBlock() block1.height = 100 @@ -566,6 +569,27 @@ def test_get_blocks_from_transfers_unconfirmed_placeholder(self) -> None: assert t1.tx.block is placeholder assert t2.tx.block is placeholder + def test_get_blocks_from_transfers_mixed_confirmed_and_unconfirmed(self) -> None: + block: MoneroBlock = MoneroBlock() + block.height = 100 + confirmed: MoneroIncomingTransfer = MoneroIncomingTransfer() + confirmed.tx = MoneroTxWallet() + confirmed.tx.hash = "a" * 64 + confirmed.tx.block = block + + unconfirmed1: MoneroIncomingTransfer = MoneroIncomingTransfer() + unconfirmed1.tx = MoneroTxWallet() + unconfirmed1.tx.hash = "b" * 64 + unconfirmed2: MoneroIncomingTransfer = MoneroIncomingTransfer() + unconfirmed2.tx = MoneroTxWallet() + unconfirmed2.tx.hash = "c" * 64 + + blocks: list[MoneroBlock] = MoneroUtils.get_blocks_from_transfers([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.tx.block is unconfirmed2.tx.block is blocks[1] + def test_get_blocks_from_transfers_missing_tx_raises(self) -> None: # a transfer with no tx set is a legitimate, reachable state (it's just # never assigned); get_blocks_from_transfers() must raise a catchable @@ -576,6 +600,25 @@ def test_get_blocks_from_transfers_missing_tx_raises(self) -> None: MoneroUtils.get_blocks_from_transfers([transfer]) assert str(exc_info.value) == "Transfer has no tx" + def test_get_blocks_from_transfers_missing_tx_raises_after_partial_mutation(self) -> None: + # get_blocks_from_transfers() is not transactional: a transfer earlier + # in the list is already mutated (attached to a placeholder block) + # before a later, invalid transfer aborts the whole call + good: MoneroIncomingTransfer = MoneroIncomingTransfer() + good.tx = MoneroTxWallet() + good.tx.hash = "a" * 64 + assert good.tx.block is None + + bad: MoneroIncomingTransfer = MoneroIncomingTransfer() # no tx + with pytest.raises(RuntimeError): + MoneroUtils.get_blocks_from_transfers([good, bad]) + + assert good.tx.block is not None + assert good.tx.block.height is None + + def test_get_blocks_from_transfers_empty(self) -> None: + assert MoneroUtils.get_blocks_from_transfers([]) == [] + def test_get_blocks_from_outputs_dedup_and_order(self) -> None: block1: MoneroBlock = MoneroBlock() block1.height = 100 @@ -613,6 +656,23 @@ def test_get_blocks_from_outputs_unconfirmed_raises(self) -> None: with pytest.raises(RuntimeError, match="Need to handle unconfirmed output"): MoneroUtils.get_blocks_from_outputs([output]) + def test_get_blocks_from_outputs_unconfirmed_raises_after_earlier_match(self) -> None: + # unlike get_blocks_from_transfers(), get_blocks_from_outputs() never mutates its inputs + block: MoneroBlock = MoneroBlock() + block.height = 100 + confirmed_tx: MoneroTxWallet = MoneroTxWallet() + confirmed_tx.hash = "a" * 64 + confirmed_tx.block = block + good: MoneroOutputWallet = MoneroOutputWallet() + good.tx = confirmed_tx + + bad: MoneroOutputWallet = MoneroOutputWallet() + bad.tx = MoneroTxWallet() + bad.tx.hash = "b" * 64 # unconfirmed + + with pytest.raises(RuntimeError, match="Need to handle unconfirmed output"): + MoneroUtils.get_blocks_from_outputs([good, bad]) + def test_get_blocks_from_outputs_missing_tx_raises(self) -> None: # same as get_blocks_from_transfers(): output.tx is a legitimate but # unchecked null; must raise rather than dereference and segfault @@ -622,6 +682,9 @@ def test_get_blocks_from_outputs_missing_tx_raises(self) -> None: MoneroUtils.get_blocks_from_outputs([output]) assert str(exc_info.value) == "Output has no tx" + def test_get_blocks_from_outputs_empty(self) -> None: + assert MoneroUtils.get_blocks_from_outputs([]) == [] + #endregion #region Free memory