From ecd2abfd6ea62c45c22f7cd4500d7b721c854a13 Mon Sep 17 00:00:00 2001 From: James Jia Date: Fri, 12 Apr 2024 10:30:37 -0400 Subject: [PATCH] Add back in Python (#150) * Add back in Python * readme update * Update version --- README.md | 10 +- v4-client-py/.gitignore | 132 +++ v4-client-py/.gitleaks.toml | 19 + v4-client-py/.gitleaksignore | 0 v4-client-py/.vscode/launch.json | 17 + v4-client-py/.vscode/settings.json | 5 + v4-client-py/LICENSE | 802 ++++++++++++++++++ v4-client-py/README.md | 98 +++ v4-client-py/examples/README.md | 32 + v4-client-py/examples/__init__.py | 0 v4-client-py/examples/account_endpoints.py | 120 +++ v4-client-py/examples/composite_example.py | 98 +++ v4-client-py/examples/faucet_endpoint.py | 22 + .../examples/human_readable_orders.json | 86 ++ .../human_readable_short_term_orders.json | 42 + .../long_term_order_cancel_example.py | 84 ++ v4-client-py/examples/markets_endpoints.py | 93 ++ v4-client-py/examples/raw_orders.json | 130 +++ .../short_term_order_cancel_example.py | 73 ++ .../short_term_order_composite_example.py | 66 ++ .../examples/transfer_example_deposit.py | 31 + .../transfer_example_subaccount_transfer.py | 33 + .../examples/transfer_example_withdraw.py | 31 + v4-client-py/examples/utility_endpoints.py | 29 + v4-client-py/examples/utils.py | 34 + .../examples/validator_get_examples.py | 99 +++ .../examples/validator_post_examples.py | 86 ++ v4-client-py/examples/wallet_address.py | 15 + v4-client-py/examples/websocket_example.py | 48 ++ v4-client-py/pyproject.toml | 41 + v4-client-py/pytest.ini | 8 + v4-client-py/pytest_integration.ini | 8 + v4-client-py/requirements-lint.txt | 2 + v4-client-py/requirements-publish.txt | 4 + v4-client-py/requirements-test.txt | 20 + v4-client-py/requirements.txt | 20 + v4-client-py/setup.py | 44 + v4-client-py/tests/__init__.py | 0 v4-client-py/tests/constants.py | 27 + .../tests/test_indexer_markets_endpoints.py | 104 +++ .../tests/test_indexer_utility_endpoints.py | 48 ++ v4-client-py/tests/test_request_helpers.py | 5 + .../tests/test_validator_get_endpoints.py | 110 +++ v4-client-py/tests_integration/__init__.py | 0 .../human_readable_orders.json | 86 ++ .../tests_integration/raw_orders.json | 130 +++ v4-client-py/tests_integration/test_faucet.py | 30 + .../test_indexer_account_endpoints.py | 130 +++ v4-client-py/tests_integration/test_trades.py | 84 ++ .../tests_integration/test_transfers.py | 67 ++ v4-client-py/tests_integration/util.py | 20 + v4-client-py/v4_client_py/__init__.py | 10 + v4-client-py/v4_client_py/chain/__init__.py | 0 .../v4_client_py/chain/aerial/__init__.py | 2 + .../chain/aerial/client/__init__.py | 692 +++++++++++++++ .../v4_client_py/chain/aerial/client/bank.py | 26 + .../chain/aerial/client/distribution.py | 18 + .../chain/aerial/client/staking.py | 108 +++ .../v4_client_py/chain/aerial/client/utils.py | 119 +++ .../v4_client_py/chain/aerial/coins.py | 32 + .../v4_client_py/chain/aerial/config.py | 120 +++ .../v4_client_py/chain/aerial/exceptions.py | 60 ++ .../v4_client_py/chain/aerial/faucet.py | 139 +++ v4-client-py/v4_client_py/chain/aerial/gas.py | 140 +++ v4-client-py/v4_client_py/chain/aerial/tx.py | 253 ++++++ .../v4_client_py/chain/aerial/tx_helpers.py | 162 ++++ .../v4_client_py/chain/aerial/urls.py | 89 ++ .../v4_client_py/chain/aerial/wallet.py | 138 +++ .../v4_client_py/chain/auth/__init__.py | 2 + .../v4_client_py/chain/auth/interface.py | 35 + .../v4_client_py/chain/auth/rest_client.py | 49 ++ .../v4_client_py/chain/bank/__init__.py | 2 + .../v4_client_py/chain/bank/interface.py | 99 +++ .../v4_client_py/chain/bank/rest_client.py | 124 +++ .../v4_client_py/chain/common/__init__.py | 2 + .../v4_client_py/chain/common/rest_client.py | 144 ++++ .../v4_client_py/chain/common/types.py | 11 + .../v4_client_py/chain/common/utils.py | 22 + .../v4_client_py/chain/crypto/__init__.py | 20 + .../v4_client_py/chain/crypto/address.py | 104 +++ .../v4_client_py/chain/crypto/hashfuncs.py | 51 ++ .../v4_client_py/chain/crypto/interface.py | 50 ++ .../v4_client_py/chain/crypto/keypairs.py | 231 +++++ .../v4_client_py/chain/crypto/keypairs_bls.py | 230 +++++ .../chain/distribution/__init__.py | 20 + .../chain/distribution/interface.py | 138 +++ .../chain/distribution/rest_client.py | 175 ++++ .../v4_client_py/chain/evidence/__init__.py | 20 + .../v4_client_py/chain/evidence/interface.py | 52 ++ .../chain/evidence/rest_client.py | 69 ++ .../v4_client_py/chain/gov/__init__.py | 20 + .../v4_client_py/chain/gov/interface.py | 123 +++ .../v4_client_py/chain/gov/rest_client.py | 165 ++++ .../v4_client_py/chain/mint/__init__.py | 20 + .../v4_client_py/chain/mint/interface.py | 56 ++ .../v4_client_py/chain/mint/rest_client.py | 105 +++ .../v4_client_py/chain/params/__init__.py | 20 + .../v4_client_py/chain/params/interface.py | 40 + .../v4_client_py/chain/params/rest_client.py | 53 ++ .../v4_client_py/chain/slashing/__init__.py | 20 + .../v4_client_py/chain/slashing/interface.py | 63 ++ .../chain/slashing/rest_client.py | 81 ++ .../v4_client_py/chain/staking/__init__.py | 20 + .../v4_client_py/chain/staking/interface.py | 201 +++++ .../v4_client_py/chain/staking/rest_client.py | 261 ++++++ .../v4_client_py/chain/tendermint/__init__.py | 19 + .../chain/tendermint/interface.py | 100 +++ .../chain/tendermint/rest_client.py | 127 +++ .../v4_client_py/chain/tx/__init__.py | 20 + .../v4_client_py/chain/tx/interface.py | 64 ++ .../v4_client_py/chain/tx/rest_client.py | 117 +++ .../v4_client_py/chain/upgrade/__init__.py | 19 + .../v4_client_py/chain/upgrade/interface.py | 50 ++ .../v4_client_py/chain/upgrade/rest_client.py | 67 ++ v4-client-py/v4_client_py/clients/__init__.py | 6 + v4-client-py/v4_client_py/clients/composer.py | 214 +++++ .../v4_client_py/clients/constants.py | 135 +++ .../clients/dydx_composite_client.py | 591 +++++++++++++ .../clients/dydx_faucet_client.py | 77 ++ .../clients/dydx_indexer_client.py | 43 + .../clients/dydx_socket_client.py | 107 +++ .../v4_client_py/clients/dydx_subaccount.py | 32 + .../clients/dydx_validator_client.py | 32 + v4-client-py/v4_client_py/clients/errors.py | 56 ++ .../v4_client_py/clients/helpers/__init__.py | 0 .../clients/helpers/chain_helpers.py | 208 +++++ .../clients/helpers/request_helpers.py | 49 ++ .../v4_client_py/clients/helpers/requests.py | 44 + .../v4_client_py/clients/modules/__init__.py | 0 .../v4_client_py/clients/modules/account.py | 421 +++++++++ .../v4_client_py/clients/modules/get.py | 260 ++++++ .../v4_client_py/clients/modules/markets.py | 214 +++++ .../v4_client_py/clients/modules/post.py | 286 +++++++ .../v4_client_py/clients/modules/utility.py | 61 ++ 134 files changed, 11746 insertions(+), 2 deletions(-) create mode 100644 v4-client-py/.gitignore create mode 100644 v4-client-py/.gitleaks.toml create mode 100644 v4-client-py/.gitleaksignore create mode 100644 v4-client-py/.vscode/launch.json create mode 100644 v4-client-py/.vscode/settings.json create mode 100644 v4-client-py/LICENSE create mode 100644 v4-client-py/README.md create mode 100644 v4-client-py/examples/README.md create mode 100644 v4-client-py/examples/__init__.py create mode 100644 v4-client-py/examples/account_endpoints.py create mode 100644 v4-client-py/examples/composite_example.py create mode 100644 v4-client-py/examples/faucet_endpoint.py create mode 100644 v4-client-py/examples/human_readable_orders.json create mode 100644 v4-client-py/examples/human_readable_short_term_orders.json create mode 100644 v4-client-py/examples/long_term_order_cancel_example.py create mode 100644 v4-client-py/examples/markets_endpoints.py create mode 100644 v4-client-py/examples/raw_orders.json create mode 100644 v4-client-py/examples/short_term_order_cancel_example.py create mode 100644 v4-client-py/examples/short_term_order_composite_example.py create mode 100644 v4-client-py/examples/transfer_example_deposit.py create mode 100644 v4-client-py/examples/transfer_example_subaccount_transfer.py create mode 100644 v4-client-py/examples/transfer_example_withdraw.py create mode 100644 v4-client-py/examples/utility_endpoints.py create mode 100644 v4-client-py/examples/utils.py create mode 100644 v4-client-py/examples/validator_get_examples.py create mode 100644 v4-client-py/examples/validator_post_examples.py create mode 100644 v4-client-py/examples/wallet_address.py create mode 100644 v4-client-py/examples/websocket_example.py create mode 100644 v4-client-py/pyproject.toml create mode 100644 v4-client-py/pytest.ini create mode 100644 v4-client-py/pytest_integration.ini create mode 100644 v4-client-py/requirements-lint.txt create mode 100644 v4-client-py/requirements-publish.txt create mode 100644 v4-client-py/requirements-test.txt create mode 100644 v4-client-py/requirements.txt create mode 100644 v4-client-py/setup.py create mode 100644 v4-client-py/tests/__init__.py create mode 100644 v4-client-py/tests/constants.py create mode 100644 v4-client-py/tests/test_indexer_markets_endpoints.py create mode 100644 v4-client-py/tests/test_indexer_utility_endpoints.py create mode 100644 v4-client-py/tests/test_request_helpers.py create mode 100644 v4-client-py/tests/test_validator_get_endpoints.py create mode 100644 v4-client-py/tests_integration/__init__.py create mode 100644 v4-client-py/tests_integration/human_readable_orders.json create mode 100644 v4-client-py/tests_integration/raw_orders.json create mode 100644 v4-client-py/tests_integration/test_faucet.py create mode 100644 v4-client-py/tests_integration/test_indexer_account_endpoints.py create mode 100644 v4-client-py/tests_integration/test_trades.py create mode 100644 v4-client-py/tests_integration/test_transfers.py create mode 100644 v4-client-py/tests_integration/util.py create mode 100644 v4-client-py/v4_client_py/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/client/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/client/bank.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/client/distribution.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/client/staking.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/client/utils.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/coins.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/config.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/exceptions.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/faucet.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/gas.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/tx.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/tx_helpers.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/urls.py create mode 100644 v4-client-py/v4_client_py/chain/aerial/wallet.py create mode 100644 v4-client-py/v4_client_py/chain/auth/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/auth/interface.py create mode 100644 v4-client-py/v4_client_py/chain/auth/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/bank/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/bank/interface.py create mode 100644 v4-client-py/v4_client_py/chain/bank/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/common/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/common/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/common/types.py create mode 100644 v4-client-py/v4_client_py/chain/common/utils.py create mode 100644 v4-client-py/v4_client_py/chain/crypto/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/crypto/address.py create mode 100644 v4-client-py/v4_client_py/chain/crypto/hashfuncs.py create mode 100644 v4-client-py/v4_client_py/chain/crypto/interface.py create mode 100644 v4-client-py/v4_client_py/chain/crypto/keypairs.py create mode 100644 v4-client-py/v4_client_py/chain/crypto/keypairs_bls.py create mode 100644 v4-client-py/v4_client_py/chain/distribution/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/distribution/interface.py create mode 100644 v4-client-py/v4_client_py/chain/distribution/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/evidence/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/evidence/interface.py create mode 100644 v4-client-py/v4_client_py/chain/evidence/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/gov/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/gov/interface.py create mode 100644 v4-client-py/v4_client_py/chain/gov/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/mint/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/mint/interface.py create mode 100644 v4-client-py/v4_client_py/chain/mint/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/params/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/params/interface.py create mode 100644 v4-client-py/v4_client_py/chain/params/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/slashing/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/slashing/interface.py create mode 100644 v4-client-py/v4_client_py/chain/slashing/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/staking/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/staking/interface.py create mode 100644 v4-client-py/v4_client_py/chain/staking/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/tendermint/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/tendermint/interface.py create mode 100644 v4-client-py/v4_client_py/chain/tendermint/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/tx/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/tx/interface.py create mode 100644 v4-client-py/v4_client_py/chain/tx/rest_client.py create mode 100644 v4-client-py/v4_client_py/chain/upgrade/__init__.py create mode 100644 v4-client-py/v4_client_py/chain/upgrade/interface.py create mode 100644 v4-client-py/v4_client_py/chain/upgrade/rest_client.py create mode 100644 v4-client-py/v4_client_py/clients/__init__.py create mode 100644 v4-client-py/v4_client_py/clients/composer.py create mode 100644 v4-client-py/v4_client_py/clients/constants.py create mode 100644 v4-client-py/v4_client_py/clients/dydx_composite_client.py create mode 100644 v4-client-py/v4_client_py/clients/dydx_faucet_client.py create mode 100644 v4-client-py/v4_client_py/clients/dydx_indexer_client.py create mode 100644 v4-client-py/v4_client_py/clients/dydx_socket_client.py create mode 100644 v4-client-py/v4_client_py/clients/dydx_subaccount.py create mode 100644 v4-client-py/v4_client_py/clients/dydx_validator_client.py create mode 100644 v4-client-py/v4_client_py/clients/errors.py create mode 100644 v4-client-py/v4_client_py/clients/helpers/__init__.py create mode 100644 v4-client-py/v4_client_py/clients/helpers/chain_helpers.py create mode 100644 v4-client-py/v4_client_py/clients/helpers/request_helpers.py create mode 100644 v4-client-py/v4_client_py/clients/helpers/requests.py create mode 100644 v4-client-py/v4_client_py/clients/modules/__init__.py create mode 100644 v4-client-py/v4_client_py/clients/modules/account.py create mode 100644 v4-client-py/v4_client_py/clients/modules/get.py create mode 100644 v4-client-py/v4_client_py/clients/modules/markets.py create mode 100644 v4-client-py/v4_client_py/clients/modules/post.py create mode 100644 v4-client-py/v4_client_py/clients/modules/utility.py diff --git a/README.md b/README.md index a8b7b545..cce119b3 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,15 @@ ## v4-client-js The dYdX Chain Client Typescript client is used for placing transactions and querying the dYdX chain. +## v4-client-py +Python client for dYdX Chain. Huge thanks to [kaloureyes3](https://github.com/kaloureyes3/v4-clients) for helping us +maintain this! + +The library is currently tested against Python versions 3.9, and 3.11. + ## v4-client-cpp (Third Party Client) +To pull the latest C++ client, run `git submodule update --init --recursive` + This client was originally developed and open-sourced through a grant by the dYdX Grants Trust — an unaffiliated and independent third-party from dYdX Trading Inc. @@ -25,6 +33,4 @@ The original client can be found [here](https://github.com/asnefedovv/dydx-v4-cl # Third-party Clients -[Python Client](https://github.com/kaloureyes3/v4-clients/tree/main/v4-client-py) - By clicking the above links to third-party clients, you will leave the dYdX Trading Inc. (“dYdX”) GitHub repository and join repositories made available by third parties, which are independent from and unaffiliated with dYdX. dYdX is not responsible for any action taken or content on third-party repositories. \ No newline at end of file diff --git a/v4-client-py/.gitignore b/v4-client-py/.gitignore new file mode 100644 index 00000000..3efd499f --- /dev/null +++ b/v4-client-py/.gitignore @@ -0,0 +1,132 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.idea/ diff --git a/v4-client-py/.gitleaks.toml b/v4-client-py/.gitleaks.toml new file mode 100644 index 00000000..c31ece1f --- /dev/null +++ b/v4-client-py/.gitleaks.toml @@ -0,0 +1,19 @@ +# Title for the gitleaks configuration file. +title = "Gitleaks title" + +[extend] +# useDefault will extend the base configuration with the default gitleaks config: +# https://github.com/zricethezav/gitleaks/blob/master/config/gitleaks.toml +useDefault = true + +[allowlist] +paths = [ + '''gitleaks\.toml''', + '''tests/test_onboarding.py''', # old V3 code +] + +regexTarget = "line" +regexes = [ + '''clientId''', + '''e92a6595c934c991d3b3e987ea9b3125bf61a076deab3a9cb519787b7b3e8d77''', # test private key +] diff --git a/v4-client-py/.gitleaksignore b/v4-client-py/.gitleaksignore new file mode 100644 index 00000000..e69de29b diff --git a/v4-client-py/.vscode/launch.json b/v4-client-py/.vscode/launch.json new file mode 100644 index 00000000..5fa5ecb6 --- /dev/null +++ b/v4-client-py/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true, + "env": {"PYTHONPATH": "${workspaceFolder}/"} + } + ] +} \ No newline at end of file diff --git a/v4-client-py/.vscode/settings.json b/v4-client-py/.vscode/settings.json new file mode 100644 index 00000000..f59dce37 --- /dev/null +++ b/v4-client-py/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.linting.pylintEnabled": false, + "python.linting.enabled": false, + "python.linting.pycodestyleEnabled": true +} \ No newline at end of file diff --git a/v4-client-py/LICENSE b/v4-client-py/LICENSE new file mode 100644 index 00000000..dc00b0e4 --- /dev/null +++ b/v4-client-py/LICENSE @@ -0,0 +1,802 @@ +Copyright (C) 2023 dYdX Trading Inc. + +Subject to your compliance with applicable law and the v4 Terms of Use, available at dydx.exchange/legal, you are granted the right to use the Program or Licensed Work (defined below) under the terms of the GNU Affero General Public License as set forth below; provided, however, that if you violate any such applicable law in your use of the Program or Licensed Work, all of your rights and licenses to use (including any rights to reproduce, distribute, install or modify) the Program or Licensed Work will automatically and immediately terminate. + + +The “Program” or “Licensed Work” shall mean any of the following: dydxprotocol/cosmos-sdk, dydxprotocol/cometbft, dydxprotocol/v4-chain, dydxprotocol/v4-clients, dydxprotocol/v4-web, dydxprotocol/v4-abacus, dydxprotocol/v4-localization, dydxprotocol/v4-documentation, and any dYdX or dYdX Trading Inc. repository reflecting a copy of, or link to, this license. + + +The GNU Affero General Public License +Version 3, 19 November 2007 + + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + + Preamble + + + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + + The precise terms and conditions for copying, distribution and +modification follow. + + + + TERMS AND CONDITIONS + + + 0. Definitions. + + + "This License" refers to version 3 of the GNU Affero General Public License. + + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + + A "covered work" means either the unmodified Program or a work based +on the Program. + + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + + + 1. Source Code. + + + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + + + The Corresponding Source for a work in source code form is that +same work. + + + 2. Basic Permissions. + + + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; Section 10 +makes it unnecessary. + + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + + + 4. Conveying Verbatim Copies. + + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with Section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + + 5. Conveying Modified Source Versions. + + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of Section 4, provided that you also meet all of these conditions: + + + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under Section + 7. This requirement modifies the requirement in Section 4 to + "keep intact all notices". + + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable Section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + + + 6. Conveying Non-Source Forms. + + + + You may convey a covered work in object code form under the terms +of Sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with Subsection 6b. + + + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under Subsection 6d. + + + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + + + 7. Additional Terms. + + + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + + + a) Disclaiming warranty or limiting liability differently from the + terms of Sections 15 and 16 of this License; or + + + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of Section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + + + 8. Termination. + + + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of Section 11). + + + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under Section 10. + + + + 9. Acceptance Not Required for Having Copies. + + + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + + + 10. Automatic Licensing of Downstream Recipients. + + + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + + + 11. Patents. + + + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + + + 12. No Surrender of Others' Freedom. + + + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + + + 13. Remote Network Interaction; Use with the GNU General Public License. + + + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + + + 14. Revised Versions of this License. + + + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + + + 15. Disclaimer of Warranty. + + + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + + + 16. Limitation of Liability. + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + 17. Interpretation of Sections 15 and 16. + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + +For more information about this software, see https://dydx.exchange. + Copyright (C) 2023 dYdX Trading Inc. \ No newline at end of file diff --git a/v4-client-py/README.md b/v4-client-py/README.md new file mode 100644 index 00000000..5cd68ae6 --- /dev/null +++ b/v4-client-py/README.md @@ -0,0 +1,98 @@ +

+ +

dYdX Chain Client for Python

+ +
+ + PyPI + + + License + +
+ +Python client for dYdX (v4 API). + +The library is currently tested against Python versions 3.9, and 3.11. + +## Installation + +The `v4-client-py` package is available on [PyPI](https://pypi.org/project/v4-client-py). Install with `pip`: + +```bash +pip install v4-client-py +``` + +## Getting Started + +Sample code is located in examples folder + +## Development Setup - VS Code + +Install Microsoft Python extensions +``` +Shift-Command-P: Create Python Environment +Select Venv +Select Python 3.9 as interpreter +Select requirements.txt as the dependencies to install +``` + + +Install requirements +``` +pip install -r requirements.txt +``` + +VS Code will automatically switch to .venv environment when running example code. Or you can manually switch + +``` +source ~//.venv/bin/activate +``` + +Set PYTHONPATH + +``` +export PYTHONPATH=~//.venv/lib//site-packages +``` + +## Troubleshootimg + +Cython and Brownie must be installed before cytoolz + +If there is any issue with cytoolz, uninstall cytoolz, Brownie and Cython, reinstall Cython, Brownie and cytoolz sequentially. + +VS Code may need to be restarted to have Cython functioning correctly + + +## Running examples + +Select the file to be debugged +Select the debug button on the left +Select "Python: Current File" + +## Running tests + +Integration tests uses testnet environment for testing. We use pytest. + +To install pytest + +``` +pip install pytest +``` + +For read-only integration tests, run: + +``` +pytest -v +``` + +For integration tests with transactions, a subaccount must exist for the specified address. +This subaccount may be reset when testnet environment is reset. To create the subaccount, run + +examples/faucet_endpoint.py + +Wait for a few seconds for the faucet transaction to commit, then run + +``` +pytest -v -c pytest_integration.ini +``` diff --git a/v4-client-py/examples/README.md b/v4-client-py/examples/README.md new file mode 100644 index 00000000..aa010d2e --- /dev/null +++ b/v4-client-py/examples/README.md @@ -0,0 +1,32 @@ +# User guide to test examples + +1. Go to your repository location for the Python client +``` +cd ~/.../v4-clients/v4-client-py +``` +2. Create a virtual environment for the DyDx client, activate it and install requirements +``` +python3 -m venv venv +source venv/bin/activate +pip3 install -r requirements.txt +``` +3. Export PYTHONPATH for your current location +``` +export PYTHONPATH='~/.../v4-clients/v4-client-py' +``` + +Now you are ready to use the examples in this folder. + +# Set up your configurations in constants.py +~/.../v4-clients/v4-client-py/v4_client_py/clients/constants.py + +``` +VALIDATOR_GRPC_ENDPOINT = <> +AERIAL_CONFIG_URL = <> +AERIAL_GRPC_OR_REST_PREFIX = <> +INDEXER_REST_ENDPOINT = <> +INDEXER_WS_ENDPOINT = <> +CHAIN_ID = <> +ENV = <> +``` + diff --git a/v4-client-py/examples/__init__.py b/v4-client-py/examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/v4-client-py/examples/account_endpoints.py b/v4-client-py/examples/account_endpoints.py new file mode 100644 index 00000000..5fb417cd --- /dev/null +++ b/v4-client-py/examples/account_endpoints.py @@ -0,0 +1,120 @@ +"""Example for placing, replacing, and canceling orders. + +Usage: python -m examples.private_endpoints +""" + +from v4_client_py.clients import IndexerClient, Subaccount +from v4_client_py.clients.constants import Network + +from tests.constants import DYDX_TEST_MNEMONIC + +client = IndexerClient( + config=Network.config_network().indexer_config, +) + +try: + subaccount = Subaccount.from_mnemonic(DYDX_TEST_MNEMONIC) + address = subaccount.address + + # Get subaccounts + try: + subaccounts_response = client.account.get_subaccounts(address) + print(f"{subaccounts_response.data}") + subaccounts = subaccounts_response.data["subaccounts"] + subaccount_0 = subaccounts[0] + print(f"{subaccount_0}") + subaccount_0_subaccountNumber = subaccount_0["subaccountNumber"] + except: + print("failed to get subaccounts") + + try: + subaccount_response = client.account.get_subaccount(address, 0) + print(f"{subaccount_response.data}") + subaccount = subaccount_response.data["subaccount"] + print(f"{subaccount}") + subaccount_subaccountNumber = subaccount["subaccountNumber"] + except: + print("failed to get subaccount") + + # Get positions + try: + asset_positions_response = client.account.get_subaccount_asset_positions(address, 0) + print(f"{asset_positions_response.data}") + asset_positions = asset_positions_response.data["positions"] + if len(asset_positions) > 0: + asset_positions_0 = asset_positions[0] + print(f"{asset_positions_0}") + except: + print("failed to get asset positions") + + try: + perpetual_positions_response = client.account.get_subaccount_perpetual_positions(address, 0) + print(f"{perpetual_positions_response.data}") + perpetual_positions = perpetual_positions_response.data["positions"] + if len(perpetual_positions) > 0: + perpetual_positions_0 = perpetual_positions[0] + print(f"{perpetual_positions_0}") + except: + print("failed to get perpetual positions") + + # Get transfers + try: + transfers_response = client.account.get_subaccount_transfers(address, 0) + print(f"{transfers_response.data}") + transfers = transfers_response.data["transfers"] + if len(transfers) > 0: + transfers_0 = transfers[0] + print(f"{transfers_0}") + except: + print("failed to get transfers") + + # Get orders + try: + orders_response = client.account.get_subaccount_orders(address, 0) + print(f"{orders_response.data}") + orders = orders_response.data + if len(orders) > 0: + order_0 = orders[0] + print(f"{order_0}") + order_0_id = order_0["id"] + order_response = client.account.get_order(order_id=order_0_id) + order = order_response.data + order_id = order["id"] + except: + print("failed to get orders") + + # Get fills + try: + fills_response = client.account.get_subaccount_fills(address, 0) + print(f"{fills_response.data}") + fills = fills_response.data["fills"] + if len(fills) > 0: + fill_0 = fills[0] + print(f"{fill_0}") + except: + print("failed to get fills") + + # Get funding + try: + funding_response = client.account.get_subaccount_funding(address, 0) + print(f"{funding_response.data}") + funding = funding_response.data["fundingPayments"] + if len(funding) > 0: + funding_0 = funding[0] + print(f"{funding_0}") + except: + print("failed to get funding") + + # Get historical pnl + try: + historical_pnl_response = client.account.get_subaccount_historical_pnls(address, 0) + print(f"{historical_pnl_response.data}") + historical_pnl = historical_pnl_response.data["historicalPnl"] + if len(historical_pnl) > 0: + historical_pnl_0 = historical_pnl[0] + print(f"{historical_pnl_0}") + except: + print("failed to get historical pnl") + +except: + print("from_mnemonic failed") diff --git a/v4-client-py/examples/composite_example.py b/v4-client-py/examples/composite_example.py new file mode 100644 index 00000000..da34d9ba --- /dev/null +++ b/v4-client-py/examples/composite_example.py @@ -0,0 +1,98 @@ +"""Example for trading with human readable numbers + +Usage: python -m examples.composite_example +""" +import asyncio +import logging +from random import randrange +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients import CompositeClient, Subaccount +from v4_client_py.clients.constants import BECH32_PREFIX, Network + +from v4_client_py.clients.helpers.chain_helpers import ( + OrderType, + OrderSide, + OrderTimeInForce, + OrderExecution, +) +from examples.utils import loadJson + +from tests.constants import DYDX_TEST_MNEMONIC + + +async def main() -> None: + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + network = Network.config_network() + client = CompositeClient( + network, + ) + subaccount = Subaccount(wallet, 0) + ordersParams = loadJson("human_readable_orders.json") + for orderParams in ordersParams: + type = OrderType[orderParams["type"]] + side = OrderSide[orderParams["side"]] + time_in_force_string = orderParams.get("timeInForce", "GTT") + time_in_force = OrderTimeInForce[time_in_force_string] + price = orderParams.get("price", 1350) + + if time_in_force == OrderTimeInForce.GTT: + time_in_force_seconds = 60 + good_til_block = 0 + else: + latest_block = client.validator_client.get.latest_block() + next_valid_block = latest_block.block.header.height + 1 + good_til_block = next_valid_block + 10 + time_in_force_seconds = 0 + + post_only = orderParams.get("postOnly", False) + try: + tx = client.place_order( + subaccount, + market="ETH-USD", + type=type, + side=side, + price=price, + size=0.01, + client_id=randrange(0, 100000000), + time_in_force=time_in_force, + good_til_block=good_til_block, + good_til_time_in_seconds=time_in_force_seconds, + execution=OrderExecution.DEFAULT, + post_only=post_only, + reduce_only=False, + ) + print("**Order Tx**") + print(tx) + except Exception as error: + print("**Order Failed**") + print(str(error)) + + await asyncio.sleep(5) # wait for placeOrder to complete + + try: + tx = client.place_order( + subaccount, + market="ETH-USD", + type=OrderType.STOP_MARKET, + side=OrderSide.SELL, + price=900.0, + size=0.01, + client_id=randrange(0, 100000000), + time_in_force=OrderTimeInForce.GTT, + good_til_block=0, # long term orders use GTBT + good_til_time_in_seconds=1000, + execution=OrderExecution.IOC, + post_only=False, + reduce_only=False, + trigger_price=1000, + ) + print("**Order Tx**") + print(tx) + except Exception as error: + print("**Order Failed**") + print(str(error)) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/examples/faucet_endpoint.py b/v4-client-py/examples/faucet_endpoint.py new file mode 100644 index 00000000..e1a6dde4 --- /dev/null +++ b/v4-client-py/examples/faucet_endpoint.py @@ -0,0 +1,22 @@ +"""Example for depositing with faucet. + +Usage: python -m examples.faucet_endpoint +""" +from v4_client_py.clients import FaucetClient, Subaccount +from v4_client_py.clients.constants import Network + +from tests.constants import DYDX_TEST_MNEMONIC + +client = FaucetClient( + host=Network.config_network().faucet_endpoint, +) + +subaccount = Subaccount.from_mnemonic(DYDX_TEST_MNEMONIC) +address = subaccount.address + + +# fill subaccount with 2000 ETH +faucet_response = client.fill(address, 0, 2000) +print(faucet_response.data) +faucet_http_code = faucet_response.status_code +print(faucet_http_code) diff --git a/v4-client-py/examples/human_readable_orders.json b/v4-client-py/examples/human_readable_orders.json new file mode 100644 index 00000000..548d39bd --- /dev/null +++ b/v4-client-py/examples/human_readable_orders.json @@ -0,0 +1,86 @@ +[ + { + "type": "LIMIT", + "timeInForce": "GTT", + "postOnly": true, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "GTT", + "postOnly": true, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce": "GTT", + "postOnly": false, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "GTT", + "postOnly": false, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce":"IOC", + "postOnly": true, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce":"IOC", + "postOnly": true, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce": "IOC", + "postOnly": false, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "IOC", + "postOnly": false, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce": "FOK", + "postOnly": true, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "FOK", + "postOnly": true, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce": "FOK", + "postOnly": false, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "FOK", + "postOnly": false, + "side": "SELL", + "price": 1000 + } +] \ No newline at end of file diff --git a/v4-client-py/examples/human_readable_short_term_orders.json b/v4-client-py/examples/human_readable_short_term_orders.json new file mode 100644 index 00000000..987bae56 --- /dev/null +++ b/v4-client-py/examples/human_readable_short_term_orders.json @@ -0,0 +1,42 @@ +[ + { + "timeInForce": "DEFAULT", + "side": "BUY", + "price": 40000 + }, + { + "timeInForce": "DEFAULT", + "side": "SELL", + "price": 1000 + }, + { + "timeInForce": "FOK", + "side": "BUY", + "price": 1000 + }, + { + "timeInForce": "FOK", + "side": "SELL", + "price": 40000 + }, + { + "timeInForce": "IOC", + "side": "BUY", + "price": 40000 + }, + { + "timeInForce": "IOC", + "side": "SELL", + "price": 1000 + }, + { + "timeInForce": "POST_ONLY", + "side": "BUY", + "price": 1000 + }, + { + "timeInForce": "POST_ONLY", + "side": "SELL", + "price": 40000 + } +] \ No newline at end of file diff --git a/v4-client-py/examples/long_term_order_cancel_example.py b/v4-client-py/examples/long_term_order_cancel_example.py new file mode 100644 index 00000000..52703a4a --- /dev/null +++ b/v4-client-py/examples/long_term_order_cancel_example.py @@ -0,0 +1,84 @@ +"""Example for trading with human readable numbers + +Usage: python -m examples.composite_example +""" +import asyncio +import logging +from random import randrange +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients import CompositeClient, Subaccount +from v4_client_py.clients.constants import BECH32_PREFIX, Network + +from v4_client_py.clients.helpers.chain_helpers import ( + ORDER_FLAGS_LONG_TERM, + OrderType, + OrderSide, + OrderTimeInForce, + OrderExecution, +) + +from tests.constants import DYDX_TEST_MNEMONIC, MAX_CLIENT_ID + + +async def main() -> None: + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + network = Network.config_network() + client = CompositeClient( + network, + ) + subaccount = Subaccount(wallet, 0) + + """ + Note this example places a stateful order. + Programmatic traders should generally not use stateful orders for following reasons: + - Stateful orders received out of order by validators will fail sequence number validation + and be dropped. + - Stateful orders have worse time priority since they are only matched after they are included + on the block. + - Stateful order rate limits are more restrictive than Short-Term orders, specifically max 2 per + block / 20 per 100 blocks. + - Stateful orders can only be canceled after they’ve been included in a block. + """ + long_term_order_client_id = randrange(0, MAX_CLIENT_ID) + try: + tx = client.place_order( + subaccount, + market="ETH-USD", + type=OrderType.LIMIT, + side=OrderSide.SELL, + price=40000, + size=0.01, + client_id=long_term_order_client_id, + time_in_force=OrderTimeInForce.GTT, + good_til_block=0, # long term orders use GTBT + good_til_time_in_seconds=60, + execution=OrderExecution.DEFAULT, + post_only=False, + reduce_only=False, + ) + print("** Long Term Order Tx**") + print(tx.tx_hash) + except Exception as error: + print("**Long Term Order Failed**") + print(str(error)) + + # cancel a long term order. + try: + tx = client.cancel_order( + subaccount, + long_term_order_client_id, + "ETH-USD", + ORDER_FLAGS_LONG_TERM, + good_til_time_in_seconds=120, + good_til_block=0, # long term orders use GTBT + ) + print("**Cancel Long Term Order Tx**") + print(tx.tx_hash) + except Exception as error: + print("**Cancel Long Term Order Failed**") + print(str(error)) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/examples/markets_endpoints.py b/v4-client-py/examples/markets_endpoints.py new file mode 100644 index 00000000..3eb0a416 --- /dev/null +++ b/v4-client-py/examples/markets_endpoints.py @@ -0,0 +1,93 @@ +"""Example for placing, replacing, and canceling orders. + +Usage: python -m examples.markets_endpoints +""" + +from v4_client_py.clients import IndexerClient +from v4_client_py.clients.constants import Network, MARKET_BTC_USD + +client = IndexerClient( + config=Network.config_network().indexer_config, +) + +# Get perp markets +try: + markets_response = client.markets.get_perpetual_markets() + print(markets_response.data) + btc_market = markets_response.data["markets"]["BTC-USD"] + btc_market_status = btc_market["status"] +except: + print("failed to get markets") + +try: + btc_market_response = client.markets.get_perpetual_markets(MARKET_BTC_USD) + print(btc_market_response.data) + btc_market = btc_market_response.data["markets"]["BTC-USD"] + btc_market_status = btc_market["status"] +except: + print("failed to get BTC market") + +# Get sparklines +try: + sparklines_response = client.markets.get_perpetual_markets_sparklines() + print(sparklines_response.data) + sparklines = sparklines_response.data + btc_sparkline = sparklines["BTC-USD"] +except: + print("failed to get sparklines") + + +# Get perp market trades +try: + btc_market_trades_response = client.markets.get_perpetual_market_trades(MARKET_BTC_USD) + print(btc_market_trades_response.data) + btc_market_trades = btc_market_trades_response.data["trades"] + btc_market_trades_0 = btc_market_trades[0] +except: + print("failed to get market trades") + +# Get perp market orderbook +try: + btc_market_orderbook_response = client.markets.get_perpetual_market_orderbook(MARKET_BTC_USD) + print(btc_market_orderbook_response.data) + btc_market_orderbook = btc_market_orderbook_response.data + btc_market_orderbook_asks = btc_market_orderbook["asks"] + btc_market_orderbook_bids = btc_market_orderbook["bids"] + if len(btc_market_orderbook_asks) > 0: + btc_market_orderbook_asks_0 = btc_market_orderbook_asks[0] + print(btc_market_orderbook_asks_0) + btc_market_orderbook_asks_0_price = btc_market_orderbook_asks_0["price"] + btc_market_orderbook_asks_0_size = btc_market_orderbook_asks_0["size"] +except: + print("failed to get market orderbook") + +# Get perp market candles +try: + btc_market_candles_response = client.markets.get_perpetual_market_candles(MARKET_BTC_USD, "1MIN") + print(btc_market_candles_response.data) + btc_market_candles = btc_market_candles_response.data["candles"] + if len(btc_market_candles) > 0: + btc_market_candles_0 = btc_market_candles[0] + print(btc_market_candles_0) + btc_market_candles_0_startedAt = btc_market_candles_0["startedAt"] + btc_market_candles_0_low = btc_market_candles_0["low"] + btc_market_candles_0_hight = btc_market_candles_0["high"] + btc_market_candles_0_open = btc_market_candles_0["open"] + btc_market_candles_0_close = btc_market_candles_0["close"] + btc_market_candles_0_baseTokenVolume = btc_market_candles_0["baseTokenVolume"] + btc_market_candles_0_usdVolume = btc_market_candles_0["usdVolume"] + btc_market_candles_0_trades = btc_market_candles_0["trades"] +except: + print("failed to get market cancles") + + +# Get perp market funding +try: + btc_market_funding_response = client.markets.get_perpetual_market_funding(MARKET_BTC_USD) + print(btc_market_funding_response.data) + btc_market_funding = btc_market_funding_response.data["historicalFunding"] + if len(btc_market_funding) > 0: + btc_market_funding_0 = btc_market_funding[0] + print(btc_market_funding_0) +except: + print("failed to get market historical funding") diff --git a/v4-client-py/examples/raw_orders.json b/v4-client-py/examples/raw_orders.json new file mode 100644 index 00000000..c9bc297b --- /dev/null +++ b/v4-client-py/examples/raw_orders.json @@ -0,0 +1,130 @@ +[ + { + "timeInForce": 0, + "reduceOnly": false, + "orderFlags": 64, + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 2, + "reduceOnly": false, + "orderFlags": 64 , + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 0, + "reduceOnly": true, + "orderFlags": 64, + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 2, + "reduceOnly": true, + "orderFlags": 64 , + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 1, + "reduceOnly": false, + "orderFlags": 0, + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 1, + "reduceOnly": true, + "orderFlags": 0 , + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 3, + "reduceOnly": false, + "orderFlags": 0, + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 3, + "reduceOnly": true, + "orderFlags": 0 , + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 0, + "reduceOnly": false, + "orderFlags": 64, + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 2, + "reduceOnly": false, + "orderFlags": 64 , + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 0, + "reduceOnly": true, + "orderFlags": 64, + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 2, + "reduceOnly": true, + "orderFlags": 64 , + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 1, + "reduceOnly": false, + "orderFlags": 0, + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 1, + "reduceOnly": true, + "orderFlags": 0 , + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 3, + "reduceOnly": false, + "orderFlags": 0, + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 3, + "reduceOnly": true, + "orderFlags": 0 , + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + } +] diff --git a/v4-client-py/examples/short_term_order_cancel_example.py b/v4-client-py/examples/short_term_order_cancel_example.py new file mode 100644 index 00000000..a22135bc --- /dev/null +++ b/v4-client-py/examples/short_term_order_cancel_example.py @@ -0,0 +1,73 @@ +"""Example for trading with human readable numbers + +Usage: python -m examples.composite_example +""" +import asyncio +import logging +from random import randrange +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients import CompositeClient, Subaccount +from v4_client_py.clients.constants import BECH32_PREFIX, Network + +from v4_client_py.clients.helpers.chain_helpers import ( + ORDER_FLAGS_SHORT_TERM, + Order_TimeInForce, + OrderSide, +) +from tests.constants import DYDX_TEST_MNEMONIC, MAX_CLIENT_ID + + +async def main() -> None: + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + network = Network.config_network() + client = CompositeClient( + network, + ) + subaccount = Subaccount(wallet, 0) + + # place a short term order. + short_term_client_id = randrange(0, MAX_CLIENT_ID) + # Get the expiration block. + current_block = client.get_current_block() + next_valid_block_height = current_block + 1 + # Note, you can change this to any number between `next_valid_block_height` to `next_valid_block_height + SHORT_BLOCK_WINDOW` + good_til_block = next_valid_block_height + 10 + + try: + tx = client.place_short_term_order( + subaccount, + market="ETH-USD", + side=OrderSide.SELL, + price=40000, + size=0.01, + client_id=short_term_client_id, + good_til_block=good_til_block, + time_in_force=Order_TimeInForce.TIME_IN_FORCE_UNSPECIFIED, + reduce_only=False, + ) + print("**Short Term Order Tx**") + print(tx.tx_hash) + except Exception as error: + print("**Short Term Order Failed**") + print(str(error)) + + # cancel a short term order. + try: + tx = client.cancel_order( + subaccount, + short_term_client_id, + "ETH-USD", + ORDER_FLAGS_SHORT_TERM, + good_til_time_in_seconds=0, # short term orders use GTB. + good_til_block=good_til_block, # GTB should be the same or greater than order to cancel + ) + print("**Cancel Short Term Order Tx**") + print(tx.tx_hash) + except Exception as error: + print("**Cancel Short Term Order Failed**") + print(str(error)) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/examples/short_term_order_composite_example.py b/v4-client-py/examples/short_term_order_composite_example.py new file mode 100644 index 00000000..e7522dc2 --- /dev/null +++ b/v4-client-py/examples/short_term_order_composite_example.py @@ -0,0 +1,66 @@ +"""Example for trading with human readable numbers + +Usage: python -m examples.composite_example +""" +import asyncio +import logging +from random import randrange +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients import CompositeClient, Subaccount +from v4_client_py.clients.constants import BECH32_PREFIX, Network + +from v4_client_py.clients.helpers.chain_helpers import ( + OrderSide, +) +from examples.utils import loadJson, orderExecutionToTimeInForce + +from tests.constants import DYDX_TEST_MNEMONIC + + +async def main() -> None: + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + network = Network.config_network() + client = CompositeClient( + network, + ) + subaccount = Subaccount(wallet, 0) + ordersParams = loadJson("human_readable_short_term_orders.json") + for orderParams in ordersParams: + side = OrderSide[orderParams["side"]] + + # Get the expiration block. + current_block = client.get_current_block() + next_valid_block_height = current_block + 1 + # Note, you can change this to any number between `next_valid_block_height` to `next_valid_block_height + SHORT_BLOCK_WINDOW` + good_til_block = next_valid_block_height + 10 + + time_in_force = orderExecutionToTimeInForce(orderParams["timeInForce"]) + + price = orderParams.get("price", 1350) + # uint32 + client_id = randrange(0, 2**32 - 1) + + try: + tx = client.place_short_term_order( + subaccount, + market="ETH-USD", + side=side, + price=price, + size=0.01, + client_id=client_id, + good_til_block=good_til_block, + time_in_force=time_in_force, + reduce_only=False, + ) + print("**Order Tx**") + print(tx.tx_hash) + except Exception as error: + print("**Order Failed**") + print(str(error)) + + await asyncio.sleep(5) # wait for placeOrder to complete + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/examples/transfer_example_deposit.py b/v4-client-py/examples/transfer_example_deposit.py new file mode 100644 index 00000000..97c73774 --- /dev/null +++ b/v4-client-py/examples/transfer_example_deposit.py @@ -0,0 +1,31 @@ +import asyncio +import logging +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients.dydx_subaccount import Subaccount + +from v4_client_py.clients.dydx_validator_client import ValidatorClient +from v4_client_py.clients.constants import BECH32_PREFIX, Network + +from tests.constants import DYDX_TEST_MNEMONIC + + +async def main() -> None: + network = Network.config_network() + client = ValidatorClient(network.validator_config) + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + subaccount = Subaccount(wallet, 0) + try: + tx = client.post.deposit( + subaccount, + 0, + 5_000_000, + ) + print("**Deposit Tx**") + print(tx) + except Exception as e: + print(e) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/examples/transfer_example_subaccount_transfer.py b/v4-client-py/examples/transfer_example_subaccount_transfer.py new file mode 100644 index 00000000..01d0c9be --- /dev/null +++ b/v4-client-py/examples/transfer_example_subaccount_transfer.py @@ -0,0 +1,33 @@ +import asyncio +import logging +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients.dydx_subaccount import Subaccount + +from v4_client_py.clients.dydx_validator_client import ValidatorClient +from v4_client_py.clients.constants import BECH32_PREFIX, Network + +from tests.constants import DYDX_TEST_MNEMONIC + + +async def main() -> None: + network = Network.config_network() + client = ValidatorClient(network.validator_config) + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + subaccount = Subaccount(wallet, 0) + try: + tx = client.post.transfer( + subaccount=subaccount, + recipient_address=subaccount.address, + recipient_subaccount_number=1, + asset_id=0, + amount=5_000_000, + ) + print("**Transfer Tx**") + print(tx) + except Exception as e: + print(e) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/examples/transfer_example_withdraw.py b/v4-client-py/examples/transfer_example_withdraw.py new file mode 100644 index 00000000..558cde62 --- /dev/null +++ b/v4-client-py/examples/transfer_example_withdraw.py @@ -0,0 +1,31 @@ +import asyncio +import logging +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients.dydx_subaccount import Subaccount + +from v4_client_py.clients.dydx_validator_client import ValidatorClient +from v4_client_py.clients.constants import BECH32_PREFIX, Network + +from tests.constants import DYDX_TEST_MNEMONIC + + +async def main() -> None: + network = Network.config_network() + client = ValidatorClient(network.validator_config) + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + subaccount = Subaccount(wallet, 0) + try: + tx = client.post.withdraw( + subaccount, + 0, + 10_000_000, + ) + print("**Withdraw Tx**") + print(tx) + except Exception as e: + print(e) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/examples/utility_endpoints.py b/v4-client-py/examples/utility_endpoints.py new file mode 100644 index 00000000..c2fe81d9 --- /dev/null +++ b/v4-client-py/examples/utility_endpoints.py @@ -0,0 +1,29 @@ +"""Example for getting Indexer server time and height. + +Usage: python -m examples.utility_endpoints +""" + +from v4_client_py.clients import IndexerClient +from v4_client_py.clients.constants import Network + +client = IndexerClient( + config=Network.config_network().indexer_config, +) + +# Get indexer server time +try: + time_response = client.utility.get_time() + print(time_response.data) + time_iso = time_response.data["iso"] + time_epoch = time_response.data["epoch"] +except: + print("failed to get time") + +# Get indexer height +try: + height_response = client.utility.get_height() + print(height_response.data) + height = height_response.data["height"] + height_time = height_response.data["time"] +except: + print("failed to get height") diff --git a/v4-client-py/examples/utils.py b/v4-client-py/examples/utils.py new file mode 100644 index 00000000..b1b96710 --- /dev/null +++ b/v4-client-py/examples/utils.py @@ -0,0 +1,34 @@ +from enum import Enum +import json +import os +from typing import Tuple + +from v4_client_py.clients.helpers.chain_helpers import Order_TimeInForce, is_order_flag_stateful_order + + +def loadJson(filename): + current_directory = os.path.dirname(os.path.abspath(__file__)) + json_file_path = os.path.join(current_directory, filename) + + with open(json_file_path, "r") as file: + return json.load(file) + + +class HumanReadableOrderTimeInForce(Enum): + DEFAULT = "DEFAULT" + FOK = "FOK" + IOC = "IOC" + POST_ONLY = "POST_ONLY" + + +def orderExecutionToTimeInForce(orderExecution: HumanReadableOrderTimeInForce) -> Order_TimeInForce: + if orderExecution == HumanReadableOrderTimeInForce.DEFAULT.value: + return Order_TimeInForce.TIME_IN_FORCE_UNSPECIFIED + elif orderExecution == HumanReadableOrderTimeInForce.FOK.value: + return Order_TimeInForce.TIME_IN_FORCE_FILL_OR_KILL + elif orderExecution == HumanReadableOrderTimeInForce.IOC.value: + return Order_TimeInForce.TIME_IN_FORCE_IOC + elif orderExecution == HumanReadableOrderTimeInForce.POST_ONLY.value: + return Order_TimeInForce.TIME_IN_FORCE_POST_ONLY + else: + raise ValueError("Unrecognized order execution") diff --git a/v4-client-py/examples/validator_get_examples.py b/v4-client-py/examples/validator_get_examples.py new file mode 100644 index 00000000..4992ad37 --- /dev/null +++ b/v4-client-py/examples/validator_get_examples.py @@ -0,0 +1,99 @@ +import asyncio +import logging + +from v4_client_py.clients.dydx_validator_client import ValidatorClient +from v4_client_py.clients.constants import Network + +from tests.constants import DYDX_TEST_ADDRESS + + +async def main() -> None: + network = Network.config_network() + client = ValidatorClient(network.validator_config) + address = DYDX_TEST_ADDRESS + try: + acc = client.get.account(address=address) + print("account:") + print(acc) + except Exception as e: + print("failed to get account") + print(e) + + try: + bank_balances = client.get.bank_balances(address) + print("bank balances:") + print(bank_balances) + except Exception as e: + print("failed to get bank balances") + print(e) + + try: + bank_balance = client.get.bank_balance( + address, "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5" + ) + print("bank balance:") + print(bank_balance) + except Exception as e: + print("failed to get bank balances") + print(e) + + try: + all_subaccounts = client.get.subaccounts() + print("subaccounts:") + print(all_subaccounts) + except Exception as e: + print("failed to get all subaccounts") + print(e) + + try: + subaccount = client.get.subaccount(address, 0) + print("subaccount:") + print(subaccount) + except Exception as e: + print("failed to get subaccount") + print(e) + + try: + clob_pairs = client.get.clob_pairs() + print("clob pairs:") + print(clob_pairs) + except Exception as e: + print("failed to get all clob pairs") + print(e) + + try: + clob_pair = client.get.clob_pair(1) + print("clob pair:") + print(clob_pair) + except Exception as e: + print("failed to get clob pair") + print(e) + + try: + prices = client.get.prices() + print("prices:") + print(prices) + except Exception as e: + print("failed to get all prices") + print(e) + + try: + price = client.get.price(1) + print("price:") + print(price) + except Exception as e: + print("failed to get price") + print(e) + + try: + config = client.get.equity_tier_limit_config() + print("equity_tier_limit_configuration:") + print(config) + except Exception as e: + print("failed to get equity_tier_limit_configuration") + print(e) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/examples/validator_post_examples.py b/v4-client-py/examples/validator_post_examples.py new file mode 100644 index 00000000..9303fb8a --- /dev/null +++ b/v4-client-py/examples/validator_post_examples.py @@ -0,0 +1,86 @@ +import asyncio +import datetime +import logging +import random +from time import sleep +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients.dydx_subaccount import Subaccount +from v4_proto.dydxprotocol.clob.order_pb2 import Order + +from v4_client_py.clients.dydx_validator_client import ValidatorClient +from v4_client_py.clients.constants import BECH32_PREFIX, Network +from v4_client_py.clients.helpers.chain_helpers import ORDER_FLAGS_SHORT_TERM +from examples.utils import loadJson + +from tests.constants import DYDX_TEST_MNEMONIC + +PERPETUAL_PAIR_BTC_USD = 0 + +default_order = { + "client_id": 0, + "order_flags": ORDER_FLAGS_SHORT_TERM, + "clob_pair_id": PERPETUAL_PAIR_BTC_USD, + "side": Order.SIDE_BUY, + "quantums": 1_000_000_000, + "subticks": 1_000_000_000, + "time_in_force": Order.TIME_IN_FORCE_UNSPECIFIED, + "reduce_only": False, + "client_metadata": 0, +} + + +def dummy_order(height): + placeOrder = default_order.copy() + placeOrder["client_id"] = random.randint(0, 1000000000) + placeOrder["good_til_block"] = height + 3 + # placeOrder["goodTilBlockTime"] = height + 3 + random_num = random.randint(0, 1000) + if random_num % 2 == 0: + placeOrder["side"] = Order.SIDE_BUY + else: + placeOrder["side"] = Order.SIDE_SELL + return placeOrder + + +async def main() -> None: + network = Network.config_network() + client = ValidatorClient(network.validator_config) + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + subaccount = Subaccount(wallet, 0) + ordersParams = loadJson("raw_orders.json") + for orderParams in ordersParams: + last_block = client.get.latest_block() + height = last_block.block.header.height + + place_order = dummy_order(height) + + place_order["time_in_force"] = orderParams["timeInForce"] + place_order["reduce_only"] = False # reduceOnly is currently disabled + place_order["order_flags"] = orderParams["orderFlags"] + place_order["side"] = orderParams["side"] + place_order["quantums"] = orderParams["quantums"] + place_order["subticks"] = orderParams["subticks"] + try: + if place_order["order_flags"] != 0: + place_order["good_til_block"] = 0 + + now = datetime.datetime.now() + interval = datetime.timedelta(seconds=60) + future = now + interval + place_order["good_til_block_time"] = int(future.timestamp()) + else: + place_order["good_til_block_time"] = 0 + + tx = client.post.place_order_object(subaccount, place_order) + print("**Order Tx**") + print(tx) + except Exception as error: + print("**Order Failed**") + print(str(error)) + + await asyncio.sleep(5) # wait for placeOrder to complete + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/examples/wallet_address.py b/v4-client-py/examples/wallet_address.py new file mode 100644 index 00000000..746ca608 --- /dev/null +++ b/v4-client-py/examples/wallet_address.py @@ -0,0 +1,15 @@ +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients.constants import BECH32_PREFIX + +from tests.constants import DYDX_TEST_ADDRESS, DYDX_TEST_PRIVATE_KEY, DYDX_TEST_MNEMONIC + +# We recommend using comspy to derive address from mnemonic +wallet = LocalWallet.from_mnemonic(mnemonic=DYDX_TEST_MNEMONIC, prefix=BECH32_PREFIX) +private_key = wallet.signer().private_key_hex +assert private_key == DYDX_TEST_PRIVATE_KEY + +public_key = wallet.public_key().public_key_hex +address = wallet.address() +print(f"public key:{public_key}, address:{address}") + +assert address == DYDX_TEST_ADDRESS diff --git a/v4-client-py/examples/websocket_example.py b/v4-client-py/examples/websocket_example.py new file mode 100644 index 00000000..bf54367d --- /dev/null +++ b/v4-client-py/examples/websocket_example.py @@ -0,0 +1,48 @@ +"""Example for connecting to private WebSockets with an existing account. + +Usage: python -m examples.websocket_example +""" + +import asyncio +import json + +from v4_client_py.clients.dydx_socket_client import SocketClient +from v4_client_py.clients.constants import Network + +from tests.constants import DYDX_TEST_ADDRESS + + +def on_open(ws): + print("WebSocket connection opened") + ws.send_ping_if_inactive_for(30) + + +def on_message(ws, message): + print(f"Received message: {message}") + payload = json.loads(message) + if payload["type"] == "connected": + my_ws.subscribe_to_markets() + my_ws.subscribe_to_orderbook("ETH-USD") + my_ws.subscribe_to_trades("ETH-USD") + my_ws.subscribe_to_candles("ETH-USD") + my_ws.subscribe_to_subaccount(DYDX_TEST_ADDRESS, 0) + ws.send_ping_if_inactive_for(30) + ws.subscribe_to_markets() + + +def on_close(ws): + print("WebSocket connection closed") + + +my_ws = SocketClient(config=Network.config_network().indexer_config, on_message=on_message, on_open=on_open, on_close=on_close) + + +async def main(): + my_ws.connect() + + # Do some stuff... + + # my_ws.close() + + +asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/pyproject.toml b/v4-client-py/pyproject.toml new file mode 100644 index 00000000..2eaf5093 --- /dev/null +++ b/v4-client-py/pyproject.toml @@ -0,0 +1,41 @@ +# pyproject.toml + +[tool.setuptools] +package-dir = {"" = "v4_client_py"} + +[tool.poetry] +name = "v4-client-py" +version = "4.0.0" +description = "dYdX Chain Client" +authors = ["John Huang "] +license = "AGPL-3.0" +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.8" +Cython = "^0.29.0" +aiohttp = "^3.8.1" +bech32 = "^1.2.0" +bip_utils = "^2.7.0" +brownie = "^0.5.0" +cytoolz = "^0.12.1" +dateparser = "^1.0.0" +ecdsa = "^0.18.0" +eth_keys = "^0.4.0" +eth-account = "^0.9.0" +protobuf = "^4.23" +grpcio-tools = "^1.56" +grpcio = "^1.56" +mpmath = "^1.3.0" +requests = "^2.31.0" +six = "^1.16" +sympy = "^1.12.0" +web3 = "^6.5.0" +websocket_client = "^1.6.1" + +[tool.poetry.dev-dependencies] +wheel = "^0.35.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/v4-client-py/pytest.ini b/v4-client-py/pytest.ini new file mode 100644 index 00000000..4170b414 --- /dev/null +++ b/v4-client-py/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +addopts = -v --showlocals --durations 10 +pythonpath = . +testpaths = tests +xfail_strict = true + +[pytest-watch] +runner = pytest --failed-first --maxfail=1 --no-success-flaky-report diff --git a/v4-client-py/pytest_integration.ini b/v4-client-py/pytest_integration.ini new file mode 100644 index 00000000..c584466c --- /dev/null +++ b/v4-client-py/pytest_integration.ini @@ -0,0 +1,8 @@ +[pytest] +addopts = -v --showlocals --durations 10 +pythonpath = . +testpaths = tests_integration +xfail_strict = true + +[pytest-watch] +runner = pytest --failed-first --maxfail=1 --no-success-flaky-report diff --git a/v4-client-py/requirements-lint.txt b/v4-client-py/requirements-lint.txt new file mode 100644 index 00000000..888726d0 --- /dev/null +++ b/v4-client-py/requirements-lint.txt @@ -0,0 +1,2 @@ +autopep8 +flake8 diff --git a/v4-client-py/requirements-publish.txt b/v4-client-py/requirements-publish.txt new file mode 100644 index 00000000..706e7347 --- /dev/null +++ b/v4-client-py/requirements-publish.txt @@ -0,0 +1,4 @@ +setuptools>=60.0.0 +wheel==0.38.4 +twine==4.0.2 +poetry>=1.5.1 diff --git a/v4-client-py/requirements-test.txt b/v4-client-py/requirements-test.txt new file mode 100644 index 00000000..0cb9495e --- /dev/null +++ b/v4-client-py/requirements-test.txt @@ -0,0 +1,20 @@ +Cython>=0.29.0 +aiohttp>=3.8.1 +bech32>=1.2.0 +bip_utils>=1.6.0 +brownie>=0.5.0 +cytoolz>=0.12.1 +dateparser>=1.0.0 +v4-proto>=0.2.1 +ecdsa>=0.16.0 +eth_keys>=0.4.0 +eth-account>=0.9.0 +grpcio>=1.34.0 +grpcio-tools>=1.34.0 +mpmath>=1.0.0 +pytest>=6.2.1 +requests>=2.22.0,<3.0.0 +six>=1.14 +sympy>=1.6.0 +web3>=6.5.0 +websocket_client>=1.5.1 diff --git a/v4-client-py/requirements.txt b/v4-client-py/requirements.txt new file mode 100644 index 00000000..f6c88b11 --- /dev/null +++ b/v4-client-py/requirements.txt @@ -0,0 +1,20 @@ +Cython>=0.29.0 +aiohttp>=3.8.1 +bech32>=1.2.0 +bip_utils>=2.7.0 +brownie>=0.5.0 +cytoolz>=0.12.1 +dateparser>=1.0.0 +v4-proto>=0.2.1 +ecdsa>=0.18.0 +eth_keys>=0.4.0 +eth-account>=0.9.0 +grpcio>=1.56.0 +grpcio-tools>=1.56.0 +mpmath>=1.3.0 +pytest>=6.2.1 +requests>=2.31.0 +six>=1.16.0 +sympy>=1.12.0 +web3>=6.5.0 +websocket_client>=1.6.1 diff --git a/v4-client-py/setup.py b/v4-client-py/setup.py new file mode 100644 index 00000000..52f4c08c --- /dev/null +++ b/v4-client-py/setup.py @@ -0,0 +1,44 @@ +from setuptools import setup, find_packages + +LONG_DESCRIPTION = open('README.md', 'r').read() + +REQUIREMENTS = [ + 'aiohttp>=3.8.1', + 'cytoolz==0.12.1', + 'dateparser==1.0.0', + 'dydx>=0.1', + 'ecdsa>=0.16.0', + 'eth_keys', + 'eth-account>=0.4.0,<0.6.0', + 'mpmath==1.0.0', + 'requests>=2.22.0,<3.0.0', + 'sympy==1.6', + 'web3>=5.0.0,<6.0.0', +] + +setup( + name='dydx-v4-python', + version='0.1', + packages=find_packages(), + package_data={ + }, + description='dYdX v4 Python Client', + long_description=LONG_DESCRIPTION, + long_description_content_type='text/markdown', + url='https://github.com/dydxprotocol/v4-client-py', + author='dYdX Trading Inc.', + license='BSL-1.1', + license_files = ("LICENSE"), + author_email='contact@dydx.exchange', + install_requires=REQUIREMENTS, + keywords='dydx exchange rest api defi ethereum eth cosmo', + classifiers=[ + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python', + 'Topic :: Software Development :: Libraries :: Python Modules', + ], +) diff --git a/v4-client-py/tests/__init__.py b/v4-client-py/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/v4-client-py/tests/constants.py b/v4-client-py/tests/constants.py new file mode 100644 index 00000000..95c6398f --- /dev/null +++ b/v4-client-py/tests/constants.py @@ -0,0 +1,27 @@ +# ------------ Constants for Testing ------------ +from v4_client_py.clients.constants import ORDER_SIDE_BUY +from v4_client_py.clients.helpers.chain_helpers import ORDER_FLAGS_SHORT_TERM +from v4_proto.dydxprotocol.clob.order_pb2 import Order + + +DEFAULT_HOST = 'http://localhost:8080' +DEFAULT_NETWORK_ID = 1001 +SEVEN_DAYS_S = 7 * 24 * 60 * 60 + +MAX_CLIENT_ID = 2 ** 32 - 1 + +DYDX_TEST_ADDRESS = 'dydx14zzueazeh0hj67cghhf9jypslcf9sh2n5k6art' +DYDX_TEST_PRIVATE_KEY = 'e92a6595c934c991d3b3e987ea9b3125bf61a076deab3a9cb519787b7b3e8d77' +DYDX_TEST_MNEMONIC = 'mirror actor skill push coach wait confirm orchard lunch mobile athlete gossip awake miracle matter bus reopen team ladder lazy list timber render wait'; + +default_order = { + "clientId": 0, + "orderFlags": ORDER_FLAGS_SHORT_TERM, + "clobPairId": "BTC-USD", + "side": ORDER_SIDE_BUY, + "quantums": 1_000_000_000, + "subticks": 1_000_000_000, + "timeInForce": Order.TIME_IN_FORCE_UNSPECIFIED, + "reduceOnly": False, + "clientMetadata": 0, +} \ No newline at end of file diff --git a/v4-client-py/tests/test_indexer_markets_endpoints.py b/v4-client-py/tests/test_indexer_markets_endpoints.py new file mode 100644 index 00000000..1737b25e --- /dev/null +++ b/v4-client-py/tests/test_indexer_markets_endpoints.py @@ -0,0 +1,104 @@ + +from v4_client_py.clients import IndexerClient +from v4_client_py.clients.constants import Network, MARKET_BTC_USD + +client = IndexerClient( + config=Network.testnet().indexer_config, +) + +def test_get_perpetual_markets(): + # Get perp markets + try: + markets_response = client.markets.get_perpetual_markets() + print(markets_response.data) + btc_market = markets_response.data['markets']['BTC-USD'] + btc_market_status = btc_market['status'] + except: + print('failed to get markets') + assert False + +def test_get_perpetual_market(): + try: + btc_market_response = client.markets.get_perpetual_markets(MARKET_BTC_USD) + print(btc_market_response.data) + btc_market = btc_market_response.data['markets']['BTC-USD'] + btc_market_status = btc_market['status'] + except: + print('failed to get BTC market') + assert False + +def test_get_perpetual_markets_sparklines(): + # Get sparklines + try: + sparklines_response = client.markets.get_perpetual_markets_sparklines() + print(sparklines_response.data) + sparklines = sparklines_response.data + btc_sparkline = sparklines['BTC-USD'] + except: + print('failed to get sparklines') + assert False + + +def test_get_perpetual_market_trades(): + # Get perp market trades + try: + btc_market_trades_response = client.markets.get_perpetual_market_trades(MARKET_BTC_USD) + print(btc_market_trades_response.data) + btc_market_trades = btc_market_trades_response.data['trades'] + if len(btc_market_trades) > 0: + btc_market_trades_0 = btc_market_trades[0] + except: + print('failed to get market trades') + assert False + +def test_get_perpetual_market_orderbook(): + # Get perp market orderbook + try: + btc_market_orderbook_response = client.markets.get_perpetual_market_orderbook(MARKET_BTC_USD) + print(btc_market_orderbook_response.data) + btc_market_orderbook = btc_market_orderbook_response.data + btc_market_orderbook_asks = btc_market_orderbook['asks'] + btc_market_orderbook_bids = btc_market_orderbook['bids'] + if len(btc_market_orderbook_asks) > 0: + btc_market_orderbook_asks_0 = btc_market_orderbook_asks[0] + print(btc_market_orderbook_asks_0) + btc_market_orderbook_asks_0_price = btc_market_orderbook_asks_0['price'] + btc_market_orderbook_asks_0_size = btc_market_orderbook_asks_0['size'] + except: + print('failed to get market orderbook') + assert False + +def test_get_market_candles(): + # Get perp market candles + try: + btc_market_candles_response = client.markets.get_perpetual_market_candles(MARKET_BTC_USD, '1MIN') + print(btc_market_candles_response.data) + btc_market_candles = btc_market_candles_response.data['candles'] + if len(btc_market_candles) > 0: + btc_market_candles_0 = btc_market_candles[0] + print(btc_market_candles_0) + btc_market_candles_0_startedAt = btc_market_candles_0['startedAt'] + btc_market_candles_0_low = btc_market_candles_0['low'] + btc_market_candles_0_hight = btc_market_candles_0['high'] + btc_market_candles_0_open = btc_market_candles_0['open'] + btc_market_candles_0_close = btc_market_candles_0['close'] + btc_market_candles_0_baseTokenVolume = btc_market_candles_0['baseTokenVolume'] + btc_market_candles_0_usdVolume = btc_market_candles_0['usdVolume'] + btc_market_candles_0_trades = btc_market_candles_0['trades'] + except: + print('failed to get market cancles') + assert False + + +def test_get_perpetual_market_funding(): + # Get perp market funding + try: + btc_market_funding_response = client.markets.get_perpetual_market_funding(MARKET_BTC_USD) + print(btc_market_funding_response.data) + btc_market_funding= btc_market_funding_response.data['historicalFunding'] + if len(btc_market_funding) > 0: + btc_market_funding_0 = btc_market_funding[0] + print(btc_market_funding_0) + except: + print('failed to get market historical funding') + assert False diff --git a/v4-client-py/tests/test_indexer_utility_endpoints.py b/v4-client-py/tests/test_indexer_utility_endpoints.py new file mode 100644 index 00000000..b40cbb10 --- /dev/null +++ b/v4-client-py/tests/test_indexer_utility_endpoints.py @@ -0,0 +1,48 @@ + +from v4_client_py.clients import IndexerClient, Subaccount +from v4_client_py.clients.constants import Network + +from tests.constants import DYDX_TEST_MNEMONIC + +subaccount = Subaccount.from_mnemonic(DYDX_TEST_MNEMONIC) +address = subaccount.address + +client = IndexerClient( + config=Network.testnet().indexer_config, +) + +def test_get_time(): + try: + time_response = client.utility.get_time() + print(time_response.data) + time_iso = time_response.data['iso'] + time_epoch = time_response.data['epoch'] + assert time_iso != None + assert time_epoch != None + except: + print('failed to get time') + assert False + +def test_get_height(): + # Get indexer height + try: + height_response = client.utility.get_height() + print(height_response.data) + height = height_response.data['height'] + height_time = height_response.data['time'] + assert height != None + assert height_time != None + except: + print('failed to get height') + assert False + +def test_screen(): + try: + screen_response = client.utility.screen(address) + print(screen_response.data) + restricted = screen_response.data['restricted'] + assert restricted != None + except: + print('failed to screen address') + assert False + diff --git a/v4-client-py/tests/test_request_helpers.py b/v4-client-py/tests/test_request_helpers.py new file mode 100644 index 00000000..8e585c8b --- /dev/null +++ b/v4-client-py/tests/test_request_helpers.py @@ -0,0 +1,5 @@ +from v4_client_py.clients.helpers.request_helpers import generate_query_path + +def test_generate_query_path(): + query_path = generate_query_path('https://google.com', {'a': True, 'b': False, 'c': 'TEST'}) + assert query_path == 'https://google.com?a=true&b=false&c=TEST' diff --git a/v4-client-py/tests/test_validator_get_endpoints.py b/v4-client-py/tests/test_validator_get_endpoints.py new file mode 100644 index 00000000..537eac26 --- /dev/null +++ b/v4-client-py/tests/test_validator_get_endpoints.py @@ -0,0 +1,110 @@ +from v4_client_py.clients.dydx_validator_client import ValidatorClient +from v4_client_py.clients.constants import Network + +from tests.constants import DYDX_TEST_ADDRESS + +network = Network.testnet() +client = ValidatorClient(network.validator_config) +address = DYDX_TEST_ADDRESS +print('address:') + +def test_get_account(): + try: + acc = client.get.account(address=address) + print('account:') + print(acc) + except Exception as e: + print('failed to get account') + print(e) + assert False + +def test_get_bank_balances(): + try: + bank_balances = client.get.bank_balances(address) + print('bank balances:') + print(bank_balances) + except Exception as e: + print('failed to get bank balances') + print(e) + assert False + +def test_get_bank_balance(): + try: + bank_balance = client.get.bank_balance(address, 'ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5') + print('bank balance:') + print(bank_balance) + except Exception as e: + print('failed to get bank balances') + print(e) + assert False + + +def test_get_subaccounts(): + try: + all_subaccounts = client.get.subaccounts() + print('subaccounts:') + print(all_subaccounts) + except Exception as e: + print('failed to get all subaccounts') + print(e) + assert False + +def test_get_subaccount(): + try: + subaccount = client.get.subaccount(address, 0) + print('subaccount:') + print(subaccount) + except Exception as e: + print('failed to get subaccount') + print(e) + assert False + +def test_get_clob_pairs(): + try: + clob_pairs = client.get.clob_pairs() + print('clob pairs:') + print(clob_pairs) + except Exception as e: + print('failed to get all clob pairs') + print(e) + assert False + +def test_get_clob_pair(): + try: + clob_pair = client.get.clob_pair(1) + print('clob pair:') + print(clob_pair) + except Exception as e: + print('failed to get clob pair') + print(e) + assert False + +def test_get_prices(): + try: + prices = client.get.prices() + print('prices:') + print(prices) + except Exception as e: + print('failed to get all prices') + print(e) + assert False + +def test_get_price(): + try: + price = client.get.price(1) + print('price:') + print(price) + except Exception as e: + print('failed to get price') + print(e) + assert False + +def test_get_equity_tier_limit_configuration(): + try: + config = client.get.equity_tier_limit_config() + print('equity_tier_limit_configuration:') + print(config) + except Exception as e: + print('failed to get equity_tier_limit_configuration') + print(e) + assert False diff --git a/v4-client-py/tests_integration/__init__.py b/v4-client-py/tests_integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/v4-client-py/tests_integration/human_readable_orders.json b/v4-client-py/tests_integration/human_readable_orders.json new file mode 100644 index 00000000..548d39bd --- /dev/null +++ b/v4-client-py/tests_integration/human_readable_orders.json @@ -0,0 +1,86 @@ +[ + { + "type": "LIMIT", + "timeInForce": "GTT", + "postOnly": true, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "GTT", + "postOnly": true, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce": "GTT", + "postOnly": false, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "GTT", + "postOnly": false, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce":"IOC", + "postOnly": true, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce":"IOC", + "postOnly": true, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce": "IOC", + "postOnly": false, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "IOC", + "postOnly": false, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce": "FOK", + "postOnly": true, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "FOK", + "postOnly": true, + "side": "SELL", + "price": 1000 + }, + { + "type": "LIMIT", + "timeInForce": "FOK", + "postOnly": false, + "side": "BUY", + "price": 40000 + }, + { + "type": "LIMIT", + "timeInForce": "FOK", + "postOnly": false, + "side": "SELL", + "price": 1000 + } +] \ No newline at end of file diff --git a/v4-client-py/tests_integration/raw_orders.json b/v4-client-py/tests_integration/raw_orders.json new file mode 100644 index 00000000..c9bc297b --- /dev/null +++ b/v4-client-py/tests_integration/raw_orders.json @@ -0,0 +1,130 @@ +[ + { + "timeInForce": 0, + "reduceOnly": false, + "orderFlags": 64, + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 2, + "reduceOnly": false, + "orderFlags": 64 , + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 0, + "reduceOnly": true, + "orderFlags": 64, + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 2, + "reduceOnly": true, + "orderFlags": 64 , + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 1, + "reduceOnly": false, + "orderFlags": 0, + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 1, + "reduceOnly": true, + "orderFlags": 0 , + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 3, + "reduceOnly": false, + "orderFlags": 0, + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 3, + "reduceOnly": true, + "orderFlags": 0 , + "side": 1, + "quantums": 10000000, + "subticks": 40000000000 + }, + { + "timeInForce": 0, + "reduceOnly": false, + "orderFlags": 64, + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 2, + "reduceOnly": false, + "orderFlags": 64 , + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 0, + "reduceOnly": true, + "orderFlags": 64, + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 2, + "reduceOnly": true, + "orderFlags": 64 , + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 1, + "reduceOnly": false, + "orderFlags": 0, + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 1, + "reduceOnly": true, + "orderFlags": 0 , + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 3, + "reduceOnly": false, + "orderFlags": 0, + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + }, + { + "timeInForce": 3, + "reduceOnly": true, + "orderFlags": 0 , + "side": 2, + "quantums": 10000000, + "subticks": 1000000000 + } +] diff --git a/v4-client-py/tests_integration/test_faucet.py b/v4-client-py/tests_integration/test_faucet.py new file mode 100644 index 00000000..213aba29 --- /dev/null +++ b/v4-client-py/tests_integration/test_faucet.py @@ -0,0 +1,30 @@ + +from v4_client_py.clients import FaucetClient, Subaccount +from v4_client_py.clients.constants import Network + +from tests.constants import DYDX_TEST_MNEMONIC + +client = FaucetClient( + host=Network.testnet().faucet_endpoint, +) + +subaccount = Subaccount.from_mnemonic(DYDX_TEST_MNEMONIC) +address = subaccount.address + + +def test_faucet(): + # Fill subaccount with 2000 USDC + faucet_response = client.fill(address, 0, 2000) + print(faucet_response.data) + faucet_http_code = faucet_response.status_code + print(faucet_http_code) + assert faucet_http_code >= 200 and faucet_http_code < 300 + +def test_native_faucet(): + # Fill wallet with DV4TNT + faucet_response = client.fill_native(address) + print(faucet_response.data) + faucet_http_code = faucet_response.status_code + print(faucet_http_code) + assert faucet_http_code >= 200 and faucet_http_code < 300 + diff --git a/v4-client-py/tests_integration/test_indexer_account_endpoints.py b/v4-client-py/tests_integration/test_indexer_account_endpoints.py new file mode 100644 index 00000000..80df1b18 --- /dev/null +++ b/v4-client-py/tests_integration/test_indexer_account_endpoints.py @@ -0,0 +1,130 @@ + +from v4_client_py.clients import IndexerClient, Subaccount +from v4_client_py.clients.constants import Network + +from tests.constants import DYDX_TEST_MNEMONIC + +client = IndexerClient( + config=Network.testnet().indexer_config, +) + +subaccount = Subaccount.from_mnemonic(DYDX_TEST_MNEMONIC) +address = subaccount.address + +def test_get_subaccounts(): + # Get subaccounts + try: + subaccounts_response = client.account.get_subaccounts(address) + print(f'{subaccounts_response.data}') + subaccounts = subaccounts_response.data['subaccounts'] + subaccount_0 = subaccounts[0] + print(f'{subaccount_0}') + subaccount_0_subaccountNumber = subaccount_0['subaccountNumber'] + except: + print('failed to get subaccounts') + assert False + +def test_get_subaccount(): + try: + subaccount_response = client.account.get_subaccount(address, 0) + print(f'{subaccount_response.data}') + subaccount = subaccount_response.data['subaccount'] + print(f'{subaccount}') + subaccount_subaccountNumber = subaccount['subaccountNumber'] + except: + print('failed to get subaccount') + assert False + +def test_get_positions(): + # Get positions + try: + asset_positions_response = client.account.get_subaccount_asset_positions(address, 0) + print(f'{asset_positions_response.data}') + asset_positions = asset_positions_response.data['positions'] + if len(asset_positions) > 0: + asset_positions_0 = asset_positions[0] + print(f'{asset_positions_0}') + except: + print('failed to get asset positions') + assert False + +def test_get_perpetual_positions(): + try: + perpetual_positions_response = client.account.get_subaccount_perpetual_positions(address, 0) + print(f'{perpetual_positions_response.data}') + perpetual_positions = perpetual_positions_response.data['positions'] + if len(perpetual_positions) > 0: + perpetual_positions_0 = perpetual_positions[0] + print(f'{perpetual_positions_0}') + except: + print('failed to get perpetual positions') + assert False + +def test_get_transfers(): + # Get transfers + try: + transfers_response = client.account.get_subaccount_transfers(address, 0) + print(f'{transfers_response.data}') + transfers = transfers_response.data['transfers'] + if len(transfers) > 0: + transfers_0 = transfers[0] + print(f'{transfers_0}') + except: + print('failed to get transfers') + assert False + +def test_get_orders(): + # Get orders + try: + orders_response = client.account.get_subaccount_orders(address, 0) + print(f'{orders_response.data}') + orders = orders_response.data + if len(orders) > 0: + order_0 = orders[0] + print(f'{order_0}') + order_0_id = order_0['id'] + order_response = client.account.get_order(order_id=order_0_id) + order = order_response.data + order_id = order['id'] + except: + print('failed to get orders') + assert False + + +def test_get_fills(): + # Get fills + try: + fills_response = client.account.get_subaccount_fills(address, 0) + print(f'{fills_response.data}') + fills = fills_response.data['fills'] + if len(fills) > 0: + fill_0 = fills[0] + print(f'{fill_0}') + except: + print('failed to get fills') + assert False + +def test_get_funding(): + # Get funding + try: + funding_response = client.account.get_subaccount_funding(address, 0) + print(f'{funding_response.data}') + funding = funding_response.data['fundingPayments'] + if len(funding) > 0: + funding_0 = funding[0] + print(f'{funding_0}') + except: + print('failed to get funding') + +def test_get_historical_pnl(): + # Get historical pnl + try: + historical_pnl_response = client.account.get_subaccount_historical_pnls(address, 0) + print(f'{historical_pnl_response.data}') + historical_pnl = historical_pnl_response.data['historicalPnl'] + if len(historical_pnl) > 0: + historical_pnl_0 = historical_pnl[0] + print(f'{historical_pnl_0}') + except: + print('failed to get historical pnl') + assert False \ No newline at end of file diff --git a/v4-client-py/tests_integration/test_trades.py b/v4-client-py/tests_integration/test_trades.py new file mode 100644 index 00000000..e61575b6 --- /dev/null +++ b/v4-client-py/tests_integration/test_trades.py @@ -0,0 +1,84 @@ +import asyncio +import datetime +import logging +import random +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients.dydx_subaccount import Subaccount +from v4_proto.dydxprotocol.clob.order_pb2 import Order + +from v4_client_py.clients.dydx_validator_client import ValidatorClient +from v4_client_py.clients.constants import BECH32_PREFIX, Network +from v4_client_py.clients.helpers.chain_helpers import ORDER_FLAGS_SHORT_TERM +from examples.utils import loadJson + +from tests.constants import DYDX_TEST_MNEMONIC + +PERPETUAL_PAIR_BTC_USD = 0 + +default_order = { + "client_id": 0, + "order_flags": ORDER_FLAGS_SHORT_TERM, + "clob_pair_id": PERPETUAL_PAIR_BTC_USD, + "side": Order.SIDE_BUY, + "quantums": 1_000_000_000, + "subticks": 1_000_000_000, + "time_in_force": Order.TIME_IN_FORCE_UNSPECIFIED, + "reduce_only": False, + "client_metadata": 0, +} + +def dummy_order(height): + placeOrder = default_order.copy() + placeOrder["client_id"] = random.randint(0, 1000000000) + placeOrder["good_til_block"] = height + 3 + # placeOrder["goodTilBlockTime"] = height + 3 + random_num = random.randint(0, 1000) + if random_num % 2 == 0: + placeOrder["side"] = Order.SIDE_BUY + else: + placeOrder["side"] = Order.SIDE_SELL + return placeOrder + +async def main() -> None: + network = Network.testnet() + client = ValidatorClient(network.validator_config) + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX); + subaccount = Subaccount(wallet, 0) + ordersParams = loadJson('raw_orders.json') + for orderParams in ordersParams: + last_block = client.get.latest_block() + height = last_block.block.header.height + + place_order = dummy_order(height) + + place_order["time_in_force"] = orderParams["timeInForce"] + place_order["reduce_only"] = False # reduceOnly is currently disabled + place_order["order_flags"] = orderParams["orderFlags"] + place_order["side"] = orderParams["side"] + place_order["quantums"] = orderParams["quantums"] + place_order["subticks"] = orderParams["subticks"] + try: + if place_order["order_flags"] != 0: + place_order["good_til_block"] = 0 + + now = datetime.datetime.now() + interval = datetime.timedelta(seconds=60) + future = now + interval + place_order["good_til_block_time"] = int(future.timestamp()) + else: + place_order["good_til_block_time"] = 0 + + tx = client.post.place_order_object(subaccount, place_order) + print('**Order Tx**') + print(tx) + except Exception as error: + print('**Order Failed**') + print(str(error)) + if not error.args[0].startswith('FillOrKill order could not be fully filled'): + assert False + + await asyncio.sleep(5) # wait for placeOrder to complete + +def test_trades(): + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/tests_integration/test_transfers.py b/v4-client-py/tests_integration/test_transfers.py new file mode 100644 index 00000000..1839d7a5 --- /dev/null +++ b/v4-client-py/tests_integration/test_transfers.py @@ -0,0 +1,67 @@ +import asyncio +import logging +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients.dydx_subaccount import Subaccount + +from v4_client_py.clients.dydx_validator_client import ValidatorClient +from v4_client_py.clients.constants import BECH32_PREFIX, Network + +from tests.constants import DYDX_TEST_MNEMONIC + + +async def main() -> None: + network = Network.testnet() + client = ValidatorClient(network.validator_config) + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + subaccount = Subaccount(wallet, 0) + try: + tx = client.post.withdraw( + subaccount, + 0, + 10_000_000, + ) + print('**Withdraw Tx**') + print(tx) + except Exception as e: + print(e) + assert False + + await asyncio.sleep(5) + + + try: + tx = client.post.transfer( + subaccount=subaccount, + recipient_address=subaccount.address, + recipient_subaccount_number=1, + asset_id=0, + amount=1_000_000, + ) + print('**Transfer Tx**') + print(tx) + except Exception as e: + print(e) + assert False + + await asyncio.sleep(5) + + network = Network.testnet() + client = ValidatorClient(network.validator_config) + wallet = LocalWallet.from_mnemonic(DYDX_TEST_MNEMONIC, BECH32_PREFIX) + subaccount = Subaccount(wallet, 0) + try: + tx = client.post.deposit( + subaccount, + 0, + 5_000_000, + ) + print('**Deposit Tx**') + print(tx) + except Exception as e: + print(e) + assert False + + +def test_transfers(): + logging.basicConfig(level=logging.INFO) + asyncio.get_event_loop().run_until_complete(main()) diff --git a/v4-client-py/tests_integration/util.py b/v4-client-py/tests_integration/util.py new file mode 100644 index 00000000..fc1191f9 --- /dev/null +++ b/v4-client-py/tests_integration/util.py @@ -0,0 +1,20 @@ +import time + + +class TimedOutWaitingForCondition(Exception): + def __init__(self, last_value, expected_value): + self.last_value = last_value + self.expected_value = expected_value + + +def wait_for_condition(fn, expected_value, timeout_s, interval_s=1): + start = time.time() + result = fn() + while result != expected_value: + if time.time() - start > timeout_s: + raise TimedOutWaitingForCondition(result, expected_value) + time.sleep(interval_s) + if time.time() - start > timeout_s: + raise TimedOutWaitingForCondition(result, expected_value) + result = fn() + return result diff --git a/v4-client-py/v4_client_py/__init__.py b/v4-client-py/v4_client_py/__init__.py new file mode 100644 index 00000000..adb909c6 --- /dev/null +++ b/v4-client-py/v4_client_py/__init__.py @@ -0,0 +1,10 @@ +from v4_client_py.clients.dydx_indexer_client import IndexerClient +from v4_client_py.clients.dydx_composite_client import CompositeClient +from v4_client_py.clients.dydx_socket_client import SocketClient +from v4_client_py.clients.dydx_faucet_client import FaucetClient +from v4_client_py.clients.errors import DydxError, DydxApiError, TransactionReverted + + +# Export useful helper functions and objects. +from v4_client_py.clients.helpers.request_helpers import epoch_seconds_to_iso +from v4_client_py.clients.helpers.request_helpers import iso_to_epoch_seconds \ No newline at end of file diff --git a/v4-client-py/v4_client_py/chain/__init__.py b/v4-client-py/v4_client_py/chain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/v4-client-py/v4_client_py/chain/aerial/__init__.py b/v4-client-py/v4_client_py/chain/aerial/__init__.py new file mode 100644 index 00000000..84cd98ae --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/__init__.py @@ -0,0 +1,2 @@ + +"""Cosmpy aerial module.""" diff --git a/v4-client-py/v4_client_py/chain/aerial/client/__init__.py b/v4-client-py/v4_client_py/chain/aerial/client/__init__.py new file mode 100644 index 00000000..cb901200 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/client/__init__.py @@ -0,0 +1,692 @@ + +"""Client functionality.""" + +import json +import math +import time +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +import certifi +import grpc + +from v4_proto.cosmos.auth.v1beta1.auth_pb2 import BaseAccount +from v4_proto.cosmos.auth.v1beta1.query_pb2 import QueryAccountRequest +from v4_proto.cosmos.auth.v1beta1.query_pb2_grpc import QueryStub as AuthGrpcClient +from v4_proto.cosmos.bank.v1beta1.query_pb2 import ( + QueryAllBalancesRequest, + QueryBalanceRequest, +) +from v4_proto.cosmos.bank.v1beta1.query_pb2_grpc import QueryStub as BankGrpcClient +from v4_proto.cosmos.crypto.ed25519.keys_pb2 import ( # noqa # pylint: disable=unused-import + PubKey, +) +from v4_proto.cosmos.distribution.v1beta1.query_pb2 import ( + QueryDelegationRewardsRequest, +) +from v4_proto.cosmos.distribution.v1beta1.query_pb2_grpc import ( + QueryStub as DistributionGrpcClient, +) +from v4_proto.cosmos.params.v1beta1.query_pb2 import QueryParamsRequest +from v4_proto.cosmos.params.v1beta1.query_pb2_grpc import ( + QueryStub as QueryParamsGrpcClient, +) +from v4_proto.cosmos.staking.v1beta1.query_pb2 import ( + QueryDelegatorDelegationsRequest, + QueryDelegatorUnbondingDelegationsRequest, + QueryValidatorsRequest, +) +from v4_proto.cosmos.staking.v1beta1.query_pb2_grpc import ( + QueryStub as StakingGrpcClient, +) +from v4_proto.cosmos.tx.v1beta1.service_pb2 import ( + BroadcastMode, + BroadcastTxRequest, + GetTxRequest, + SimulateRequest, +) +from v4_proto.cosmos.tx.v1beta1.service_pb2_grpc import ServiceStub as TxGrpcClient + +from .bank import create_bank_send_msg +from .distribution import create_withdraw_delegator_reward +from .staking import ( + ValidatorStatus, + create_delegate_msg, + create_redelegate_msg, + create_undelegate_msg, +) +from .utils import ( + ensure_timedelta, + get_paginated, + prepare_and_broadcast_basic_transaction, +) +from ..config import NetworkConfig +from ..exceptions import NotFoundError, QueryTimeoutError +from ..gas import GasStrategy, SimulationGasStrategy +from ..tx import Transaction, TxState +from ..tx_helpers import MessageLog, SubmittedTx, TxResponse +from ..urls import Protocol, parse_url +from ..wallet import Wallet + +from ...auth.rest_client import AuthRestClient +from ...bank.rest_client import BankRestClient +from ...common.rest_client import RestClient +from ...crypto.address import Address +from ...distribution.rest_client import DistributionRestClient +from ...params.rest_client import ParamsRestClient +from ...staking.rest_client import StakingRestClient +from ...tx.rest_client import TxRestClient + +DEFAULT_QUERY_TIMEOUT_SECS = 15 +DEFAULT_QUERY_INTERVAL_SECS = 2 +COSMOS_SDK_DEC_COIN_PRECISION = 10**18 + + +@dataclass +class Account: + """Account.""" + + address: Address + number: int + sequence: int + + +@dataclass +class StakingPosition: + """Staking positions.""" + + validator: Address + amount: int + reward: int + + +@dataclass +class UnbondingPositions: + """Unbonding positions.""" + + validator: Address + amount: int + + +@dataclass +class Validator: + """Validator.""" + + address: Address # the operators address + tokens: int # The total amount of tokens for the validator + moniker: str + status: ValidatorStatus + + +@dataclass +class Coin: + """Coins.""" + + amount: int + denom: str + + +@dataclass +class StakingSummary: + """Get the staking summary.""" + + current_positions: List[StakingPosition] + unbonding_positions: List[UnbondingPositions] + + @property + def total_staked(self) -> int: + """Get the total staked amount.""" + return sum(map(lambda p: p.amount, self.current_positions)) + + @property + def total_rewards(self) -> int: + """Get the total rewards.""" + return sum(map(lambda p: p.reward, self.current_positions)) + + @property + def total_unbonding(self) -> int: + """total unbonding.""" + return sum(map(lambda p: p.amount, self.unbonding_positions)) + + +class LedgerClient: + """Ledger client.""" + + def __init__( + self, + cfg: NetworkConfig, + query_interval_secs: int = DEFAULT_QUERY_INTERVAL_SECS, + query_timeout_secs: int = DEFAULT_QUERY_TIMEOUT_SECS, + ): + """Init ledger client. + + :param cfg: Network configurations + :param query_interval_secs: int. optional interval int seconds + :param query_timeout_secs: int. optional interval int seconds + """ + self._query_interval_secs = query_interval_secs + self._query_timeout_secs = query_timeout_secs + cfg.validate() + self._network_config = cfg + self._gas_strategy: GasStrategy = SimulationGasStrategy(self) + + parsed_url = parse_url(cfg.url) + + if parsed_url.protocol == Protocol.GRPC: + if parsed_url.secure: + with open(certifi.where(), "rb") as f: + trusted_certs = f.read() + credentials = grpc.ssl_channel_credentials( + root_certificates=trusted_certs + ) + grpc_client = grpc.secure_channel(parsed_url.host_and_port, credentials) + else: + grpc_client = grpc.insecure_channel(parsed_url.host_and_port) + + self.auth = AuthGrpcClient(grpc_client) + self.txs = TxGrpcClient(grpc_client) + self.bank = BankGrpcClient(grpc_client) + self.staking = StakingGrpcClient(grpc_client) + self.distribution = DistributionGrpcClient(grpc_client) + self.params = QueryParamsGrpcClient(grpc_client) + else: + rest_client = RestClient(parsed_url.rest_url) + + self.auth = AuthRestClient(rest_client) # type: ignore + self.txs = TxRestClient(rest_client) # type: ignore + self.bank = BankRestClient(rest_client) # type: ignore + self.staking = StakingRestClient(rest_client) # type: ignore + self.distribution = DistributionRestClient(rest_client) # type: ignore + self.params = ParamsRestClient(rest_client) # type: ignore + + @property + def network_config(self) -> NetworkConfig: + """Get the network config. + + :return: network config + """ + return self._network_config + + @property + def gas_strategy(self) -> GasStrategy: + """Get gas strategy. + + :return: gas strategy + """ + return self._gas_strategy + + @gas_strategy.setter + def gas_strategy(self, strategy: GasStrategy): + """Set gas strategy. + + :param strategy: strategy + :raises RuntimeError: Invalid strategy must implement GasStrategy interface + """ + if not isinstance(strategy, GasStrategy): + raise RuntimeError("Invalid strategy must implement GasStrategy interface") + self._gas_strategy = strategy + + def query_account(self, address: Address) -> Account: + """Query account. + + :param address: address + :raises RuntimeError: Unexpected account type returned from query + :return: account details + """ + request = QueryAccountRequest(address=str(address)) + response = self.auth.Account(request) + + account = BaseAccount() + if not response.account.Is(BaseAccount.DESCRIPTOR): + raise RuntimeError("Unexpected account type returned from query") + response.account.Unpack(account) + + return Account( + address=address, + number=account.account_number, + sequence=account.sequence, + ) + + def query_params(self, subspace: str, key: str) -> Any: + """Query Prams. + + :param subspace: subspace + :param key: key + :return: Query params + """ + req = QueryParamsRequest(subspace=subspace, key=key) + resp = self.params.Params(req) + return json.loads(resp.param.value) + + def query_bank_balance(self, address: Address, denom: Optional[str] = None) -> int: + """Query bank balance. + + :param address: address + :param denom: denom, defaults to None + :return: bank balance + """ + denom = denom or self.network_config.fee_denomination + + req = QueryBalanceRequest( + address=str(address), + denom=denom, + ) + + resp = self.bank.Balance(req) + assert resp.balance.denom == denom # sanity check + + return int(resp.balance.amount) + + def query_bank_all_balances(self, address: Address) -> List[Coin]: + """Query bank all balances. + + :param address: address + :return: bank all balances + """ + req = QueryAllBalancesRequest(address=str(address)) + resp = self.bank.AllBalances(req) + + return [Coin(amount=coin.amount, denom=coin.denom) for coin in resp.balances] + + def send_tokens( + self, + destination: Address, + amount: int, + denom: str, + sender: Wallet, + memo: Optional[str] = None, + gas_limit: Optional[int] = None, + ) -> SubmittedTx: + """Send tokens. + + :param destination: destination address + :param amount: amount + :param denom: denom + :param sender: sender + :param memo: memo, defaults to None + :param gas_limit: gas limit, defaults to None + :return: prepare and broadcast the transaction and transaction details + """ + # build up the store transaction + tx = Transaction() + tx.add_message( + create_bank_send_msg(sender.address(), destination, amount, denom) + ) + + return prepare_and_broadcast_basic_transaction( + self, tx, sender, gas_limit=gas_limit, memo=memo + ) + + def query_validators( + self, status: Optional[ValidatorStatus] = None + ) -> List[Validator]: + """Query validators. + + :param status: validator status, defaults to None + :return: List of validators + """ + filtered_status = status or ValidatorStatus.BONDED + + req = QueryValidatorsRequest() + if filtered_status != ValidatorStatus.UNSPECIFIED: + req.status = filtered_status.value + + resp = self.staking.Validators(req) + + validators: List[Validator] = [] + for validator in resp.validators: + validators.append( + Validator( + address=Address(validator.operator_address), + tokens=int(validator.tokens), + moniker=str(validator.description.moniker), + status=ValidatorStatus.from_proto(validator.status), + ) + ) + return validators + + def query_staking_summary(self, address: Address) -> StakingSummary: + """Query staking summary. + + :param address: address + :return: staking summary + """ + current_positions: List[StakingPosition] = [] + + req = QueryDelegatorDelegationsRequest(delegator_addr=str(address)) + + for resp in get_paginated( + req, self.staking.DelegatorDelegations, per_page_limit=1 + ): + for item in resp.delegation_responses: + + req = QueryDelegationRewardsRequest( + delegator_address=str(address), + validator_address=str(item.delegation.validator_address), + ) + rewards_resp = self.distribution.DelegationRewards(req) + + stake_reward = 0 + for reward in rewards_resp.rewards: + if reward.denom == self.network_config.staking_denomination: + stake_reward = ( + int(reward.amount) // COSMOS_SDK_DEC_COIN_PRECISION + ) + break + + current_positions.append( + StakingPosition( + validator=Address(item.delegation.validator_address), + amount=int(item.balance.amount), + reward=stake_reward, + ) + ) + + unbonding_summary: Dict[str, int] = {} + req = QueryDelegatorUnbondingDelegationsRequest(delegator_addr=str(address)) + + for resp in get_paginated(req, self.staking.DelegatorUnbondingDelegations): + for item in resp.unbonding_responses: + validator = str(item.validator_address) + total_unbonding = unbonding_summary.get(validator, 0) + + for entry in item.entries: + total_unbonding += int(entry.balance) + + unbonding_summary[validator] = total_unbonding + + # build the final list of unbonding positions + unbonding_positions: List[UnbondingPositions] = [] + for validator, total_unbonding in unbonding_summary.items(): + unbonding_positions.append( + UnbondingPositions( + validator=Address(validator), + amount=total_unbonding, + ) + ) + + return StakingSummary( + current_positions=current_positions, unbonding_positions=unbonding_positions + ) + + def delegate_tokens( + self, + validator: Address, + amount: int, + sender: Wallet, + memo: Optional[str] = None, + gas_limit: Optional[int] = None, + ) -> SubmittedTx: + """Delegate tokens. + + :param validator: validator address + :param amount: amount + :param sender: sender + :param memo: memo, defaults to None + :param gas_limit: gas limit, defaults to None + :return: prepare and broadcast the transaction and transaction details + """ + tx = Transaction() + tx.add_message( + create_delegate_msg( + sender.address(), + validator, + amount, + self.network_config.staking_denomination, + ) + ) + + return prepare_and_broadcast_basic_transaction( + self, tx, sender, gas_limit=gas_limit, memo=memo + ) + + def redelegate_tokens( + self, + current_validator: Address, + next_validator: Address, + amount: int, + sender: Wallet, + memo: Optional[str] = None, + gas_limit: Optional[int] = None, + ) -> SubmittedTx: + """Redelegate tokens. + + :param current_validator: current validator address + :param next_validator: next validator address + :param amount: amount + :param sender: sender + :param memo: memo, defaults to None + :param gas_limit: gas limit, defaults to None + :return: prepare and broadcast the transaction and transaction details + """ + tx = Transaction() + tx.add_message( + create_redelegate_msg( + sender.address(), + current_validator, + next_validator, + amount, + self.network_config.staking_denomination, + ) + ) + + return prepare_and_broadcast_basic_transaction( + self, tx, sender, gas_limit=gas_limit, memo=memo + ) + + def undelegate_tokens( + self, + validator: Address, + amount: int, + sender: Wallet, + memo: Optional[str] = None, + gas_limit: Optional[int] = None, + ) -> SubmittedTx: + """Undelegate tokens. + + :param validator: validator + :param amount: amount + :param sender: sender + :param memo: memo, defaults to None + :param gas_limit: gas limit, defaults to None + :return: prepare and broadcast the transaction and transaction details + """ + tx = Transaction() + tx.add_message( + create_undelegate_msg( + sender.address(), + validator, + amount, + self.network_config.staking_denomination, + ) + ) + + return prepare_and_broadcast_basic_transaction( + self, tx, sender, gas_limit=gas_limit, memo=memo + ) + + def claim_rewards( + self, + validator: Address, + sender: Wallet, + memo: Optional[str] = None, + gas_limit: Optional[int] = None, + ) -> SubmittedTx: + """claim rewards. + + :param validator: validator + :param sender: sender + :param memo: memo, defaults to None + :param gas_limit: gas limit, defaults to None + :return: prepare and broadcast the transaction and transaction details + """ + tx = Transaction() + tx.add_message(create_withdraw_delegator_reward(sender.address(), validator)) + + return prepare_and_broadcast_basic_transaction( + self, tx, sender, gas_limit=gas_limit, memo=memo + ) + + def estimate_gas_for_tx(self, tx: Transaction) -> int: + """Estimate gas for transaction. + + :param tx: transaction + :return: Estimated gas for transaction + """ + return self._gas_strategy.estimate_gas(tx) + + def estimate_fee_from_gas(self, gas_limit: int) -> str: + """Estimate fee from gas. + + :param gas_limit: gas limit + :return: Estimated fee for transaction + """ + fee = math.ceil(gas_limit * self.network_config.fee_minimum_gas_price) + return f"{fee}{self.network_config.fee_denomination}" + + def estimate_gas_and_fee_for_tx(self, tx: Transaction) -> Tuple[int, str]: + """Estimate gas and fee for transaction. + + :param tx: transaction + :return: estimate gas, fee for transaction + """ + gas_estimate = self.estimate_gas_for_tx(tx) + fee = self.estimate_fee_from_gas(gas_estimate) + return gas_estimate, fee + + def wait_for_query_tx( + self, + tx_hash: str, + timeout: Optional[timedelta] = None, + poll_period: Optional[timedelta] = None, + ) -> TxResponse: + """Wait for query transaction. + + :param tx_hash: transaction hash + :param timeout: timeout, defaults to None + :param poll_period: poll_period, defaults to None + + :raises QueryTimeoutError: timeout + + :return: transaction response + """ + timeout = ( + ensure_timedelta(timeout) + if timeout + else timedelta(seconds=self._query_timeout_secs) + ) + poll_period = ( + ensure_timedelta(poll_period) + if poll_period + else timedelta(seconds=self._query_interval_secs) + ) + + start = datetime.now() + while True: + try: + return self.query_tx(tx_hash) + except NotFoundError: + pass + + delta = datetime.now() - start + if delta >= timeout: + raise QueryTimeoutError() + + time.sleep(poll_period.total_seconds()) + + def query_tx(self, tx_hash: str) -> TxResponse: + """query transaction. + + :param tx_hash: transaction hash + :raises NotFoundError: Tx details not found + :raises grpc.RpcError: RPC connection issue + :return: query response + """ + req = GetTxRequest(hash=tx_hash) + try: + resp = self.txs.GetTx(req) + except grpc.RpcError as e: + details = e.details() + if "not found" in details: + raise NotFoundError() from e + raise + except RuntimeError as e: + details = str(e) + if "tx" in details and "not found" in details: + raise NotFoundError() from e + raise + + return self._parse_tx_response(resp.tx_response) + + @staticmethod + def _parse_tx_response(tx_response: Any) -> TxResponse: + # parse the transaction logs + logs = [] + for log_data in tx_response.logs: + events = {} + for event in log_data.events: + events[event.type] = {a.key: a.value for a in event.attributes} + logs.append( + MessageLog( + index=int(log_data.msg_index), log=log_data.msg_index, events=events + ) + ) + + # parse the transaction events + events = {} + for event in tx_response.events: + event_data = events.get(event.type, {}) + for attribute in event.attributes: + try: + event_data[attribute.key.decode()] = attribute.value.decode() + except: + event_data[attribute.key] = attribute.value + events[event.type] = event_data + + return TxResponse( + hash=str(tx_response.txhash), + height=int(tx_response.height), + code=int(tx_response.code), + gas_wanted=int(tx_response.gas_wanted), + gas_used=int(tx_response.gas_used), + raw_log=str(tx_response.raw_log), + logs=logs, + events=events, + ) + + def simulate_tx(self, tx: Transaction) -> int: + """simulate transaction. + + :param tx: transaction + :raises RuntimeError: Unable to simulate non final transaction + :return: gas used in transaction + """ + if tx.state != TxState.Final: + raise RuntimeError("Unable to simulate non final transaction") + + req = SimulateRequest(tx=tx.tx) + resp = self.txs.Simulate(req) + + return int(resp.gas_info.gas_used) + + def broadcast_tx(self, tx: Transaction) -> SubmittedTx: + """Broadcast transaction. + + :param tx: transaction + :return: Submitted transaction + """ + # create the broadcast request + broadcast_req = BroadcastTxRequest( + tx_bytes=tx.tx.SerializeToString(), mode=BroadcastMode.BROADCAST_MODE_SYNC + ) + + # broadcast the transaction + resp = self.txs.BroadcastTx(broadcast_req) + tx_digest = resp.tx_response.txhash + + # check that the response is successful + initial_tx_response = self._parse_tx_response(resp.tx_response) + initial_tx_response.ensure_successful() + + return SubmittedTx(self, tx_digest) diff --git a/v4-client-py/v4_client_py/chain/aerial/client/bank.py b/v4-client-py/v4_client_py/chain/aerial/client/bank.py new file mode 100644 index 00000000..76a45781 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/client/bank.py @@ -0,0 +1,26 @@ + +"""Bank send message.""" + +from v4_proto.cosmos.bank.v1beta1.tx_pb2 import MsgSend +from v4_proto.cosmos.base.v1beta1.coin_pb2 import Coin + +from ...crypto.address import Address + +def create_bank_send_msg( + from_address: Address, to_address: Address, amount: int, denom: str +) -> MsgSend: + """Create bank send message. + + :param from_address: from address + :param to_address: to address + :param amount: amount + :param denom: denom + :return: bank send message + """ + msg = MsgSend( + from_address=str(from_address), + to_address=str(to_address), + amount=[Coin(amount=str(amount), denom=denom)], + ) + + return msg diff --git a/v4-client-py/v4_client_py/chain/aerial/client/distribution.py b/v4-client-py/v4_client_py/chain/aerial/client/distribution.py new file mode 100644 index 00000000..b6c66f9a --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/client/distribution.py @@ -0,0 +1,18 @@ + +"""Distribution.""" + +from v4_proto.cosmos.distribution.v1beta1.tx_pb2 import MsgWithdrawDelegatorReward + +from ...crypto.address import Address + +def create_withdraw_delegator_reward(delegator: Address, validator: Address): + """Create withdraw delegator reward. + + :param delegator: delegator address + :param validator: validator address + :return: withdraw delegator reward message + """ + return MsgWithdrawDelegatorReward( + delegator_address=str(delegator), + validator_address=str(validator), + ) diff --git a/v4-client-py/v4_client_py/chain/aerial/client/staking.py b/v4-client-py/v4_client_py/chain/aerial/client/staking.py new file mode 100644 index 00000000..935b90ac --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/client/staking.py @@ -0,0 +1,108 @@ + +"""Staking functionality.""" + +from enum import Enum + +from v4_proto.cosmos.base.v1beta1.coin_pb2 import Coin +from v4_proto.cosmos.staking.v1beta1.tx_pb2 import ( + MsgBeginRedelegate, + MsgDelegate, + MsgUndelegate, +) + +from ...crypto.address import Address + +class ValidatorStatus(Enum): + """Validator status.""" + + UNSPECIFIED = "BOND_STATUS_UNSPECIFIED" + BONDED = "BOND_STATUS_BONDED" + UNBONDING = "BOND_STATUS_UNBONDING" + UNBONDED = "BOND_STATUS_UNBONDED" + + @classmethod + def from_proto(cls, value: int) -> "ValidatorStatus": + """Get the validator status from proto. + + :param value: value + :raises RuntimeError: Unable to decode validator status + :return: Validator status + """ + if value == 0: + return cls.UNSPECIFIED + if value == 1: + return cls.UNBONDED + if value == 2: + return cls.UNBONDING + if value == 3: + return cls.BONDED + raise RuntimeError(f"Unable to decode validator status: {value}") + + +def create_delegate_msg( + delegator: Address, validator: Address, amount: int, denom: str +) -> MsgDelegate: + """Create delegate message. + + :param delegator: delegator + :param validator: validator + :param amount: amount + :param denom: denom + :return: Delegate message + """ + return MsgDelegate( + delegator_address=str(delegator), + validator_address=str(validator), + amount=Coin( + amount=str(amount), + denom=denom, + ), + ) + + +def create_redelegate_msg( + delegator_address: Address, + validator_src_address: Address, + validator_dst_address: Address, + amount: int, + denom: str, +) -> MsgBeginRedelegate: + """Create redelegate message. + + :param delegator_address: delegator address + :param validator_src_address: source validation address + :param validator_dst_address: destination validation address + :param amount: amount + :param denom: denom + :return: Redelegate message + """ + return MsgBeginRedelegate( + delegator_address=str(delegator_address), + validator_src_address=str(validator_src_address), + validator_dst_address=str(validator_dst_address), + amount=Coin( + amount=str(amount), + denom=str(denom), + ), + ) + + +def create_undelegate_msg( + delegator_address: Address, validator_address: Address, amount: int, denom: str +) -> MsgUndelegate: + """Create undelegate message. + + :param delegator_address: delegator address + :param validator_address: validator address + :param amount: amount + :param denom: denom + :return: Undelegate message + """ + return MsgUndelegate( + delegator_address=str(delegator_address), + validator_address=str(validator_address), + amount=Coin( + amount=str(amount), + denom=str(denom), + ), + ) diff --git a/v4-client-py/v4_client_py/chain/aerial/client/utils.py b/v4-client-py/v4_client_py/chain/aerial/client/utils.py new file mode 100644 index 00000000..09622770 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/client/utils.py @@ -0,0 +1,119 @@ + +"""Helper functions.""" + +from datetime import timedelta +from typing import Any, Callable, List, Optional, Union +from v4_client_py.clients.constants import BroadcastMode + +from v4_proto.cosmos.base.query.v1beta1.pagination_pb2 import PageRequest + +from ..tx import SigningCfg +from ..tx_helpers import SubmittedTx + +def prepare_and_broadcast_basic_transaction( + client: "LedgerClient", # type: ignore # noqa: F821 + tx: "Transaction", # type: ignore # noqa: F821 + sender: "Wallet", # type: ignore # noqa: F821 + account: Optional["Account"] = None, # type: ignore # noqa: F821 + gas_limit: Optional[int] = None, + memo: Optional[str] = None, + broadcast_mode: BroadcastMode = None, + fee: Optional[int] = None, +) -> SubmittedTx: + """Prepare and broadcast basic transaction. + + :param client: Ledger client + :param tx: The transaction + :param sender: The transaction sender + :param account: The account + :param gas_limit: The gas limit + :param memo: Transaction memo, defaults to None + :param broadcast_mode: Broadcast mode, defaults to None + :param fee: Transaction fee, defaults to 5000. Denomination is determined by the network config. + + :return: broadcast transaction + """ + # query the account information for the sender + if account is None: + account = client.query_account(sender.address()) + if fee is None: + fee = client.network_config.fee_minimum_gas_price + if gas_limit is None: + + # we need to build up a representative transaction so that we can accurately simulate it + tx.seal( + SigningCfg.direct(sender.public_key(), account.sequence), + fee="", + gas_limit=0, + memo=memo, + ) + tx.sign(sender.signer(), client.network_config.chain_id, account.number) + tx.complete() + + # simulate the gas and fee for the transaction + gas_limit, _ = client.estimate_gas_and_fee_for_tx(tx) + + # finally, build the final transaction that will be executed with the correct gas and fee values + tx.seal( + SigningCfg.direct(sender.public_key(), account.sequence), + fee=f"{fee}{client.network_config.fee_denomination}", + gas_limit=gas_limit, + memo=memo, + ) + tx.sign(sender.signer(), client.network_config.chain_id, account.number) + tx.complete() + + result = client.broadcast_tx(tx) + if broadcast_mode == BroadcastMode.BroadcastTxCommit: + client.wait_for_query_tx(result.tx_hash) + + return result + + +def ensure_timedelta(interval: Union[int, float, timedelta]) -> timedelta: + """ + Return timedelta for interval. + + :param interval: timedelta or seconds in int or float + + :return: timedelta + """ + return interval if isinstance(interval, timedelta) else timedelta(seconds=interval) + + +DEFAULT_PER_PAGE_LIMIT = None + + +def get_paginated( + initial_request: Any, + request_method: Callable, + pages_limit: int = 0, + per_page_limit: Optional[int] = DEFAULT_PER_PAGE_LIMIT, +) -> List[Any]: + """ + Get pages for specific request. + + :param initial_request: request supports pagination + :param request_method: function to perform request + :param pages_limit: max number of pages to return. default - 0 unlimited + :param per_page_limit: Optional int: amount of records per one page. default is None, determined by server + + :return: List of responses + """ + pages: List[Any] = [] + pagination = PageRequest(limit=per_page_limit) + + while pagination and (len(pages) < pages_limit or pages_limit == 0): + request = initial_request.__class__() + request.CopyFrom(initial_request) + request.pagination.CopyFrom(pagination) + + resp = request_method(request) + + pages.append(resp) + + pagination = None + + if resp.pagination.next_key: + pagination = PageRequest(limit=per_page_limit, key=resp.pagination.next_key) + return pages diff --git a/v4-client-py/v4_client_py/chain/aerial/coins.py b/v4-client-py/v4_client_py/chain/aerial/coins.py new file mode 100644 index 00000000..6e8f9b8a --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/coins.py @@ -0,0 +1,32 @@ + +"""Parse the coins.""" + +import re +from typing import List + +from v4_proto.cosmos.base.v1beta1.coin_pb2 import Coin + +def parse_coins(value: str) -> List[Coin]: + """Parse the coins. + + :param value: coins + :raises RuntimeError: If unable to parse the value + :return: coins + """ + coins = [] + + parts = re.split(r",\s*", value) + for part in parts: + part = part.strip() + if part == "": + continue + + match = re.match(r"(\d+)(\w+)", part) + if match is None: + raise RuntimeError(f"Unable to parse value {part}") + + # extract out the groups + amount, denom = match.groups() + coins.append(Coin(amount=amount, denom=denom)) + + return coins diff --git a/v4-client-py/v4_client_py/chain/aerial/config.py b/v4-client-py/v4_client_py/chain/aerial/config.py new file mode 100644 index 00000000..77c5ce8c --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/config.py @@ -0,0 +1,120 @@ +"""Network configurations.""" + +import warnings +from dataclasses import dataclass +from typing import Optional, Union + + +class NetworkConfigError(RuntimeError): + """Network config error. + + :param RuntimeError: Runtime error + """ + + +URL_PREFIXES = ( + "grpc+https", + "grpc+http", + "rest+https", + "rest+http", +) + + +@dataclass +class NetworkConfig: + """Network configurations. + + :raises NetworkConfigError: Network config error + :raises RuntimeError: Runtime error + """ + + chain_id: str + fee_minimum_gas_price: Union[int, float] + fee_denomination: str + staking_denomination: str + url: str + faucet_url: Optional[str] = None + + def validate(self): + """Validate the network configuration. + + :raises NetworkConfigError: Network config error + """ + if self.chain_id == "": + raise NetworkConfigError("Chain id must be set") + if self.url == "": + raise NetworkConfigError("URL must be set") + if not any( + map( + lambda x: self.url.startswith(x), # noqa: # pylint: disable=unnecessary-lambda + URL_PREFIXES, + ) + ): + prefix_list = ", ".join(map(lambda x: f'"{x}"', URL_PREFIXES)) + raise NetworkConfigError(f"URL must start with one of the following prefixes: {prefix_list}") + + @classmethod + def fetchai_dorado_testnet(cls) -> "NetworkConfig": + """Fetchai dorado testnet. + + :return: Network configuration + """ + return NetworkConfig( + chain_id="dorado-1", + url="grpc+https://grpc-dorado.fetch.ai", + fee_minimum_gas_price=5000000000, + fee_denomination="atestfet", + staking_denomination="atestfet", + faucet_url="https://faucet-dorado.fetch.ai", + ) + + @classmethod + def fetchai_alpha_testnet(cls): + """Get the fetchai alpha testnet. + + :raises RuntimeError: No alpha testnet available + """ + raise RuntimeError("No alpha testnet available") + + @classmethod + def fetchai_beta_testnet(cls): + """Get the Fetchai beta testnet. + + :raises RuntimeError: No beta testnet available + """ + raise RuntimeError("No beta testnet available") + + @classmethod + def fetch_dydx_stable_testnet(cls): + """Get the dydx stable testnet. + + :return: dydx stable testnet. + """ + return cls.fetch_dydx_testnet() + + @classmethod + def latest_stable_testnet(cls) -> "NetworkConfig": + """Get the latest stable testnet. + + :return: latest stable testnet + """ + warnings.warn( + "latest_stable_testnet is deprecated, use fetch_dydx_stable_testnet instead", + DeprecationWarning, + ) + return cls.fetch_dydx_stable_testnet() + + @classmethod + def fetchai_network_config(cls, chain_id: str, url_prefix: str, url: str) -> "NetworkConfig": + """Get the fetchai mainnet configuration. + + :return: fetch mainnet configuration + """ + return cls( + chain_id=chain_id, + url=f"{url_prefix}+{url}", + fee_minimum_gas_price=0, + fee_denomination="afet", + staking_denomination="afet", + faucet_url=None, + ) diff --git a/v4-client-py/v4_client_py/chain/aerial/exceptions.py b/v4-client-py/v4_client_py/chain/aerial/exceptions.py new file mode 100644 index 00000000..ad23a20c --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/exceptions.py @@ -0,0 +1,60 @@ + +"""Exceptions.""" + + +class QueryError(RuntimeError): + """Invalid Query Error.""" + + +class NotFoundError(QueryError): + """Not found Error.""" + + +class QueryTimeoutError(QueryError): + """Query timeout Error.""" + + +class BroadcastError(RuntimeError): + """Broadcast Error.""" + + def __init__(self, tx_hash: str, message: str): + """Init Broadcast error. + + :param tx_hash: transaction hash + :param message: message + """ + super().__init__(message) + self.tx_hash = tx_hash + + +class OutOfGasError(BroadcastError): + """Insufficient Fess Error.""" + + def __init__(self, tx_hash: str, gas_wanted: int, gas_used: int): + """Initialize. + + :param tx_hash: transaction hash + :param gas_wanted: gas required to complete the transaction + :param gas_used: gas used + """ + self.gas_wanted = gas_wanted + self.gas_used = gas_used + super().__init__( + tx_hash, f"Out of Gas (wanted: {self.gas_wanted}, used: {self.gas_used})" + ) + + +class InsufficientFeesError(BroadcastError): + """Insufficient Fess Error.""" + + def __init__(self, tx_hash: str, minimum_required_fee: str): + """Initialize. + + :param tx_hash: transaction hash + :param minimum_required_fee: Minimum required fee + """ + self.minimum_required_fee = minimum_required_fee + super().__init__( + tx_hash, + f"Insufficient Fees (minimum required: {self.minimum_required_fee})", + ) diff --git a/v4-client-py/v4_client_py/chain/aerial/faucet.py b/v4-client-py/v4_client_py/chain/aerial/faucet.py new file mode 100644 index 00000000..35a530f1 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/faucet.py @@ -0,0 +1,139 @@ + +"""Ledger faucet API interface.""" + +import time +from collections import namedtuple +from typing import Optional, Union + +import requests + +from .config import NetworkConfig +from ..crypto.address import Address + +CosmosFaucetStatus = namedtuple("CosmosFaucetStatus", ["tx_digest", "status"]) + + +class FaucetApi: + """Faucet API.""" + + MAX_RETRY_ATTEMPTS = 30 + POLL_INTERVAL = 2 + FINAL_WAIT_INTERVAL = 5 + + FAUCET_STATUS_PENDING = "pending" # noqa: F841 + FAUCET_STATUS_PROCESSING = "processing" # noqa: F841 + FAUCET_STATUS_COMPLETED = "complete" # noqa: F841 + FAUCET_STATUS_FAILED = "failed" # noqa: F841 + + def __init__(self, net_config: NetworkConfig): + """ + Init faucet API. + + :param net_config: Ledger network configuration. + :raises ValueError: Network config has no faucet url set + """ + if net_config.faucet_url is None: + raise ValueError("Network config has no faucet url set!") # pragma: nocover + self._net_config = net_config + + def _claim_url(self) -> str: + """ + Get claim url. + + :return: url string + """ + return f"{self._net_config.faucet_url}/api/v3/claims" + + def _status_uri(self, uid: str) -> str: + """ + Generate the status URI derived . + + :param uid: claim uid. + :return: url string + """ + return f"{self._claim_url()}/{uid}" + + def _try_create_faucet_claim(self, address: str) -> Optional[str]: + """ + Create a token faucet claim request. + + :param address: the address to request funds + :return: None on failure, otherwise the request uid + :raises ValueError: key `uid` not found in response + """ + uri = self._claim_url() + response = requests.post(url=uri, json={"address": address}) + uid = None + if response.status_code == 200: + try: + uid = response.json()["uuid"] + except KeyError as error: # pragma: nocover + raise ValueError( + f"key `uid` not found in response_json={response.json()}" + ) from error + + return uid + + def _try_check_faucet_claim(self, uid: str) -> Optional[CosmosFaucetStatus]: + """ + Check the status of a faucet request. + + :param uid: The request uid to be checked + :return: None on failure otherwise a CosmosFaucetStatus for the specified uid + """ + response = requests.get(self._status_uri(uid)) + if response.status_code != 200: # pragma: nocover + return None + + # parse the response + data = response.json() + tx_digest = None + if "txStatus" in data["claim"]: + tx_digest = data["claim"]["txStatus"]["hash"] + + return CosmosFaucetStatus( + tx_digest=tx_digest, + status=data["claim"]["status"], + ) + + def get_wealth(self, address: Union[Address, str]) -> None: + """ + Get wealth from the faucet for the provided address. + + :param address: the address. + :raises RuntimeError: Unable to create faucet claim + :raises RuntimeError: Failed to check faucet claim status + :raises RuntimeError: Failed to get wealth for address + :raises ValueError: Faucet claim check timed out + """ + address = str(address) + uid = self._try_create_faucet_claim(address) + if uid is None: # pragma: nocover + raise RuntimeError("Unable to create faucet claim") + + retry_attempts = self.MAX_RETRY_ATTEMPTS + while retry_attempts > 0: + retry_attempts -= 1 + + # lookup status form the claim uid + status = self._try_check_faucet_claim(uid) + if status is None: # pragma: nocover + raise RuntimeError("Failed to check faucet claim status") + + # if the status is complete + if status.status == self.FAUCET_STATUS_COMPLETED: + break + + # if the status is failure + if status.status not in ( + self.FAUCET_STATUS_PENDING, + self.FAUCET_STATUS_PROCESSING, + ): # pragma: nocover + raise RuntimeError(f"Failed to get wealth for {address}") + + # if the status is incomplete + time.sleep(self.POLL_INTERVAL) + if retry_attempts == 0: + raise ValueError("Faucet claim check timed out!") # pragma: nocover + # Wait to ensure that balance is increased on chain + time.sleep(self.FINAL_WAIT_INTERVAL) diff --git a/v4-client-py/v4_client_py/chain/aerial/gas.py b/v4-client-py/v4_client_py/chain/aerial/gas.py new file mode 100644 index 00000000..1120e25f --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/gas.py @@ -0,0 +1,140 @@ + +"""Transaction gas strategy.""" + +from abc import ABC, abstractmethod +from typing import Dict, Optional + +from .tx import Transaction + + +class GasStrategy(ABC): + """Transaction gas strategy.""" + + @abstractmethod + def estimate_gas(self, tx: Transaction) -> int: + """Estimate the transaction gas. + + :param tx: Transaction + :return: None + """ + + @abstractmethod + def block_gas_limit(self) -> int: + """Get the block gas limit. + + :return: None + """ + + def _clip_gas(self, value: int) -> int: + block_limit = self.block_gas_limit() + if block_limit < 0: + return value + + return min(value, block_limit) + + +class SimulationGasStrategy(GasStrategy): + """Simulation transaction gas strategy. + + :param GasStrategy: gas strategy + """ + + DEFAULT_MULTIPLIER = 1.65 + + def __init__(self, client: "LedgerClient", multiplier: Optional[float] = None): # type: ignore # noqa: F821 + """Init the Simulation transaction gas strategy. + + :param client: Ledger client + :param multiplier: multiplier, defaults to None + """ + self._client = client + self._max_gas: Optional[int] = None + self._multiplier = multiplier or self.DEFAULT_MULTIPLIER + + def estimate_gas(self, tx: Transaction) -> int: + """Get estimated transaction gas. + + :param tx: transaction + :return: Estimated transaction gas + """ + gas_estimate = self._client.simulate_tx(tx) + return self._clip_gas(int(gas_estimate * self._multiplier)) + + def block_gas_limit(self) -> int: + """Get the block gas limit. + + :return: block gas limit + """ + if self._max_gas is None: + try: + block_params = self._client.query_params("baseapp", "BlockParams") + self._max_gas = int(block_params["max_gas"]) + except: + self._max_gas = -1 + + return self._max_gas or -1 + + +class OfflineMessageTableStrategy(GasStrategy): + """Offline message table strategy. + + :param GasStrategy: gas strategy + """ + + DEFAULT_FALLBACK_GAS_LIMIT = 400_000 + DEFAULT_BLOCK_LIMIT = 2_000_000 + + @staticmethod + def default_table() -> "OfflineMessageTableStrategy": + """offline message strategy default table. + + :return: offline message default table strategy + """ + strategy = OfflineMessageTableStrategy() + strategy.update_entry("cosmos.bank.v1beta1.MsgSend", 100_000) + strategy.update_entry("cosmwasm.wasm.v1.MsgStoreCode", 2_000_000) + strategy.update_entry("cosmwasm.wasm.v1.MsgInstantiateContract", 250_000) + strategy.update_entry("cosmwasm.wasm.v1.MsgExecuteContract", 400_000) + return strategy + + def __init__( + self, + fallback_gas_limit: Optional[int] = None, + block_limit: Optional[int] = None, + ): + """Init offline message table strategy. + + :param fallback_gas_limit: Fallback gas limit, defaults to None + :param block_limit: Block limit, defaults to None + """ + self._table: Dict[str, int] = {} + self._block_limit = block_limit or self.DEFAULT_BLOCK_LIMIT + self._fallback_gas_limit = fallback_gas_limit or self.DEFAULT_FALLBACK_GAS_LIMIT + + def update_entry(self, transaction_type: str, gas_limit: int): + """Update the entry of the transaction. + + :param transaction_type: transaction type + :param gas_limit: gas limit + """ + self._table[str(transaction_type)] = int(gas_limit) + + def estimate_gas(self, tx: Transaction) -> int: + """Get estimated transaction gas. + + :param tx: transaction + :return: Estimated transaction gas + """ + gas_estimate = 0 + for msg in tx.msgs: + gas_estimate += self._table.get( + msg.DESCRIPTOR.full_name, self._fallback_gas_limit + ) + return self._clip_gas(gas_estimate) + + def block_gas_limit(self) -> int: + """Get the block gas limit. + + :return: block gas limit + """ + return self._block_limit diff --git a/v4-client-py/v4_client_py/chain/aerial/tx.py b/v4-client-py/v4_client_py/chain/aerial/tx.py new file mode 100644 index 00000000..2aaa116f --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/tx.py @@ -0,0 +1,253 @@ + +"""Transaction.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Any, List, Optional, Union + +from google.protobuf.any_pb2 import Any as ProtoAny + +from v4_proto.cosmos.crypto.secp256k1.keys_pb2 import PubKey as ProtoPubKey +from v4_proto.cosmos.tx.signing.v1beta1.signing_pb2 import SignMode +from v4_proto.cosmos.tx.v1beta1.tx_pb2 import ( + AuthInfo, + Fee, + ModeInfo, + SignDoc, + SignerInfo, + Tx, + TxBody, +) + +from .coins import parse_coins +from ..crypto.interface import Signer +from ..crypto.keypairs import PublicKey + + +class TxState(Enum): + """Transaction state. + + :param Enum: Draft, Sealed, Final + """ + + Draft = 0 + Sealed = 1 + Final = 2 + + +def _is_iterable(value) -> bool: + try: + iter(value) + return True + except TypeError: + return False + + +def _wrap_in_proto_any(values: List[Any]) -> List[ProtoAny]: + any_values = [] + for value in values: + proto_any = ProtoAny() + proto_any.Pack(value, type_url_prefix="/") # type: ignore + any_values.append(proto_any) + return any_values + + +def _create_proto_public_key(public_key: PublicKey) -> ProtoAny: + proto_public_key = ProtoAny() + proto_public_key.Pack( + ProtoPubKey( + key=public_key.public_key_bytes, + ), + type_url_prefix="/", + ) + return proto_public_key + + +class SigningMode(Enum): + """Signing mode. + + :param Enum: Direct + """ + + Direct = 1 + + +@dataclass +class SigningCfg: + """Transaction signing configuration.""" + + mode: SigningMode + sequence_num: int + public_key: PublicKey + + @staticmethod + def direct(public_key: PublicKey, sequence_num: int) -> "SigningCfg": + """Transaction signing configuration using direct mode. + + :param public_key: public key + :param sequence_num: sequence number + :return: Transaction signing configuration + """ + return SigningCfg( + mode=SigningMode.Direct, + sequence_num=sequence_num, + public_key=public_key, + ) + + +class Transaction: + """Transaction.""" + + def __init__(self): + """Init the Transactions with transaction message, state, fee and body.""" + self._msgs: List[Any] = [] + self._state: TxState = TxState.Draft + self._tx_body: Optional[TxBody] = None + self._tx = None + self._fee = None + + @property # noqa + def state(self) -> TxState: + """Get the transaction state. + + :return: current state of the transaction + """ + return self._state + + @property # noqa + def msgs(self): + """Get the transaction messages. + + :return: transaction messages + """ + return self._msgs + + @property + def fee(self) -> Optional[str]: + """Get the transaction fee. + + :return: transaction fee + """ + return self._fee + + @property + def tx(self): + """Initialize. + + :raises RuntimeError: If the transaction has not been completed. + :return: transaction + """ + if self._state != TxState.Final: + raise RuntimeError("The transaction has not been completed") + return self._tx + + def add_message(self, msg: Any) -> "Transaction": + """Initialize. + + :param msg: transaction message (memo) + :raises RuntimeError: If the transaction is not in the draft state. + :return: transaction with message added + """ + if self._state != TxState.Draft: + raise RuntimeError( + "The transaction is not in the draft state. No further messages may be appended" + ) + self._msgs.append(msg) + return self + + def seal( + self, + signing_cfgs: Union[SigningCfg, List[SigningCfg]], + fee: str, + gas_limit: int, + memo: Optional[str] = None, + ) -> "Transaction": + """Seal the transaction. + + :param signing_cfgs: signing configs + :param fee: transaction fee + :param gas_limit: transaction gas limit + :param memo: transaction memo, defaults to None + :return: sealed transaction. + """ + self._state = TxState.Sealed + + input_signing_cfgs: List[SigningCfg] = ( + signing_cfgs if _is_iterable(signing_cfgs) else [signing_cfgs] # type: ignore + ) + + signer_infos = [] + for signing_cfg in input_signing_cfgs: + assert signing_cfg.mode == SigningMode.Direct + + signer_infos.append( + SignerInfo( + public_key=_create_proto_public_key(signing_cfg.public_key), + mode_info=ModeInfo( + single=ModeInfo.Single(mode=SignMode.SIGN_MODE_DIRECT) + ), + sequence=signing_cfg.sequence_num, + ) + ) + + auth_info = AuthInfo( + signer_infos=signer_infos, + fee=Fee(amount=parse_coins(fee), gas_limit=gas_limit), + ) + + self._fee = fee + + self._tx_body = TxBody() + self._tx_body.memo = memo or "" + self._tx_body.messages.extend( + _wrap_in_proto_any(self._msgs) + ) # pylint: disable=E1101 + + self._tx = Tx(body=self._tx_body, auth_info=auth_info) + return self + + def sign( + self, + signer: Signer, + chain_id: str, + account_number: int, + deterministic: bool = False, + ) -> "Transaction": + """Sign the transaction. + + :param signer: Signer + :param chain_id: chain id + :param account_number: account number + :param deterministic: deterministic, defaults to False + :raises RuntimeError: If transaction is not sealed + :return: signed transaction + """ + if self.state != TxState.Sealed: + raise RuntimeError( + "Transaction is not sealed. It must be sealed before signing is possible." + ) + + sd = SignDoc() + sd.body_bytes = self._tx.body.SerializeToString() + sd.auth_info_bytes = self._tx.auth_info.SerializeToString() + sd.chain_id = chain_id + sd.account_number = account_number + + data_for_signing = sd.SerializeToString() + + # Generating deterministic signature: + signature = signer.sign( + data_for_signing, + deterministic=deterministic, + canonicalise=True, + ) + self._tx.signatures.extend([signature]) + return self + + def complete(self) -> "Transaction": + """Update transaction state to Final. + + :return: transaction with updated state + """ + self._state = TxState.Final + return self diff --git a/v4-client-py/v4_client_py/chain/aerial/tx_helpers.py b/v4-client-py/v4_client_py/chain/aerial/tx_helpers.py new file mode 100644 index 00000000..67bf8743 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/tx_helpers.py @@ -0,0 +1,162 @@ + +"""Transaction helpers.""" + +import re +from dataclasses import dataclass +from datetime import timedelta +from typing import Dict, List, Optional, Union + +from .exceptions import ( + BroadcastError, + InsufficientFeesError, + OutOfGasError, +) +from ..crypto.address import Address + + +@dataclass +class MessageLog: + """Message Log.""" + + index: int # noqa + log: str # noqa + events: Dict[str, Dict[str, str]] + + +@dataclass +class TxResponse: + """Transaction response. + + :raises OutOfGasError: Out of gas error + :raises InsufficientFeesError: Insufficient fees + :raises BroadcastError: Broadcast Exception + """ + + hash: str + height: int + code: int + gas_wanted: int + gas_used: int + raw_log: str + logs: List[MessageLog] + events: Dict[str, Dict[str, str]] + + def is_successful(self) -> bool: + """Check transaction is successful. + + :return: transaction status + """ + return self.code == 0 + + def ensure_successful(self): + """Ensure transaction is successful. + + :raises OutOfGasError: Out of gas error + :raises InsufficientFeesError: Insufficient fees + :raises BroadcastError: Broadcast Exception + """ + if self.code != 0: + if "out of gas" in self.raw_log: + match = re.search( + r"gasWanted:\s*(\d+).*?gasUsed:\s*(\d+)", self.raw_log + ) + if match is not None: + gas_wanted = int(match.group(1)) + gas_used = int(match.group(2)) + else: + gas_wanted = -1 + gas_used = -1 + + raise OutOfGasError(self.hash, gas_wanted=gas_wanted, gas_used=gas_used) + if "insufficient fees" in self.raw_log: + match = re.search(r"required:\s*(\d+\w+)", self.raw_log) + if match is not None: + required_fee = match.group(1) + else: + required_fee = f"more than {self.gas_wanted}" + raise InsufficientFeesError(self.hash, required_fee) + raise BroadcastError(self.hash, self.raw_log) + + +class SubmittedTx: + """Submitted transaction.""" + + def __init__( + self, client: "LedgerClient", tx_hash: str # type: ignore # noqa: F821 + ): + """Init the Submitted transaction. + + :param client: Ledger client + :param tx_hash: transaction hash + """ + self._client = client + self._response: Optional[TxResponse] = None + self._tx_hash = str(tx_hash) + + @property + def tx_hash(self) -> str: + """Get the transaction hash. + + :return: transaction hash + """ + return self._tx_hash + + @property + def response(self) -> Optional[TxResponse]: + """Get the transaction response. + + :return: response + """ + return self._response + + @property + def contract_code_id(self) -> Optional[int]: + """Get the contract code id. + + :return: return contract code id if exist else None + """ + if self._response is None: + return None + + code_id = self._response.events.get("store_code", {}).get("code_id") + if code_id is None: + return None + + return int(code_id) + + @property + def contract_address(self) -> Optional[Address]: + """Get the contract address. + + :return: return contract address if exist else None + """ + if self._response is None: + return None + + contract_address = self._response.events.get("instantiate", {}).get( + "_contract_address" + ) + if contract_address is None: + return None + + return Address(contract_address) + + def wait_to_complete( + self, + timeout: Optional[Union[int, float, timedelta]] = None, + poll_period: Optional[Union[int, float, timedelta]] = None, + ) -> "SubmittedTx": + """Wait to complete the transaction. + + :param timeout: timeout, defaults to None + :param poll_period: poll_period, defaults to None + + :return: Submitted Transaction + """ + self._response = self._client.wait_for_query_tx( + self.tx_hash, timeout=timeout, poll_period=poll_period + ) + assert self._response is not None + self._response.ensure_successful() + + return self diff --git a/v4-client-py/v4_client_py/chain/aerial/urls.py b/v4-client-py/v4_client_py/chain/aerial/urls.py new file mode 100644 index 00000000..7cc522fa --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/urls.py @@ -0,0 +1,89 @@ + +"""Parsing the URL.""" + +from dataclasses import dataclass +from enum import Enum +from urllib.parse import urlparse + + +class Protocol(Enum): + """Protocol Enum. + + :param Enum: Enum + """ + + GRPC = 1 + REST = 2 + + +@dataclass +class ParsedUrl: + """Parse URL. + + :return: Parsed URL + """ + + protocol: Protocol + secure: bool + hostname: str + port: int + + @property + def host_and_port(self) -> str: + """Get the host and port of the url. + + :return: host and port + """ + return f"{self.hostname}:{self.port}" + + @property + def rest_url(self) -> str: + """Get the rest url. + + :return: rest url + """ + assert self.protocol == Protocol.REST + if self.secure: + prefix = "https" + default_port = 443 + else: + prefix = "http" + default_port = 80 + + url = f"{prefix}://{self.hostname}" + if self.port != default_port: + url += f":{self.port}" + return url + + +def parse_url(url: str) -> ParsedUrl: + """Initialize. + + :param url: url + :raises RuntimeError: If url scheme is unsupported + :return: Parsed URL + """ + result = urlparse(url) + if result.scheme == "grpc+https": + protocol = Protocol.GRPC + secure = True + default_port = 443 + elif result.scheme == "grpc+http": + protocol = Protocol.GRPC + secure = False + default_port = 80 + elif result.scheme == "rest+https": + protocol = Protocol.REST + secure = True + default_port = 443 + elif result.scheme == "rest+http": + protocol = Protocol.REST + secure = False + default_port = 80 + else: + raise RuntimeError(f"Unsupported url scheme: {result.scheme}") + + hostname = str(result.hostname) + port = default_port if result.port is None else int(result.port) + + return ParsedUrl(protocol=protocol, secure=secure, hostname=hostname, port=port) diff --git a/v4-client-py/v4_client_py/chain/aerial/wallet.py b/v4-client-py/v4_client_py/chain/aerial/wallet.py new file mode 100644 index 00000000..c0c12295 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/aerial/wallet.py @@ -0,0 +1,138 @@ + +"""Wallet Generation.""" + +from abc import ABC, abstractmethod +from collections import UserString +from typing import Optional + +from bip_utils import Bip39SeedGenerator, Bip44, Bip44Coins # type: ignore + +from ..crypto.address import Address +from ..crypto.hashfuncs import sha256 +from ..crypto.interface import Signer +from ..crypto.keypairs import PrivateKey, PublicKey + + +class Wallet(ABC, UserString): + """Wallet Generation. + + :param ABC: ABC abstract method + :param UserString: user string + """ + + @abstractmethod + def address(self) -> Address: + """get the address of the wallet. + + :return: None + """ + + @abstractmethod + def public_key(self) -> PublicKey: + """get the public key of the wallet. + + :return: None + """ + + @abstractmethod + def signer(self) -> Signer: + """get the signer of the wallet. + + :return: None + """ + + @property + def data(self): + """Get the address of the wallet. + + :return: Address + """ + return self.address() + + def __json__(self): + """ + Return the address in string format. + + :return: address in string format + """ + return str(self.address()) + + +class LocalWallet(Wallet): + """Generate local wallet. + + :param Wallet: wallet + """ + + @staticmethod + def generate(prefix: Optional[str] = None) -> "LocalWallet": + """generate the local wallet. + + :param prefix: prefix, defaults to None + :return: local wallet + """ + return LocalWallet(PrivateKey(), prefix=prefix) + + @staticmethod + def from_mnemonic(mnemonic: str, prefix: Optional[str] = None) -> "LocalWallet": + """Generate local wallet from mnemonic. + + :param mnemonic: mnemonic + :param prefix: prefix, defaults to None + :return: local wallet + """ + seed_bytes = Bip39SeedGenerator(mnemonic).Generate() + bip44_def_ctx = Bip44.FromSeed( + seed_bytes, Bip44Coins.COSMOS + ).DeriveDefaultPath() + return LocalWallet( + PrivateKey(bip44_def_ctx.PrivateKey().Raw().ToBytes()), prefix=prefix + ) + + @staticmethod + def from_unsafe_seed( + text: str, index: Optional[int] = None, prefix: Optional[str] = None + ) -> "LocalWallet": + """Generate local wallet from unsafe seed. + + :param text: text + :param index: index, defaults to None + :param prefix: prefix, defaults to None + :return: Local wallet + """ + private_key_bytes = sha256(text.encode()) + if index is not None: + private_key_bytes = sha256( + private_key_bytes + index.to_bytes(4, byteorder="big") + ) + return LocalWallet(PrivateKey(private_key_bytes), prefix=prefix) + + def __init__(self, private_key: PrivateKey, prefix: Optional[str] = None): + """Init wallet with. + + :param private_key: private key of the wallet + :param prefix: prefix, defaults to None + """ + self._private_key = private_key + self._prefix = prefix + + def address(self) -> Address: + """Get the wallet address. + + :return: Wallet address. + """ + return Address(self._private_key, self._prefix) + + def public_key(self) -> PublicKey: + """Get the public key of the wallet. + + :return: public key + """ + return self._private_key + + def signer(self) -> PrivateKey: + """Get the signer of the wallet. + + :return: signer + """ + return self._private_key diff --git a/v4-client-py/v4_client_py/chain/auth/__init__.py b/v4-client-py/v4_client_py/chain/auth/__init__.py new file mode 100644 index 00000000..5aba6d30 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/auth/__init__.py @@ -0,0 +1,2 @@ + +"""This package contains the Auth modules.""" diff --git a/v4-client-py/v4_client_py/chain/auth/interface.py b/v4-client-py/v4_client_py/chain/auth/interface.py new file mode 100644 index 00000000..b0f6cd14 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/auth/interface.py @@ -0,0 +1,35 @@ + +"""Interface for the Auth functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.auth.v1beta1.query_pb2 import ( + QueryAccountRequest, + QueryAccountResponse, + QueryParamsRequest, + QueryParamsResponse, +) + + +class Auth(ABC): + """Auth abstract class.""" + + @abstractmethod + def Account(self, request: QueryAccountRequest) -> QueryAccountResponse: + """ + Query account data - sequence, account_id, etc. + + :param request: QueryAccountRequest that contains account address + + :return: QueryAccountResponse + """ + + @abstractmethod + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Query all parameters. + + :param request: QueryParamsRequest + + :return: QueryParamsResponse + """ diff --git a/v4-client-py/v4_client_py/chain/auth/rest_client.py b/v4-client-py/v4_client_py/chain/auth/rest_client.py new file mode 100644 index 00000000..80bae429 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/auth/rest_client.py @@ -0,0 +1,49 @@ + +"""Implementation of Auth interface using REST.""" + +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.auth.v1beta1.query_pb2 import ( + QueryAccountRequest, + QueryAccountResponse, + QueryParamsRequest, + QueryParamsResponse, +) + +from ..auth.interface import Auth +from ..common.rest_client import RestClient + +class AuthRestClient(Auth): + """Auth REST client.""" + + API_URL = "/cosmos/auth/v1beta1" + + def __init__(self, rest_api: RestClient): + """ + Initialize authentication rest client. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def Account(self, request: QueryAccountRequest) -> QueryAccountResponse: + """ + Query account data - sequence, account_id, etc. + + :param request: QueryAccountRequest that contains account address + + :return: QueryAccountResponse + """ + json_response = self._rest_api.get(f"{self.API_URL}/accounts/{request.address}") + return Parse(json_response, QueryAccountResponse()) + + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Query all parameters. + + :param request: QueryParamsRequest + + :return: QueryParamsResponse + """ + json_response = self._rest_api.get(f"{self.API_URL}/params") + return Parse(json_response, QueryParamsResponse()) diff --git a/v4-client-py/v4_client_py/chain/bank/__init__.py b/v4-client-py/v4_client_py/chain/bank/__init__.py new file mode 100644 index 00000000..11e06e00 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/bank/__init__.py @@ -0,0 +1,2 @@ + +"""This package contains the Bank modules.""" diff --git a/v4-client-py/v4_client_py/chain/bank/interface.py b/v4-client-py/v4_client_py/chain/bank/interface.py new file mode 100644 index 00000000..359d3021 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/bank/interface.py @@ -0,0 +1,99 @@ + +"""Interface for the Bank functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.bank.v1beta1.query_pb2 import ( + QueryAllBalancesRequest, + QueryAllBalancesResponse, + QueryBalanceRequest, + QueryBalanceResponse, + QueryDenomMetadataRequest, + QueryDenomMetadataResponse, + QueryDenomsMetadataRequest, + QueryDenomsMetadataResponse, + QueryParamsRequest, + QueryParamsResponse, + QuerySupplyOfRequest, + QuerySupplyOfResponse, + QueryTotalSupplyRequest, + QueryTotalSupplyResponse, +) + + +class Bank(ABC): + """Bank abstract class.""" + + @abstractmethod + def Balance(self, request: QueryBalanceRequest) -> QueryBalanceResponse: + """ + Query balance of selected denomination from specific account. + + :param request: QueryBalanceRequest with address and denomination + + :return: QueryBalanceResponse + """ + + @abstractmethod + def AllBalances(self, request: QueryAllBalancesRequest) -> QueryAllBalancesResponse: + """ + Query balance of all denominations from specific account. + + :param request: QueryAllBalancesRequest with account address + + :return: QueryAllBalancesResponse + """ + + @abstractmethod + def TotalSupply(self, request: QueryTotalSupplyRequest) -> QueryTotalSupplyResponse: + """ + Query total supply of all denominations. + + :param request: QueryTotalSupplyRequest + + :return: QueryTotalSupplyResponse + """ + + @abstractmethod + def SupplyOf(self, request: QuerySupplyOfRequest) -> QuerySupplyOfResponse: + """ + Query total supply of specific denomination. + + :param request: QuerySupplyOfRequest with denomination + + :return: QuerySupplyOfResponse + """ + + @abstractmethod + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Query the parameters of bank module. + + :param request: QueryParamsRequest + + :return: QueryParamsResponse + """ + + @abstractmethod + def DenomMetadata( + self, request: QueryDenomMetadataRequest + ) -> QueryDenomMetadataResponse: + """ + Query the client metadata for all registered coin denominations. + + :param request: QueryDenomMetadataRequest with denomination + + :return: QueryDenomMetadataResponse + """ + + @abstractmethod + def DenomsMetadata( + self, request: QueryDenomsMetadataRequest + ) -> QueryDenomsMetadataResponse: + """ + Query the client metadata of a given coin denomination. + + :param request: QueryDenomsMetadataRequest + + :return: QueryDenomsMetadataResponse + """ diff --git a/v4-client-py/v4_client_py/chain/bank/rest_client.py b/v4-client-py/v4_client_py/chain/bank/rest_client.py new file mode 100644 index 00000000..b2a2850e --- /dev/null +++ b/v4-client-py/v4_client_py/chain/bank/rest_client.py @@ -0,0 +1,124 @@ + +"""Implementation of Bank interface using REST.""" + +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.bank.v1beta1.query_pb2 import ( + QueryAllBalancesRequest, + QueryAllBalancesResponse, + QueryBalanceRequest, + QueryBalanceResponse, + QueryDenomMetadataRequest, + QueryDenomMetadataResponse, + QueryDenomsMetadataRequest, + QueryDenomsMetadataResponse, + QueryParamsRequest, + QueryParamsResponse, + QuerySupplyOfRequest, + QuerySupplyOfResponse, + QueryTotalSupplyRequest, + QueryTotalSupplyResponse, +) + +from ..bank.interface import Bank +from ..common.rest_client import RestClient + +class BankRestClient(Bank): + """Bank REST client.""" + + API_URL = "/cosmos/bank/v1beta1" + + def __init__(self, rest_api: RestClient): + """ + Create bank rest client. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def Balance(self, request: QueryBalanceRequest) -> QueryBalanceResponse: + """ + Query balance of selected denomination from specific account. + + :param request: QueryBalanceRequest with address and denomination + + :return: QueryBalanceResponse + """ + response = self._rest_api.get( + f"{self.API_URL}/balances/{request.address}/by_denom?denom={request.denom}", + request, + ["address", "denom"], + ) + return Parse(response, QueryBalanceResponse()) + + def AllBalances(self, request: QueryAllBalancesRequest) -> QueryAllBalancesResponse: + """ + Query balance of all denominations from specific account. + + :param request: QueryAllBalancesRequest with account address + + :return: QueryAllBalancesResponse + """ + response = self._rest_api.get( + f"{self.API_URL}/balances/{request.address}", request, ["address"] + ) + return Parse(response, QueryAllBalancesResponse()) + + def TotalSupply(self, request: QueryTotalSupplyRequest) -> QueryTotalSupplyResponse: + """ + Query total supply of all denominations. + + :param request: QueryTotalSupplyRequest + + :return: QueryTotalSupplyResponse + """ + response = self._rest_api.get(f"{self.API_URL}/supply", request) + return Parse(response, QueryTotalSupplyResponse()) + + def SupplyOf(self, request: QuerySupplyOfRequest) -> QuerySupplyOfResponse: + """ + Query total supply of specific denomination. + + :param request: QuerySupplyOfRequest with denomination + + :return: QuerySupplyOfResponse + """ + response = self._rest_api.get(f"{self.API_URL}/supply/{request.denom}") + return Parse(response, QuerySupplyOfResponse()) + + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Query the parameters of bank module. + + :param request: QueryParamsRequest + + :return: QueryParamsResponse + """ + response = self._rest_api.get(f"{self.API_URL}/params") + return Parse(response, QueryParamsResponse()) + + def DenomMetadata( + self, request: QueryDenomMetadataRequest + ) -> QueryDenomMetadataResponse: + """ + Query the client metadata for all registered coin denominations. + + :param request: QueryDenomMetadataRequest with denomination + + :return: QueryDenomMetadataResponse + """ + response = self._rest_api.get(f"{self.API_URL}/denoms_metadata/{request.denom}") + return Parse(response, QueryDenomMetadataResponse()) + + def DenomsMetadata( + self, request: QueryDenomsMetadataRequest + ) -> QueryDenomsMetadataResponse: + """ + Query the client metadata of a given coin denomination. + + :param request: QueryDenomsMetadataRequest + + :return: QueryDenomsMetadataResponse + """ + response = self._rest_api.get(f"{self.API_URL}/denoms_metadata", request) + return Parse(response, QueryDenomsMetadataResponse()) diff --git a/v4-client-py/v4_client_py/chain/common/__init__.py b/v4-client-py/v4_client_py/chain/common/__init__.py new file mode 100644 index 00000000..1380a4ab --- /dev/null +++ b/v4-client-py/v4_client_py/chain/common/__init__.py @@ -0,0 +1,2 @@ + +"""This package contains modules used across other modules in cosm.""" diff --git a/v4-client-py/v4_client_py/chain/common/rest_client.py b/v4-client-py/v4_client_py/chain/common/rest_client.py new file mode 100644 index 00000000..9d417c1c --- /dev/null +++ b/v4-client-py/v4_client_py/chain/common/rest_client.py @@ -0,0 +1,144 @@ + +"""Implementation of REST api client.""" + +import base64 +import json +from typing import List, Optional +from urllib.parse import urlencode + +import requests +from google.protobuf.json_format import MessageToDict +from google.protobuf.message import Message + + +class RestClient: + """REST api client.""" + + def __init__(self, rest_address: str): + """ + Create REST api client. + + :param rest_address: Address of REST node + """ + self._session = requests.session() + self.rest_address = rest_address + + def get( + self, + url_base_path: str, + request: Optional[Message] = None, + used_params: Optional[List[str]] = None, + ) -> bytes: + """ + Send a GET request. + + :param url_base_path: URL base path + :param request: Protobuf coded request + :param used_params: Parameters to be removed from request after converting it to dict + + :raises RuntimeError: if response code is not 200 + + :return: Content of response + """ + url = self._make_url( + url_base_path=url_base_path, request=request, used_params=used_params + ) + + response = self._session.get(url=url) + if response.status_code != 200: + raise RuntimeError( + f"Error when sending a GET request.\n Response: {response.status_code}, {str(response.content)})" + ) + return response.content + + def _make_url( + self, + url_base_path: str, + request: Optional[Message] = None, + used_params: Optional[List[str]] = None, + ) -> str: + """ + Construct URL for get request. + + :param url_base_path: URL base path + :param request: Protobuf coded request + :param used_params: Parameters to be removed from request after converting it to dict + + :return: URL string + """ + json_request = MessageToDict(request) if request else {} + + # Remove params that are already in url_base_path + for param in used_params or []: + json_request.pop(param) + + url_encoded_request = self._url_encode(json_request) + + url = f"{self.rest_address}{url_base_path}" + if url_encoded_request: + url = f"{url}?{url_encoded_request}" + + return url + + def post(self, url_base_path: str, request: Message) -> bytes: + """ + Send a POST request. + + :param url_base_path: URL base path + :param request: Protobuf coded request + + :raises RuntimeError: if response code is not 200 + + :return: Content of response + """ + json_request = MessageToDict(request) + + # Workaround + if "tx" in json_request: + if "body" in json_request["tx"]: + if "messages" in json_request["tx"]["body"]: + for message in json_request["tx"]["body"]["messages"]: + if "msg" in message: + message["msg"] = json.loads( + base64.b64decode(message["msg"]) + ) + + headers = {"Content-type": "application/json", "Accept": "application/json"} + response = self._session.post( + url=f"{self.rest_address}{url_base_path}", + json=json_request, + headers=headers, + ) + + if response.status_code != 200: + raise RuntimeError( + f"Error when sending a POST request.\n Request: {json_request}\n Response: {response.status_code}, {str(response.content)})" + ) + return response.content + + @staticmethod + def _url_encode(json_request): + """A Custom URL encodes that breaks down nested dictionaries to match REST api format. + + It converts dicts from: + {"pagination": {"limit": "1", "something": "2"},} + + To: + {"pagination.limit": "1","pagination.something": "2"} + + + :param json_request: JSON request + + :return: urlencoded json_request + """ # noqa: D401 + for outer_k, outer_v in json_request.copy().items(): + if isinstance(outer_v, dict): + for inner_k, inner_v in outer_v.items(): + json_request[f"{outer_k}.{inner_k}"] = inner_v + json_request.pop(outer_k) + + return urlencode(json_request, doseq=True) + + def __del__(self): + """Destructor method.""" + self._session.close() diff --git a/v4-client-py/v4_client_py/chain/common/types.py b/v4-client-py/v4_client_py/chain/common/types.py new file mode 100644 index 00000000..5ce48ee1 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/common/types.py @@ -0,0 +1,11 @@ + +"""Common types.""" + +from typing import Any, Dict, List, Optional, Union + +Primitive = Union[str, int, bool, float] +_JSONDict = Dict[Any, Any] # temporary placeholder +_JSONList = List[Any] # temporary placeholder +_JSONType = Optional[Union[Primitive, _JSONDict, _JSONList]] +# Added Dict[str, _JSONDict] as workaround to not properly resolving recursive types - _JSONDict should be subset of _JSONType +JSONLike = Union[Dict[str, _JSONType], Dict[str, _JSONDict]] diff --git a/v4-client-py/v4_client_py/chain/common/utils.py b/v4-client-py/v4_client_py/chain/common/utils.py new file mode 100644 index 00000000..73d7d43a --- /dev/null +++ b/v4-client-py/v4_client_py/chain/common/utils.py @@ -0,0 +1,22 @@ + +"""Utils.""" + +import json +from typing import Any + + +class JSONEncoder(json.JSONEncoder): + """JSONEncoder subclass that encode basic python objects.""" # noqa: D401 + + def default(self, o: Any) -> Any: + """Default json encode.""" # noqa: D401 + if not hasattr(o, "__json__"): + return super().default(o) + if callable(o.__json__): + return o.__json__() + return o.__json__ + + +def json_encode(data, **kwargs): + """Json encode.""" + return JSONEncoder(**kwargs).encode(data) diff --git a/v4-client-py/v4_client_py/chain/crypto/__init__.py b/v4-client-py/v4_client_py/chain/crypto/__init__.py new file mode 100644 index 00000000..61f33c8c --- /dev/null +++ b/v4-client-py/v4_client_py/chain/crypto/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""This package contains the Crypto modules.""" diff --git a/v4-client-py/v4_client_py/chain/crypto/address.py b/v4-client-py/v4_client_py/chain/crypto/address.py new file mode 100644 index 00000000..0bdb7af4 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/crypto/address.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Address of the Crypto package.""" + +from collections import UserString +from typing import Optional, Union + +import bech32 + +from ..crypto.hashfuncs import ripemd160, sha256 +from ..crypto.keypairs import PublicKey + +DEFAULT_PREFIX = "dydx" + + +def _to_bech32(prefix: str, data: bytes) -> str: + data_base5 = bech32.convertbits(data, 8, 5, True) + if data_base5 is None: + raise RuntimeError("Unable to parse address") # pragma: no cover + return bech32.bech32_encode(prefix, data_base5) + + +class Address(UserString): + """Address class.""" + + def __init__( + self, + value: Union[str, bytes, PublicKey, "Address"], + prefix: Optional[str] = None, + ): + """Initialize Address instance. + + :param value: str, byte, public key or Address another instance + :param prefix: optional string + :raises RuntimeError: Unable to parse address + :raises RuntimeError: Incorrect address length + :raises TypeError: Unexpected type of `value` parameter + """ + # pylint: disable=super-init-not-called + if prefix is None: + prefix = DEFAULT_PREFIX + + if isinstance(value, str): + _, data_base5 = bech32.bech32_decode(value) + if data_base5 is None: + raise RuntimeError("Unable to parse address") + + data_base8 = bech32.convertbits(data_base5, 5, 8, False) + if data_base8 is None: + raise RuntimeError("Unable to parse address") # pragma: no cover + + self._address = bytes(data_base8) + self._display = value + + elif isinstance(value, bytes): + if len(value) != 20: + raise RuntimeError("Incorrect address length") + + self._address = value + self._display = _to_bech32(prefix, self._address) + + elif isinstance(value, PublicKey): + self._address = ripemd160(sha256(value.public_key_bytes)) + self._display = _to_bech32(prefix, self._address) + + elif isinstance(value, Address): + self._address = value._address + # prefix might be different from the original Address, so we need to reencode it here. + self._display = _to_bech32(prefix, self._address) + else: + raise TypeError("Unexpected type of `value` parameter") # pragma: no cover + + def __str__(self): + """String representation of the address.""" # noqa: D401 + return self._display + + def __bytes__(self): + """bytes representation of the address.""" + return self._address + + @property + def data(self): # noqa: + """Return address in string.""" + return str(self) + + def __json__(self): # noqa: + return str(self) diff --git a/v4-client-py/v4_client_py/chain/crypto/hashfuncs.py b/v4-client-py/v4_client_py/chain/crypto/hashfuncs.py new file mode 100644 index 00000000..1c2fd70c --- /dev/null +++ b/v4-client-py/v4_client_py/chain/crypto/hashfuncs.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Hash functions of Crypto package.""" + +import hashlib + +# pycryptodome, a dependency of bip-utils +from Crypto.Hash import RIPEMD160 # type: ignore # nosec + + +def sha256(contents: bytes) -> bytes: + """ + Get sha256 hash. + + :param contents: bytes contents. + + :return: bytes sha256 hash. + """ + h = hashlib.sha256() + h.update(contents) + return h.digest() + + +def ripemd160(contents: bytes) -> bytes: + """ + Get ripemd160 hash using PyCryptodome. + + :param contents: bytes contents. + + :return: bytes ripemd160 hash. + """ + h = RIPEMD160.new() + h.update(contents) + return h.digest() diff --git a/v4-client-py/v4_client_py/chain/crypto/interface.py b/v4-client-py/v4_client_py/chain/crypto/interface.py new file mode 100644 index 00000000..eefb5de2 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/crypto/interface.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Interface for a Signer.""" + +from abc import ABC, abstractmethod + + +class Signer(ABC): + """Signer abstract class.""" + + @abstractmethod + def sign( + self, message: bytes, deterministic: bool = False, canonicalise: bool = True + ) -> bytes: + """ + Perform signing. + + :param message: bytes to sign + :param deterministic: bool, default false + :param canonicalise: bool,default True + """ + + @abstractmethod + def sign_digest( + self, digest: bytes, deterministic=False, canonicalise: bool = True + ) -> bytes: + """ + Perform digest signing. + + :param digest: bytes to sign + :param deterministic: bool, default false + :param canonicalise: bool,default True + """ diff --git a/v4-client-py/v4_client_py/chain/crypto/keypairs.py b/v4-client-py/v4_client_py/chain/crypto/keypairs.py new file mode 100644 index 00000000..9f742de6 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/crypto/keypairs.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Crypto KeyPairs (Public Key and Private Key).""" + +import base64 +import hashlib +from typing import Callable, Optional, Union + +import ecdsa +from ecdsa.curves import Curve +from ecdsa.util import sigencode_string, sigencode_string_canonize + +from ..crypto.interface import Signer + + +def _base64_decode(value: str) -> bytes: + try: + return base64.b64decode(value) + except Exception as error: + raise RuntimeError("Unable to parse base64 value") from error + + +class PublicKey: + """Public key class.""" + + curve: Curve = ecdsa.SECP256k1 + hash_function: Callable = hashlib.sha256 + + def __init__(self, public_key: Union[bytes, "PublicKey", ecdsa.VerifyingKey]): + """Initialize. + + :param public_key: butes, public key or ecdsa verifying key instance + :raises RuntimeError: Invalid public key + """ + if isinstance(public_key, bytes): + self._verifying_key = ecdsa.VerifyingKey.from_string( + public_key, curve=self.curve, hashfunc=self.hash_function + ) + elif isinstance(public_key, PublicKey): + self._verifying_key = public_key._verifying_key + elif isinstance(public_key, ecdsa.VerifyingKey): + self._verifying_key = public_key + else: + raise RuntimeError("Invalid public key type") # noqa + + self._public_key_bytes: bytes = self._verifying_key.to_string("compressed") + self._public_key: str = base64.b64encode(self._public_key_bytes).decode() + + @property + def public_key(self) -> str: + """ + Get public key. + + :return: str public key. + """ + return self._public_key + + @property + def public_key_hex(self) -> str: + """ + Get public key hex. + + :return: str public key hex. + """ + return self.public_key_bytes.hex() + + @property + def public_key_bytes(self) -> bytes: + """ + Get bytes public key. + + :return: bytes public key. + """ + return self._public_key_bytes + + def verify(self, message: bytes, signature: bytes) -> bool: + """ + Verify message and signature. + + :param message: bytes message content. + :param signature: bytes signature. + :return: bool is message and signature valid. + """ + success: bool = False + + try: + success = self._verifying_key.verify(signature, message) + + except ecdsa.keys.BadSignatureError: + ... + + return success + + def verify_digest(self, digest: bytes, signature: bytes) -> bool: + """ + Verify digest. + + :param digest: bytes digest. + :param signature: bytes signature. + :return: bool is digest valid. + """ + success: bool = False + + try: + success = self._verifying_key.verify_digest(signature, digest) + + except ecdsa.keys.BadSignatureError: # pragma: no cover + ... + + return success + + +class PrivateKey(PublicKey, Signer): + """Private key class.""" + + def __init__(self, private_key: Optional[Union[bytes, str]] = None): + """ + Initialize. + + :param private_key: bytes private key (optional, None by default). + :raises RuntimeError: if unable to load private key from input. + """ + if private_key is None: + self._signing_key = ecdsa.SigningKey.generate( + curve=self.curve, hashfunc=self.hash_function + ) + elif isinstance(private_key, bytes): + self._signing_key = ecdsa.SigningKey.from_string( + private_key, curve=self.curve, hashfunc=self.hash_function + ) + elif isinstance(private_key, str): + raw_private_key = _base64_decode(private_key) + self._signing_key = ecdsa.SigningKey.from_string( + raw_private_key, curve=self.curve, hashfunc=self.hash_function + ) + + else: + raise RuntimeError("Unable to load private key from input") + + # cache the binary representations of the private key + self._private_key_bytes = self._signing_key.to_string() + self._private_key = base64.b64encode(self._private_key_bytes).decode() + + # construct the base class + super().__init__(self._signing_key.get_verifying_key()) + + @property + def private_key(self) -> str: + """ + Get private key. + + :return: str private key. + """ + return self._private_key + + @property + def private_key_hex(self) -> str: + """ + Get private key hex. + + :return: str private key hex. + """ + return self.private_key_bytes.hex() + + @property + def private_key_bytes(self) -> bytes: + """ + Get bytes private key. + + :return: bytes private key. + """ + return self._private_key_bytes + + def sign( + self, message: bytes, deterministic: bool = True, canonicalise: bool = True + ) -> bytes: + """ + Sign message. + + :param message: bytes message content. + :param deterministic: bool is deterministic. + :param canonicalise: bool is canonicalise. + + :return: bytes signed message. + """ + sigencode = sigencode_string_canonize if canonicalise else sigencode_string + sign_fnc = ( + self._signing_key.sign_deterministic + if deterministic + else self._signing_key.sign + ) + + return sign_fnc(message, sigencode=sigencode) + + def sign_digest( + self, digest: bytes, deterministic=True, canonicalise: bool = True + ) -> bytes: + """ + Sign digest. + + :param digest: bytes digest content. + :param deterministic: bool is deterministic. + :param canonicalise: bool is canonicalise. + + :return: bytes signed digest. + """ + sigencode = sigencode_string_canonize if canonicalise else sigencode_string + sign_fnc = ( + self._signing_key.sign_digest_deterministic + if deterministic + else self._signing_key.sign_digest + ) + + return sign_fnc(digest, sigencode=sigencode) diff --git a/v4-client-py/v4_client_py/chain/crypto/keypairs_bls.py b/v4-client-py/v4_client_py/chain/crypto/keypairs_bls.py new file mode 100644 index 00000000..66f6ff99 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/crypto/keypairs_bls.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""BLS Crypto KeyPairs (Public Key and Private Key) and utility functions.""" +import base64 +import hashlib +from typing import Callable, List, Optional + +from blspy import ( # type: ignore # pylint: disable=no-name-in-module + AugSchemeMPL, + G1Element, + G2Element, +) +from blspy import PrivateKey as BLSPrivateKey # pylint: disable=no-name-in-module +from ecdsa.curves import NIST256p +from ecdsa.keys import SigningKey + +from ..crypto.interface import Signer + + +class PublicKey: + """BLS public key class.""" + + HASH_FUNCTION: Callable = hashlib.sha256 + + def __init__(self, public_key: bytes): + """ + Initialize. + + :param public_key: bytes. + """ + self._public_key_bytes = public_key + self._public_key = base64.b64encode(self._public_key_bytes).decode() + self._verifying_key = G1Element.from_bytes(self._public_key_bytes) + + @property + def public_key(self) -> str: + """ + Get public key. + + :return: str public key. + """ + return self._public_key + + @property + def public_key_hex(self) -> str: + """ + Get public key hex. + + :return: str public key hex. + """ + return self.public_key_bytes.hex() + + @property + def public_key_bytes(self) -> bytes: + """ + Get bytes public key. + + :return: bytes public key. + """ + return self._public_key_bytes + + def verify(self, message: bytes, signature: bytes) -> bool: + """ + Verify message and signature. + + :param message: bytes message content. + :param signature: bytes signature. + :return: bool is message and signature valid. + """ + digest = self.HASH_FUNCTION(message).digest() + return self.verify_digest(digest, signature) + + def verify_digest(self, digest: bytes, signature: bytes) -> bool: + """ + Verify digest. + + :param digest: bytes digest. + :param signature: bytes signature. + :return: bool is digest valid. + """ + return AugSchemeMPL.verify( + self._verifying_key, digest, G2Element.from_bytes(signature) + ) + + +class PrivateKey(Signer, PublicKey): + """BLS private key class.""" + + HASH_FUNCTION: Callable = hashlib.sha256 + + def __init__(self, private_key: Optional[bytes] = None): + """ + Initialize. + + :param private_key: the private key. Defaults to None.. + """ + self._private_key_bytes = private_key or self._generate_bytes() + self._private_key = base64.b64encode(self._private_key_bytes).decode() + self._signing_key: BLSPrivateKey = AugSchemeMPL.key_gen(self._private_key_bytes) + PublicKey.__init__(self, public_key=bytes(self._signing_key.get_g1())) + + @property + def private_key(self) -> str: + """ + Get private key. + + :return: str private key. + """ + return self._private_key + + @property + def private_key_hex(self) -> str: + """ + Get private key hex. + + :return: str private key hex. + """ + return self.private_key_bytes.hex() + + @property + def private_key_bytes(self) -> bytes: + """ + Get bytes private key. + + :return: bytes private key. + """ + return self._private_key_bytes + + @staticmethod + def _generate_bytes() -> bytes: + """ + Generate random bytes sequence 32 bytes long. + + :return: bytes + """ + return SigningKey.generate(curve=NIST256p).to_string() + + def sign( + self, message: bytes, deterministic: bool = True, canonicalise: bool = True + ) -> bytes: + """ + Sign message. + + :param message: bytes message content. + :param deterministic: bool is deterministic. + :param canonicalise: bool is canonicalise. + + :return: bytes signed message. + """ + digest = self.HASH_FUNCTION(message).digest() + return self.sign_digest(digest) + + def sign_digest( + self, digest: bytes, deterministic=True, canonicalise: bool = True + ) -> bytes: + """ + Sign digest. + + :param digest: bytes digest content. + :param deterministic: bool is deterministic. + :param canonicalise: bool is canonicalise. + + :return: bytes signed digest. + """ + return bytes(AugSchemeMPL.sign(self._signing_key, digest)) + + +def aggregate_signatures(*sigs: List[bytes]) -> bytes: + """ + Combine signatures into one. + + :param *sigs: list of signatures bytes. + :return: bytes + """ + return bytes(AugSchemeMPL.aggregate([G2Element.from_bytes(i) for i in sigs])) + + +def verify_aggregated_signatures( + pks: List[PublicKey], + msgs: List[bytes], + aggregated_signature: bytes, + hashfunc=hashlib.sha256, +): + """ + Verify signatures with pub keys and messages. + + :param pks: list of public keys + :param msgs: list of messages + :param aggregated_signature: aggregated signature bytes + :param hashfunc: hash method from hashlib. default is hashlib.sha256 + :return: bool + """ + return verify_aggregated_signatures_digests( + pks, [hashfunc(i).digest() for i in msgs], aggregated_signature + ) + + +def verify_aggregated_signatures_digests( + pks: List[PublicKey], digests: List[bytes], aggregated_signature: bytes +): + """ + Verify signatures with pub keys and messages. + + :param pks: list of public keys + :param digests: list of digests calculated + :param aggregated_signature: aggregated signature bytes + :return: bool + """ + return AugSchemeMPL.aggregate_verify( + [G1Element.from_bytes(pk.public_key_bytes) for pk in pks], + digests, + G2Element.from_bytes(aggregated_signature), + ) diff --git a/v4-client-py/v4_client_py/chain/distribution/__init__.py b/v4-client-py/v4_client_py/chain/distribution/__init__.py new file mode 100644 index 00000000..56966d3a --- /dev/null +++ b/v4-client-py/v4_client_py/chain/distribution/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""This package contains the Distribution modules.""" diff --git a/v4-client-py/v4_client_py/chain/distribution/interface.py b/v4-client-py/v4_client_py/chain/distribution/interface.py new file mode 100644 index 00000000..324e4544 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/distribution/interface.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Interface for the Distribution functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.distribution.v1beta1.query_pb2 import ( + QueryCommunityPoolResponse, + QueryDelegationRewardsRequest, + QueryDelegationRewardsResponse, + QueryDelegationTotalRewardsRequest, + QueryDelegationTotalRewardsResponse, + QueryDelegatorValidatorsRequest, + QueryDelegatorValidatorsResponse, + QueryDelegatorWithdrawAddressRequest, + QueryDelegatorWithdrawAddressResponse, + QueryParamsResponse, + QueryValidatorCommissionRequest, + QueryValidatorCommissionResponse, + QueryValidatorOutstandingRewardsRequest, + QueryValidatorOutstandingRewardsResponse, + QueryValidatorSlashesRequest, + QueryValidatorSlashesResponse, +) + + +class Distribution(ABC): + """Distribution abstract class.""" + + @abstractmethod + def CommunityPool(self) -> QueryCommunityPoolResponse: + """ + CommunityPool queries the community pool coins. + + :return: a QueryCommunityPoolResponse instance + """ + + @abstractmethod + def DelegationTotalRewards( + self, request: QueryDelegationTotalRewardsRequest + ) -> QueryDelegationTotalRewardsResponse: + """ + DelegationTotalRewards queries the total rewards accrued by each validator. + + :param request: a QueryDelegationTotalRewardsRequest instance + :return: a QueryDelegationTotalRewardsResponse instance + """ + + @abstractmethod + def DelegationRewards( + self, request: QueryDelegationRewardsRequest + ) -> QueryDelegationRewardsResponse: + """ + DelegationRewards queries the total rewards accrued by a delegation. + + :param request: a QueryDelegationRewardsRequest instance + :return: a QueryDelegationRewardsResponse instance + """ + + @abstractmethod + def DelegatorValidators( + self, request: QueryDelegatorValidatorsRequest + ) -> QueryDelegatorValidatorsResponse: + """ + DelegatorValidators queries the validators of a delegator. + + :param request: a QueryDelegatorValidatorsRequest instance + :return: a QueryDelegatorValidatorsResponse instance + """ + + @abstractmethod + def DelegatorWithdrawAddress( + self, request: QueryDelegatorWithdrawAddressRequest + ) -> QueryDelegatorWithdrawAddressResponse: + """ + DelegatorWithdrawAddress queries withdraw address of a delegator. + + :param request: a QueryDelegatorWithdrawAddressRequest instance + :return: a QueryDelegatorWithdrawAddressResponse instance + """ + + @abstractmethod + def Params(self) -> QueryParamsResponse: + """ + Params queries params of the distribution module. + + :return: a QueryParamsResponse instance + """ + + @abstractmethod + def ValidatorCommission( + self, request: QueryValidatorCommissionRequest + ) -> QueryValidatorCommissionResponse: + """ + ValidatorCommission queries accumulated commission for a validator. + + :param request: QueryValidatorCommissionRequest + :return: QueryValidatorCommissionResponse + """ + + @abstractmethod + def ValidatorOutstandingRewards( + self, request: QueryValidatorOutstandingRewardsRequest + ) -> QueryValidatorOutstandingRewardsResponse: + """ + ValidatorOutstandingRewards queries rewards of a validator address. + + :param request: QueryValidatorOutstandingRewardsRequest + :return: QueryValidatorOutstandingRewardsResponse + """ + + @abstractmethod + def ValidatorSlashes( + self, request: QueryValidatorSlashesRequest + ) -> QueryValidatorSlashesResponse: + """ + ValidatorSlashes queries slash events of a validator. + + :param request: QueryValidatorSlashesRequest + :return: QueryValidatorSlashesResponse + """ diff --git a/v4-client-py/v4_client_py/chain/distribution/rest_client.py b/v4-client-py/v4_client_py/chain/distribution/rest_client.py new file mode 100644 index 00000000..15f06159 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/distribution/rest_client.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Implementation of Distribution interface using REST.""" + +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.distribution.v1beta1.query_pb2 import ( + QueryCommunityPoolResponse, + QueryDelegationRewardsRequest, + QueryDelegationRewardsResponse, + QueryDelegationTotalRewardsRequest, + QueryDelegationTotalRewardsResponse, + QueryDelegatorValidatorsRequest, + QueryDelegatorValidatorsResponse, + QueryDelegatorWithdrawAddressRequest, + QueryDelegatorWithdrawAddressResponse, + QueryParamsResponse, + QueryValidatorCommissionRequest, + QueryValidatorCommissionResponse, + QueryValidatorOutstandingRewardsRequest, + QueryValidatorOutstandingRewardsResponse, + QueryValidatorSlashesRequest, + QueryValidatorSlashesResponse, +) + +from .interface import Distribution +from ..common.rest_client import RestClient + +class DistributionRestClient(Distribution): + """Distribution REST client.""" + + API_URL = "/cosmos/distribution/v1beta1" + + def __init__(self, rest_api: RestClient) -> None: + """ + Initialize. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def CommunityPool(self) -> QueryCommunityPoolResponse: + """ + CommunityPool queries the community pool coins. + + :return: a QueryCommunityPoolResponse instance + """ + json_response = self._rest_api.get(f"{self.API_URL}/community_pool") + return Parse(json_response, QueryCommunityPoolResponse()) + + def DelegationTotalRewards( + self, request: QueryDelegationTotalRewardsRequest + ) -> QueryDelegationTotalRewardsResponse: + """ + DelegationTotalRewards queries the total rewards accrued by each validator. + + :param request: a QueryDelegationTotalRewardsRequest instance + :return: a QueryDelegationTotalRewardsResponse instance + """ + json_response = self._rest_api.get( + f"{self.API_URL}/delegators/{request.delegator_address}/rewards" + ) + return Parse(json_response, QueryDelegationTotalRewardsResponse()) + + def DelegationRewards( + self, request: QueryDelegationRewardsRequest + ) -> QueryDelegationRewardsResponse: + """ + DelegationRewards queries the total rewards accrued by a delegation. + + :param request: a QueryDelegationRewardsRequest instance + :return: a QueryDelegationRewardsResponse instance + """ + json_response = self._rest_api.get( + f"{self.API_URL}/delegators/{request.delegator_address}/rewards/{request.validator_address}" + ) + return Parse(json_response, QueryDelegationRewardsResponse()) + + def DelegatorValidators( + self, request: QueryDelegatorValidatorsRequest + ) -> QueryDelegatorValidatorsResponse: + """ + DelegatorValidators queries the validators of a delegator. + + :param request: a QueryDelegatorValidatorsRequest instance + :return: a QueryDelegatorValidatorsResponse instance + """ + json_response = self._rest_api.get( + f"{self.API_URL}/delegators/{request.delegator_address}/validators" + ) + return Parse(json_response, QueryDelegatorValidatorsResponse()) + + def DelegatorWithdrawAddress( + self, request: QueryDelegatorWithdrawAddressRequest + ) -> QueryDelegatorWithdrawAddressResponse: + """ + DelegatorWithdrawAddress queries withdraw address of a delegator. + + :param request: a QueryDelegatorWithdrawAddressRequest instance + :return: a QueryDelegatorWithdrawAddressResponse instance + """ + json_response = self._rest_api.get( + f"{self.API_URL}/delegators/{request.delegator_address}/withdraw_address" + ) + return Parse(json_response, QueryDelegatorWithdrawAddressResponse()) + + def Params(self) -> QueryParamsResponse: + """ + Params queries params of the distribution module. + + :return: a QueryParamsResponse instance + """ + json_response = self._rest_api.get(f"{self.API_URL}/params") + return Parse(json_response, QueryParamsResponse()) + + def ValidatorCommission( + self, request: QueryValidatorCommissionRequest + ) -> QueryValidatorCommissionResponse: + """ + ValidatorCommission queries accumulated commission for a validator. + + :param request: QueryValidatorCommissionRequest + :return: QueryValidatorCommissionResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validators/{request.validator_address}/commission" + ) + return Parse(json_response, QueryValidatorCommissionResponse()) + + def ValidatorOutstandingRewards( + self, request: QueryValidatorOutstandingRewardsRequest + ) -> QueryValidatorOutstandingRewardsResponse: + """ + ValidatorOutstandingRewards queries rewards of a validator address. + + :param request: QueryValidatorOutstandingRewardsRequest + :return: QueryValidatorOutstandingRewardsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validators/{request.validator_address}/outstanding_rewards" + ) + return Parse(json_response, QueryValidatorOutstandingRewardsResponse()) + + def ValidatorSlashes( + self, request: QueryValidatorSlashesRequest + ) -> QueryValidatorSlashesResponse: + """ + ValidatorSlashes queries slash events of a validator. + + :param request: QueryValidatorSlashesRequest + :return: QueryValidatorSlashesResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validators/{request.validator_address}/slashes", + request, + ["validatorAddress"], + ) + return Parse(json_response, QueryValidatorSlashesResponse()) diff --git a/v4-client-py/v4_client_py/chain/evidence/__init__.py b/v4-client-py/v4_client_py/chain/evidence/__init__.py new file mode 100644 index 00000000..9e6eed25 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/evidence/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""This package contains the Evidence modules.""" diff --git a/v4-client-py/v4_client_py/chain/evidence/interface.py b/v4-client-py/v4_client_py/chain/evidence/interface.py new file mode 100644 index 00000000..39af274d --- /dev/null +++ b/v4-client-py/v4_client_py/chain/evidence/interface.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Interface for the Evidence functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.evidence.v1beta1.query_pb2 import ( + QueryAllEvidenceRequest, + QueryAllEvidenceResponse, + QueryEvidenceRequest, + QueryEvidenceResponse, +) + + +class Evidence(ABC): + """Evidence abstract class.""" + + @abstractmethod + def Evidence(self, request: QueryEvidenceRequest) -> QueryEvidenceResponse: + """ + Evidence queries evidence based on evidence hash. + + :param request: QueryEvidenceRequest + + :return: QueryEvidenceResponse + """ + + @abstractmethod + def AllEvidence(self, request: QueryAllEvidenceRequest) -> QueryAllEvidenceResponse: + """ + AllEvidence queries all evidence. + + :param request: QueryAllEvidenceRequest + + :return: QueryAllEvidenceResponse + """ diff --git a/v4-client-py/v4_client_py/chain/evidence/rest_client.py b/v4-client-py/v4_client_py/chain/evidence/rest_client.py new file mode 100644 index 00000000..a62b7889 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/evidence/rest_client.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Implementation of Evidence interface using REST.""" + +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.evidence.v1beta1.query_pb2 import ( + QueryAllEvidenceRequest, + QueryAllEvidenceResponse, + QueryEvidenceRequest, + QueryEvidenceResponse, +) + +from .interface import Evidence +from ..common.rest_client import RestClient + +class EvidenceRestClient(Evidence): + """Evidence REST client.""" + + API_URL = "/cosmos/evidence/v1beta1" + + def __init__(self, rest_api: RestClient) -> None: + """ + Initialize. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def Evidence(self, request: QueryEvidenceRequest) -> QueryEvidenceResponse: + """ + Evidence queries evidence based on evidence hash. + + :param request: QueryEvidenceRequest + + :return: QueryEvidenceResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/evidence/{request.evidence_hash}", + ) + return Parse(json_response, QueryEvidenceResponse()) + + def AllEvidence(self, request: QueryAllEvidenceRequest) -> QueryAllEvidenceResponse: + """ + AllEvidence queries all evidence. + + :param request: QueryAllEvidenceRequest + + :return: QueryAllEvidenceResponse + """ + json_response = self._rest_api.get(f"{self.API_URL}/evidence", request) + return Parse(json_response, QueryAllEvidenceResponse()) diff --git a/v4-client-py/v4_client_py/chain/gov/__init__.py b/v4-client-py/v4_client_py/chain/gov/__init__.py new file mode 100644 index 00000000..b8672493 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/gov/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""This package contains the Gov modules.""" diff --git a/v4-client-py/v4_client_py/chain/gov/interface.py b/v4-client-py/v4_client_py/chain/gov/interface.py new file mode 100644 index 00000000..487fff87 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/gov/interface.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Interface for the Gov functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.gov.v1beta1.query_pb2 import ( + QueryDepositRequest, + QueryDepositResponse, + QueryDepositsRequest, + QueryDepositsResponse, + QueryParamsRequest, + QueryParamsResponse, + QueryProposalRequest, + QueryProposalResponse, + QueryProposalsRequest, + QueryProposalsResponse, + QueryTallyResultRequest, + QueryTallyResultResponse, + QueryVoteRequest, + QueryVoteResponse, + QueryVotesRequest, + QueryVotesResponse, +) + + +class Gov(ABC): + """Gov abstract class.""" + + @abstractmethod + def Proposal(self, request: QueryProposalRequest) -> QueryProposalResponse: + """ + Proposal queries proposal details based on ProposalID. + + :param request: QueryProposalRequest with proposal id + + :return: QueryProposalResponse + """ + + @abstractmethod + def Proposals(self, request: QueryProposalsRequest) -> QueryProposalsResponse: + """ + Proposals queries all proposals based on given status. + + :param request: QueryProposalsRequest + + :return: QueryProposalsResponse + """ + + @abstractmethod + def Vote(self, request: QueryVoteRequest) -> QueryVoteResponse: + """ + Vote queries voted information based on proposalID, voterAddr. + + :param request: QueryVoteRequest with voter and proposal id + + :return: QueryVoteResponse + """ + + @abstractmethod + def Votes(self, request: QueryVotesRequest) -> QueryVotesResponse: + """ + Votes queries votes of a given proposal. + + :param request: QueryVotesResponse with proposal id + + :return: QueryVotesResponse + """ + + @abstractmethod + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Params queries all parameters of the gov module. + + :param request: QueryParamsRequest with params_type + + :return: QueryParamsResponse + """ + + @abstractmethod + def Deposit(self, request: QueryDepositRequest) -> QueryDepositResponse: + """ + Deposit queries single deposit information based proposalID, depositAddr. + + :param request: QueryDepositRequest with depositor and proposal_id + + :return: QueryDepositResponse + """ + + @abstractmethod + def Deposits(self, request: QueryDepositsRequest) -> QueryDepositsResponse: + """Deposits queries all deposits of a single proposal. + + :param request: QueryDepositsRequest with proposal_id + + :return: QueryDepositsResponse + """ + + @abstractmethod + def TallyResult(self, request: QueryTallyResultRequest) -> QueryTallyResultResponse: + """ + Tally Result queries the tally of a proposal vote. + + :param request: QueryTallyResultRequest with proposal_id + + :return: QueryTallyResultResponse + """ diff --git a/v4-client-py/v4_client_py/chain/gov/rest_client.py b/v4-client-py/v4_client_py/chain/gov/rest_client.py new file mode 100644 index 00000000..d159b833 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/gov/rest_client.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Implementation of Gov interface using REST.""" + +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.gov.v1beta1.query_pb2 import ( + QueryDepositRequest, + QueryDepositResponse, + QueryDepositsRequest, + QueryDepositsResponse, + QueryParamsRequest, + QueryParamsResponse, + QueryProposalRequest, + QueryProposalResponse, + QueryProposalsRequest, + QueryProposalsResponse, + QueryTallyResultRequest, + QueryTallyResultResponse, + QueryVoteRequest, + QueryVoteResponse, + QueryVotesRequest, + QueryVotesResponse, +) + +from .interface import Gov +from ..common.rest_client import RestClient + + +class GovRestClient(Gov): + """Gov REST client.""" + + API_URL = "/cosmos/gov/v1beta1" + + def __init__(self, rest_api: RestClient) -> None: + """ + Initialize. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def Proposal(self, request: QueryProposalRequest) -> QueryProposalResponse: + """ + Proposal queries proposal details based on ProposalID. + + :param request: QueryProposalRequest with proposal id + + :return: QueryProposalResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/proposals/{request.proposal_id}", + ) + return Parse(json_response, QueryProposalResponse()) + + def Proposals(self, request: QueryProposalsRequest) -> QueryProposalsResponse: + """ + Proposals queries all proposals based on given status. + + :param request: QueryProposalsRequest + + :return: QueryProposalsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/proposals/", + request, + ) + return Parse(json_response, QueryProposalsResponse()) + + def Vote(self, request: QueryVoteRequest) -> QueryVoteResponse: + """ + Vote queries voted information based on proposalID, voterAddr. + + :param request: QueryVoteRequest with voter and proposal id + + :return: QueryVoteResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/proposals/{request.proposal_id}/votes/{request.voter}" + ) + return Parse(json_response, QueryVoteResponse()) + + def Votes(self, request: QueryVotesRequest) -> QueryVotesResponse: + """ + Votes queries votes of a given proposal. + + :param request: QueryVotesResponse with proposal id + + :return: QueryVotesResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/proposals/{request.proposal_id}/votes/", + request, + ["proposalID"], + ) + return Parse(json_response, QueryVotesResponse()) + + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Params queries all parameters of the gov module. + + :param request: QueryParamsRequest with params_type + + :return: QueryParamsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/params/{request.params_type}" + ) + return Parse(json_response, QueryParamsResponse()) + + def Deposit(self, request: QueryDepositRequest) -> QueryDepositResponse: + """ + Deposit queries single deposit information based proposalID, depositAddr. + + :param request: QueryDepositRequest with depositor and proposal_id + + :return: QueryDepositResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/proposals/{request.proposal_id}/deposits/{request.depositor}" + ) + return Parse(json_response, QueryDepositResponse()) + + def Deposits(self, request: QueryDepositsRequest) -> QueryDepositsResponse: + """Deposits queries all deposits of a single proposal. + + :param request: QueryDepositsRequest with proposal_id + + :return: QueryDepositsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/proposals/{request.proposal_id}/deposits/", + request, + ["proposalID"], + ) + return Parse(json_response, QueryDepositsResponse()) + + def TallyResult(self, request: QueryTallyResultRequest) -> QueryTallyResultResponse: + """ + Tally Result queries the tally of a proposal vote. + + :param request: QueryTallyResultRequest with proposal_id + + :return: QueryTallyResultResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/proposals/{request.proposal_id}/tally" + ) + return Parse(json_response, QueryTallyResultResponse()) diff --git a/v4-client-py/v4_client_py/chain/mint/__init__.py b/v4-client-py/v4_client_py/chain/mint/__init__.py new file mode 100644 index 00000000..07f2f258 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/mint/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""This package contains the Mint module.""" diff --git a/v4-client-py/v4_client_py/chain/mint/interface.py b/v4-client-py/v4_client_py/chain/mint/interface.py new file mode 100644 index 00000000..2d0499f6 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/mint/interface.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Interface for the Mint functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.mint.v1beta1.query_pb2 import ( + QueryAnnualProvisionsResponse, + QueryInflationResponse, + QueryParamsResponse, +) + + +class Mint(ABC): + """Mint abstract class.""" + + @abstractmethod + def AnnualProvisions(self) -> QueryAnnualProvisionsResponse: + """ + AnnualProvisions current minting annual provisions value. + + :return: a QueryAnnualProvisionsResponse instance + """ + + @abstractmethod + def Inflation(self) -> QueryInflationResponse: + """ + Inflation returns the current minting inflation value. + + :return: a QueryInflationResponse instance + """ + + @abstractmethod + def Params(self) -> QueryParamsResponse: + """ + Params returns the total set of minting parameters. + + :return: QueryParamsResponse + """ diff --git a/v4-client-py/v4_client_py/chain/mint/rest_client.py b/v4-client-py/v4_client_py/chain/mint/rest_client.py new file mode 100644 index 00000000..682db225 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/mint/rest_client.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Implementation of Mint interface using REST.""" +import base64 +import json +from typing import Union + +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.mint.v1beta1.query_pb2 import ( + QueryAnnualProvisionsResponse, + QueryInflationResponse, + QueryParamsResponse, +) + +from .interface import Mint +from ..common.rest_client import RestClient +from ..common.utils import json_encode + +def isNumber(value: Union[str, bytes]) -> bool: + """ + Check is string ob bytes is number. + + :param value: str, bytes + :return: bool + """ + try: + float(str(value)) + return True + except ValueError: + return False + + +class MintRestClient(Mint): + """Mint REST client.""" + + API_URL = "/cosmos/mint/v1beta1" + + def __init__(self, rest_api: RestClient) -> None: + """ + Initialize. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def AnnualProvisions(self) -> QueryAnnualProvisionsResponse: + """ + AnnualProvisions current minting annual provisions value. + + :return: a QueryAnnualProvisionsResponse instance + """ + json_response = self._rest_api.get(f"{self.API_URL}/annual_provisions") + # The QueryAnnualProvisionsResponse expect a base64 encoded value + # but the Rest endpoint return digits + j = json.loads(json_response) + if isNumber(j["annual_provisions"]): + j["annual_provisions"] = base64.b64encode( + j["annual_provisions"].encode() + ).decode("utf8") + json_response = json_encode(j).encode("utf-8") + + return Parse(json_response, QueryAnnualProvisionsResponse()) + + def Inflation(self) -> QueryInflationResponse: + """ + Inflation returns the current minting inflation value. + + :return: a QueryInflationResponse instance + """ + json_response = self._rest_api.get(f"{self.API_URL}/inflation") + # The QueryInflationResponse expect a base64 encoded value + # but the Rest endpoint return digits + j = json.loads(json_response) + if isNumber(j["inflation"]): + j["inflation"] = base64.b64encode(j["inflation"].encode()).decode("utf8") + json_response = json_encode(j).encode("utf-8") + + return Parse(json_response, QueryInflationResponse()) + + def Params(self) -> QueryParamsResponse: + """ + Params queries params of the Mint module. + + :return: a QueryParamsResponse instance + """ + json_response = self._rest_api.get(f"{self.API_URL}/params") + return Parse(json_response, QueryParamsResponse()) diff --git a/v4-client-py/v4_client_py/chain/params/__init__.py b/v4-client-py/v4_client_py/chain/params/__init__.py new file mode 100644 index 00000000..973fe3ee --- /dev/null +++ b/v4-client-py/v4_client_py/chain/params/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""This package contains the Params module.""" diff --git a/v4-client-py/v4_client_py/chain/params/interface.py b/v4-client-py/v4_client_py/chain/params/interface.py new file mode 100644 index 00000000..96535c33 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/params/interface.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Interface for the Params functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.params.v1beta1.query_pb2 import ( + QueryParamsRequest, + QueryParamsResponse, +) + + +class Params(ABC): + """Params abstract class.""" + + @abstractmethod + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Params queries a specific Cosmos SDK parameter. + + :param request: QueryParamsRequest + :return: QueryParamsResponse + """ diff --git a/v4-client-py/v4_client_py/chain/params/rest_client.py b/v4-client-py/v4_client_py/chain/params/rest_client.py new file mode 100644 index 00000000..929f7013 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/params/rest_client.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Implementation of Params interface using REST.""" + +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.params.v1beta1.query_pb2 import ( + QueryParamsRequest, + QueryParamsResponse, +) +from .interface import Params +from ..common.rest_client import RestClient + + +class ParamsRestClient(Params): + """Params REST client.""" + + API_URL = "/cosmos/params/v1beta1" + + def __init__(self, rest_api: RestClient) -> None: + """ + Initialize. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Params queries a specific Cosmos SDK parameter. + + :param request: QueryParamsRequest + :return: QueryParamsResponse + """ + json_response = self._rest_api.get(f"{self.API_URL}/params", request) + return Parse(json_response, QueryParamsResponse()) diff --git a/v4-client-py/v4_client_py/chain/slashing/__init__.py b/v4-client-py/v4_client_py/chain/slashing/__init__.py new file mode 100644 index 00000000..4b3d183b --- /dev/null +++ b/v4-client-py/v4_client_py/chain/slashing/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""This package contains the Slashing modules.""" diff --git a/v4-client-py/v4_client_py/chain/slashing/interface.py b/v4-client-py/v4_client_py/chain/slashing/interface.py new file mode 100644 index 00000000..1cabdcdc --- /dev/null +++ b/v4-client-py/v4_client_py/chain/slashing/interface.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Interface for the Slashing functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.slashing.v1beta1.query_pb2 import ( + QueryParamsResponse, + QuerySigningInfoRequest, + QuerySigningInfoResponse, + QuerySigningInfosRequest, + QuerySigningInfosResponse, +) + + +class Slashing(ABC): + """Slashing abstract class.""" + + @abstractmethod + def Params(self) -> QueryParamsResponse: + """ + Params queries the parameters of slashing module. + + :return: QueryParamsResponse + """ + + @abstractmethod + def SigningInfo(self, request: QuerySigningInfoRequest) -> QuerySigningInfoResponse: + """ + SigningInfo queries the signing info of given cons address. + + :param request: QuerySigningInfoRequest + + :return: QuerySigningInfoResponse + """ + + @abstractmethod + def SigningInfos( + self, request: QuerySigningInfosRequest + ) -> QuerySigningInfosResponse: + """ + SigningInfos queries signing info of all validators. + + :param request: QuerySigningInfosRequest + + :return: QuerySigningInfosResponse + """ diff --git a/v4-client-py/v4_client_py/chain/slashing/rest_client.py b/v4-client-py/v4_client_py/chain/slashing/rest_client.py new file mode 100644 index 00000000..00cab1cf --- /dev/null +++ b/v4-client-py/v4_client_py/chain/slashing/rest_client.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Implementation of Slashing interface using REST.""" + +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.slashing.v1beta1.query_pb2 import ( + QueryParamsResponse, + QuerySigningInfoRequest, + QuerySigningInfoResponse, + QuerySigningInfosRequest, + QuerySigningInfosResponse, +) +from .interface import Slashing +from ..common.rest_client import RestClient + +class SlashingRestClient(Slashing): + """Slashing REST client.""" + + API_URL = "/cosmos/slashing/v1beta1" + + def __init__(self, rest_api: RestClient) -> None: + """ + Initialize. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def Params(self) -> QueryParamsResponse: + """ + Params queries the parameters of slashing module. + + :return: QueryParamsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/params", + ) + return Parse(json_response, QueryParamsResponse()) + + def SigningInfo(self, request: QuerySigningInfoRequest) -> QuerySigningInfoResponse: + """ + SigningInfo queries the signing info of given cons address. + + :param request: QuerySigningInfoRequest + + :return: QuerySigningInfoResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/signing_infos/{request.cons_address}", + ) + return Parse(json_response, QuerySigningInfoResponse()) + + def SigningInfos( + self, request: QuerySigningInfosRequest + ) -> QuerySigningInfosResponse: + """ + SigningInfos queries signing info of all validators. + + :param request: QuerySigningInfosRequest + + :return: QuerySigningInfosResponse + """ + json_response = self._rest_api.get(f"{self.API_URL}/signing_infos", request) + return Parse(json_response, QuerySigningInfosResponse()) diff --git a/v4-client-py/v4_client_py/chain/staking/__init__.py b/v4-client-py/v4_client_py/chain/staking/__init__.py new file mode 100644 index 00000000..598043ff --- /dev/null +++ b/v4-client-py/v4_client_py/chain/staking/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""This package contains the Staking modules.""" diff --git a/v4-client-py/v4_client_py/chain/staking/interface.py b/v4-client-py/v4_client_py/chain/staking/interface.py new file mode 100644 index 00000000..662b0454 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/staking/interface.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Interface for the Staking functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.staking.v1beta1.query_pb2 import ( + QueryDelegationRequest, + QueryDelegationResponse, + QueryDelegatorDelegationsRequest, + QueryDelegatorDelegationsResponse, + QueryDelegatorUnbondingDelegationsRequest, + QueryDelegatorUnbondingDelegationsResponse, + QueryDelegatorValidatorRequest, + QueryDelegatorValidatorResponse, + QueryDelegatorValidatorsRequest, + QueryDelegatorValidatorsResponse, + QueryHistoricalInfoRequest, + QueryHistoricalInfoResponse, + QueryParamsRequest, + QueryParamsResponse, + QueryPoolRequest, + QueryPoolResponse, + QueryRedelegationsRequest, + QueryRedelegationsResponse, + QueryUnbondingDelegationRequest, + QueryUnbondingDelegationResponse, + QueryValidatorDelegationsRequest, + QueryValidatorDelegationsResponse, + QueryValidatorRequest, + QueryValidatorResponse, + QueryValidatorsRequest, + QueryValidatorsResponse, + QueryValidatorUnbondingDelegationsRequest, + QueryValidatorUnbondingDelegationsResponse, +) + + +class Staking(ABC): + """Staking abstract class.""" + + @abstractmethod + def Validators(self, request: QueryValidatorsRequest) -> QueryValidatorsResponse: + """ + Query all validators that match the given status. + + :param request: QueryValidatorsRequest + :return: QueryValidatorsResponse + """ + + @abstractmethod + def Validator(self, request: QueryValidatorRequest) -> QueryValidatorResponse: + """ + Query validator info for given validator address. + + :param request: QueryValidatorRequest + :return: QueryValidatorResponse + """ + + @abstractmethod + def ValidatorDelegations( + self, request: QueryValidatorDelegationsRequest + ) -> QueryValidatorDelegationsResponse: + """ + Query delegate info for given validator. + + :param request: QueryValidatorDelegationsRequest + :return: QueryValidatorDelegationsResponse + """ + + @abstractmethod + def ValidatorUnbondingDelegations( + self, request: QueryValidatorUnbondingDelegationsRequest + ) -> QueryValidatorUnbondingDelegationsResponse: + """ + Query unbonding delegations of a validator. + + :param request: ValidatorUnbondingDelegations + :return: QueryValidatorUnbondingDelegationsResponse + """ + + @abstractmethod + def Delegation(self, request: QueryDelegationRequest) -> QueryDelegationResponse: + """ + Query delegate info for given validator delegator pair. + + :param request: QueryDelegationRequest + :return: QueryDelegationResponse + """ + + @abstractmethod + def UnbondingDelegation( + self, request: QueryUnbondingDelegationRequest + ) -> QueryUnbondingDelegationResponse: + """ + UnbondingDelegation queries unbonding info for given validator delegator pair. + + :param request: QueryUnbondingDelegationRequest + :return: QueryUnbondingDelegationResponse + """ + + @abstractmethod + def DelegatorDelegations( + self, request: QueryDelegatorDelegationsRequest + ) -> QueryDelegatorDelegationsResponse: + """ + DelegatorDelegations queries all delegations of a given delegator address. + + :param request: QueryDelegatorDelegationsRequest + :return: QueryDelegatorDelegationsResponse + """ + + @abstractmethod + def DelegatorUnbondingDelegations( + self, request: QueryDelegatorUnbondingDelegationsRequest + ) -> QueryDelegatorUnbondingDelegationsResponse: + """ + DelegatorUnbondingDelegations queries all unbonding delegations of a given delegator address. + + :param request: QueryDelegatorUnbondingDelegationsRequest + :return: QueryDelegatorUnbondingDelegationsResponse + """ + + @abstractmethod + def Redelegations( + self, request: QueryRedelegationsRequest + ) -> QueryRedelegationsResponse: + """ + Redelegations queries redelegations of given address. + + :param request: QueryRedelegationsRequest + :return: QueryRedelegationsResponse + """ + + @abstractmethod + def DelegatorValidators( + self, request: QueryDelegatorValidatorsRequest + ) -> QueryDelegatorValidatorsResponse: + """ + DelegatorValidators queries all validators info for given delegator address. + + :param request: QueryDelegatorValidatorsRequest + :return: QueryDelegatorValidatorsRequest + """ + + @abstractmethod + def DelegatorValidator( + self, request: QueryDelegatorValidatorRequest + ) -> QueryDelegatorValidatorResponse: + """ + DelegatorValidator queries validator info for given delegator validator pair. + + :param request: QueryDelegatorValidatorRequest + :return: QueryDelegatorValidatorResponse + """ + + @abstractmethod + def HistoricalInfo( + self, request: QueryHistoricalInfoRequest + ) -> QueryHistoricalInfoResponse: + """ + HistoricalInfo queries the historical info for given height. + + :param request: QueryHistoricalInfoRequest + :return: QueryHistoricalInfoResponse + """ + + @abstractmethod + def Pool(self, request: QueryPoolRequest) -> QueryPoolResponse: + """ + Pool queries the pool info. + + :param request: QueryPoolRequest + :return: QueryPoolResponse + """ + + @abstractmethod + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Parameters queries the staking parameters. + + :param request: QueryParamsRequest + :return: QueryParamsResponse + """ diff --git a/v4-client-py/v4_client_py/chain/staking/rest_client.py b/v4-client-py/v4_client_py/chain/staking/rest_client.py new file mode 100644 index 00000000..9c42c79e --- /dev/null +++ b/v4-client-py/v4_client_py/chain/staking/rest_client.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Implementation of Staking interface using REST.""" + +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.staking.v1beta1.query_pb2 import ( + QueryDelegationRequest, + QueryDelegationResponse, + QueryDelegatorDelegationsRequest, + QueryDelegatorDelegationsResponse, + QueryDelegatorUnbondingDelegationsRequest, + QueryDelegatorUnbondingDelegationsResponse, + QueryDelegatorValidatorRequest, + QueryDelegatorValidatorResponse, + QueryDelegatorValidatorsRequest, + QueryDelegatorValidatorsResponse, + QueryHistoricalInfoRequest, + QueryHistoricalInfoResponse, + QueryParamsRequest, + QueryParamsResponse, + QueryPoolRequest, + QueryPoolResponse, + QueryRedelegationsRequest, + QueryRedelegationsResponse, + QueryUnbondingDelegationRequest, + QueryUnbondingDelegationResponse, + QueryValidatorDelegationsRequest, + QueryValidatorDelegationsResponse, + QueryValidatorRequest, + QueryValidatorResponse, + QueryValidatorsRequest, + QueryValidatorsResponse, + QueryValidatorUnbondingDelegationsRequest, + QueryValidatorUnbondingDelegationsResponse, +) +from .interface import Staking +from ..common.rest_client import RestClient + + +class StakingRestClient(Staking): + """Staking REST client.""" + + API_URL = "/cosmos/staking/v1beta1" + + def __init__(self, rest_api: RestClient) -> None: + """ + Initialize. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def Validators(self, request: QueryValidatorsRequest) -> QueryValidatorsResponse: + """ + Query all validators that match the given status. + + :param request: QueryValidatorsRequest + :return: QueryValidatorsResponse + """ + json_response = self._rest_api.get(f"{self.API_URL}/validators", request) + return Parse(json_response, QueryValidatorsResponse()) + + def Validator(self, request: QueryValidatorRequest) -> QueryValidatorResponse: + """ + Query validator info for given validator address. + + :param request: QueryValidatorRequest + :return: QueryValidatorResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validators/{request.validator_addr}", + ) + return Parse(json_response, QueryValidatorResponse()) + + def ValidatorDelegations( + self, request: QueryValidatorDelegationsRequest + ) -> QueryValidatorDelegationsResponse: + """ + ValidatorDelegations queries delegate info for given validator. + + :param request: QueryValidatorDelegationsRequest + :return: QueryValidatorDelegationsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validators/{request.validator_addr}/delegations", + request, + ["validatorAddr"], + ) + return Parse(json_response, QueryValidatorDelegationsResponse()) + + def ValidatorUnbondingDelegations( + self, request: QueryValidatorUnbondingDelegationsRequest + ) -> QueryValidatorUnbondingDelegationsResponse: + """ + ValidatorUnbondingDelegations queries unbonding delegations of a validator. + + :param request: ValidatorUnbondingDelegations + :return: QueryValidatorUnbondingDelegationsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validators/{request.validator_addr}/unbonding_delegations", + request, + ["validatorAddr"], + ) + return Parse(json_response, QueryValidatorUnbondingDelegationsResponse()) + + def Delegation(self, request: QueryDelegationRequest) -> QueryDelegationResponse: + """ + Query delegate info for given validator delegator pair. + + :param request: QueryDelegationRequest + :return: QueryDelegationResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validators/{request.validator_addr}/delegations/{request.delegator_addr}", + ) + return Parse(json_response, QueryDelegationResponse()) + + def UnbondingDelegation( + self, request: QueryUnbondingDelegationRequest + ) -> QueryUnbondingDelegationResponse: + """ + UnbondingDelegation queries unbonding info for given validator delegator pair. + + :param request: QueryUnbondingDelegationRequest + :return: QueryUnbondingDelegationResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validators/{request.validator_addr}/delegations/{request.delegator_addr}/unbonding_delegation", + ) + return Parse(json_response, QueryUnbondingDelegationResponse()) + + def DelegatorDelegations( + self, request: QueryDelegatorDelegationsRequest + ) -> QueryDelegatorDelegationsResponse: + """ + DelegatorDelegations queries all delegations of a given delegator address. + + :param request: QueryDelegatorDelegationsRequest + :return: QueryDelegatorDelegationsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/delegations/{request.delegator_addr}", + request, + ["delegatorAddr"], + ) + return Parse(json_response, QueryDelegatorDelegationsResponse()) + + def DelegatorUnbondingDelegations( + self, request: QueryDelegatorUnbondingDelegationsRequest + ) -> QueryDelegatorUnbondingDelegationsResponse: + """ + DelegatorUnbondingDelegations queries all unbonding delegations of a given delegator address. + + :param request: QueryDelegatorUnbondingDelegationsRequest + :return: QueryDelegatorUnbondingDelegationsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/delegators/{request.delegator_addr}/unbonding_delegations", + request, + ["delegatorAddr"], + ) + return Parse(json_response, QueryDelegatorUnbondingDelegationsResponse()) + + def Redelegations( + self, request: QueryRedelegationsRequest + ) -> QueryRedelegationsResponse: + """ + Redelegations queries redelegations of given address. + + :param request: QueryRedelegationsRequest + :return: QueryRedelegationsResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/delegators/{request.delegator_addr}/redelegations", + request, + ["delegatorAddr"], + ) + return Parse(json_response, QueryRedelegationsResponse()) + + def DelegatorValidators( + self, request: QueryDelegatorValidatorsRequest + ) -> QueryDelegatorValidatorsResponse: + """ + DelegatorValidators queries all validators info for given delegator address. + + :param request: QueryDelegatorValidatorsRequest + :return: QueryDelegatorValidatorsRequest + """ + json_response = self._rest_api.get( + f"{self.API_URL}/delegators/{request.delegator_addr}/validators", + request, + ["delegatorAddr"], + ) + return Parse(json_response, QueryDelegatorValidatorsResponse()) + + def DelegatorValidator( + self, request: QueryDelegatorValidatorRequest + ) -> QueryDelegatorValidatorResponse: + """ + DelegatorValidator queries validator info for given delegator validator pair. + + :param request: QueryDelegatorValidatorRequest + :return: QueryDelegatorValidatorResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/delegators/{request.delegator_addr}/validators/{request.validator_addr}", + ) + return Parse(json_response, QueryDelegatorValidatorResponse()) + + def HistoricalInfo( + self, request: QueryHistoricalInfoRequest + ) -> QueryHistoricalInfoResponse: + """ + HistoricalInfo queries the historical info for given height. + + :param request: QueryHistoricalInfoRequest + :return: QueryHistoricalInfoResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/historical_info/{request.height}" + ) + return Parse(json_response, QueryHistoricalInfoResponse()) + + def Pool(self, request: QueryPoolRequest) -> QueryPoolResponse: + """ + Pool queries the pool info. + + :param request: QueryPoolRequest + :return: QueryPoolResponse + """ + json_response = self._rest_api.get(f"{self.API_URL}/pool") + return Parse(json_response, QueryPoolResponse()) + + def Params(self, request: QueryParamsRequest) -> QueryParamsResponse: + """ + Parameters queries the staking parameters. + + :param request: QueryParamsRequest + :return: QueryParamsResponse + """ + json_response = self._rest_api.get(f"{self.API_URL}/params") + return Parse(json_response, QueryParamsResponse()) diff --git a/v4-client-py/v4_client_py/chain/tendermint/__init__.py b/v4-client-py/v4_client_py/chain/tendermint/__init__.py new file mode 100644 index 00000000..6bf2a972 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/tendermint/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""This package contains the Cosmos Base Tendermint modules.""" diff --git a/v4-client-py/v4_client_py/chain/tendermint/interface.py b/v4-client-py/v4_client_py/chain/tendermint/interface.py new file mode 100644 index 00000000..84e205d8 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/tendermint/interface.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Interface for the Cosmos Base Tendermint functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.base.tendermint.v1beta1.query_pb2 import ( + GetBlockByHeightRequest, + GetBlockByHeightResponse, + GetLatestBlockRequest, + GetLatestBlockResponse, + GetLatestValidatorSetRequest, + GetLatestValidatorSetResponse, + GetNodeInfoRequest, + GetNodeInfoResponse, + GetSyncingRequest, + GetSyncingResponse, + GetValidatorSetByHeightRequest, + GetValidatorSetByHeightResponse, +) + + +class CosmosBaseTendermint(ABC): + """Cosmos Base Tendermint abstract class.""" + + @abstractmethod + def GetNodeInfo(self, request: GetNodeInfoRequest) -> GetNodeInfoResponse: + """ + GetNodeInfo queries the current node info. + + :param request: GetNodeInfoRequest + :return: GetNodeInfoResponse + """ + + @abstractmethod + def GetSyncing(self, request: GetSyncingRequest) -> GetSyncingResponse: + """ + GetSyncing queries node syncing. + + :param request: GetSyncingRequest + :return: GetSyncingResponse + """ + + @abstractmethod + def GetLatestBlock(self, request: GetLatestBlockRequest) -> GetLatestBlockResponse: + """ + GetLatestBlock returns the latest block. + + :param request: GetLatestBlockRequest + :return: GetLatestBlockResponse + """ + + @abstractmethod + def GetBlockByHeight( + self, request: GetBlockByHeightRequest + ) -> GetBlockByHeightResponse: + """ + GetBlockByHeight queries block for given height. + + :param request: GetBlockByHeightRequest + :return: GetBlockByHeightResponse + """ + + @abstractmethod + def GetLatestValidatorSet( + self, request: GetLatestValidatorSetRequest + ) -> GetLatestValidatorSetResponse: + """ + GetLatestValidatorSet queries latest validator-set. + + :param request: GetLatestValidatorSetRequest + :return: GetLatestValidatorSetResponse + """ + + @abstractmethod + def GetValidatorSetByHeight( + self, request: GetValidatorSetByHeightRequest + ) -> GetValidatorSetByHeightResponse: + """ + GetValidatorSetByHeight queries validator-set at a given height. + + :param request: GetValidatorSetByHeightRequest + :return: GetValidatorSetByHeightResponse + """ diff --git a/v4-client-py/v4_client_py/chain/tendermint/rest_client.py b/v4-client-py/v4_client_py/chain/tendermint/rest_client.py new file mode 100644 index 00000000..d3500b20 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/tendermint/rest_client.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Implementation of IBC Applications Transfer interface using REST.""" +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.base.tendermint.v1beta1.query_pb2 import ( + GetBlockByHeightRequest, + GetBlockByHeightResponse, + GetLatestBlockRequest, + GetLatestBlockResponse, + GetLatestValidatorSetRequest, + GetLatestValidatorSetResponse, + GetNodeInfoRequest, + GetNodeInfoResponse, + GetSyncingRequest, + GetSyncingResponse, + GetValidatorSetByHeightRequest, + GetValidatorSetByHeightResponse, +) +from .interface import CosmosBaseTendermint +from ..common.rest_client import RestClient + + +class CosmosBaseTendermintRestClient(CosmosBaseTendermint): + """Cosmos Base Tendermint REST client.""" + + API_URL = "/cosmos/base/tendermint/v1beta1" + + def __init__(self, rest_api: RestClient) -> None: + """ + Initialize. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def GetNodeInfo(self, request: GetNodeInfoRequest) -> GetNodeInfoResponse: + """ + GetNodeInfo queries the current node info. + + :param request: GetNodeInfoRequest + :return: GetNodeInfoResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/node_info", + ) + return Parse(json_response, GetNodeInfoResponse()) + + def GetSyncing(self, request: GetSyncingRequest) -> GetSyncingResponse: + """ + GetSyncing queries node syncing. + + :param request: GetSyncingRequest + :return: GetSyncingResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/syncing", + ) + return Parse(json_response, GetSyncingResponse()) + + def GetLatestBlock(self, request: GetLatestBlockRequest) -> GetLatestBlockResponse: + """ + GetLatestBlock returns the latest block. + + :param request: GetLatestBlockRequest + :return: GetLatestBlockResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/blocks/latest", + ) + return Parse(json_response, GetLatestBlockResponse()) + + def GetBlockByHeight( + self, request: GetBlockByHeightRequest + ) -> GetBlockByHeightResponse: + """ + GetBlockByHeight queries block for given height. + + :param request: GetBlockByHeightRequest + :return: GetBlockByHeightResponse + """ + json_response = self._rest_api.get(f"{self.API_URL}/blocks/{request.height}") + return Parse(json_response, GetBlockByHeightResponse()) + + def GetLatestValidatorSet( + self, request: GetLatestValidatorSetRequest + ) -> GetLatestValidatorSetResponse: + """ + GetLatestValidatorSet queries latest validator-set. + + :param request: GetLatestValidatorSetRequest + :return: GetLatestValidatorSetResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validatorsets/latest", request + ) + return Parse(json_response, GetLatestValidatorSetResponse()) + + def GetValidatorSetByHeight( + self, request: GetValidatorSetByHeightRequest + ) -> GetValidatorSetByHeightResponse: + """ + GetValidatorSetByHeight queries validator-set at a given height. + + :param request: GetValidatorSetByHeightRequest + :return: GetValidatorSetByHeightResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/validatorsets/{request.height}", request + ) + return Parse(json_response, GetValidatorSetByHeightResponse()) diff --git a/v4-client-py/v4_client_py/chain/tx/__init__.py b/v4-client-py/v4_client_py/chain/tx/__init__.py new file mode 100644 index 00000000..4439926b --- /dev/null +++ b/v4-client-py/v4_client_py/chain/tx/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""This package contains the Tx modules.""" diff --git a/v4-client-py/v4_client_py/chain/tx/interface.py b/v4-client-py/v4_client_py/chain/tx/interface.py new file mode 100644 index 00000000..d46f5d18 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/tx/interface.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Interface for the Tx functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +import v4_proto.cosmos.tx.v1beta1.service_pb2 as svc + + +class TxInterface(ABC): + """Tx abstract class.""" + + @abstractmethod + def Simulate(self, request: svc.SimulateRequest) -> svc.SimulateResponse: + """ + Simulate executing a transaction to estimate gas usage. + + :param request: SimulateRequest + :return: SimulateResponse + """ + + @abstractmethod + def GetTx(self, request: svc.GetTxRequest) -> svc.GetTxResponse: + """ + GetTx fetches a tx by hash. + + :param request: GetTxRequest + :return: GetTxResponse + """ + + @abstractmethod + def BroadcastTx(self, request: svc.BroadcastTxRequest) -> svc.BroadcastTxResponse: + """ + BroadcastTx broadcast transaction. + + :param request: BroadcastTxRequest + :return: BroadcastTxResponse + """ + + @abstractmethod + def GetTxsEvent(self, request: svc.GetTxsEventRequest) -> svc.GetTxsEventResponse: + """ + GetTxsEvent fetches txs by event. + + :param request: GetTxsEventRequest + :return: GetTxsEventResponse + """ diff --git a/v4-client-py/v4_client_py/chain/tx/rest_client.py b/v4-client-py/v4_client_py/chain/tx/rest_client.py new file mode 100644 index 00000000..8c3cdd72 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/tx/rest_client.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2023 dYdX Trading Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Implementation of Tx interface using REST.""" + +import base64 +import json +from typing import Any, Dict, List + +from google.protobuf.json_format import Parse, ParseDict + +from v4_proto.cosmos.crypto.secp256k1.keys_pb2 import ( # noqa: F401 # pylint: disable=unused-import + PubKey as ProtoPubKey, +) +from v4_proto.cosmos.tx.v1beta1.service_pb2 import ( + BroadcastTxRequest, + BroadcastTxResponse, + GetTxRequest, + GetTxResponse, + GetTxsEventRequest, + GetTxsEventResponse, + SimulateRequest, + SimulateResponse, +) + +from .interface import TxInterface +from ..common.rest_client import RestClient + +# Unused imports are required to make sure that related types get generated - Parse and ParseDict fail without them + + +class TxRestClient(TxInterface): + """Tx REST client.""" + + API_URL = "/cosmos/tx/v1beta1" + + def __init__(self, rest_client: RestClient) -> None: + """ + Create a Tx rest client. + + :param rest_client: RestClient api + """ + self.rest_client = rest_client + + def Simulate(self, request: SimulateRequest) -> SimulateResponse: + """ + Simulate executing a transaction to estimate gas usage. + + :param request: SimulateRequest + :return: SimulateResponse + """ + response = self.rest_client.post( + f"{self.API_URL}/simulate", + request, + ) + return Parse(response, SimulateResponse()) + + def GetTx(self, request: GetTxRequest) -> GetTxResponse: + """ + GetTx fetches a tx by hash. + + :param request: GetTxRequest + :return: GetTxResponse + """ + response = self.rest_client.get(f"{self.API_URL}/txs/{request.hash}") + + # JSON in case of CosmWasm messages workaround + dict_response = json.loads(response) + self._fix_messages(dict_response["tx"]["body"]["messages"]) + self._fix_messages(dict_response["tx_response"]["tx"]["body"]["messages"]) + + return ParseDict(dict_response, GetTxResponse()) + + def BroadcastTx(self, request: BroadcastTxRequest) -> BroadcastTxResponse: + """ + BroadcastTx broadcast transaction. + + :param request: BroadcastTxRequest + :return: BroadcastTxResponse + """ + response = self.rest_client.post(f"{self.API_URL}/txs", request) + return Parse(response, BroadcastTxResponse()) + + def GetTxsEvent(self, request: GetTxsEventRequest) -> GetTxsEventResponse: + """ + GetTxsEvent fetches txs by event. + + :param request: GetTxsEventRequest + :return: GetTxsEventResponse + """ + response = self.rest_client.get(f"{self.API_URL}/txs", request) + + # JSON in case of CosmWasm messages workaround + dict_response = json.loads(response) + for tx in dict_response["txs"]: + self._fix_messages(tx["body"]["messages"]) + + for tx_response in dict_response["tx_responses"]: + self._fix_messages(tx_response["tx"]["body"]["messages"]) + + return ParseDict(dict_response, GetTxsEventResponse()) diff --git a/v4-client-py/v4_client_py/chain/upgrade/__init__.py b/v4-client-py/v4_client_py/chain/upgrade/__init__.py new file mode 100644 index 00000000..0d8de346 --- /dev/null +++ b/v4-client-py/v4_client_py/chain/upgrade/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""This package contains the Cosmos Upgrade modules.""" diff --git a/v4-client-py/v4_client_py/chain/upgrade/interface.py b/v4-client-py/v4_client_py/chain/upgrade/interface.py new file mode 100644 index 00000000..972caf8f --- /dev/null +++ b/v4-client-py/v4_client_py/chain/upgrade/interface.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Interface for the Cosmos Upgrade functionality of CosmosSDK.""" + +from abc import ABC, abstractmethod + +from v4_proto.cosmos.upgrade.v1beta1.query_pb2 import ( + QueryAppliedPlanRequest, + QueryAppliedPlanResponse, + QueryCurrentPlanRequest, + QueryCurrentPlanResponse, +) + + +class CosmosUpgrade(ABC): + """Cosmos Upgrade abstract class.""" + + @abstractmethod + def CurrentPlan(self, request: QueryCurrentPlanRequest) -> QueryCurrentPlanResponse: + """ + CurrentPlan queries the current upgrade plan. + + :param request: QueryCurrentPlanRequest + :return: QueryCurrentPlanResponse + """ + + @abstractmethod + def AppliedPlan(self, request: QueryAppliedPlanRequest) -> QueryAppliedPlanResponse: + """ + AppliedPlan queries a previously applied upgrade plan by its name. + + :param request: QueryAppliedPlanRequest + :return: QueryAppliedPlanResponse + """ diff --git a/v4-client-py/v4_client_py/chain/upgrade/rest_client.py b/v4-client-py/v4_client_py/chain/upgrade/rest_client.py new file mode 100644 index 00000000..ce0a934a --- /dev/null +++ b/v4-client-py/v4_client_py/chain/upgrade/rest_client.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Implementation of IBC Applications Transfer interface using REST.""" +from google.protobuf.json_format import Parse + +from v4_proto.cosmos.upgrade.v1beta1.query_pb2 import ( + QueryAppliedPlanRequest, + QueryAppliedPlanResponse, + QueryCurrentPlanRequest, + QueryCurrentPlanResponse, +) +from .interface import CosmosUpgrade +from ..common.rest_client import RestClient + + +class CosmosUpgradeRestClient(CosmosUpgrade): + """Cosmos Upgrade REST client.""" + + API_URL = "/cosmos/upgrade/v1beta1" + + def __init__(self, rest_api: RestClient) -> None: + """ + Initialize. + + :param rest_api: RestClient api + """ + self._rest_api = rest_api + + def CurrentPlan(self, request: QueryCurrentPlanRequest) -> QueryCurrentPlanResponse: + """ + CurrentPlan queries the current upgrade plan. + + :param request: QueryCurrentPlanRequest + :return: QueryCurrentPlanResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/current_plan", + ) + return Parse(json_response, QueryCurrentPlanResponse()) + + def AppliedPlan(self, request: QueryAppliedPlanRequest) -> QueryAppliedPlanResponse: + """ + AppliedPlan queries a previously applied upgrade plan by its name. + + :param request: QueryAppliedPlanRequest + :return: QueryAppliedPlanResponse + """ + json_response = self._rest_api.get( + f"{self.API_URL}/applied_plan/{request.name}", request + ) + return Parse(json_response, QueryAppliedPlanResponse()) diff --git a/v4-client-py/v4_client_py/clients/__init__.py b/v4-client-py/v4_client_py/clients/__init__.py new file mode 100644 index 00000000..e482179c --- /dev/null +++ b/v4-client-py/v4_client_py/clients/__init__.py @@ -0,0 +1,6 @@ +from .dydx_indexer_client import IndexerClient +from .dydx_socket_client import SocketClient +from .dydx_faucet_client import FaucetClient +from .dydx_validator_client import ValidatorClient +from .dydx_composite_client import CompositeClient +from .dydx_subaccount import Subaccount \ No newline at end of file diff --git a/v4-client-py/v4_client_py/clients/composer.py b/v4-client-py/v4_client_py/clients/composer.py new file mode 100644 index 00000000..cbd909b6 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/composer.py @@ -0,0 +1,214 @@ + + +from v4_proto.dydxprotocol.clob.tx_pb2 import MsgPlaceOrder, MsgCancelOrder +from v4_proto.dydxprotocol.clob.order_pb2 import Order, OrderId +from v4_proto.dydxprotocol.subaccounts.subaccount_pb2 import SubaccountId +from v4_proto.dydxprotocol.sending.transfer_pb2 import Transfer, MsgWithdrawFromSubaccount, MsgDepositToSubaccount +from v4_proto.dydxprotocol.sending.tx_pb2 import MsgCreateTransfer + +from v4_client_py.clients.helpers.chain_helpers import is_order_flag_stateful_order, validate_good_til_fields + + +class Composer: + def compose_msg_place_order( + self, + address: str, + subaccount_number: int, + client_id: int, + clob_pair_id: int, + order_flags: int, + good_til_block: int, + good_til_block_time: int, + side: Order.Side, + quantums: int, + subticks: int, + time_in_force: Order.TimeInForce, + reduce_only: bool, + client_metadata: int, + condition_type: Order.ConditionType, + conditional_order_trigger_subticks: int, + ) -> MsgPlaceOrder: + ''' + Compose a place order message + + :param address: required + :type address: str + + :param subaccount_number: required + :type subaccount_number: int + + :param client_id: required + :type client_id: int + + :param clob_pair_id: required + :type clob_pair_id: int + + :param order_flags: required + :type order_flags: int + + :param good_til_block: required + :type good_til_block: int + + :param good_til_block_time: required + :type good_til_block_time: int + + :param side: required + :type side: Order.Side + + :param quantums: required + :type quantums: int + + :param subticks: required + :type subticks: int + + :param time_in_force: required + :type time_in_force: int + + :param reduce_only: required + :type reduce_only: bool + + :param client_metadata: required + :type client_metadata: int + + :param condition_type: required + :type condition_type: int + + :param conditional_order_trigger_subticks: required + :type conditional_order_trigger_subticks: int + + :returns: Place order message, to be sent to chain + ''' + subaccount_id = SubaccountId(owner=address, number=subaccount_number) + + is_stateful_order = is_order_flag_stateful_order(order_flags) + validate_good_til_fields(is_stateful_order, good_til_block_time, good_til_block) + + order_id = OrderId( + subaccount_id=subaccount_id, + client_id=client_id, + order_flags=order_flags, + clob_pair_id=int(clob_pair_id) + ) + + order = Order( + order_id=order_id, + side=side, + quantums=quantums, + subticks=subticks, + good_til_block=good_til_block, + time_in_force=time_in_force.value, + reduce_only=reduce_only, + client_metadata=client_metadata, + condition_type=condition_type, + conditional_order_trigger_subticks=conditional_order_trigger_subticks, + ) if (good_til_block != 0) else Order( + order_id=order_id, + side=side, + quantums=quantums, + subticks=subticks, + good_til_block_time=good_til_block_time, + time_in_force=time_in_force.value, + reduce_only=reduce_only, + client_metadata=client_metadata, + condition_type=condition_type, + conditional_order_trigger_subticks=conditional_order_trigger_subticks, + ) + return MsgPlaceOrder(order=order) + + def compose_msg_cancel_order( + self, + address: str, + subaccount_number: int, + client_id: int, + clob_pair_id: int, + order_flags: int, + good_til_block: int, + good_til_block_time: int, + ) -> MsgCancelOrder: + ''' + Compose a cancel order messasge + + :param address: required + :type address: str + + :param subaccount_number: required + :type subaccount_number: int + + :param client_id: required + :type client_id: int + + :param clob_pair_id: required + :type clob_pair_id: int + + :param order_flags: required + :type order_flags: int + + :param good_til_block: required + :type good_til_block: int + + :param good_til_block_time: required + :type good_til_block_time: int + + + :returns: Tx information + ''' + subaccount_id = SubaccountId(owner=address, number=subaccount_number) + is_stateful_order = is_order_flag_stateful_order(order_flags) + validate_good_til_fields(is_stateful_order, good_til_block_time, good_til_block) + + order_id = OrderId( + subaccount_id=subaccount_id, + client_id=client_id, + order_flags=order_flags, + clob_pair_id=int(clob_pair_id) + ) + + if is_stateful_order: + return MsgCancelOrder( + order_id=order_id, + good_til_block_time=good_til_block_time + ) + return MsgCancelOrder( + order_id=order_id, + good_til_block=good_til_block + ) + + def compose_msg_transfer( + self, + address: str, + subaccount_number: int, + recipient_address: str, + recipient_subaccount_number: int, + asset_id: int, + amount: int + ) -> MsgCreateTransfer: + sender = SubaccountId(owner=address, number=subaccount_number) + recipient = SubaccountId(owner=recipient_address, number=recipient_subaccount_number) + + transfer = Transfer(sender=sender, recipient=recipient, asset_id=asset_id, amount=amount) + + return MsgCreateTransfer(transfer=transfer) + + + def compose_msg_deposit_to_subaccount( + self, + address: str, + subaccount_number: int, + asset_id: int, + quantums: int + ) -> MsgDepositToSubaccount: + recipient = SubaccountId(owner=address, number=subaccount_number) + + return MsgDepositToSubaccount(sender=address, recipient=recipient, asset_id=asset_id, quantums=quantums) + + + def compose_msg_withdraw_from_subaccount( + self, + address: str, + subaccount_number: int, + asset_id: int, + quantums: int + ) -> MsgWithdrawFromSubaccount: + sender = SubaccountId(owner=address, number=subaccount_number) + + return MsgWithdrawFromSubaccount(sender=sender, recipient=address, asset_id=asset_id, quantums=quantums) diff --git a/v4-client-py/v4_client_py/clients/constants.py b/v4-client-py/v4_client_py/clients/constants.py new file mode 100644 index 00000000..4e0d202e --- /dev/null +++ b/v4-client-py/v4_client_py/clients/constants.py @@ -0,0 +1,135 @@ +from enum import Enum + +# define constants +VALIDATOR_GRPC_ENDPOINT = '{get from deployer}' +AERIAL_CONFIG_URL = '{get from deployer}' +AERIAL_GRPC_OR_REST_PREFIX = '{get from deployer}' +INDEXER_REST_ENDPOINT = '{get from deployer}' +INDEXER_WS_ENDPOINT = '{get from deployer}' +CHAIN_ID = '{get from deployer}' +ENV = '{get from deployer}' + +# ------------ Market Statistic Day Types ------------ +MARKET_STATISTIC_DAY_ONE = "1" +MARKET_STATISTIC_DAY_SEVEN = "7" +MARKET_STATISTIC_DAY_THIRTY = "30" + +# ------------ Order Types ------------ +ORDER_TYPE_LIMIT = "LIMIT" +ORDER_TYPE_MARKET = "MARKET" +ORDER_TYPE_STOP = "STOP_LIMIT" +ORDER_TYPE_TRAILING_STOP = "TRAILING_STOP" +ORDER_TYPE_TAKE_PROFIT = "TAKE_PROFIT" + +# ------------ Order Side ------------ +ORDER_SIDE_BUY = "BUY" +ORDER_SIDE_SELL = "SELL" + +# ------------ Time in Force Types ------------ +TIME_IN_FORCE_GTT = "GTT" +TIME_IN_FORCE_FOK = "FOK" +TIME_IN_FORCE_IOC = "IOC" + +# ------------ Position Status Types ------------ +POSITION_STATUS_OPEN = "OPEN" +POSITION_STATUS_CLOSED = "CLOSED" +POSITION_STATUS_LIQUIDATED = "LIQUIDATED" + +# ------------ Order Status Types ------------ +ORDER_STATUS_PENDING = "PENDING" +ORDER_STATUS_OPEN = "OPEN" +ORDER_STATUS_FILLED = "FILLED" +ORDER_STATUS_CANCELED = "CANCELED" +ORDER_STATUS_UNTRIGGERED = "UNTRIGGERED" + +# ------------ Transfer Status Types ------------ +TRANSFER_STATUS_PENDING = "PENDING" +TRANSFER_STATUS_CONFIRMED = "CONFIRMED" +TRANSFER_STATUS_QUEUED = "QUEUED" +TRANSFER_STATUS_CANCELED = "CANCELED" +TRANSFER_STATUS_UNCONFIRMED = "UNCONFIRMED" + +# ------------ Markets ------------ +MARKET_BTC_USD = "BTC-USD" +MARKET_ETH_USD = "ETH-USD" + + +# ------------ Assets ------------ +ASSET_USDC = "USDC" +ASSET_BTC = "BTC" +ASSET_ETH = "ETH" +COLLATERAL_ASSET = ASSET_USDC + +# ------------ Synthetic Assets by Market ------------ +SYNTHETIC_ASSET_MAP = { + MARKET_BTC_USD: ASSET_BTC, + MARKET_ETH_USD: ASSET_ETH, +} + +# ------------ API Defaults ------------ +DEFAULT_API_TIMEOUT = 3000 + +MAX_MEMO_CHARACTERS = 256 + +BECH32_PREFIX = "dydx" + + +class BroadcastMode(Enum): + BroadcastTxSync = 0 + BroadcastTxCommit = 1 + + +class IndexerConfig: + def __init__( + self, + rest_endpoint: str, + websocket_endpoint: str, + ): + if rest_endpoint.endswith("/"): + rest_endpoint = rest_endpoint[:-1] + self.rest_endpoint = rest_endpoint + self.websocket_endpoint = websocket_endpoint + + +class ValidatorConfig: + def __init__(self, grpc_endpoint: str, chain_id: str, ssl_enabled: bool, url_prefix: str, aerial_url: str): + self.grpc_endpoint = grpc_endpoint + self.chain_id = chain_id + self.ssl_enabled = ssl_enabled + self.url_prefix = url_prefix + self.url = aerial_url + + +class Network: + def __init__( + self, + env: str, + validator_config: ValidatorConfig, + indexer_config: IndexerConfig, + faucet_endpoint: str, + ): + self.env = env + self.validator_config = validator_config + self.indexer_config = indexer_config + if faucet_endpoint is not None and faucet_endpoint.endswith("/"): + faucet_endpoint = faucet_endpoint[:-1] + self.faucet_endpoint = faucet_endpoint + + @classmethod + def config_network( + cls + ) -> "Network": + validator_config = ValidatorConfig( + grpc_endpoint=VALIDATOR_GRPC_ENDPOINT, + chain_id=CHAIN_ID, + ssl_enabled=True, + url_prefix=AERIAL_GRPC_OR_REST_PREFIX, + aerial_url=AERIAL_CONFIG_URL, + ) + indexer_config = IndexerConfig(rest_endpoint=INDEXER_REST_ENDPOINT, websocket_endpoint=INDEXER_WS_ENDPOINT) + return cls( + env=ENV, + validator_config=validator_config, + indexer_config=indexer_config, + faucet_endpoint=None, + ) diff --git a/v4-client-py/v4_client_py/clients/dydx_composite_client.py b/v4-client-py/v4_client_py/clients/dydx_composite_client.py new file mode 100644 index 00000000..627e50f1 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/dydx_composite_client.py @@ -0,0 +1,591 @@ +from typing import Tuple +import grpc + +from datetime import datetime, timedelta + +from v4_proto.dydxprotocol.clob.tx_pb2 import MsgPlaceOrder +from v4_client_py.clients.helpers.chain_helpers import ( + QUOTE_QUANTUMS_ATOMIC_RESOLUTION, + Order, + Order_TimeInForce, + OrderType, + OrderSide, + OrderTimeInForce, + OrderExecution, + calculate_side, + calculate_quantums, + calculate_subticks, + calculate_time_in_force, + calculate_order_flags, + ORDER_FLAGS_SHORT_TERM, + SHORT_BLOCK_WINDOW, + is_order_flag_stateful_order, +) + +from v4_client_py.clients.constants import Network +from v4_client_py.clients.dydx_indexer_client import IndexerClient +from v4_client_py.clients.dydx_validator_client import ValidatorClient +from v4_client_py.clients.dydx_subaccount import Subaccount + +from v4_client_py.chain.aerial.tx_helpers import SubmittedTx + + +class CompositeClient: + def __init__( + self, + network: Network, + api_timeout=None, + send_options=None, + credentials=grpc.ssl_channel_credentials(), + ): + self.indexer_client = IndexerClient(network.indexer_config, api_timeout, send_options) + self.validator_client = ValidatorClient(network.validator_config, credentials) + + def get_current_block(self) -> int: + response = self.validator_client.get.latest_block() + return response.block.header.height + + def calculate_good_til_block_time(self, good_til_time_in_seconds: int) -> int: + now = datetime.now() + interval = timedelta(seconds=good_til_time_in_seconds) + future = now + interval + return int(future.timestamp()) + + # Helper function to generate the corresponding + # good_til_block, good_til_block_time fields to construct an order. + # good_til_block is the exact block number the short term order will expire on. + # good_til_time_in_seconds is the number of seconds until the stateful order expires. + def generate_good_til_fields( + self, + order_flags: int, + good_til_block: int, + good_til_time_in_seconds: int, + ) -> Tuple[int, int]: + if is_stateful_order := is_order_flag_stateful_order(order_flags): + return 0, self.calculate_good_til_block_time(good_til_time_in_seconds) + else: + return good_til_block, 0 + + def validate_good_til_block(self, good_til_block: int) -> None: + response = self.validator_client.get.latest_block() + next_valid_block_height = response.block.header.height + 1 + lower_bound = next_valid_block_height + upper_bound = next_valid_block_height + SHORT_BLOCK_WINDOW + if good_til_block < lower_bound or good_til_block > upper_bound: + raise Exception( + f"Invalid Short-Term order GoodTilBlock. " + f"Should be greater-than-or-equal-to {lower_bound} " + f"and less-than-or-equal-to {upper_bound}. " + f"Provided good til block: {good_til_block}" + ) + + # Only MARKET and LIMIT types are supported right now + # Use human readable form of input, including price and size + # The quantum and subticks are calculated and submitted + + def place_order( + self, + subaccount: Subaccount, + market: str, + type: OrderType, + side: OrderSide, + price: float, + size: float, + client_id: int, + time_in_force: OrderTimeInForce, + good_til_block: int, + good_til_time_in_seconds: int, + execution: OrderExecution, + post_only: bool, + reduce_only: bool, + trigger_price: float = None, + ) -> SubmittedTx: + """ + Place order + + :param subaccount: required + :type subaccount: Subaccount + + :param market: required + :type market: str + + :param side: required + :type side: Order.Side + + :param price: required + :type price: float + + :param size: required + :type size: float + + :param client_id: required + :type client_id: int + + :param time_in_force: required + :type time_in_force: OrderTimeInForce + + :param good_til_block: required + :type good_til_block: int + + :param good_til_time_in_seconds: required + :type good_til_time_in_seconds: int + + :param execution: required + :type execution: OrderExecution + + :param post_only: required + :type post_only: bool + + :param reduce_only: required + :type reduce_only: bool + + :returns: Tx information + """ + msg = self.place_order_message( + subaccount=subaccount, + market=market, + type=type, + side=side, + price=price, + size=size, + client_id=client_id, + time_in_force=time_in_force, + good_til_block=good_til_block, + good_til_time_in_seconds=good_til_time_in_seconds, + execution=execution, + post_only=post_only, + reduce_only=reduce_only, + trigger_price=trigger_price, + ) + return self.validator_client.post.send_message(subaccount=subaccount, msg=msg, zeroFee=True) + + def place_short_term_order( + self, + subaccount: Subaccount, + market: str, + side: OrderSide, + price: float, + size: float, + client_id: int, + good_til_block: int, + time_in_force: Order_TimeInForce, + reduce_only: bool, + ) -> SubmittedTx: + """ + Place Short-Term order + + :param subaccount: required + :type subaccount: Subaccount + + :param market: required + :type market: str + + :param side: required + :type side: Order.Side + + :param price: required + :type price: float + + :param size: required + :type size: float + + :param client_id: required + :type client_id: int + + :param good_til_block: required + :type good_til_block: int + + :param time_in_force: required + :type time_in_force: OrderExecution + + :param reduce_only: required + :type reduce_only: bool + + :returns: Tx information + """ + msg = self.place_short_term_order_message( + subaccount=subaccount, + market=market, + type=type, + side=side, + price=price, + size=size, + client_id=client_id, + good_til_block=good_til_block, + time_in_force=time_in_force, + reduce_only=reduce_only, + ) + return self.validator_client.post.send_message(subaccount=subaccount, msg=msg, zeroFee=True) + + def calculate_client_metadata(self, order_type: OrderType) -> int: + """ + Calculate Client Metadata + + :param order_type: required + :type order_type: OrderType + + :returns: Client Metadata + """ + return ( + 1 + if ( + order_type == OrderType.MARKET + or order_type == OrderType.STOP_MARKET + or order_type == OrderType.TAKE_PROFIT_MARKET + ) + else 0 + ) + + def calculate_condition_type(self, order_type: OrderType) -> Order.ConditionType: + """ + Calculate Condition Type + + :param order_type: required + :type order_type: OrderType + + :returns: Condition Type + """ + if order_type == OrderType.LIMIT: + return Order.CONDITION_TYPE_UNSPECIFIED + elif order_type == OrderType.MARKET: + return Order.CONDITION_TYPE_UNSPECIFIED + elif order_type == OrderType.STOP_LIMIT or order_type == OrderType.STOP_MARKET: + return Order.CONDITION_TYPE_STOP_LOSS + elif order_type == OrderType.TAKE_PROFIT_LIMIT or order_type == OrderType.TAKE_PROFIT_MARKET: + return Order.CONDITION_TYPE_TAKE_PROFIT + else: + raise ValueError("order_type is invalid") + + def calculate_conditional_order_trigger_subticks( + self, + order_type: OrderType, + atomic_resolution: int, + quantum_conversion_exponent: int, + subticks_per_tick: int, + trigger_price: float, + ) -> int: + """ + Calculate Conditional Order Trigger Subticks + + :param order_type: required + :type order_type: OrderType + + :param atomic_resolution: required + :type atomic_resolution: int + + :param quantum_conversion_exponent: required + :type quantum_conversion_exponent: int + + :param subticks_per_tick: required + :type subticks_per_tick: int + + :param trigger_price: required + :type trigger_price: float + + :returns: Conditional Order Trigger Subticks + """ + if order_type == OrderType.LIMIT or order_type == OrderType.MARKET: + return 0 + elif ( + order_type == OrderType.STOP_LIMIT + or order_type == OrderType.STOP_MARKET + or order_type == OrderType.TAKE_PROFIT_LIMIT + or order_type == OrderType.TAKE_PROFIT_MARKET + ): + if trigger_price is None: + raise ValueError("trigger_price is required for conditional orders") + return calculate_subticks(trigger_price, atomic_resolution, quantum_conversion_exponent, subticks_per_tick) + else: + raise ValueError("order_type is invalid") + + def place_order_message( + self, + subaccount: Subaccount, + market: str, + type: OrderType, + side: OrderSide, + price: float, + size: float, + client_id: int, + time_in_force: OrderTimeInForce, + good_til_block: int, + good_til_time_in_seconds: int, + execution: OrderExecution, + post_only: bool, + reduce_only: bool, + trigger_price: float = None, + ) -> MsgPlaceOrder: + markets_response = self.indexer_client.markets.get_perpetual_markets(market) + market = markets_response.data["markets"][market] + clob_pair_id = market["clobPairId"] + atomic_resolution = market["atomicResolution"] + step_base_quantums = market["stepBaseQuantums"] + quantum_conversion_exponent = market["quantumConversionExponent"] + subticks_per_tick = market["subticksPerTick"] + order_side = calculate_side(side) + quantums = calculate_quantums(size, atomic_resolution, step_base_quantums) + subticks = calculate_subticks(price, atomic_resolution, quantum_conversion_exponent, subticks_per_tick) + order_flags = calculate_order_flags(type, time_in_force) + time_in_force = calculate_time_in_force(type, time_in_force, execution, post_only) + good_til_block, good_til_block_time = self.generate_good_til_fields( + order_flags, + good_til_block, + good_til_time_in_seconds, + ) + client_metadata = self.calculate_client_metadata(type) + condition_type = self.calculate_condition_type(type) + conditional_order_trigger_subticks = self.calculate_conditional_order_trigger_subticks( + type, atomic_resolution, quantum_conversion_exponent, subticks_per_tick, trigger_price + ) + return self.validator_client.post.composer.compose_msg_place_order( + address=subaccount.address, + subaccount_number=subaccount.subaccount_number, + client_id=client_id, + clob_pair_id=clob_pair_id, + order_flags=order_flags, + good_til_block=good_til_block, + good_til_block_time=good_til_block_time, + side=order_side, + quantums=quantums, + subticks=subticks, + time_in_force=time_in_force, + reduce_only=reduce_only, + client_metadata=client_metadata, + condition_type=condition_type, + conditional_order_trigger_subticks=conditional_order_trigger_subticks, + ) + + def place_short_term_order_message( + self, + subaccount: Subaccount, + market: str, + type: OrderType, + side: OrderSide, + price: float, + size: float, + client_id: int, + time_in_force: Order_TimeInForce, + good_til_block: int, + reduce_only: bool, + ) -> MsgPlaceOrder: + # Validate the GoodTilBlock. + self.validate_good_til_block(good_til_block=good_til_block) + + # Construct the MsgPlaceOrder. + markets_response = self.indexer_client.markets.get_perpetual_markets(market) + market = markets_response.data["markets"][market] + clob_pair_id = market["clobPairId"] + atomic_resolution = market["atomicResolution"] + step_base_quantums = market["stepBaseQuantums"] + quantum_conversion_exponent = market["quantumConversionExponent"] + subticks_per_tick = market["subticksPerTick"] + order_side = calculate_side(side) + quantums = calculate_quantums(size, atomic_resolution, step_base_quantums) + subticks = calculate_subticks(price, atomic_resolution, quantum_conversion_exponent, subticks_per_tick) + order_flags = ORDER_FLAGS_SHORT_TERM + client_metadata = self.calculate_client_metadata(type) + return self.validator_client.post.composer.compose_msg_place_order( + address=subaccount.address, + subaccount_number=subaccount.subaccount_number, + client_id=client_id, + clob_pair_id=clob_pair_id, + order_flags=order_flags, + good_til_block=good_til_block, + good_til_block_time=0, + side=order_side, + quantums=quantums, + subticks=subticks, + time_in_force=time_in_force, + reduce_only=reduce_only, + client_metadata=client_metadata, + condition_type=Order.CONDITION_TYPE_UNSPECIFIED, + conditional_order_trigger_subticks=0, + ) + + def cancel_order( + self, + subaccount: Subaccount, + client_id: int, + market: str, + order_flags: int, + good_til_time_in_seconds: int, + good_til_block: int, + ) -> SubmittedTx: + """ + Cancel order + + :param subaccount: required + :type subaccount: Subaccount + + :param client_id: required + :type client_id: int + + :param market: required + :type market: str + + :param order_flags: required + :type order_flags: int + + :param good_til_block: required + :type good_til_block: int + + :param good_til_block_time: required + :type good_til_block_time: int + + :returns: Tx information + """ + msg = self.cancel_order_message( + subaccount, + market, + client_id, + order_flags, + good_til_time_in_seconds, + good_til_block, + ) + + return self.validator_client.post.send_message(subaccount=subaccount, msg=msg, zeroFee=True) + + def cancel_short_term_order( + self, + subaccount: Subaccount, + client_id: int, + market: str, + good_til_block: int, + ) -> SubmittedTx: + """ + Cancel order + + :param subaccount: required + :type subaccount: Subaccount + + :param client_id: required + :type client_id: int + + :param clob_pair_id: required + :type clob_pair_id: int + + :param good_til_block: required + :type good_til_block: int + + :returns: Tx information + """ + msg = self.cancel_order_message( + subaccount, + market, + client_id, + order_flags=ORDER_FLAGS_SHORT_TERM, + good_til_time_in_seconds=0, + good_til_block=good_til_block, + ) + + return self.validator_client.post.send_message(subaccount=subaccount, msg=msg, zeroFee=True) + + def cancel_order_message( + self, + subaccount: Subaccount, + market: str, + client_id: int, + order_flags: int, + good_til_time_in_seconds: int, + good_til_block: int, + ) -> MsgPlaceOrder: + # Validate the GoodTilBlock for short term orders. + if not is_order_flag_stateful_order(order_flags): + self.validate_good_til_block(good_til_block) + + # Construct the MsgPlaceOrder. + markets_response = self.indexer_client.markets.get_perpetual_markets(market) + market = markets_response.data["markets"][market] + clob_pair_id = market["clobPairId"] + + good_til_block, good_til_block_time = self.generate_good_til_fields( + order_flags, + good_til_block, + good_til_time_in_seconds, + ) + + return self.validator_client.post.composer.compose_msg_cancel_order( + address=subaccount.address, + subaccount_number=subaccount.subaccount_number, + client_id=client_id, + clob_pair_id=clob_pair_id, + order_flags=order_flags, + good_til_block=good_til_block, + good_til_block_time=good_til_block_time, + ) + + def transfer_to_subaccount( + self, + subaccount: Subaccount, + recipient_address: str, + recipient_subaccount_number: int, + amount: float, + ) -> SubmittedTx: + """ + Cancel order + + :param subaccount: required + :type subaccount: Subaccount + + :param recipient_address: required + :type recipient_address: str + + :param recipient_subaccount_number: required + :type recipient_subaccount_number: int + + :param amount: required + :type amount: float + + :returns: Tx information + """ + return self.validator_client.post.transfer( + subaccount=subaccount, + recipient_address=recipient_address, + recipient_subaccount_number=recipient_subaccount_number, + asset_id=0, + amount=amount * 10**6, + ) + + def deposit_to_subaccount( + self, + subaccount: Subaccount, + amount: float, + ) -> SubmittedTx: + """ + Cancel order + + :param subaccount: required + :type subaccount: Subaccount + + :param amount: required + :type amount: float + + :returns: Tx information + """ + return self.validator_client.post.deposit( + subaccount=subaccount, + asset_id=0, + quantums=amount * 10 ** (-QUOTE_QUANTUMS_ATOMIC_RESOLUTION), + ) + + def withdraw_from_subaccount( + self, + subaccount: Subaccount, + amount: float, + ) -> SubmittedTx: + """ + Cancel order + + :param subaccount: required + :type subaccount: Subaccount + + :param amount: required + :type amount: float + + :returns: Tx information + """ + return self.validator_client.post.withdraw( + subaccount=subaccount, + asset_id=0, + quantums=amount * 10 ** (-QUOTE_QUANTUMS_ATOMIC_RESOLUTION), + ) diff --git a/v4-client-py/v4_client_py/clients/dydx_faucet_client.py b/v4-client-py/v4_client_py/clients/dydx_faucet_client.py new file mode 100644 index 00000000..c6281e33 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/dydx_faucet_client.py @@ -0,0 +1,77 @@ +from .helpers.request_helpers import generate_query_path +from .helpers.requests import request, Response +from .constants import DEFAULT_API_TIMEOUT + +class FaucetClient(object): + def __init__( + self, + host, + api_timeout = None, + ): + self.host = host + self.api_timeout = api_timeout or DEFAULT_API_TIMEOUT + + # ============ Request Helpers ============ + + def _post(self, request_path, params = {}, body = {}) -> Response: + return request( + generate_query_path(self.host + request_path, params), + 'post', + data_values = body, + api_timeout = self.api_timeout, + ) + + # ============ Requests ============ + + def fill( + self, + address: str, + subaccount_number: int, + amount: int, + ) -> Response: + ''' + fill account + + :param address: required + :type address: str + + :param amount: required + :type amount: int + + :returns: + + :raises: DydxAPIError + ''' + path = '/faucet/tokens' + return self._post( + path, + {}, + { + 'address': address, + 'subaccountNumber': subaccount_number, + 'amount': amount, + } + ) + + def fill_native( + self, + address: str, + ) -> Response: + ''' + fill account with native token + + :param address: required + :type address: str + + :returns: + + :raises: DydxAPIError + ''' + path = '/faucet/native-token' + return self._post( + path, + {}, + { + 'address': address, + } + ) diff --git a/v4-client-py/v4_client_py/clients/dydx_indexer_client.py b/v4-client-py/v4_client_py/clients/dydx_indexer_client.py new file mode 100644 index 00000000..37fe8fe3 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/dydx_indexer_client.py @@ -0,0 +1,43 @@ +from .constants import DEFAULT_API_TIMEOUT, IndexerConfig +from .modules.account import Account +from .modules.markets import Markets +from .modules.utility import Utility + +class IndexerClient(object): + def __init__( + self, + config: IndexerConfig, + api_timeout = None, + send_options = None, + ): + self.config = config + self.api_timeout = api_timeout or DEFAULT_API_TIMEOUT + self.send_options = send_options or {} + + # Initialize the markets and account module. + self._markets = Markets(config.rest_endpoint) + self._account = Account(config.rest_endpoint) + self._utility = Utility(config.rest_endpoint) + + + @property + def markets(self) -> Markets: + ''' + Get the market module, used for interacting with public market endpoints. + ''' + return self._markets + + @property + def account(self) -> Account: + ''' + Get the private module, used for interacting with endpoints that + require dYdX address. + ''' + return self._account + + @property + def utility(self) -> Utility: + ''' + Get the utility module, used for interacting with public endpoints. + ''' + return self._utility diff --git a/v4-client-py/v4_client_py/clients/dydx_socket_client.py b/v4-client-py/v4_client_py/clients/dydx_socket_client.py new file mode 100644 index 00000000..85833fde --- /dev/null +++ b/v4-client-py/v4_client_py/clients/dydx_socket_client.py @@ -0,0 +1,107 @@ +import json +import websocket +import threading +import time + +from .constants import IndexerConfig + +class SocketClient: + def __init__( + self, + config: IndexerConfig, + on_message=None, + on_open=None, + on_close=None + ): + self.url = config.websocket_endpoint + self.ws = None + self.on_message = on_message + self.on_open = on_open + self.on_close = on_close + self.last_activity_time = None + + def connect(self): + self.ws = websocket.WebSocketApp(self.url, + on_open=self._on_open, + on_message=self._on_message, + on_close=self._on_close) + + self.ws.run_forever() + + def _on_open(self, ws): + if self.on_open: + self.on_open(ws) + else: + print('WebSocket connection opened') + self.last_activity_time = time.time() + + def _on_message(self, ws, message): + if self.on_message: + self.on_message(ws, message) + else: + print(f'Received message: {message}') + self.last_activity_time = time.time() + + def _on_close(self, ws): + if self.on_close: + self.on_close(ws) + else: + print('WebSocket connection closed') + self.last_activity_time = None + + def send(self, message): + if self.ws: + self.ws.send(message) + self.last_activity_time = time.time() + else: + print('Error: WebSocket is not connected') + + def close(self): + if self.ws: + self.ws.close() + else: + print('Error: WebSocket is not connected') + + def subscribe(self, channel, params=None): + if params is None: + params = {} + message = json.dumps({'type': 'subscribe', 'channel': channel, **params}) + self.send(message) + + def unsubscribe(self, channel, params=None): + if params is None: + params = {} + message = json.dumps({'type': 'unsubscribe', 'channel': channel, **params}) + self.send(message) + + def subscribe_to_markets(self): + self.subscribe('v4_markets', {'batched': 'true'}) + + def unsubscribe_from_markets(self): + self.unsubscribe('v4_markets', {}) + + def subscribe_to_trades(self, market: str): + self.subscribe('v4_trades', {'id': market, 'batched': 'true'}) + + def unsubscribe_from_trades(self, market: str): + self.unsubscribe('v4_trades', {'id': market}) + + def subscribe_to_orderbook(self, market: str): + self.subscribe('v4_orderbook', {'id': market, 'batched': 'true'}) + + def unsubscribe_from_orderbook(self, market: str): + self.unsubscribe('v4_orderbook', {'id': market}) + + def subscribe_to_candles(self, market: str): + self.subscribe('v4_candles', {'id': market, 'batched': 'true'}) + + def unsubscribe_from_candles(self, market: str): + self.unsubscribe('v4_candles', {'id': market}) + + def subscribe_to_subaccount(self, address: str, subaccount_number: int): + subaccount_id = '/'.join([address, str(subaccount_number)]) + self.subscribe('v4_subaccounts', {'id': subaccount_id}) + + def unsubscribe_from_subaccount(self, address: str, subaccount_number: int): + subaccount_id = '/'.join([address, str(subaccount_number)]) + self.unsubscribe('v4_subaccounts', {'id': subaccount_id}) diff --git a/v4-client-py/v4_client_py/clients/dydx_subaccount.py b/v4-client-py/v4_client_py/clients/dydx_subaccount.py new file mode 100644 index 00000000..d13dc275 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/dydx_subaccount.py @@ -0,0 +1,32 @@ + +from v4_client_py.chain.aerial.wallet import LocalWallet +from v4_client_py.clients.constants import BECH32_PREFIX + +class Subaccount: + def __init__( + self, + wallet: LocalWallet, + subaccount_number: int=0, + ): + self.wallet = wallet + self.subaccount_number = subaccount_number + + @classmethod + def random(cls): + wallet = LocalWallet.generate(BECH32_PREFIX) + return cls(wallet) + + @classmethod + def from_mnemonic(cls, mnemonic: str): + wallet = LocalWallet.from_mnemonic(mnemonic, BECH32_PREFIX) + return cls(wallet) + + @property + def address(self) -> str: + return self.wallet.address().__str__() + + @property + def account_number(self) -> int: + # Only use account number 0 for now. + return 0 + diff --git a/v4-client-py/v4_client_py/clients/dydx_validator_client.py b/v4-client-py/v4_client_py/clients/dydx_validator_client.py new file mode 100644 index 00000000..aed1364f --- /dev/null +++ b/v4-client-py/v4_client_py/clients/dydx_validator_client.py @@ -0,0 +1,32 @@ + +import grpc + +from .modules.get import Get +from .modules.post import Post +from .constants import ValidatorConfig + +class ValidatorClient: + def __init__( + self, + config: ValidatorConfig, + credentials = grpc.ssl_channel_credentials(), + ): + self._get = Get(config, credentials) + self._post = Post(config) + + @property + def get(self) -> Get: + ''' + Get the public module, used for retrieving on-chain data. + ''' + return self._get + + @property + def post(self) -> Post: + ''' + Get the Post module, used for sending transactions + ''' + return self._post + + + diff --git a/v4-client-py/v4_client_py/clients/errors.py b/v4-client-py/v4_client_py/clients/errors.py new file mode 100644 index 00000000..a408d56f --- /dev/null +++ b/v4-client-py/v4_client_py/clients/errors.py @@ -0,0 +1,56 @@ +class DydxError(Exception): + """Base error class for all exceptions raised in this library. + Will never be raised naked; more specific subclasses of this exception will + be raised when appropriate.""" + +class ValueTooLargeError(DydxError): + pass + + +class EmptyMsgError(DydxError): + pass + + +class NotFoundError(DydxError): + pass + + +class UndefinedError(DydxError): + pass + + +class DecodeError(DydxError): + pass + + +class ConvertError(DydxError): + pass + + +class SchemaError(DydxError): + pass + + +class DydxApiError(DydxError): + def __init__(self, response): + self.status_code = response.status_code + try: + self.msg = response.json() + except ValueError: + self.msg = response.text + self.response = response + self.request = getattr(response, 'request', None) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'DydxApiError(status_code={}, response={})'.format( + self.status_code, + self.msg, + ) + + +class TransactionReverted(DydxError): + def __init__(self, tx_receipt): + self.tx_receipt = tx_receipt diff --git a/v4-client-py/v4_client_py/clients/helpers/__init__.py b/v4-client-py/v4_client_py/clients/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/v4-client-py/v4_client_py/clients/helpers/chain_helpers.py b/v4-client-py/v4_client_py/clients/helpers/chain_helpers.py new file mode 100644 index 00000000..6b9b26d7 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/helpers/chain_helpers.py @@ -0,0 +1,208 @@ + +from enum import Flag, auto, Enum +from v4_proto.dydxprotocol.clob.order_pb2 import Order + +class OrderType(Flag): + MARKET = auto() + LIMIT = auto() + STOP_MARKET = auto() + TAKE_PROFIT_MARKET = auto() + STOP_LIMIT = auto() + TAKE_PROFIT_LIMIT = auto() + +class OrderSide(Flag): + BUY = auto() + SELL = auto() + +# FE enums. Do not pass these directly into the order proto TimeInForce field. +class OrderTimeInForce(Flag): + GTT = auto() # Good Til Time + IOC = auto() # Immediate or Cancel + FOK = auto() # Fill or Kill + +class OrderExecution(Flag): + DEFAULT = 0 # Default. Note proto enums start at 0, which is why this start at 0. + IOC = auto() # Immediate or Cancel + POST_ONLY = auto() # Post-only + FOK = auto() # Fill or Kill + +# Enums to use in order proto fields. Use proto generated fields once that's fixed. +# should match https://github.com/dydxprotocol/v4-chain/blob/main/proto/dydxprotocol/clob/order.proto#L159 +class Order_TimeInForce(Flag): + + ''' + TIME_IN_FORCE_UNSPECIFIED - TIME_IN_FORCE_UNSPECIFIED represents the default behavior where an + order will first match with existing orders on the book, and any + remaining size will be added to the book as a maker order. + ''' + TIME_IN_FORCE_UNSPECIFIED = 0 + + ''' + TIME_IN_FORCE_IOC - TIME_IN_FORCE_IOC enforces that an order only be matched with + maker orders on the book. If the order has remaining size after + matching with existing orders on the book, the remaining size + is not placed on the book. + ''' + TIME_IN_FORCE_IOC = 1 + + ''' + TIME_IN_FORCE_POST_ONLY - TIME_IN_FORCE_POST_ONLY enforces that an order only be placed + on the book as a maker order. Note this means that validators will cancel + any newly-placed post only orders that would cross with other maker + orders. + ''' + TIME_IN_FORCE_POST_ONLY = 2 + + ''' + TIME_IN_FORCE_FILL_OR_KILL - TIME_IN_FORCE_FILL_OR_KILL enforces that an order will either be filled + completely and immediately by maker orders on the book or canceled if the + entire amount can‘t be matched. + ''' + TIME_IN_FORCE_FILL_OR_KILL = 3 + +ORDER_FLAGS_SHORT_TERM = 0 +ORDER_FLAGS_LONG_TERM = 64 +ORDER_FLAGS_CONDITIONAL = 32 + +SHORT_BLOCK_WINDOW = 20 + +QUOTE_QUANTUMS_ATOMIC_RESOLUTION = -6 + +def is_order_flag_stateful_order( + order_flag: int +) -> bool: + if order_flag == ORDER_FLAGS_SHORT_TERM: + return False + elif order_flag == ORDER_FLAGS_LONG_TERM: + return True + elif order_flag == ORDER_FLAGS_CONDITIONAL: + return True + else: + raise ValueError('Invalid order flag') + +def validate_good_til_fields( + is_stateful_order: bool, + good_til_block_time: int, + good_til_block: int, +): + if is_stateful_order: + if good_til_block_time == 0: + raise ValueError( + "stateful orders must have a valid GTBT. GTBT: ${0}".format( + good_til_block_time, + ) + ) + if good_til_block != 0: + raise ValueError( + "stateful order uses GTBT. GTB must be zero. GTB: ${0}".format( + good_til_block, + ) + ) + else: + if good_til_block == 0: + raise ValueError( + "short term orders must have a valid GTB. GTB: ${0}".format( + good_til_block, + ) + ) + if good_til_block_time != 0: + raise ValueError( + "stateful order uses GTB. GTBT must be zero. GTBT: ${0}".format( + good_til_block_time, + ) + ) + +def round( + number: float, + base: int +) -> int: + return int(number / base) * base + +def calculate_quantums( + size: float, + atomic_resolution: int, + step_base_quantums: int, +): + raw_quantums = size * 10**(-1 * atomic_resolution) + quantums = round(raw_quantums, step_base_quantums) + # step_base_quantums functions as the minimum order size + return max(quantums, step_base_quantums) + +def calculate_subticks( + price: float, + atomic_resolution: int, + quantum_conversion_exponent: int, + subticks_per_tick: int +): + exponent = atomic_resolution - quantum_conversion_exponent - QUOTE_QUANTUMS_ATOMIC_RESOLUTION + raw_subticks = price * 10**(exponent) + subticks = round(raw_subticks, subticks_per_tick) + return max(subticks, subticks_per_tick) + +def calculate_side( + side: OrderSide, +) -> Order.Side: + return Order.SIDE_BUY if side == OrderSide.BUY else Order.SIDE_SELL + +def calculate_time_in_force( + type: OrderType, + time_in_force: OrderTimeInForce, + execution: OrderExecution, + post_only: bool +) -> Order_TimeInForce: + if type == OrderType.MARKET: + return Order_TimeInForce.TIME_IN_FORCE_IOC + elif type == OrderType.LIMIT: + if time_in_force == OrderTimeInForce.GTT: + if post_only: + return Order_TimeInForce.TIME_IN_FORCE_POST_ONLY + else: + return Order_TimeInForce.TIME_IN_FORCE_UNSPECIFIED + elif time_in_force == OrderTimeInForce.FOK: + return Order_TimeInForce.TIME_IN_FORCE_FILL_OR_KILL + elif time_in_force == OrderTimeInForce.IOC: + return Order_TimeInForce.TIME_IN_FORCE_IOC + else: + raise Exception("Unexpected code path: time_in_force") + elif type == OrderType.STOP_LIMIT or type == OrderType.TAKE_PROFIT_LIMIT: + if execution == OrderExecution.DEFAULT: + return Order_TimeInForce.TIME_IN_FORCE_UNSPECIFIED + elif execution == OrderExecution.POST_ONLY: + return Order_TimeInForce.TIME_IN_FORCE_POST_ONLY + if execution == OrderExecution.FOK: + return Order_TimeInForce.TIME_IN_FORCE_FILL_OR_KILL + elif execution == OrderExecution.IOC: + return Order_TimeInForce.TIME_IN_FORCE_IOC + else: + raise Exception("Unexpected code path: time_in_force") + elif type == OrderType.STOP_MARKET or type == OrderType.TAKE_PROFIT_MARKET: + if execution == OrderExecution.DEFAULT: + raise Exception("Execution value DEFAULT not supported for STOP_MARKET or TAKE_PROFIT_MARKET") + elif execution == OrderExecution.POST_ONLY: + raise Exception("Execution value POST_ONLY not supported for STOP_MARKET or TAKE_PROFIT_MARKET") + if execution == OrderExecution.FOK: + return Order_TimeInForce.TIME_IN_FORCE_FILL_OR_KILL + elif execution == OrderExecution.IOC: + return Order_TimeInForce.TIME_IN_FORCE_IOC + else: + raise Exception("Unexpected code path: time_in_force") + else: + raise Exception("Unexpected code path: time_in_force") + +def calculate_execution_condition(reduce_only: bool) -> int: + if reduce_only: + return Order.EXECUTION_CONDITION_REDUCE_ONLY + else: + return Order.EXECUTION_CONDITION_UNSPECIFIED + +def calculate_order_flags(type: OrderType, time_in_force: OrderTimeInForce) -> int: + if type == OrderType.MARKET: + return ORDER_FLAGS_SHORT_TERM + elif type == OrderType.LIMIT: + if time_in_force == OrderTimeInForce.GTT: + return ORDER_FLAGS_LONG_TERM + else: + return ORDER_FLAGS_SHORT_TERM + else: + return ORDER_FLAGS_CONDITIONAL + \ No newline at end of file diff --git a/v4-client-py/v4_client_py/clients/helpers/request_helpers.py b/v4-client-py/v4_client_py/clients/helpers/request_helpers.py new file mode 100644 index 00000000..59727b47 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/helpers/request_helpers.py @@ -0,0 +1,49 @@ + +import json +import random + +from datetime import datetime + +import dateutil.parser as dp + + +def generate_query_path(url, params): + entries = params.items() + if not entries: + return url + + paramsString = '&'.join('{key}={value}'.format( + key=x[0], + value=str(x[1]).lower() if isinstance(x[1], bool) else x[1]) for x in entries if x[1] is not None) + if paramsString: + return url + '?' + paramsString + + return url + + +def json_stringify(data): + return json.dumps(data, separators=(',', ':')) + + +def random_client_id(): + return str(int(float(str(random.random())[2:]))) + + +def generate_now_iso(): + return datetime.utcnow().strftime( + '%Y-%m-%dT%H:%M:%S.%f', + )[:-3] + 'Z' + + +def iso_to_epoch_seconds(iso): + return dp.parse(iso).timestamp() + + +def epoch_seconds_to_iso(epoch): + return datetime.utcfromtimestamp(epoch).strftime( + '%Y-%m-%dT%H:%M:%S.%f', + )[:-3] + 'Z' + + +def remove_nones(original): + return {k: v for k, v in original.items() if v is not None} diff --git a/v4-client-py/v4_client_py/clients/helpers/requests.py b/v4-client-py/v4_client_py/clients/helpers/requests.py new file mode 100644 index 00000000..c68f0a80 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/helpers/requests.py @@ -0,0 +1,44 @@ +import json + +import requests + +from ..errors import DydxApiError +from ..helpers.request_helpers import remove_nones + +# TODO: Use a separate session per client instance. +session = requests.session() +session.headers.update({ + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'dydx/python', +}) + + +class Response(object): + def __init__(self, status_code: int, data = {}, headers = None): + self.status_code = status_code + self.data = data + self.headers = headers + + +def request(uri, method, headers = None, data_values = {}, api_timeout = None): + response = send_request( + uri, + method, + headers, + data=json.dumps( + remove_nones(data_values) + ), + timeout=api_timeout + ) + if not str(response.status_code).startswith('2'): + raise DydxApiError(response) + + if response.content: + return Response(response.status_code, response.json(), response.headers) + else: + return Response(response.status_code, '{}', response.headers) + + +def send_request(uri, method, headers=None, **kwargs): + return getattr(session, method)(uri, headers=headers, **kwargs) diff --git a/v4-client-py/v4_client_py/clients/modules/__init__.py b/v4-client-py/v4_client_py/clients/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/v4-client-py/v4_client_py/clients/modules/account.py b/v4-client-py/v4_client_py/clients/modules/account.py new file mode 100644 index 00000000..d2e9ec65 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/modules/account.py @@ -0,0 +1,421 @@ + +from ..helpers.request_helpers import generate_query_path +from ..helpers.requests import request, Response +from ..constants import DEFAULT_API_TIMEOUT + +class Account(object): + def __init__( + self, + indexerHost, + api_timeout = None, + ): + self.host = indexerHost + self.api_timeout = api_timeout or DEFAULT_API_TIMEOUT + + # ============ Request Helpers ============ + + def _get(self, request_path, params = {}) -> Response: + return request( + generate_query_path(self.host + request_path, params), + 'get', + api_timeout = self.api_timeout, + ) + + # ============ Requests ============ + + def get_subaccounts( + self, + address: str, + limit: int = None, + ) -> Response: + ''' + Get subaccounts + + :param limit: optional + :type limit: number + + :returns: Array of subaccounts for a dYdX address + + :raises: DydxAPIError + ''' + path = '/'.join(['/v4/addresses', address]) + return self._get( + path, + { + 'limit': limit, + }, + ) + + def get_subaccount( + self, + address: str, + subaccount_number: int, + ) -> Response: + ''' + Get subaccount given a subaccountNumber + + :param address: required + :type address: str + + :param subaccount_number: required + :type subaccount_number: int + + :returns: subaccount of a dYdX address + + :raises: DydxAPIError + ''' + path = '/'.join(['/v4/addresses', address, 'subaccountNumber', str(subaccount_number)]) + return self._get( + path, + { + }, + ) + + def get_subaccount_perpetual_positions( + self, + address: str, + subaccount_number: int, + status: str = None, + limit: int = None, + created_before_or_at_height: int = None, + created_before_or_at_time: str = None, + ) -> Response: + ''' + Get perpetual positions + + :param address: required + :type address: str + + :param subaccount_number: required + :type subaccount_number: int + + :param status: optional + :type status: str in list [ + "OPEN", + "CLOSED", + "LIQUIDATED", + ... + ] + + :param limit: optional + :type limit: number + + :param created_before_or_at_height: optional + :type created_before_or_at_height: number + + :param created_before_or_at_time: optional + :type created_before_or_at_time: ISO str + + :returns: Array of perpetual positions + + :raises: DydxAPIError + ''' + return self._get( + '/v4/perpetualPositions', + { + 'address': address, + 'subaccountNumber': subaccount_number, + 'status': status, + 'limit': limit, + 'createdBeforeOrAtHeight': created_before_or_at_height, + 'createdBeforeOrAt': created_before_or_at_time, + }, + ) + + def get_subaccount_asset_positions( + self, + address: str, + subaccount_number: int, + status: str = None, + limit: int = None, + created_before_or_at_height: int = None, + created_before_or_at_time: str = None, + ) -> Response: + ''' + Get asset positions + + :param address: required + :type address: str + + :param subaccount_number: required + :type subaccount_number: int + + :param status: optional + :type status: str in list [ + "OPEN", + "CLOSED", + "LIQUIDATED", + ... + ] + + :param limit: optional + :type limit: number + + :param created_before_or_at_height: optional + :type created_before_or_at_height: number + + :param created_before_or_at_time: optional + :type created_before_or_at_time: ISO str + + :returns: Array of asset positions + + :raises: DydxAPIError + ''' + return self._get( + '/v4/assetPositions', + { + 'address': address, + 'subaccountNumber': subaccount_number, + 'status': status, + 'limit': limit, + 'createdBeforeOrAtHeight': created_before_or_at_height, + 'createdBeforeOrAt': created_before_or_at_time, + }, + ) + + def get_subaccount_transfers( + self, + address: str, + subaccount_number: int, + limit: int = None, + created_before_or_at_height: int = None, + created_before_or_at_time: str = None, + ) -> Response: + ''' + Get asset transfers record + + :param address: required + :type address: str + + :param subaccount_number: required + :type subaccount_number: int + + :param limit: optional + :type limit: number + + :param created_before_or_at_height: optional + :type created_before_or_at_height: number + + :param created_before_or_at_time: optional + :type created_before_or_at_time: ISO str + + :returns: Array of transfers + + :raises: DydxAPIError + ''' + return self._get( + '/v4/transfers', + { + 'address': address, + 'subaccountNumber': subaccount_number, + 'limit': limit, + 'createdBeforeOrAtHeight': created_before_or_at_height, + 'createdBeforeOrAt': created_before_or_at_time, + }, + ) + + def get_subaccount_orders( + self, + address: str, + subaccount_number: int, + ticker: str = None, + ticker_type: str = 'PERPETUAL', + side: str = None, + status: str = None, + type: str = None, + limit: int = None, + good_til_block_before_or_at: int = None, + good_til_block_time_before_or_at: str = None, + return_latest_orders: bool = None + ) -> Response: + ''' + Get asset transfers record + + :param address: required + :type address: str + + :param subaccount_number: required + :type subaccount_number: int + + :param ticker: optional + :type ticker: str in list [ + "BTC-USD", + "ETH-USD", + "LINK-USD", + ... + ] + + :param ticker_type: optional + :type ticker_type: str in list [ + "PERPETUAL", + "ASSET", + ] + + :param side: optional + :type side: str in list [ + "BUY", + "SELL", + ] + + :param status: optional + :type status: str in list [ + ... + ] + + :param type: optional + :type type: str in list [ + "MARKET", + "LIMIT", + ... + ] + + :param limit: optional + :type limit: number + + :param good_til_block_before_or_at: optional + :type good_til_block_before_or_at: number + + :param good_til_block_time_before_or_at: optional + :type good_til_block_time_before_or_at: ISO str + + :param return_latest_orders: optional + :type return_latest_orders: boolean + + :returns: Array of orders + + :raises: DydxAPIError + ''' + return self._get( + '/v4/orders', + { + 'address': address, + 'subaccountNumber': subaccount_number, + 'ticker': ticker, + 'tickerType': ticker_type, + 'side': side, + 'status': status, + 'type': type, + 'limit': limit, + 'goodTilBlockBeforeOrAt': good_til_block_before_or_at, + 'goodTilBlockTimeBeforeOrAt': good_til_block_time_before_or_at, + 'returnLatestOrders': return_latest_orders, + }, + ) + + def get_order( + self, + order_id: str + ) -> Response: + ''' + Get asset transfers record + + :param order_id: required + :type order_id: str + + :returns: Order + + :raises: DydxAPIError + ''' + + path = '/'.join(['/v4/orders', order_id]) + return self._get( + path, + { + }, + ) + + + def get_subaccount_fills( + self, + address: str, + subaccount_number: int, + ticker: str = None, + ticker_type: str = None, + limit: int = None, + created_before_or_at_height: int = None, + created_before_or_at_time: str = None, + ) -> Response: + ''' + Get asset transfers record + + :param address: required + :type address: str + + :param subaccount_number: required + :type subaccount_number: int + + :param ticker: optional + :type ticker: str in list [ + "BTC-USD", + "ETH-USD", + "LINK-USD", + ... + ] + + :param ticker_type: optional + :type ticker_type: str in list [ + "PERPETUAL", + "ASSET", + ] + + :param limit: optional + :type limit: number + + :param created_before_or_at_height: optional + :type created_before_or_at_height: number + + :param created_before_or_at_time: optional + :type created_before_or_at_time: ISO str + + :returns: Array of fills + + :raises: DydxAPIError + ''' + return self._get( + '/v4/fills', + { + 'address': address, + 'subaccountNumber': subaccount_number, + 'market': ticker, + 'marketType': ticker_type, + 'limit': limit, + 'createdBeforeOrAtHeight': created_before_or_at_height, + 'createdBeforeOrAt': created_before_or_at_time, + }, + ) + + def get_subaccount_historical_pnls( + self, + address: str, + subaccount_number: int, + effective_before_or_at: str = None, + effective_at_or_after: str = None, + ) -> Response: + ''' + Get asset transfers record + + :param address: required + :type address: str + + :param subaccount_number: required + :type subaccount_number: int + + :param effective_before_or_at: optional + :type effective_before_or_at: ISO str + + :param effective_at_or_after: optional + :type effective_at_or_after: ISO str + + :returns: Array of historical PNL + + :raises: DydxAPIError + ''' + return self._get( + '/v4/historical-pnl', + { + 'address': address, + 'subaccountNumber': subaccount_number, + 'effectiveBeforeOrAt': effective_before_or_at, + 'effectiveAtOrAfter': effective_at_or_after, + }, + ) diff --git a/v4-client-py/v4_client_py/clients/modules/get.py b/v4-client-py/v4_client_py/clients/modules/get.py new file mode 100644 index 00000000..8ae62561 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/modules/get.py @@ -0,0 +1,260 @@ + +import grpc +import logging + +from typing import Optional + +from ..constants import ValidatorConfig + +from v4_proto.dydxprotocol.clob.order_pb2 import * +from v4_proto.dydxprotocol.clob.tx_pb2 import * +from v4_proto.dydxprotocol.clob.query_pb2 import * + +from v4_proto.dydxprotocol.subaccounts.subaccount_pb2 import * +from v4_proto.dydxprotocol.subaccounts.query_pb2 import * + +from v4_proto.dydxprotocol.sending.transfer_pb2 import * +from v4_proto.dydxprotocol.sending.tx_pb2 import * + +from v4_proto.dydxprotocol.assets.genesis_pb2 import * +from v4_proto.dydxprotocol.assets.query_pb2 import * +from v4_proto.dydxprotocol.assets.asset_pb2 import * + +from v4_proto.dydxprotocol.perpetuals.query_pb2 import * +from v4_proto.dydxprotocol.perpetuals.perpetual_pb2 import * + +from v4_proto.dydxprotocol.prices.query_pb2 import * +from v4_proto.dydxprotocol.prices.market_price_pb2 import * + +from v4_proto.cosmos.base.tendermint.v1beta1 import ( + query_pb2_grpc as tendermint_query_grpc, + query_pb2 as tendermint_query, +) + +from v4_proto.cosmos.auth.v1beta1 import ( + query_pb2_grpc as auth_query_grpc, + query_pb2 as auth_query, + auth_pb2 as auth_type, +) + +from v4_proto.cosmos.authz.v1beta1 import ( + query_pb2_grpc as authz_query_grpc, +) + +from v4_proto.cosmos.bank.v1beta1 import ( + query_pb2_grpc as bank_query_grpc, + query_pb2 as bank_query, +) +from v4_proto.cosmos.tx.v1beta1 import ( + service_pb2_grpc as tx_service_grpc, + service_pb2 as tx_service, +) + +from v4_proto.dydxprotocol.subaccounts import ( + query_pb2_grpc as subaccounts_query_grpc, + subaccount_pb2 as subaccount_type, +) + +from v4_proto.dydxprotocol.assets import ( + query_pb2_grpc as assets_query_grpc, +) + +from v4_proto.dydxprotocol.perpetuals import ( + query_pb2_grpc as perpetuals_query_grpc, +) + +from v4_proto.dydxprotocol.prices import ( + query_pb2_grpc as prices_query_grpc, + market_price_pb2 as market_price_type, +) + +from v4_proto.dydxprotocol.clob import ( + query_pb2_grpc as clob_query_grpc, + query_pb2 as clob_query, + clob_pair_pb2 as clob_pair_type, + equity_tier_limit_config_pb2 as equity_tier_limit_config_type, +) + + +DEFAULT_TIMEOUTHEIGHT = 30 # blocks + +class Get: + def __init__( + self, + config: ValidatorConfig, + credentials = grpc.ssl_channel_credentials(), + ): + # chain stubs + self.chain_channel = ( + grpc.secure_channel(config.grpc_endpoint, credentials) if config.ssl_enabled + else grpc.insecure_channel(config.grpc_endpoint) + ) + self.config = config + + # chain stubs + self.stubCosmosTendermint = tendermint_query_grpc.ServiceStub( + self.chain_channel + ) + self.stubAuth = auth_query_grpc.QueryStub(self.chain_channel) + self.stubAuthz = authz_query_grpc.QueryStub(self.chain_channel) + self.stubBank = bank_query_grpc.QueryStub(self.chain_channel) + self.stubTx = tx_service_grpc.ServiceStub(self.chain_channel) + self.stubAssets = assets_query_grpc.QueryStub(self.chain_channel) + self.stubSubaccounts = subaccounts_query_grpc.QueryStub(self.chain_channel) + self.stubPerpetuals = perpetuals_query_grpc.QueryStub(self.chain_channel) + self.stubPrices = prices_query_grpc.QueryStub(self.chain_channel) + self.stubClob = clob_query_grpc.QueryStub(self.chain_channel) + + # default client methods + def latest_block(self) -> tendermint_query.GetLatestBlockResponse: + ''' + Get lastest block + + :returns: Response, containing block information + + ''' + return self.stubCosmosTendermint.GetLatestBlock( + tendermint_query.GetLatestBlockRequest() + ) + + def sync_timeout_height(self): + try: + block = self.latest_block() + self.timeout_height = block.block.header.height + DEFAULT_TIMEOUTHEIGHT + except Exception as e: + logging.debug("error while fetching latest block, setting timeout height to 0:{}".format(e)) + self.timeout_height = 0 + + def tx(self, tx_hash: str): + ''' + Get tx + + :param tx_hash: required + :type: str + + :returns: Transaction + ''' + return self.stubTx.GetTx(tx_service.GetTxRequest(hash=tx_hash)) + + def bank_balances(self, address: str): + ''' + Get wallet account balances + + :returns: All assets in the wallet + ''' + return self.stubBank.AllBalances( + bank_query.QueryAllBalancesRequest(address=address) + ) + + def bank_balance(self, address: str, denom: str): + ''' + Get wallet asset balance + + :param denom: required + :type demon: str + + :returns: Asset balance given the denom + + :raises: DydxAPIError + ''' + return self.stubBank.Balance( + bank_query.QueryBalanceRequest(address=address, denom=denom) + ) + + def account(self, address: str) -> Optional[auth_type.BaseAccount]: + ''' + Get account information + + :param address: required + :type address: str + + :returns: Account information, including account number and sequence + ''' + account_any = self.stubAuth.Account( + auth_query.QueryAccountRequest(address=address) + ).account + account = auth_type.BaseAccount() + if account_any.Is(account.DESCRIPTOR): + account_any.Unpack(account) + return account + else: + return None + + def subaccounts(self) -> QuerySubaccountAllResponse: + ''' + Get all subaccounts + + :returns: Subaccount information, including account number and sequence + ''' + return self.stubSubaccounts.SubaccountAll( + QueryAllSubaccountRequest() + ) + + def subaccount(self, address: str, account_number: int) -> Optional[subaccount_type.Subaccount]: + ''' + Get subaccount information + + :param address: required + :type address: str + + :returns: Subaccount information, including account number and sequence + ''' + return self.stubSubaccounts.Subaccount( + QueryGetSubaccountRequest(owner=address, number=account_number) + ).subaccount + + def clob_pairs(self) -> QueryClobPairAllResponse: + ''' + Get all pairs + + :returns: All pairs + ''' + return self.stubClob.ClobPairAll( + QueryAllClobPairRequest() + ) + + def clob_pair(self, pair_id: int) -> clob_pair_type.ClobPair: + ''' + Get pair information + + :param pair_id: required + :type pair_id: int + + :returns: Pair information + ''' + return self.stubClob.ClobPair( + clob_query.QueryGetClobPairRequest(id=pair_id) + ).clob_pair + + def prices(self) -> QueryAllMarketPricesResponse: + ''' + Get all market prices + + :returns: All market prices + ''' + return self.stubPrices.AllMarketPrices( + QueryAllMarketPricesRequest() + ) + + def price(self, market_id: int) -> market_price_type.MarketPrice: + ''' + Get market price + + :param market_id: required + :type market_id: int + + :returns: Market price + ''' + return self.stubPrices.MarketPrice( + QueryMarketPriceRequest(id=market_id) + ).market_price + + def equity_tier_limit_config(self) -> equity_tier_limit_config_type.EquityTierLimitConfiguration: + ''' + Get equity tier limit configuration + + :returns: Equity tier limit configuration + ''' + return self.stubClob.EquityTierLimitConfiguration( + clob_query.QueryEquityTierLimitConfigurationRequest() + ).equity_tier_limit_config \ No newline at end of file diff --git a/v4-client-py/v4_client_py/clients/modules/markets.py b/v4-client-py/v4_client_py/clients/modules/markets.py new file mode 100644 index 00000000..72e1df70 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/modules/markets.py @@ -0,0 +1,214 @@ +from ..constants import DEFAULT_API_TIMEOUT +from ..helpers.request_helpers import generate_query_path +from ..helpers.requests import request, Response + +class Markets(object): + def __init__( + self, + indexerHost, + api_timeout = None, + ): + self.host = indexerHost + self.api_timeout = api_timeout or DEFAULT_API_TIMEOUT + + # ============ Request Helpers ============ + + def _get(self, request_path, params = {}) -> Response: + return request( + generate_query_path(self.host + request_path, params), + 'get', + api_timeout=self.api_timeout, + ) + + # ============ Requests ============ + + def get_perpetual_markets(self, market: str = None) -> Response: + ''' + Get one or perpetual markets + + :param market: optional + :type market: str in list [ + "BTC-USD", + "ETH-USD", + "LINK-USD", + ... + ] + + :returns: Market array + + :raises: DydxAPIError + ''' + uri = '/v4/perpetualMarkets' + return self._get(uri, { + 'ticker': market, + }) + + def get_perpetual_market_orderbook(self, market: str) -> Response: + ''' + Get orderbook for a perpetual market + + :param market: required + :type market: str in list [ + "BTC-USD", + "ETH-USD", + "LINK-USD", + ... + ] + + :returns: Object containing bid array and ask array of open orders + for a market + + :raises: DydxAPIError + ''' + uri = '/'.join(['/v4/orderbooks/perpetualMarket', market]) + return self._get(uri) + + def get_perpetual_market_trades( + self, + market: str, + starting_before_or_at_height: int = None, + limit: int = None + ) -> Response: + ''' + Get trades for a perpetual market + + :param market: required + :type market: str in list [ + "BTC-USD", + "ETH-USD", + "LINK-USD", + ... + ] + + :param starting_before_or_at_height: optional + :type starting_before_or_at_height: number + + :returns: Trade array + + :raises: DydxAPIError + ''' + uri = '/'.join(['/v4/trades/perpetualMarket', market]) + return self._get( + uri, + {'createdBeforeOrAtHeight': starting_before_or_at_height, 'limit': limit}, + ) + + def get_perpetual_market_candles( + self, + market: str, + resolution: str, + from_iso: str = None, + to_iso: str = None, + limit: int = None, + ) -> Response: + ''' + Get Candles + + :param market: required + :type market: str in list [ + "BTC-USD", + "ETH-USD", + "LINK-USD", + ... + ] + + :param resolution: required + :type resolution: str in list [ + "ONE_MINUTE", + "FIVE_MINUTES", + "FIFTEEN_MINUTES", + "THIRTY_MINUTES", + "ONE_HOUR", + "FOUR_HOURS", + "ONE_DAY", + ] + + :param from_iso: optional + :type from_iso: ISO str + + :param to_iso: optional + :type to_iso: ISO str + + :param limit: optional + :type limit: number + + :returns: Array of candles + + :raises: DydxAPIError + ''' + uri = '/'.join(['/v4/candles/perpetualMarkets', market]) + return self._get( + uri, + { + 'resolution': resolution, + 'fromISO': from_iso, + 'toISO': to_iso, + 'limit': limit, + }, + ) + + def get_perpetual_market_funding( + self, + market: str, + effective_before_or_at: str = None, + effective_before_or_at_height: int = None, + limit: int = None, + ) -> Response: + ''' + Get Candles + + :param market: required + :type market: str in list [ + "BTC-USD", + "ETH-USD", + "LINK-USD", + ... + ] + + :param effective_before_or_at: optional + :type effective_before_or_at: ISO str + + :param effective_before_or_at_height: optional + :type effective_before_or_at_height: number + + :param limit: optional + :type limit: number + + :returns: Array of candles + + :raises: DydxAPIError + ''' + uri = '/'.join(['/v4/historicalFunding', market]) + return self._get( + uri, + { + 'effectiveBeforeOrAt': effective_before_or_at, + 'effectiveBeforeOrAtHeight': effective_before_or_at_height, + 'limit': limit, + }, + ) + + def get_perpetual_markets_sparklines( + self, + period: str = 'ONE_DAY' + ) -> Response: + ''' + Get Sparklines + + :param period: required + :type period: str in list [ + "ONE_DAY", + "SEVEN_DAYS" + ] + + :returns: Array of sparklines + + :raises: DydxAPIError + ''' + uri = '/v4/sparklines' + return self._get( + uri, + { + 'timePeriod': period, + }, + ) diff --git a/v4-client-py/v4_client_py/clients/modules/post.py b/v4-client-py/v4_client_py/clients/modules/post.py new file mode 100644 index 00000000..ec8395b4 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/modules/post.py @@ -0,0 +1,286 @@ +from google.protobuf import message as _message + +from v4_proto.dydxprotocol.clob.tx_pb2 import MsgPlaceOrder +from v4_proto.dydxprotocol.clob.order_pb2 import Order + +from v4_client_py.clients.helpers.chain_helpers import ORDER_FLAGS_LONG_TERM, ORDER_FLAGS_SHORT_TERM + +from ..constants import BroadcastMode, ValidatorConfig +from ..composer import Composer +from ..dydx_subaccount import Subaccount + +from ...chain.aerial.tx import Transaction +from ...chain.aerial.tx_helpers import SubmittedTx +from ...chain.aerial.client import LedgerClient, NetworkConfig +from ...chain.aerial.client.utils import prepare_and_broadcast_basic_transaction + + +class Post: + def __init__( + self, + config: ValidatorConfig, + ): + self.config = config + self.composer = Composer() + + def send_message( + self, + subaccount: Subaccount, + msg: _message.Message, + zeroFee: bool = False, + broadcast_mode: BroadcastMode = None, + ) -> SubmittedTx: + """ + Send a message + + :param subaccount: required + :type subaccount: Subaccount + + :param msg: required + :type msg: Message + + :returns: Tx information + """ + + wallet = subaccount.wallet + network = NetworkConfig.fetchai_network_config( + chain_id=self.config.chain_id, url_prefix=self.config.url_prefix, url=self.config.url + ) + ledger = LedgerClient(network) + + tx = Transaction() + tx.add_message(msg) + gas_limit = 0 if zeroFee else None + + return prepare_and_broadcast_basic_transaction( + client=ledger, + tx=tx, + sender=wallet, + gas_limit=gas_limit, + memo=None, + broadcast_mode=broadcast_mode if (broadcast_mode != None) else self.default_broadcast_mode(msg), + fee=0 if zeroFee else None, + ) + + def place_order( + self, + subaccount: Subaccount, + client_id: int, + clob_pair_id: int, + side: Order.Side, + quantums: int, + subticks: int, + time_in_force: Order.TimeInForce, + order_flags: int, + reduce_only: bool, + good_til_block: int, + good_til_block_time: int, + client_metadata: int, + condition_type: Order.ConditionType = Order.ConditionType.CONDITION_TYPE_UNSPECIFIED, + conditional_order_trigger_subticks: int = 0, + broadcast_mode: BroadcastMode = None, + ) -> SubmittedTx: + """ + Place order + + :param subaccount: required + :type subaccount: Subaccount + + :param clob_pair_id: required + :type clob_pair_id: int + + :param side: required + :type side: Order.Side + + :param quantums: required + :type quantums: int + + :param subticks: required + :type subticks: int + + :param good_til_block: required + :type good_til_block: int + + :param good_til_block_time: required + :type good_til_block_time: int + + :param client_id: required + :type client_id: int + + :param time_in_force: required + :type time_in_force: int + + :param order_flags: required + :type order_flags: int + + :param reduce_only: required + :type reduce_only: bool + + :returns: Tx information + """ + # prepare tx msg + subaccount_number = subaccount.subaccount_number + + msg = self.composer.compose_msg_place_order( + address=subaccount.address, + subaccount_number=subaccount_number, + client_id=client_id, + clob_pair_id=clob_pair_id, + order_flags=order_flags, + good_til_block=good_til_block, + good_til_block_time=good_til_block_time, + side=side, + quantums=quantums, + subticks=subticks, + time_in_force=time_in_force, + reduce_only=reduce_only, + client_metadata=client_metadata, + condition_type=condition_type, + conditional_order_trigger_subticks=conditional_order_trigger_subticks, + ) + return self.send_message(subaccount=subaccount, msg=msg, zeroFee=True, broadcast_mode=broadcast_mode) + + def place_order_object( + self, + subaccount: Subaccount, + place_order: any, + broadcast_mode: BroadcastMode = None, + ) -> SubmittedTx: + return self.place_order( + subaccount, + place_order["client_id"], + place_order["clob_pair_id"], + place_order["side"], + place_order["quantums"], + place_order["subticks"], + place_order["time_in_force"], + place_order["order_flags"], + place_order["reduce_only"], + place_order.get("good_til_block", 0), + place_order.get("good_til_block_time", 0), + place_order.get("client_metadata", 0), + broadcast_mode, + ) + + def cancel_order( + self, + subaccount: Subaccount, + client_id: int, + clob_pair_id: int, + order_flags: int, + good_til_block: int, + good_til_block_time: int, + broadcast_mode: BroadcastMode = None, + ) -> SubmittedTx: + """ + Cancel order + + :param subaccount: required + :type subaccount: Subaccount + + :param client_id: required + :type client_id: int + + :param clob_pair_id: required + :type clob_pair_id: int + + :param order_flags: required + :type order_flags: int + + :param good_til_block: optional + :type good_til_block: int + + :param good_til_block_time: optional + :type good_til_block_time: int + + :param broadcast_mode: optional + :type broadcast_mode: BroadcastMode + + :returns: Tx information + """ + msg = self.composer.compose_msg_cancel_order( + subaccount.address, + subaccount.subaccount_number, + client_id, + clob_pair_id, + order_flags, + good_til_block, + good_til_block_time, + ) + return self.send_message(subaccount, msg, zeroFee=True, broadcast_mode=broadcast_mode) + + def cancel_order_object( + self, + subaccount: Subaccount, + cancel_order: any, + broadcast_mode: BroadcastMode = None, + ) -> SubmittedTx: + return self.cancel_order( + subaccount, + cancel_order.client_id, + cancel_order.clob_pair_id, + cancel_order.order_flags, + cancel_order.good_til_block, + cancel_order.good_til_block_time, + broadcast_mode=broadcast_mode, + ) + + def transfer( + self, + subaccount: Subaccount, + recipient_address: str, + recipient_subaccount_number: int, + asset_id: int, + amount: int, + broadcast_mode: BroadcastMode = None, + ) -> SubmittedTx: + msg = self.composer.compose_msg_transfer( + subaccount.address, + subaccount.subaccount_number, + recipient_address, + recipient_subaccount_number, + asset_id, + amount, + ) + return self.send_message(subaccount, msg, broadcast_mode=broadcast_mode) + + def deposit( + self, + subaccount: Subaccount, + asset_id: int, + quantums: int, + broadcast_mode: BroadcastMode = None, + ) -> SubmittedTx: + msg = self.composer.compose_msg_deposit_to_subaccount( + subaccount.address, + subaccount.subaccount_number, + asset_id, + quantums, + ) + return self.send_message(subaccount, msg, broadcast_mode=broadcast_mode) + + def withdraw( + self, + subaccount: Subaccount, + asset_id: int, + quantums: int, + broadcast_mode: BroadcastMode = None, + ) -> SubmittedTx: + msg = self.composer.compose_msg_withdraw_from_subaccount( + subaccount.address, + subaccount.subaccount_number, + asset_id, + quantums, + ) + return self.send_message(subaccount, msg, broadcast_mode=broadcast_mode) + + def default_broadcast_mode(self, msg: _message.Message) -> BroadcastMode: + if isinstance(msg, MsgPlaceOrder): + order_flags = msg.order.order_id.order_flags + if order_flags == ORDER_FLAGS_SHORT_TERM: + return BroadcastMode.BroadcastTxSync + elif order_flags == ORDER_FLAGS_LONG_TERM: + return BroadcastMode.BroadcastTxCommit + else: + return BroadcastMode.BroadcastTxCommit + return BroadcastMode.BroadcastTxSync diff --git a/v4-client-py/v4_client_py/clients/modules/utility.py b/v4-client-py/v4_client_py/clients/modules/utility.py new file mode 100644 index 00000000..193d4b04 --- /dev/null +++ b/v4-client-py/v4_client_py/clients/modules/utility.py @@ -0,0 +1,61 @@ +from ..constants import DEFAULT_API_TIMEOUT +from ..helpers.request_helpers import generate_query_path +from ..helpers.requests import request, Response + +class Utility(object): + def __init__( + self, + indexerHost, + api_timeout = None, + ): + self.host = indexerHost + self.api_timeout = api_timeout or DEFAULT_API_TIMEOUT + + # ============ Request Helpers ============ + + def _get(self, request_path, params = {}) -> Response: + return request( + generate_query_path(self.host + request_path, params), + 'get', + api_timeout=self.api_timeout, + ) + + # ============ Requests ============ + + def get_time(self) -> Response: + ''' + Get api server time as iso and as epoch in seconds with MS + + :returns: ISO string and Epoch number in seconds with MS of server time + + :raises: DydxAPIError + ''' + uri = '/v4/time' + return self._get(uri) + + + def get_height(self) -> Response: + ''' + Get indexer last block height + + :returns: last block height and block timestamp + + :raises: DydxAPIError + ''' + uri = '/v4/height' + return self._get(uri) + + def screen(self, address) -> Response: + ''' + Screen an address to see if it is restricted + + :param address: required + + :returns: whether the specified address is restricted + + :raises: DydxAPIError + ''' + uri = '/v4/screen' + return self._get(uri, { + 'address': address, + })