diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml index 63d4324..60abe18 100644 --- a/.github/workflows/codacy.yml +++ b/.github/workflows/codacy.yml @@ -13,7 +13,7 @@ permissions: jobs: report-coverage-linux: - if: github.repository == 'everoddandeven/monero-python' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') + if: github.repository == 'everoddandeven/monero-python' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion != 'cancelled') runs-on: ubuntu-latest steps: - name: Checkout code @@ -22,6 +22,8 @@ jobs: fetch-depth: 0 - name: Download coverage report + id: download + continue-on-error: true uses: actions/download-artifact@v4 with: name: coverage-reports-linux @@ -29,6 +31,7 @@ jobs: run-id: ${{ github.event.workflow_run.id }} - name: Report python coverage + if: steps.download.outcome == 'success' uses: codacy/codacy-coverage-reporter-action@v1 with: project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} @@ -36,6 +39,7 @@ jobs: coverage-reports: coverage.xml - name: Report c++ coverage + if: steps.download.outcome == 'success' uses: codacy/codacy-coverage-reporter-action@v1 with: project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} @@ -43,6 +47,7 @@ jobs: coverage-reports: coverage.info - name: Report c coverage + if: steps.download.outcome == 'success' uses: codacy/codacy-coverage-reporter-action@v1 with: project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} diff --git a/bin/cleanup_test_environment.sh b/bin/cleanup_test_environment.sh index dd45abb..bd09b32 100755 --- a/bin/cleanup_test_environment.sh +++ b/bin/cleanup_test_environment.sh @@ -2,5 +2,9 @@ # 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 +rm -rf test_wallets 2>&1 +rm monero_tests_* 2>&1 +rm -rf .pytest_cache 2>&1 +rm -rf __pycache__ 2>&1 +rm -rf tests/__pycache__ 2>&1 +rm -rf tests/utils/__pycache__ 2>&1 diff --git a/src/cpp/utils/py_monero_utils.cpp b/src/cpp/utils/py_monero_utils.cpp index 04f6a3b..b7b92a3 100644 --- a/src/cpp/utils/py_monero_utils.cpp +++ b/src/cpp/utils/py_monero_utils.cpp @@ -56,6 +56,34 @@ #include "py_monero_utils.h" +void PyMoneroUtils::validate_payment_id_long(const std::string& payment_id_str) { + crypto::hash payment_id; + if (!monero_utils::parse_payment_id_long(payment_id_str, payment_id)) throw std::runtime_error("Invalid long payment id"); +} + +void PyMoneroUtils::validate_payment_id_short(const std::string& payment_id_str) { + crypto::hash8 payment_id; + if (!monero_utils::parse_payment_id_short(payment_id_str, payment_id)) throw std::runtime_error("Invalid short payment id"); +} + +bool PyMoneroUtils::is_valid_payment_id_long(const std::string& payment_id_str) { + try { + validate_payment_id_long(payment_id_str); + return true; + } catch (...) { + return false; + } +} + +bool PyMoneroUtils::is_valid_payment_id_short(const std::string& payment_id_str) { + try { + validate_payment_id_short(payment_id_str); + return true; + } catch (...) { + return false; + } +} + std::string PyMoneroUtils::json_to_binary(const std::string &json) { std::string bin; monero_utils::json_to_binary(json, bin); diff --git a/src/cpp/utils/py_monero_utils.h b/src/cpp/utils/py_monero_utils.h index 81d8526..ebc419d 100644 --- a/src/cpp/utils/py_monero_utils.h +++ b/src/cpp/utils/py_monero_utils.h @@ -71,6 +71,11 @@ class PyMoneroUtils { static std::string binary_blocks_to_json(const std::string &bin); static std::string binary_blocks_fast_to_json(const std::string &bin); + static void validate_payment_id_long(const std::string& payment_id_str); + static void validate_payment_id_short(const std::string& payment_id_str); + static bool is_valid_payment_id_long(const std::string& payment_id_str); + static bool is_valid_payment_id_short(const std::string& payment_id_str); + static void sort_txs_wallet(std::vector>& txs, const std::vector& hashes); static std::vector> get_and_sort_txs(const monero_wallet& wallet, const std::vector& tx_hashes); static std::vector> get_and_sort_txs(const monero_wallet& wallet, const monero_tx_query& tx_query); diff --git a/src/cpp/utils/py_monero_utils_bindings.cpp b/src/cpp/utils/py_monero_utils_bindings.cpp index c7019c0..0249594 100644 --- a/src/cpp/utils/py_monero_utils_bindings.cpp +++ b/src/cpp/utils/py_monero_utils_bindings.cpp @@ -92,6 +92,12 @@ void py_monero_bind_utils(py::module_& m, PyMoneroTypes& t) { .def_static("is_valid_payment_id", [](const std::string& payment_id) { MONERO_CATCH_AND_RETHROW(monero_utils::is_valid_payment_id(payment_id)); }, py::arg("payment_id")) + .def_static("is_valid_payment_id_long", [](const std::string& payment_id) { + return PyMoneroUtils::is_valid_payment_id_long(payment_id); + }, py::arg("payment_id")) + .def_static("is_valid_payment_id_short", [](const std::string& payment_id) { + return PyMoneroUtils::is_valid_payment_id_short(payment_id); + }, py::arg("payment_id")) .def_static("is_valid_mnemonic", [](const std::string& mnemonic, const std::string& language) { MONERO_CATCH_AND_RETHROW(monero_utils::is_valid_mnemonic(mnemonic, language)); }, py::arg("mnemonic"), py::arg("language") = "") @@ -116,6 +122,12 @@ void py_monero_bind_utils(py::module_& m, PyMoneroTypes& t) { .def_static("validate_payment_id", [](const std::string& payment_id) { MONERO_CATCH_AND_RETHROW(monero_utils::validate_payment_id(payment_id)); }, py::arg("payment_id")) + .def_static("validate_payment_id_long", [](const std::string& payment_id) { + MONERO_CATCH_AND_RETHROW(PyMoneroUtils::validate_payment_id_long(payment_id)); + }, py::arg("payment_id")) + .def_static("validate_payment_id_short", [](const std::string& payment_id) { + MONERO_CATCH_AND_RETHROW(PyMoneroUtils::validate_payment_id_short(payment_id)); + }, py::arg("payment_id")) .def_static("validate_mnemonic", [](const std::string& mnemonic, const std::string& language) { MONERO_CATCH_AND_RETHROW(monero_utils::validate_mnemonic(mnemonic, language)); }, py::arg("mnemonic"), py::arg("language") = "") @@ -149,6 +161,9 @@ void py_monero_bind_utils(py::module_& m, PyMoneroTypes& t) { .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) + .def_static("parse_payment_uri", [](const std::string& uri, monero_network_type network_type) { + MONERO_CATCH_AND_RETHROW(monero_utils::parse_payment_uri(uri, network_type)); + }, py::arg("uri"), py::arg("network_type") = monero_network_type::MAINNET) .def_static("xmr_to_atomic_units", [](double amount_xmr) { MONERO_CATCH_AND_RETHROW(monero_utils::xmr_to_atomic_units(amount_xmr)); }, py::arg("amount_xmr")) diff --git a/src/cpp/wallet/py_monero_wallet_bindings.cpp b/src/cpp/wallet/py_monero_wallet_bindings.cpp index 2ed7da8..c737074 100644 --- a/src/cpp/wallet/py_monero_wallet_bindings.cpp +++ b/src/cpp/wallet/py_monero_wallet_bindings.cpp @@ -999,14 +999,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { .def("parse_payment_uri", [](PyMoneroWallet& self, const std::string& uri) { MONERO_CATCH_AND_RETHROW(self.parse_payment_uri(uri)); }, py::arg("uri"), py::call_guard()) - .def("get_attribute", [](PyMoneroWallet& self, const std::string& key) { - try { - std::string val; - self.get_attribute(key, val); - return val; - } catch (const std::exception& ex) { - throw monero_error(ex.what()); - } + .def("get_attribute", [](PyMoneroWallet& self, const std::string& key) -> std::string { + std::string val; + MONERO_CATCH_AND_RETHROW((self.get_attribute(key, val), val)); }, py::arg("key"), py::call_guard()) .def("set_attribute", [](PyMoneroWallet& self, const std::string& key, const std::string& val) { MONERO_CATCH_AND_RETHROW(self.set_attribute(key, val)); @@ -1091,16 +1086,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { }, py::arg("path"), py::arg("password"), py::arg("nettype"), py::arg("regtest") = false, py::call_guard()) .def_static("open_wallet_data", [](const std::string& password, monero_network_type nettype, const std::string& keys_data, const std::string& cache_data, const std::shared_ptr& daemon_connection, bool regtest) { MONERO_CATCH_AND_RETHROW(monero_wallet_full::open_wallet_data(password, nettype, keys_data, cache_data, daemon_connection, nullptr, regtest)); - }, py::arg("password"), py::arg("nettype"), py::arg("keys_data"), py::arg("cache_data"), py::arg("daemon_connection") = std::make_shared(), py::arg("regtest") = false, py::call_guard()) + }, py::arg("password"), py::arg("nettype"), py::arg("keys_data"), py::arg("cache_data"), py::arg("daemon_connection") = py::none(), py::arg("regtest") = false, py::call_guard()) .def_static("create_wallet", [](const monero_wallet_config& config) { - try { - return monero_wallet_full::create_wallet(config); - } catch(const std::exception& ex) { - std::string msg = ex.what(); - if (msg.find("file already exists") != std::string::npos && config.m_path != boost::none) - msg = std::string("Wallet already exists: ") + config.m_path.get(); - throw monero_error(msg); - } + MONERO_CATCH_AND_RETHROW(monero_wallet_full::create_wallet(config)); }, py::arg("config"), py::call_guard()) .def_static("get_seed_languages", []() { MONERO_CATCH_AND_RETHROW(monero_wallet_full::get_seed_languages()); diff --git a/src/python/monero_utils.pyi b/src/python/monero_utils.pyi index 427df24..7fbb5c2 100644 --- a/src/python/monero_utils.pyi +++ b/src/python/monero_utils.pyi @@ -199,6 +199,18 @@ class MoneroUtils: """ ... + @staticmethod + def parse_payment_uri(uri: str, network_type: MoneroNetworkType = MoneroNetworkType.MAINNET) -> MoneroTxConfig: + """ + Parses a payment URI into a tx configuration. + + :param str uri: the payment URI to parse. + :param MoneroNetworkType network_type: address network type (optional). + :returns MoneroTxConfig: the parsed tx configuration. + :raise MoneroError: if the given URI is malformed. + """ + ... + @staticmethod def get_ring_size() -> int: """ @@ -259,6 +271,26 @@ class MoneroUtils: """ ... + @staticmethod + def is_valid_payment_id_long(payment_id: str) -> bool: + """ + Indicates if a long (64 hex character) payment id is valid. + + :param str payment_id: is the payment id to validate. + :returns bool: `True` if the payment id is a valid long payment id, `False` otherwise. + """ + ... + + @staticmethod + def is_valid_payment_id_short(payment_id: str) -> bool: + """ + Indicates if a short (16 hex character) payment id is valid. + + :param str payment_id: is the payment id to validate. + :returns bool: `True` if the payment id is a valid short payment id, `False` otherwise. + """ + ... + @staticmethod def is_valid_private_spend_key(private_spend_key: str) -> bool: """ @@ -402,6 +434,26 @@ class MoneroUtils: """ ... + @staticmethod + def validate_payment_id_long(payment_id: str) -> None: + """ + Validate a long (64 hex character) payment id. + + :param str payment_id: is the payment id to validate. + :raise MoneroError: if the given payment id is not a valid long payment id. + """ + ... + + @staticmethod + def validate_payment_id_short(payment_id: str) -> None: + """ + Validate a short (16 hex character) payment id. + + :param str payment_id: is the payment id to validate. + :raise MoneroError: if the given payment id is not a valid short payment id. + """ + ... + @staticmethod def validate_private_spend_key(private_spend_key: str) -> None: """ diff --git a/src/python/monero_wallet_full.pyi b/src/python/monero_wallet_full.pyi index c2a4b0b..5f47c0b 100644 --- a/src/python/monero_wallet_full.pyi +++ b/src/python/monero_wallet_full.pyi @@ -55,7 +55,7 @@ class MoneroWalletFull(MoneroWallet): nettype: MoneroNetworkType, keys_data: bytes, cache_data: bytes, - daemon_connection: MoneroRpcConnection = MoneroRpcConnection(), + daemon_connection: MoneroRpcConnection | None = None, regtest: bool = False, ) -> MoneroWalletFull: """ @@ -65,7 +65,7 @@ class MoneroWalletFull(MoneroWallet): :param MoneroNetworkType nettype: is the wallet's network type. :param bytes keys_data: contains the contents of the ".keys" file (`b""` to open without one). :param bytes cache_data: contents of the wallet cache file, no extension (`b""` for keys only). - :param MoneroRpcConnection daemon_connection: is connection information to a daemon (default = an unconnected wallet). + :param MoneroRpcConnection | None daemon_connection: is connection information to a daemon. :param bool regtest: indicates if wallet to open is a regtest wallet (optional). :returns MoneroWalletFull: reference to the wallet instance. """ diff --git a/tests/config/config.ini b/tests/config/config.ini index 00ec0c4..bb0c0f0 100644 --- a/tests/config/config.ini +++ b/tests/config/config.ini @@ -7,6 +7,7 @@ test_resets=True network_type=regtest auto_connect_timeout_ms=3000 log_level=3 +log_categories=*:WARNING,net:FATAL,net.http:FATAL,net.ssl:FATAL,net.p2p:FATAL,net.cn:FATAL,daemon.rpc:FATAL,global:INFO,verify:FATAL,serialization:FATAL,daemon.rpc.payment:ERROR,stacktrace:INFO,logging:INFO,msgwriter:INFO [daemon] rpc_uri=http://127.0.0.1:18081 diff --git a/tests/test_monero_utils.py b/tests/test_monero_utils.py index 5f59170..56bf741 100644 --- a/tests/test_monero_utils.py +++ b/tests/test_monero_utils.py @@ -248,6 +248,7 @@ def test_address_validation(self, config: TestMoneroUtils.Config) -> None: # Can validate keys def test_key_validation(self, config: TestMoneroUtils.Config) -> None: + invalid_hex_64: str = "z" * 64 # right length but not hex # test private view key validation assert MoneroUtils.is_valid_private_view_key(config.keys.private_view_key) @@ -255,6 +256,7 @@ def test_key_validation(self, config: TestMoneroUtils.Config) -> None: WalletUtils.test_invalid_private_view_key("") WalletUtils.test_invalid_private_view_key(None) WalletUtils.test_invalid_private_view_key(config.keys.invalid_private_view_key) + WalletUtils.test_invalid_private_view_key(invalid_hex_64) # test public view key validation assert MoneroUtils.is_valid_public_view_key(config.keys.public_view_key) @@ -262,12 +264,14 @@ def test_key_validation(self, config: TestMoneroUtils.Config) -> None: WalletUtils.test_invalid_public_view_key("") WalletUtils.test_invalid_public_view_key(None) WalletUtils.test_invalid_public_view_key(config.keys.invalid_public_view_key) + WalletUtils.test_invalid_public_view_key(invalid_hex_64) # test private spend key validation assert MoneroUtils.is_valid_private_spend_key(config.keys.private_spend_key) WalletUtils.test_invalid_private_spend_key("") WalletUtils.test_invalid_private_spend_key(None) WalletUtils.test_invalid_private_spend_key(config.keys.invalid_private_spend_key) + WalletUtils.test_invalid_private_spend_key(invalid_hex_64) # test public spend key validation assert MoneroUtils.is_valid_public_spend_key(config.keys.public_spend_key) @@ -275,6 +279,7 @@ def test_key_validation(self, config: TestMoneroUtils.Config) -> None: WalletUtils.test_invalid_public_spend_key("") WalletUtils.test_invalid_public_spend_key(None) WalletUtils.test_invalid_public_spend_key(config.keys.invalid_public_spend_key) + WalletUtils.test_invalid_public_spend_key(invalid_hex_64) # Can validate seed def test_mnemonic_validation(self, config: TestMoneroUtils.Config) -> None: @@ -323,7 +328,8 @@ def test_payment_id_validation(self) -> None: invalid_payment_ids: list[str] = [ "", "wijqwnn38y", "87fdf837b5e6a39", "3b5ac230d26661778", - "304e0fa65b9c9e14304e0fa65b9c9e14" + "304e0fa65b9c9e14304e0fa65b9c9e14", + "z" * 16, "z" * 64 ] for payment_id in invalid_payment_ids: @@ -335,6 +341,54 @@ def test_payment_id_validation(self) -> None: e_str: str = str(e) assert expected == e_str, f"Expected error '{expected}', got {e_str}" + # Can validate a long payment id specifically + def test_payment_id_long_validation(self) -> None: + long_payment_id: str = "87fdf837b5e6a390ef35647e9842991c8434d5452ad1b0ab304e0fa65b9c9e14" + assert MoneroUtils.is_valid_payment_id_long(long_payment_id) + + short_payment_id: str = "87fdf837b5e6a390" + assert not MoneroUtils.is_valid_payment_id_long(short_payment_id) + + invalid_payment_ids: list[str] = ["", "wijqwnn38y", long_payment_id[:-1]] + + for payment_id in invalid_payment_ids: + assert not MoneroUtils.is_valid_payment_id_long(payment_id), f"Expected invalid long payment id: {payment_id}" + + # Can validate a short payment id specifically + def test_payment_id_short_validation(self) -> None: + short_payment_id: str = "87fdf837b5e6a390" + assert MoneroUtils.is_valid_payment_id_short(short_payment_id) + + long_payment_id: str = "87fdf837b5e6a390ef35647e9842991c8434d5452ad1b0ab304e0fa65b9c9e14" + assert not MoneroUtils.is_valid_payment_id_short(long_payment_id) + + invalid_payment_ids: list[str] = ["", "wijqwnn38y", short_payment_id[:-1]] + + for payment_id in invalid_payment_ids: + assert not MoneroUtils.is_valid_payment_id_short(payment_id), f"Expected invalid short payment id: {payment_id}" + + # Can validate a long payment id, raising on failure + def test_validate_payment_id_long(self) -> None: + long_payment_id: str = "87fdf837b5e6a390ef35647e9842991c8434d5452ad1b0ab304e0fa65b9c9e14" + MoneroUtils.validate_payment_id_long(long_payment_id) + + with pytest.raises(RuntimeError, match="Invalid long payment id"): + MoneroUtils.validate_payment_id_long("87fdf837b5e6a390") # too short + + with pytest.raises(RuntimeError, match="Invalid long payment id"): + MoneroUtils.validate_payment_id_long("wijqwnn38y") # not hex + + # Can validate a short payment id, raising on failure + def test_validate_payment_id_short(self) -> None: + short_payment_id: str = "87fdf837b5e6a390" + MoneroUtils.validate_payment_id_short(short_payment_id) + + with pytest.raises(RuntimeError, match="Invalid short payment id"): + MoneroUtils.validate_payment_id_short("87fdf837b5e6a390ef35647e9842991c8434d5452ad1b0ab304e0fa65b9c9e14") # too long + + with pytest.raises(RuntimeError, match="Invalid short payment id"): + MoneroUtils.validate_payment_id_short("wijqwnn38y") # not hex + # Can convert between XMR and atomic units def test_atomic_unit_conversion(self) -> None: assert 1000000000000 == MoneroUtils.xmr_to_atomic_units(1) @@ -450,6 +504,106 @@ def test_payment_uri_deprecated_payment_uri(self, config: TestMoneroUtils.Config # get_payment_uri() wraps make_uri()'s error with context, unlike e.g. validate_address() assert str(exc_info.value) == "Cannot make URI from supplied parameters: Standalone payment id deprecated, use integrated address instead" + # Test single payment id given with an integrated address + def test_payment_uri_single_payment_id_with_integrated_address(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.integrated_1 + tx_config: MoneroTxConfig = WalletUtils.build_payment_uri_config(address) + tx_config.payment_id = "03284e41c342f03603284e41c342f03603284e41c342f03603284e41c342f036" + with pytest.raises(Exception) as exc_info: + MoneroUtils.get_payment_uri(tx_config) + + # get_payment_uri() wraps make_uri()'s error with context, unlike e.g. validate_address() + assert str(exc_info.value) == "Cannot make URI from supplied parameters: A single payment id is allowed" + + # Can parse a payment uri without any query string + def test_parse_payment_uri_no_query(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + parsed: MoneroTxConfig = MoneroUtils.parse_payment_uri(f"monero:{address}") + assert parsed.destinations[0].address == address + assert parsed.destinations[0].amount == 0 + assert parsed.recipient_name is None + assert parsed.note is None + assert parsed.payment_id is None + + # Can parse a payment uri with an empty query string + def test_parse_payment_uri_empty_query(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + parsed: MoneroTxConfig = MoneroUtils.parse_payment_uri(f"monero:{address}?") + assert parsed.destinations[0].address == address + + # Can parse a payment uri round trip built by get_payment_uri() + def test_parse_payment_uri_round_trip(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + tx_config: MoneroTxConfig = WalletUtils.build_payment_uri_config(address) + uri: str = MoneroUtils.get_payment_uri(tx_config) + parsed: MoneroTxConfig = MoneroUtils.parse_payment_uri(uri) + assert parsed.destinations[0].address == address + assert parsed.destinations[0].amount == tx_config.amount + assert parsed.recipient_name == tx_config.recipient_name + assert parsed.note == tx_config.note + + # Payment uri must use the "monero:" scheme + def test_parse_payment_uri_wrong_scheme(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + with pytest.raises(RuntimeError) as exc_info: + MoneroUtils.parse_payment_uri(f"bitcoin:{address}") + assert str(exc_info.value) == f'Error parsing URI: URI has wrong scheme (expected "monero:"): bitcoin:{address}' + + # Payment uri address must be valid + def test_parse_payment_uri_wrong_address(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.invalid_1 + with pytest.raises(RuntimeError) as exc_info: + MoneroUtils.parse_payment_uri(f"monero:{address}") + assert str(exc_info.value) == f"Error parsing URI: URI has wrong address: {address}" + + # Payment uri parameters must be "key=value" pairs + def test_parse_payment_uri_malformed_parameter(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + with pytest.raises(RuntimeError) as exc_info: + MoneroUtils.parse_payment_uri(f"monero:{address}?tx_amount") + assert str(exc_info.value) == "Error parsing URI: URI has wrong parameter: tx_amount" + + # Payment uri parameters must not repeat + def test_parse_payment_uri_duplicate_parameter(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + with pytest.raises(RuntimeError) as exc_info: + MoneroUtils.parse_payment_uri(f"monero:{address}?tx_amount=1&tx_amount=2") + assert str(exc_info.value) == "Error parsing URI: URI has more than one instance of tx_amount" + + # Payment uri tx_amount must be a valid amount + def test_parse_payment_uri_invalid_amount(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + with pytest.raises(RuntimeError) as exc_info: + MoneroUtils.parse_payment_uri(f"monero:{address}?tx_amount=abc") + assert str(exc_info.value) == "Error parsing URI: URI has invalid amount: abc" + + # Payment uri tx_payment_id must be a valid long payment id + def test_parse_payment_uri_invalid_payment_id(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + with pytest.raises(RuntimeError) as exc_info: + MoneroUtils.parse_payment_uri(f"monero:{address}?tx_payment_id=nothex") + assert str(exc_info.value) == "Error parsing URI: Invalid payment id: nothex" + + # Payment uri can carry a separate, valid tx_payment_id for a non-integrated address + def test_parse_payment_uri_valid_payment_id(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + payment_id: str = "a" * 64 + parsed: MoneroTxConfig = MoneroUtils.parse_payment_uri(f"monero:{address}?tx_payment_id={payment_id}") + assert parsed.payment_id == payment_id + + # Payment uri must not combine a separate tx_payment_id with an integrated address + def test_parse_payment_uri_separate_payment_id_with_integrated_address(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.integrated_1 + with pytest.raises(RuntimeError) as exc_info: + MoneroUtils.parse_payment_uri(f"monero:{address}?tx_payment_id={'a' * 64}") + assert str(exc_info.value) == "Error parsing URI: Separate payment id given with an integrated address" + + # Unknown payment uri parameters are silently discarded + def test_parse_payment_uri_unknown_parameter(self, config: TestMoneroUtils.Config) -> None: + address: str = config.mainnet.primary_address_1 + parsed: MoneroTxConfig = MoneroUtils.parse_payment_uri(f"monero:{address}?foo=bar") + assert parsed.destinations[0].address == address + # Can get version def test_get_version(self) -> None: version: str = MoneroUtils.get_version() diff --git a/tests/test_monero_wallet_full.py b/tests/test_monero_wallet_full.py index a0c61f5..cfa458f 100644 --- a/tests/test_monero_wallet_full.py +++ b/tests/test_monero_wallet_full.py @@ -795,8 +795,8 @@ def test_export_and_import_wallet_files(self) -> None: assert len(cache_data) > 0 # open from the keys buffer alone, then from keys + cache - from_keys = MoneroWalletFull.open_wallet_data(Utils.WALLET_PASSWORD, MoneroNetworkType.MAINNET, keys_data, b"") - from_both = MoneroWalletFull.open_wallet_data(Utils.WALLET_PASSWORD, MoneroNetworkType.MAINNET, keys_data, cache_data) + from_keys = MoneroWalletFull.open_wallet_data(Utils.WALLET_PASSWORD, MoneroNetworkType.MAINNET, keys_data, b"", None, Utils.REGTEST) + from_both = MoneroWalletFull.open_wallet_data(Utils.WALLET_PASSWORD, MoneroNetworkType.MAINNET, keys_data, cache_data, None, Utils.REGTEST) for restored in (from_keys, from_both): assert restored.get_seed() == wallet.get_seed() diff --git a/tests/test_monero_wallet_keys.py b/tests/test_monero_wallet_keys.py index 479c9a8..9f09b2f 100644 --- a/tests/test_monero_wallet_keys.py +++ b/tests/test_monero_wallet_keys.py @@ -6,7 +6,8 @@ from monero import ( MoneroWalletKeys, MoneroWalletConfig, MoneroWallet, MoneroUtils, MoneroAccount, MoneroSubaddress, - MoneroDaemonRpc, MoneroDaemon + MoneroDaemonRpc, MoneroDaemon, MoneroIntegratedAddress, + MoneroMessageSignatureType, MoneroMessageSignatureResult ) from utils import TestUtils as Utils, AssertUtils, WalletUtils, WalletType @@ -857,6 +858,145 @@ def test_close_with_save_not_supported(self) -> None: assert w.is_closed() is False w.close() + #region Integrated Address + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_integrated_address_with_empty_standard_address(self) -> None: + """An empty standard_address uses the wallet's own primary address (account 0, subaddress 0).""" + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + integrated: MoneroIntegratedAddress = w.get_integrated_address("", "a" * 16) + assert integrated.standard_address == w.get_primary_address() + assert integrated.payment_id == "a" * 16 + finally: + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_integrated_address_with_standard_address(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + address: str = w.get_primary_address() + integrated: MoneroIntegratedAddress = w.get_integrated_address(address, "b" * 16) + assert integrated.standard_address == address + assert integrated.payment_id == "b" * 16 + finally: + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_integrated_address_invalid_address_raises(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + with pytest.raises(RuntimeError, match="Invalid address"): + w.get_integrated_address("notanaddress", "a" * 16) + finally: + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_integrated_address_subaddress_raises(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + subaddress: str = w.get_address(1, 1) + with pytest.raises(RuntimeError, match="Subaddress shouldn't be used"): + w.get_integrated_address(subaddress, "a" * 16) + finally: + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_integrated_address_already_integrated_raises(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + integrated: str = w.get_integrated_address("", "a" * 16).integrated_address + with pytest.raises(RuntimeError, match="Already integrated address"): + w.get_integrated_address(integrated, "a" * 16) + finally: + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_integrated_address_missing_payment_id_raises(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + with pytest.raises(RuntimeError, match="Payment ID shouldn't be left unspecified"): + w.get_integrated_address(w.get_primary_address(), "") + finally: + w.close() + + #endregion + + #region Message Signing + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_sign_message_invalid_signature_type_base_address_raises(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + invalid_type: MoneroMessageSignatureType = MoneroMessageSignatureType(99) # type: ignore + with pytest.raises(RuntimeError, match="Invalid signature type requested"): + w.sign_message("hello", invalid_type, 0, 0) + finally: + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_sign_message_invalid_signature_type_subaddress_raises(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + invalid_type: MoneroMessageSignatureType = MoneroMessageSignatureType(99) # type: ignore + with pytest.raises(RuntimeError, match="Invalid signature type requested"): + w.sign_message("hello", invalid_type, 1, 1) + finally: + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_verify_message_no_signature_header(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + result: MoneroMessageSignatureResult = w.verify_message("hello", w.get_primary_address(), "not-a-signature") + WalletUtils.test_message_signature_result(result, False) + finally: + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_verify_message_invalid_base58(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + result: MoneroMessageSignatureResult = w.verify_message("hello", w.get_primary_address(), "SigV2!!!not-base58!!!") + WalletUtils.test_message_signature_result(result, False) + finally: + w.close() + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_verify_message_wrong_decoded_size(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + try: + address: str = w.get_primary_address() + signature: str = w.sign_message("hello", MoneroMessageSignatureType.SIGN_WITH_SPEND_KEY, 0, 0) + # truncate the base58 payload so it decodes to fewer bytes than crypto::signature expects + truncated: str = signature[:5] + signature[5:-10] + result: MoneroMessageSignatureResult = w.verify_message("hello", address, truncated) + WalletUtils.test_message_signature_result(result, False) + finally: + w.close() + #endregion #region Utils diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index b0deab6..94f8bbd 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -1182,6 +1182,14 @@ def test_tx_set_signed_tx_hex_deserialize(self) -> None: tx_set.signed_tx_hex = "deadbeef" AssertUtils.assert_serialization_integrity(tx_set) + def test_tx_set_txs_deserialize(self) -> None: + json_str: str = '{"txs": [{"hash": "' + "a" * 64 + '"}, {"hash": "' + "b" * 64 + '"}]}' + tx_set: MoneroTxSet = MoneroTxSet.deserialize(json_str) + assert len(tx_set.txs) == 2 + assert all(isinstance(tx, MoneroTxWallet) for tx in tx_set.txs) + assert tx_set.txs[0].hash == "a" * 64 + assert tx_set.txs[1].hash == "b" * 64 + def test_sync_result_deserialize(self) -> None: result: MoneroSyncResult = MoneroSyncResult() result.num_blocks_fetched = 42 @@ -1264,6 +1272,49 @@ def test_incoming_transfer_merge(self) -> None: a.merge(b) assert a.address == TestUtils.ADDRESS + def test_incoming_transfer_merge_recurses_into_tx_merge(self) -> None: + tx_a: MoneroTxWallet = MoneroTxWallet() + tx_a.hash = "a" * 64 + tx_a.is_confirmed = True + + tx_b: MoneroTxWallet = MoneroTxWallet() + tx_b.hash = "a" * 64 + tx_b.is_confirmed = True + tx_b.num_confirmations = 9 # tx_a's num_confirmations is unset -> merge fills the gap + + a: MoneroIncomingTransfer = MoneroIncomingTransfer() + a.tx = tx_a + a.account_index = 0 + a.amount = 100 + + b: MoneroIncomingTransfer = MoneroIncomingTransfer() + b.tx = tx_b + b.account_index = 0 + b.amount = 100 + + # transfers on different txs -> merge delegates to tx merge (which comes back to merging transfers) + a.merge(b) + assert tx_a.num_confirmations == 9 + + def test_incoming_transfer_merge_zero_amount_conflict_keeps_original(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + + a: MoneroIncomingTransfer = MoneroIncomingTransfer() + a.tx = tx + a.account_index = 0 + a.amount = 500 + + b: MoneroIncomingTransfer = MoneroIncomingTransfer() + b.tx = tx + b.account_index = 0 + b.amount = 0 + + # conflicting amounts where one side is 0 are a known monero-project quirk (failed tx in + # pool): merge() warns and leaves the amount as-is rather than reconciling it + a.merge(b) + assert a.amount == 500 + 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) @@ -1597,6 +1648,28 @@ def make_input(account_index: int, amount: int) -> MoneroOutputWallet: # an unconstrained query keeps everything assert len(tx.filter_inputs_wallet(MoneroOutputQuery())) == 2 + def test_tx_wallet_get_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 + + # get_inputs_wallet() does not mutate tx.inputs + matched: list[MoneroOutputWallet] = tx.get_inputs_wallet(query) + assert [i.amount for i in matched] == [100, 300] + assert len(tx.inputs) == 3 + + # unconstrained default query (no args) returns all inputs + assert len(tx.get_inputs_wallet()) == 3 + def test_tx_wallet_filter_outputs_wallet(self) -> None: def make_output(account_index: int, amount: int) -> MoneroOutputWallet: output: MoneroOutputWallet = MoneroOutputWallet() @@ -1620,6 +1693,34 @@ 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_filter_transfers(self) -> None: + tx: MoneroTxWallet = MoneroTxWallet() + tx.hash = "a" * 64 + + outgoing: MoneroOutgoingTransfer = MoneroOutgoingTransfer() + outgoing.amount = 1000 + + incoming_1: MoneroIncomingTransfer = MoneroIncomingTransfer() + incoming_1.account_index = 0 + incoming_1.amount = 100 + + incoming_2: MoneroIncomingTransfer = MoneroIncomingTransfer() + incoming_2.account_index = 1 + incoming_2.amount = 200 + + tx.outgoing_transfer = outgoing + tx.incoming_transfers = [incoming_1, incoming_2] + + query: MoneroTransferQuery = MoneroTransferQuery() + query.incoming = True + query.account_index = 0 + + # filter returns the matches and drops the rest (outgoing_transfer cleared, incoming_2 removed) + matched: list[MoneroTransfer] = tx.filter_transfers(query) + assert [t.amount for t in matched] == [100] + assert tx.outgoing_transfer is None + assert [t.amount for t in tx.incoming_transfers] == [100] + def test_tx_wallet_get_transfers(self) -> None: tx: MoneroTxWallet = MoneroTxWallet() tx.hash = "a" * 64 @@ -1685,6 +1786,17 @@ def test_transfer_query_copy(self) -> None: assert copy is not query assert copy.serialize() == query.serialize() + def test_transfer_query_copy_deep_copies_destinations(self) -> None: + query: MoneroTransferQuery = MoneroTransferQuery() + destination: MoneroDestination = MoneroDestination(TestUtils.ADDRESS, 100) + query.destinations = [destination] + + copy: MoneroTransferQuery = query.copy() + assert len(copy.destinations) == 1 + assert copy.destinations[0] is not destination + assert copy.destinations[0].address == TestUtils.ADDRESS + assert copy.destinations[0].amount == 100 + def test_output_query_copy(self) -> None: query: MoneroOutputQuery = MoneroOutputQuery() query.amount = 1000000 diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 9f2d031..48e2df8 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -163,6 +163,8 @@ class TestUtils(ABC): LOG_LEVEL: int = 4 """Monero core internal log level.""" + LOG_CATEGORIES: str = "*:WARNING,net:FATAL,net.http:FATAL,net.ssl:FATAL,net.p2p:FATAL,net.cn:FATAL,daemon.rpc:FATAL,global:INFO,verify:FATAL,serialization:FATAL,daemon.rpc.payment:ERROR,stacktrace:INFO,logging:INFO,msgwriter:INFO" + """Monero core internal log categories (monero-project's own default).""" DAEMON_LOG_LEVEL: int = 3 """Daemon rpc log level.""" DAEMON_POLL_PERIOD_IN_MS: int = 10000 @@ -191,6 +193,8 @@ def load_config(cls) -> None: cls.TEST_RESETS = parser.getboolean('general', 'test_resets') cls.AUTO_CONNECT_TIMEOUT_MS = parser.getint('general', 'auto_connect_timeout_ms') cls.LOG_LEVEL = parser.getint('general', 'log_level', fallback=cls.LOG_LEVEL) + cls.LOG_CATEGORIES = parser.get('general', 'log_categories', fallback=cls.LOG_CATEGORIES) + MoneroUtils.set_log_categories(cls.LOG_CATEGORIES) cls.NETWORK_TYPE = DaemonUtils.parse_network_type(nettype_str) cls.REGTEST = DaemonUtils.is_regtest(nettype_str)