diff --git a/src/cpp/py_monero.cpp b/src/cpp/py_monero.cpp index 0c6afe4..b0d3e1e 100644 --- a/src/cpp/py_monero.cpp +++ b/src/cpp/py_monero.cpp @@ -1798,9 +1798,28 @@ PYBIND11_MODULE(monero, m) { .def("get_transfers", [](PyMoneroWallet& self, const monero::monero_transfer_query& query) { MONERO_CATCH_AND_RETHROW(self.get_transfers(query)); }, py::arg("query")) + .def("get_transfers", [](PyMoneroWallet& self) { + monero::monero_transfer_query query; + MONERO_CATCH_AND_RETHROW(self.get_transfers(query)); + }) + .def("get_transfers", [](PyMoneroWallet& self, uint32_t account_index) { + monero::monero_transfer_query query; + query.m_account_index = account_index; + MONERO_CATCH_AND_RETHROW(self.get_transfers(query)); + }, py::arg("account_index")) + .def("get_transfers", [](PyMoneroWallet& self, uint32_t account_index, uint32_t subaddress_index) { + monero::monero_transfer_query query; + query.m_account_index = account_index; + query.m_subaddress_index = subaddress_index; + MONERO_CATCH_AND_RETHROW(self.get_transfers(query)); + }, py::arg("account_index"), py::arg("subaddress_index")) .def("get_outputs", [](PyMoneroWallet& self, const monero::monero_output_query& query) { MONERO_CATCH_AND_RETHROW(self.get_outputs(query)); }, py::arg("query")) + .def("get_outputs", [](PyMoneroWallet& self) { + monero::monero_output_query query; + MONERO_CATCH_AND_RETHROW(self.get_outputs(query)); + }) .def("export_outputs", [](PyMoneroWallet& self, bool all) { MONERO_CATCH_AND_RETHROW(self.export_outputs(all)); }, py::arg("all") = false) @@ -1858,6 +1877,16 @@ PYBIND11_MODULE(monero, m) { .def("describe_tx_set", [](PyMoneroWallet& self, const monero::monero_tx_set& tx_set) { MONERO_CATCH_AND_RETHROW(self.describe_tx_set(tx_set)); }, py::arg("tx_set")) + .def("describe_unsigned_tx_set", [](PyMoneroWallet& self, const std::string& unsigned_tx_hex) { + monero::monero_tx_set tx_set; + tx_set.m_unsigned_tx_hex = unsigned_tx_hex; + MONERO_CATCH_AND_RETHROW(self.describe_tx_set(tx_set)); + }, py::arg("unsigned_tx_hex")) + .def("describe_multisig_tx_set", [](PyMoneroWallet& self, const std::string& multisig_tx_hex) { + monero::monero_tx_set tx_set; + tx_set.m_multisig_tx_hex = multisig_tx_hex; + MONERO_CATCH_AND_RETHROW(self.describe_tx_set(tx_set)); + }, py::arg("multisig_tx_hex")) .def("sign_txs", [](PyMoneroWallet& self, const std::string& unsigned_tx_hex) { MONERO_CATCH_AND_RETHROW(self.sign_txs(unsigned_tx_hex)); }, py::arg("unsigned_tx_hex")) diff --git a/src/python/monero_wallet.pyi b/src/python/monero_wallet.pyi index 43e75de..65ff3cf 100644 --- a/src/python/monero_wallet.pyi +++ b/src/python/monero_wallet.pyi @@ -165,6 +165,22 @@ class MoneroWallet: :param int index: is the index of the entry to delete """ ... + def describe_unsigned_tx_set(self, unsigned_tx_hex: str) -> MoneroTxSet: + """ + Describe a tx set from unsigned tx hex. + + :param str unsigned_tx_hex: unsigned tx hex + :returns MoneroTxSet: the tx set containing structured transactions + """ + ... + def describe_multisig_tx_set(self, multisig_tx_hex: str) -> MoneroTxSet: + """ + Describe a tx set containing unsigned or multisig tx hex to a new tx set containing structured transactions. + + :param str multisig_tx_hex: multisig tx hex + :returns MoneroTxSet: the tx set containing structured transactions + """ + ... def describe_tx_set(self, tx_set: MoneroTxSet) -> MoneroTxSet: """ Describes a tx set containing unsigned or multisig tx hex to a new tx set containing structured transactions. @@ -443,6 +459,17 @@ class MoneroWallet: :return list[MoneroKeyImage]: the key images from the last imported outputs. """ ... + @typing.overload + def get_outputs(self) -> list[MoneroOutputWallet]: + """ + Get outputs created from previous transactions that belong to the wallet + (i.e. that the wallet can spend one time). Outputs are part of + transactions which are stored in blocks on the blockchain. + + :return list[MoneroOutputWallet]: all wallet outputs + """ + ... + @typing.overload def get_outputs(self, query: MoneroOutputQuery) -> list[MoneroOutputWallet]: """ Get outputs created from previous transactions that belong to the wallet @@ -583,6 +610,25 @@ class MoneroWallet: :return list[MoneroSubaddress]: the retrieved subaddresses """ ... + @typing.overload + def get_transfers(self) -> list[MoneroTransfer]: + """ + Get incoming and outgoing transfers to and from this wallet. An outgoing + transfer represents a total amount sent from one or more subaddresses + within an account to individual destination addresses, each with their + own amount. An incoming transfer represents a total amount received into + a subaddress within an account. Transfers belong to transactions which + are stored on the blockchain. + + Query results can be filtered by passing in a monero_transfer_query. + Transfers must meet every criteria defined in the query in order to be + returned. All filtering is optional and no filtering is applied when not + defined. + + :return list[MoneroTransfer]: wallet transfers per the query (free memory using MoneroUtils.free()) + """ + ... + @typing.overload def get_transfers(self, query: MoneroTransferQuery) -> list[MoneroTransfer]: """ Get incoming and outgoing transfers to and from this wallet. An outgoing @@ -601,6 +647,45 @@ class MoneroWallet: :return list[MoneroTransfer]: wallet transfers per the query (free memory using MoneroUtils.free()) """ ... + @typing.overload + def get_transfers(self, account_idx: int) -> list[MoneroTransfer]: + """ + Get incoming and outgoing transfers to and from this wallet. An outgoing + transfer represents a total amount sent from one or more subaddresses + within an account to individual destination addresses, each with their + own amount. An incoming transfer represents a total amount received into + a subaddress within an account. Transfers belong to transactions which + are stored on the blockchain. + + Query results can be filtered by passing in a monero_transfer_query. + Transfers must meet every criteria defined in the query in order to be + returned. All filtering is optional and no filtering is applied when not + defined. + + :param int account_idx: is the index of the account to get transfers from + :return list[MoneroTransfer]: transfers to/from the account + """ + ... + @typing.overload + def get_transfers(self, account_idx: int, subaddress_idx: int) -> list[MoneroTransfer]: + """ + Get incoming and outgoing transfers to and from this wallet. An outgoing + transfer represents a total amount sent from one or more subaddresses + within an account to individual destination addresses, each with their + own amount. An incoming transfer represents a total amount received into + a subaddress within an account. Transfers belong to transactions which + are stored on the blockchain. + + Query results can be filtered by passing in a monero_transfer_query. + Transfers must meet every criteria defined in the query in order to be + returned. All filtering is optional and no filtering is applied when not + defined. + + :param int account_idx: is the index of the account to get transfers from + :param int subaddress_idx: is the index of the subaddress to get transfers from + :return list[MoneroTransfer]: transfers to/from the accsubaddressount + """ + ... def get_tx_key(self, tx_hash: str) -> str: """ Get a transaction's secret key from its hash. diff --git a/tests/test_monero_wallet_common.py b/tests/test_monero_wallet_common.py index 686e9da..397adf3 100644 --- a/tests/test_monero_wallet_common.py +++ b/tests/test_monero_wallet_common.py @@ -22,7 +22,8 @@ TestUtils, WalletEqualityUtils, StringUtils, AssertUtils, TxUtils, TxContext, GenUtils, WalletUtils, - WalletType, IntegrationTestUtils + WalletType, IntegrationTestUtils, + ViewOnlyAndOfflineWalletTester ) logger: logging.Logger = logging.getLogger("TestMoneroWalletCommon") @@ -2732,6 +2733,35 @@ def test_import_key_images(self, wallet: MoneroWallet) -> None: GenUtils.test_unsigned_big_integer(result.spent_amount, has_spent) GenUtils.test_unsigned_big_integer(result.unspent_amount, has_unspent) + # Supports view-only and offline wallets to create, sign and submit transactions + @pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled") + @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False and TestUtils.TEST_RELAYS is False, reason="TEST_NON_RELAYS and TEST_RELAYS disabled") + def test_view_only_and_offline_wallets(self, wallet: MoneroWallet) -> None: + # create view-only and offline wallets + config: MoneroWalletConfig = MoneroWalletConfig() + config.primary_address = wallet.get_primary_address() + config.private_view_key = wallet.get_private_view_key() + config.restore_height = TestUtils.FIRST_RECEIVE_HEIGHT + view_only_wallet: MoneroWallet = self._create_wallet(config) + + config = MoneroWalletConfig() + config.primary_address = wallet.get_primary_address() + config.private_view_key = wallet.get_private_view_key() + config.private_spend_key = wallet.get_private_spend_key() + config.server = MoneroRpcConnection(TestUtils.OFFLINE_SERVER_URI) + config.restore_height = 0 + offline_wallet: MoneroWallet = self._create_wallet(config) + assert offline_wallet.is_connected_to_daemon() is False + view_only_wallet.sync() + + # test tx signing with wallets + try: + tester = ViewOnlyAndOfflineWalletTester(wallet, view_only_wallet, offline_wallet) + tester.test() + finally: + self._close_wallet(view_only_wallet) + self._close_wallet(offline_wallet) + # Can sign and verify messages # TODO test with view-only wallet @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") diff --git a/tests/test_monero_wallet_keys.py b/tests/test_monero_wallet_keys.py index 53f8f3d..d095b98 100644 --- a/tests/test_monero_wallet_keys.py +++ b/tests/test_monero_wallet_keys.py @@ -457,6 +457,11 @@ def test_get_reserve_proof_wallet(self, wallet: MoneroWallet) -> None: def test_get_reserve_proof_account(self, wallet: MoneroWallet) -> None: return super().test_get_reserve_proof_account(wallet) + @pytest.mark.not_supported + @override + def test_view_only_and_offline_wallets(self, wallet: MoneroWallet) -> None: + return super().test_view_only_and_offline_wallets(wallet) + #endregion #region Tests diff --git a/tests/test_monero_wallet_rpc.py b/tests/test_monero_wallet_rpc.py index e5ff515..a2e1b62 100644 --- a/tests/test_monero_wallet_rpc.py +++ b/tests/test_monero_wallet_rpc.py @@ -362,4 +362,8 @@ def test_check_tx_key(self, wallet: MoneroWallet) -> None: def test_check_tx_proof(self, wallet: MoneroWallet) -> None: return super().test_check_tx_proof(wallet) + @pytest.mark.skip(reason="TODO setup another docker monero-wallet-rpc resource") + def test_view_only_and_offline_wallets(self, wallet: MoneroWallet) -> None: + return super().test_view_only_and_offline_wallets(wallet) + #endregion diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index df55fcf..312e1af 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -24,6 +24,7 @@ from .blockchain_utils import BlockchainUtils from .integration_test_utils import IntegrationTestUtils from .wallet_type import WalletType +from .view_only_and_offline_wallet_tester import ViewOnlyAndOfflineWalletTester __all__ = [ 'WalletUtils', @@ -51,5 +52,6 @@ 'TxSpammer', 'BlockchainUtils', 'IntegrationTestUtils', - 'WalletType' + 'WalletType', + 'ViewOnlyAndOfflineWalletTester' ] diff --git a/tests/utils/tx_utils.py b/tests/utils/tx_utils.py index 0ff7ab7..5d81b2b 100644 --- a/tests/utils/tx_utils.py +++ b/tests/utils/tx_utils.py @@ -763,6 +763,49 @@ def test_check_reserve(cls, check: MoneroCheckReserve) -> None: assert check.total_amount is None assert check.unconfirmed_spent_amount is None + @classmethod + def test_described_tx_set(cls, described_tx_set: MoneroTxSet) -> None: + """ + Test described tx set + + :param MoneroTxSet described_tx_set: described tx set to test + """ + assert len(described_tx_set.txs) > 0 + assert described_tx_set.signed_tx_hex is None + assert described_tx_set.unsigned_tx_hex is None + + # test each transaction + # TODO use common tx wallet test? + assert described_tx_set.multisig_tx_hex is None + for parsed_tx in described_tx_set.txs: + # TODO monero-cpp full wallet is not assigning tx set to parsed txs + #assert parsed_tx.tx_set is not None + #assert parsed_tx.tx_set == described_tx_set, f"{parsed_tx.tx_set.serialize()} != {described_tx_set.serialize()}" + GenUtils.test_unsigned_big_integer(parsed_tx.input_sum, True) + GenUtils.test_unsigned_big_integer(parsed_tx.output_sum, True) + GenUtils.test_unsigned_big_integer(parsed_tx.fee) + GenUtils.test_unsigned_big_integer(parsed_tx.change_amount) + if parsed_tx.change_amount == 0: + assert parsed_tx.change_address is None + else: + assert parsed_tx.change_address is not None + MoneroUtils.validate_address(parsed_tx.change_address, TestUtils.NETWORK_TYPE) + assert parsed_tx.ring_size is not None + assert parsed_tx.ring_size > 1 + assert parsed_tx.unlock_time is not None + assert parsed_tx.unlock_time >= 0 + assert parsed_tx.num_dummy_outputs is not None + assert parsed_tx.num_dummy_outputs >= 0 + assert parsed_tx.extra_hex is not None + assert len(parsed_tx.extra_hex) > 0 + assert parsed_tx.payment_id is None or len(parsed_tx.payment_id) > 0 + assert parsed_tx.is_outgoing is True + assert parsed_tx.outgoing_transfer is not None + assert len(parsed_tx.outgoing_transfer.destinations) > 0 + assert parsed_tx.is_incoming is None + for destination in parsed_tx.outgoing_transfer.destinations: + cls.test_destination(destination) + @classmethod def test_invalid_address_error(cls, ex: Exception) -> None: """ diff --git a/tests/utils/view_only_and_offline_wallet_tester.py b/tests/utils/view_only_and_offline_wallet_tester.py new file mode 100644 index 0000000..5c6d2e2 --- /dev/null +++ b/tests/utils/view_only_and_offline_wallet_tester.py @@ -0,0 +1,159 @@ +from monero import ( + MoneroWallet, MoneroTxWallet, MoneroTransfer, + MoneroOutputWallet, MoneroWalletRpc, MoneroTxQuery, + MoneroKeyImage, MoneroTxConfig, MoneroTxSet +) +from .test_utils import TestUtils +from .tx_utils import TxUtils + + +class ViewOnlyAndOfflineWalletTester: + """Test view-only and offline wallet compatibility""" + + _wallet: MoneroWallet + """Wallet test instance""" + _view_only_wallet: MoneroWallet + """View-only wallet test instance""" + _offline_wallet: MoneroWallet + """Offline full wallet test instance""" + + def __init__(self, wallet: MoneroWallet, view_only_wallet: MoneroWallet, offline_wallet: MoneroWallet) -> None: + """ + Initialize a new view only and offline wallet tester + + :param MoneroWallet wallet: wallet test instance + :param MoneroWallet view_only_wallet: view-only wallet test instance + :param MoneroWallet offline_wallet: offline full wallet test instance + """ + self._wallet = wallet + self._view_only_wallet = view_only_wallet + self._offline_wallet = offline_wallet + + #region Private Methods + + def _setup(self) -> None: + # wait for txs to confirm and for sufficient unlocked balance + TestUtils.WALLET_TX_TRACKER.wait_for_txs_to_clear_pool([self._wallet, self._view_only_wallet]) + TestUtils.WALLET_TX_TRACKER.wait_for_unlocked_balance(self._wallet, 0, None, TxUtils.MAX_FEE * 4) + + # test getting txs, transfers, and outputs from view-only wallet + txs: list[MoneroTxWallet] = self._view_only_wallet.get_txs() + assert len(txs) > 0 + + transfers: list[MoneroTransfer] = self._view_only_wallet.get_transfers() + assert len(transfers) > 0 + + outputs: list[MoneroOutputWallet] = self._view_only_wallet.get_outputs() + assert len(outputs) > 0 + + def _test_view_only_wallet(self) -> str: + """ + Test view-only wallet and returns primary address + + :returns str: view-only wallet primary address + """ + # collect info from main test wallet + primary_address: str = self._wallet.get_primary_address() + private_view_key: str = self._wallet.get_private_view_key() + + # test and sync view-only + assert primary_address == self._view_only_wallet.get_primary_address() + assert private_view_key == self._view_only_wallet.get_private_view_key() + assert self._view_only_wallet.is_view_only() + err_msg: str = "Should have failed" + + try: + self._view_only_wallet.get_seed() + raise Exception(err_msg) + except Exception as e: + assert err_msg != str(e), str(e) + + try: + self._view_only_wallet.get_seed_language() + raise Exception(err_msg) + except Exception as e: + assert err_msg != str(e), str(e) + + try: + self._view_only_wallet.get_private_spend_key() + raise Exception(err_msg) + except Exception as e: + assert err_msg != str(e), str(e) + + # TODO: this fails with monero-wallet-rpc and monerod with authentication + assert self._view_only_wallet.is_connected_to_daemon() + self._view_only_wallet.sync() + assert len(self._view_only_wallet.get_txs()) > 0 + + return primary_address + + def _test_offline_wallet(self) -> None: + # test offline wallet + assert self._offline_wallet.is_connected_to_daemon() is False + assert self._offline_wallet.is_view_only() is False + + if not isinstance(self._offline_wallet, MoneroWalletRpc): + # TODO monero-project: cannot get seed from offline wallet rpc + assert TestUtils.SEED == self._offline_wallet.get_seed() + + query: MoneroTxQuery = MoneroTxQuery() + query.in_tx_pool = False + txs = self._offline_wallet.get_txs(query) + assert len(txs) == 0 + + #endregion + + def test(self) -> None: + """Run test""" + # cleanup wallet state + self._setup() + + # test view-only wallet and get primary address + primary_address: str = self._test_view_only_wallet() + + # export outputs from view-only wallet + outputs_hex: str = self._view_only_wallet.export_outputs() + + self._test_offline_wallet() + + # import outputs to offline wallet + num_outputs_imported: int = self._offline_wallet.import_outputs(outputs_hex) + assert num_outputs_imported > 0, "No outputs imported" + + # export key images from offline wallet + key_images: list[MoneroKeyImage] = self._offline_wallet.export_key_images() + + # import key images to view-only wallet + assert self._view_only_wallet.is_connected_to_daemon() + self._view_only_wallet.import_key_images(key_images) + assert self._wallet.get_balance() == self._view_only_wallet.get_balance() + + # create unsigned tx using view-only wallet + tx_config: MoneroTxConfig = MoneroTxConfig() + tx_config.account_index = 0 + tx_config.address = primary_address + tx_config.amount = TxUtils.MAX_FEE * 3 + unsigned_tx: MoneroTxWallet = self._view_only_wallet.create_tx(tx_config) + assert unsigned_tx.tx_set is not None + assert unsigned_tx.tx_set.unsigned_tx_hex is not None + + # sign tx using offline wallet + signed_tx_set: MoneroTxSet = self._offline_wallet.sign_txs(unsigned_tx.tx_set.unsigned_tx_hex) + assert signed_tx_set.signed_tx_hex is not None + assert len(signed_tx_set.signed_tx_hex) > 0 + assert len(signed_tx_set.txs) == 1 + tx_from_set = signed_tx_set.txs[0] + assert tx_from_set.hash is not None + assert len(tx_from_set.hash) > 0 + + # parse or "describe" unsigned tx set + described_tx_set: MoneroTxSet = self._offline_wallet.describe_unsigned_tx_set(unsigned_tx.tx_set.unsigned_tx_hex) + TxUtils.test_described_tx_set(described_tx_set) + + # submit signed tx using view-only wallet + if TestUtils.TEST_RELAYS: + tx_hashes: list[str] = self._view_only_wallet.submit_txs(signed_tx_set.signed_tx_hex) + assert len(tx_hashes) == 1 + assert len(tx_hashes[0]) == 64 + # wait for confirmation for other tests + TestUtils.WALLET_TX_TRACKER.wait_for_txs_to_clear_pool(self._view_only_wallet)