├── .github └── workflows │ ├── lint.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml ├── dydx3 ├── __init__.py ├── abi │ ├── erc20.json │ └── starkware-perpetuals.json ├── constants.py ├── dydx_client.py ├── errors.py ├── eth_signing │ ├── __init__.py │ ├── eth_prive_action.py │ ├── onboarding_action.py │ ├── sign_off_chain_action.py │ ├── signers.py │ └── util.py ├── helpers │ ├── __init__.py │ ├── db.py │ ├── request_helpers.py │ └── requests.py ├── modules │ ├── __init__.py │ ├── eth.py │ ├── eth_private.py │ ├── onboarding.py │ ├── private.py │ └── public.py └── starkex │ ├── __init__.py │ ├── conditional_transfer.py │ ├── constants.py │ ├── helpers.py │ ├── order.py │ ├── signable.py │ ├── starkex_resources │ ├── __init__.py │ ├── cpp_signature.py │ ├── math_utils.py │ ├── pedersen_params.json │ ├── proxy.py │ └── python_signature.py │ ├── transfer.py │ └── withdrawal.py ├── examples ├── onboard.py ├── orders.py └── websockets.py ├── integration_tests ├── __init__.py ├── test_auth_levels.py ├── test_integration.py └── util.py ├── pytest.ini ├── requirements-lint.txt ├── requirements-publish.txt ├── requirements-test.txt ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── constants.py ├── eth_signing │ ├── __init__.py │ ├── test_api_key_action.py │ └── test_onboarding_action.py ├── starkex │ ├── __init__.py │ ├── test_conditional_transfer.py │ ├── test_helpers.py │ ├── test_order.py │ ├── test_transfer.py │ └── test_withdrawal.py ├── test_constants.py ├── test_onboarding.py └── test_public.py └── tox.ini /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out repository code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Python 3.11 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: 3.11 20 | 21 | - name: Install Dependencies 22 | run: pip install -r requirements-lint.txt 23 | 24 | - name: Lint 25 | run: flake8 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish: 10 | environment: master branch 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out repository code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Python 3.11 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: 3.11 20 | 21 | - name: Install Dependencies 22 | run: pip install -r requirements-publish.txt 23 | 24 | - name: create packages 25 | run: python setup.py sdist bdist_wheel 26 | 27 | - name: upload to pypi 28 | env: 29 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 30 | run: python -m twine upload -u __token__ -p $PYPI_TOKEN dist/* --skip-existing 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out repository code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Python 3.11 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: 3.11 20 | 21 | - name: Run Ganache 22 | run: docker-compose up -d 23 | 24 | - name: Install Dependencies 25 | run: sudo pip install -r requirements-test.txt 26 | 27 | - name: Test 28 | env: 29 | V3_API_HOST: https://api.stage.dydx.exchange 30 | run: tox 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Misc 2 | .DS_Store 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | .pytest_cache/ 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | .env.local 30 | 31 | # VIM 32 | *.swo 33 | *.swp 34 | 35 | # VS Code 36 | .vscode/ 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | 42 | # Local testing 43 | test_local.py 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | 5 | PyPI 6 | 7 | 8 | License 9 | 10 |
11 |
12 | 13 | Python client for dYdX (v3 API). 14 | 15 | The library is currently tested against Python versions 2.7, 3.4, 3.5, 3.6, 3.9, and 3.11. 16 | 17 | ## Installation 18 | 19 | The `dydx-v3-python` package is available on [PyPI](https://pypi.org/project/dydx-v3-python). Install with `pip`: 20 | 21 | ```bash 22 | pip install dydx-v3-python 23 | ``` 24 | 25 | ## Getting Started 26 | 27 | The `Client` object can be created with different levels of authentication depending on which features are needed. For more complete examples, see the [examples](./examples/) directory, as well as [the integration tests](./integration_tests/). 28 | 29 | ### Public endpoints 30 | 31 | No authentication information is required to access public endpoints. 32 | 33 | ```python 34 | from dydx3 import Client 35 | from web3 import Web3 36 | 37 | # 38 | # Access public API endpoints. 39 | # 40 | public_client = Client( 41 | host='http://localhost:8080', 42 | ) 43 | public_client.public.get_markets() 44 | ``` 45 | 46 | ### Private endpoints 47 | 48 | One of the following is required: 49 | * `api_key_credentials` 50 | * `eth_private_key` 51 | * `web3` 52 | * `web3_account` 53 | * `web3_provider` 54 | 55 | ```python 56 | # 57 | # Access private API endpoints, without providing a STARK private key. 58 | # 59 | private_client = Client( 60 | host='http://localhost:8080', 61 | api_key_credentials={ 'key': '...', ... }, 62 | ) 63 | private_client.private.get_orders() 64 | private_client.private.create_order( 65 | # No STARK key, so signatures are required for orders and withdrawals. 66 | signature='...', 67 | # ... 68 | ) 69 | 70 | # 71 | # Access private API endpoints, with a STARK private key. 72 | # 73 | private_client_with_key = Client( 74 | host='http://localhost:8080', 75 | api_key_credentials={ 'key': '...', ... }, 76 | stark_private_key='...', 77 | ) 78 | private_client.private.create_order( 79 | # Order will be signed using the provided STARK private key. 80 | # ... 81 | ) 82 | ``` 83 | 84 | ### Onboarding and API key management endpoints 85 | 86 | One of the following is required: 87 | * `eth_private_key` 88 | * `web3` 89 | * `web3_account` 90 | * `web3_provider` 91 | 92 | ```python 93 | # 94 | # Onboard a new user or manage API keys, without providing private keys. 95 | # 96 | web3_client = Client( 97 | host='http://localhost:8080', 98 | web3_provider=Web3.HTTPProvider('http://localhost:8545'), 99 | ) 100 | web3_client.onboarding.create_user( 101 | stark_public_key='...', 102 | ethereum_address='...', 103 | ) 104 | web3_client.eth_private.create_api_key( 105 | ethereum_address='...', 106 | ) 107 | 108 | # 109 | # Onboard a new user or manage API keys, with private keys. 110 | # 111 | web3_client_with_keys = Client( 112 | host='http://localhost:8080', 113 | stark_private_key='...', 114 | eth_private_key='...', 115 | ) 116 | web3_client_with_keys.onboarding.create_user() 117 | web3_client_with_keys.eth_private.create_api_key() 118 | ``` 119 | 120 | ### Using the C++ Library for STARK Signing 121 | 122 | By default, STARK curve operations such as signing and verification will use the Python native implementation. These operations occur whenever placing an order or requesting a withdrawal. To use the C++ implementation, initialize the client object with `crypto_c_exports_path`: 123 | 124 | ```python 125 | client = Client( 126 | crypto_c_exports_path='./libcrypto_c_exports.so', 127 | ... 128 | ) 129 | ``` 130 | 131 | The path should point to a C++ shared library file, built from Starkware's `crypto-cpp` library ([CMake target](https://github.com/starkware-libs/crypto-cpp/blob/601de408bee9f897315b8a5cb0c88e2450a91282/src/starkware/crypto/ffi/CMakeLists.txt#L3)) for the particular platform (e.g. Linux, etc.) that you are running your trading program on. 132 | 133 | ## Running tests 134 | 135 | If you want to run tests when developing the library locally, clone the repo and run: 136 | 137 | ``` 138 | pip install -r requirements.txt 139 | docker-compose up # In a separate terminal 140 | V3_API_HOST= tox 141 | ``` 142 | 143 | NOTE: `api-host` should be `https://api.stage.dydx.exchange` to test in staging. 144 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | ganache: 4 | image: trufflesuite/ganache-cli:v6.12.2 5 | ports: 6 | - 8545:8545 7 | command: ganache-cli -d -k=petersburg -i 1001 8 | -------------------------------------------------------------------------------- /dydx3/__init__.py: -------------------------------------------------------------------------------- 1 | from dydx3.dydx_client import Client 2 | from dydx3.errors import DydxError 3 | from dydx3.errors import DydxApiError 4 | from dydx3.errors import TransactionReverted 5 | 6 | # Export useful helper functions and objects. 7 | from dydx3.helpers.request_helpers import epoch_seconds_to_iso 8 | from dydx3.helpers.request_helpers import iso_to_epoch_seconds 9 | from dydx3.starkex.helpers import generate_private_key_hex_unsafe 10 | from dydx3.starkex.helpers import private_key_from_bytes 11 | from dydx3.starkex.helpers import private_key_to_public_hex 12 | from dydx3.starkex.helpers import private_key_to_public_key_pair_hex 13 | from dydx3.starkex.order import SignableOrder 14 | from dydx3.starkex.withdrawal import SignableWithdrawal 15 | -------------------------------------------------------------------------------- /dydx3/abi/erc20.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "constant": true, 4 | "inputs": [], 5 | "name": "name", 6 | "outputs": [ 7 | { 8 | "name": "", 9 | "type": "string" 10 | } 11 | ], 12 | "payable": false, 13 | "stateMutability": "view", 14 | "type": "function" 15 | }, 16 | { 17 | "constant": false, 18 | "inputs": [ 19 | { 20 | "name": "_spender", 21 | "type": "address" 22 | }, 23 | { 24 | "name": "_value", 25 | "type": "uint256" 26 | } 27 | ], 28 | "name": "approve", 29 | "outputs": [ 30 | { 31 | "name": "", 32 | "type": "bool" 33 | } 34 | ], 35 | "payable": false, 36 | "stateMutability": "nonpayable", 37 | "type": "function" 38 | }, 39 | { 40 | "constant": true, 41 | "inputs": [], 42 | "name": "totalSupply", 43 | "outputs": [ 44 | { 45 | "name": "", 46 | "type": "uint256" 47 | } 48 | ], 49 | "payable": false, 50 | "stateMutability": "view", 51 | "type": "function" 52 | }, 53 | { 54 | "constant": false, 55 | "inputs": [ 56 | { 57 | "name": "_from", 58 | "type": "address" 59 | }, 60 | { 61 | "name": "_to", 62 | "type": "address" 63 | }, 64 | { 65 | "name": "_value", 66 | "type": "uint256" 67 | } 68 | ], 69 | "name": "transferFrom", 70 | "outputs": [ 71 | { 72 | "name": "", 73 | "type": "bool" 74 | } 75 | ], 76 | "payable": false, 77 | "stateMutability": "nonpayable", 78 | "type": "function" 79 | }, 80 | { 81 | "constant": true, 82 | "inputs": [], 83 | "name": "decimals", 84 | "outputs": [ 85 | { 86 | "name": "", 87 | "type": "uint8" 88 | } 89 | ], 90 | "payable": false, 91 | "stateMutability": "view", 92 | "type": "function" 93 | }, 94 | { 95 | "constant": true, 96 | "inputs": [ 97 | { 98 | "name": "_owner", 99 | "type": "address" 100 | } 101 | ], 102 | "name": "balanceOf", 103 | "outputs": [ 104 | { 105 | "name": "balance", 106 | "type": "uint256" 107 | } 108 | ], 109 | "payable": false, 110 | "stateMutability": "view", 111 | "type": "function" 112 | }, 113 | { 114 | "constant": true, 115 | "inputs": [], 116 | "name": "symbol", 117 | "outputs": [ 118 | { 119 | "name": "", 120 | "type": "string" 121 | } 122 | ], 123 | "payable": false, 124 | "stateMutability": "view", 125 | "type": "function" 126 | }, 127 | { 128 | "constant": false, 129 | "inputs": [ 130 | { 131 | "name": "_to", 132 | "type": "address" 133 | }, 134 | { 135 | "name": "_value", 136 | "type": "uint256" 137 | } 138 | ], 139 | "name": "transfer", 140 | "outputs": [ 141 | { 142 | "name": "", 143 | "type": "bool" 144 | } 145 | ], 146 | "payable": false, 147 | "stateMutability": "nonpayable", 148 | "type": "function" 149 | }, 150 | { 151 | "constant": true, 152 | "inputs": [ 153 | { 154 | "name": "_owner", 155 | "type": "address" 156 | }, 157 | { 158 | "name": "_spender", 159 | "type": "address" 160 | } 161 | ], 162 | "name": "allowance", 163 | "outputs": [ 164 | { 165 | "name": "", 166 | "type": "uint256" 167 | } 168 | ], 169 | "payable": false, 170 | "stateMutability": "view", 171 | "type": "function" 172 | }, 173 | { 174 | "payable": true, 175 | "stateMutability": "payable", 176 | "type": "fallback" 177 | }, 178 | { 179 | "anonymous": false, 180 | "inputs": [ 181 | { 182 | "indexed": true, 183 | "name": "owner", 184 | "type": "address" 185 | }, 186 | { 187 | "indexed": true, 188 | "name": "spender", 189 | "type": "address" 190 | }, 191 | { 192 | "indexed": false, 193 | "name": "value", 194 | "type": "uint256" 195 | } 196 | ], 197 | "name": "Approval", 198 | "type": "event" 199 | }, 200 | { 201 | "anonymous": false, 202 | "inputs": [ 203 | { 204 | "indexed": true, 205 | "name": "from", 206 | "type": "address" 207 | }, 208 | { 209 | "indexed": true, 210 | "name": "to", 211 | "type": "address" 212 | }, 213 | { 214 | "indexed": false, 215 | "name": "value", 216 | "type": "uint256" 217 | } 218 | ], 219 | "name": "Transfer", 220 | "type": "event" 221 | } 222 | ] 223 | -------------------------------------------------------------------------------- /dydx3/constants.py: -------------------------------------------------------------------------------- 1 | # ------------ API URLs ------------ 2 | API_HOST_MAINNET = 'https://api.dydx.exchange' 3 | API_HOST_SEPOLIA = 'https://api.stage.dydx.exchange' 4 | WS_HOST_MAINNET = 'wss://api.dydx.exchange/v3/ws' 5 | WS_HOST_SEPOLIA = 'wss://api.stage.dydx.exchange/v3/ws' 6 | 7 | # ------------ Ethereum Network IDs ------------ 8 | NETWORK_ID_MAINNET = 1 9 | NETWORK_ID_SEPOLIA = 11155111 10 | 11 | # ------------ Signature Types ------------ 12 | SIGNATURE_TYPE_NO_PREPEND = 0 13 | SIGNATURE_TYPE_DECIMAL = 1 14 | SIGNATURE_TYPE_HEXADECIMAL = 2 15 | 16 | # ------------ Market Statistic Day Types ------------ 17 | MARKET_STATISTIC_DAY_ONE = '1' 18 | MARKET_STATISTIC_DAY_SEVEN = '7' 19 | MARKET_STATISTIC_DAY_THIRTY = '30' 20 | 21 | # ------------ Order Types ------------ 22 | ORDER_TYPE_LIMIT = 'LIMIT' 23 | ORDER_TYPE_MARKET = 'MARKET' 24 | ORDER_TYPE_STOP = 'STOP_LIMIT' 25 | ORDER_TYPE_TRAILING_STOP = 'TRAILING_STOP' 26 | ORDER_TYPE_TAKE_PROFIT = 'TAKE_PROFIT' 27 | 28 | # ------------ Order Side ------------ 29 | ORDER_SIDE_BUY = 'BUY' 30 | ORDER_SIDE_SELL = 'SELL' 31 | 32 | # ------------ Time in Force Types ------------ 33 | TIME_IN_FORCE_GTT = 'GTT' 34 | TIME_IN_FORCE_FOK = 'FOK' 35 | TIME_IN_FORCE_IOC = 'IOC' 36 | 37 | # ------------ Position Status Types ------------ 38 | POSITION_STATUS_OPEN = 'OPEN' 39 | POSITION_STATUS_CLOSED = 'CLOSED' 40 | POSITION_STATUS_LIQUIDATED = 'LIQUIDATED' 41 | 42 | # ------------ Order Status Types ------------ 43 | ORDER_STATUS_PENDING = 'PENDING' 44 | ORDER_STATUS_OPEN = 'OPEN' 45 | ORDER_STATUS_FILLED = 'FILLED' 46 | ORDER_STATUS_CANCELED = 'CANCELED' 47 | ORDER_STATUS_UNTRIGGERED = 'UNTRIGGERED' 48 | 49 | # ------------ Transfer Status Types ------------ 50 | TRANSFER_STATUS_PENDING = 'PENDING' 51 | TRANSFER_STATUS_CONFIRMED = 'CONFIRMED' 52 | TRANSFER_STATUS_QUEUED = 'QUEUED' 53 | TRANSFER_STATUS_CANCELED = 'CANCELED' 54 | TRANSFER_STATUS_UNCONFIRMED = 'UNCONFIRMED' 55 | 56 | # ------------ Account Action Types ------------ 57 | ACCOUNT_ACTION_DEPOSIT = 'DEPOSIT' 58 | ACCOUNT_ACTION_WITHDRAWAL = 'WITHDRAWAL' 59 | 60 | # ------------ Markets ------------ 61 | MARKET_BTC_USD = 'BTC-USD' 62 | MARKET_ETH_USD = 'ETH-USD' 63 | MARKET_LINK_USD = 'LINK-USD' 64 | MARKET_AAVE_USD = 'AAVE-USD' 65 | MARKET_UNI_USD = 'UNI-USD' 66 | MARKET_SUSHI_USD = 'SUSHI-USD' 67 | MARKET_SOL_USD = 'SOL-USD' 68 | MARKET_YFI_USD = 'YFI-USD' 69 | MARKET_ONEINCH_USD = '1INCH-USD' 70 | MARKET_AVAX_USD = 'AVAX-USD' 71 | MARKET_SNX_USD = 'SNX-USD' 72 | MARKET_CRV_USD = 'CRV-USD' 73 | MARKET_UMA_USD = 'UMA-USD' 74 | MARKET_DOT_USD = 'DOT-USD' 75 | MARKET_DOGE_USD = 'DOGE-USD' 76 | MARKET_MATIC_USD = 'MATIC-USD' 77 | MARKET_MKR_USD = 'MKR-USD' 78 | MARKET_FIL_USD = 'FIL-USD' 79 | MARKET_ADA_USD = 'ADA-USD' 80 | MARKET_ATOM_USD = 'ATOM-USD' 81 | MARKET_COMP_USD = 'COMP-USD' 82 | MARKET_BCH_USD = 'BCH-USD' 83 | MARKET_LTC_USD = 'LTC-USD' 84 | MARKET_EOS_USD = 'EOS-USD' 85 | MARKET_ALGO_USD = 'ALGO-USD' 86 | MARKET_ZRX_USD = 'ZRX-USD' 87 | MARKET_XMR_USD = 'XMR-USD' 88 | MARKET_ZEC_USD = 'ZEC-USD' 89 | MARKET_ENJ_USD = 'ENJ-USD' 90 | MARKET_ETC_USD = 'ETC-USD' 91 | MARKET_XLM_USD = 'XLM-USD' 92 | MARKET_TRX_USD = 'TRX-USD' 93 | MARKET_XTZ_USD = 'XTZ-USD' 94 | MARKET_ICP_USD = 'ICP-USD' 95 | MARKET_RUNE_USD = 'RUNE-USD' 96 | MARKET_LUNA_USD = 'LUNA-USD' 97 | MARKET_NEAR_USD = 'NEAR-USD' 98 | MARKET_CELO_USD = 'CELO-USD' 99 | 100 | 101 | # ------------ Assets ------------ 102 | ASSET_USDC = 'USDC' 103 | ASSET_BTC = 'BTC' 104 | ASSET_ETH = 'ETH' 105 | ASSET_LINK = 'LINK' 106 | ASSET_AAVE = 'AAVE' 107 | ASSET_UNI = 'UNI' 108 | ASSET_SUSHI = 'SUSHI' 109 | ASSET_SOL = 'SOL' 110 | ASSET_YFI = 'YFI' 111 | ASSET_ONEINCH = '1INCH' 112 | ASSET_AVAX = 'AVAX' 113 | ASSET_SNX = 'SNX' 114 | ASSET_CRV = 'CRV' 115 | ASSET_UMA = 'UMA' 116 | ASSET_DOT = 'DOT' 117 | ASSET_DOGE = 'DOGE' 118 | ASSET_MATIC = 'MATIC' 119 | ASSET_MKR = 'MKR' 120 | ASSET_FIL = 'FIL' 121 | ASSET_ADA = 'ADA' 122 | ASSET_ATOM = 'ATOM' 123 | ASSET_COMP = 'COMP' 124 | ASSET_BCH = 'BCH' 125 | ASSET_LTC = 'LTC' 126 | ASSET_EOS = 'EOS' 127 | ASSET_ALGO = 'ALGO' 128 | ASSET_ZRX = 'ZRX' 129 | ASSET_XMR = 'XMR' 130 | ASSET_ZEC = 'ZEC' 131 | ASSET_ENJ = 'ENJ' 132 | ASSET_ETC = 'ETC' 133 | ASSET_XLM = 'XLM' 134 | ASSET_TRX = 'TRX' 135 | ASSET_XTZ = 'XTZ' 136 | ASSET_ICP = 'ICP' 137 | ASSET_RUNE = 'RUNE' 138 | ASSET_LUNA = 'LUNA' 139 | ASSET_NEAR = 'NEAR' 140 | ASSET_CELO = 'CELO' 141 | COLLATERAL_ASSET = ASSET_USDC 142 | 143 | # ------------ Synthetic Assets by Market ------------ 144 | SYNTHETIC_ASSET_MAP = { 145 | MARKET_BTC_USD: ASSET_BTC, 146 | MARKET_ETH_USD: ASSET_ETH, 147 | MARKET_LINK_USD: ASSET_LINK, 148 | MARKET_AAVE_USD: ASSET_AAVE, 149 | MARKET_UNI_USD: ASSET_UNI, 150 | MARKET_SUSHI_USD: ASSET_SUSHI, 151 | MARKET_SOL_USD: ASSET_SOL, 152 | MARKET_YFI_USD: ASSET_YFI, 153 | MARKET_ONEINCH_USD: ASSET_ONEINCH, 154 | MARKET_AVAX_USD: ASSET_AVAX, 155 | MARKET_SNX_USD: ASSET_SNX, 156 | MARKET_CRV_USD: ASSET_CRV, 157 | MARKET_UMA_USD: ASSET_UMA, 158 | MARKET_DOT_USD: ASSET_DOT, 159 | MARKET_DOGE_USD: ASSET_DOGE, 160 | MARKET_MATIC_USD: ASSET_MATIC, 161 | MARKET_MKR_USD: ASSET_MKR, 162 | MARKET_FIL_USD: ASSET_FIL, 163 | MARKET_ADA_USD: ASSET_ADA, 164 | MARKET_ATOM_USD: ASSET_ATOM, 165 | MARKET_COMP_USD: ASSET_COMP, 166 | MARKET_BCH_USD: ASSET_BCH, 167 | MARKET_LTC_USD: ASSET_LTC, 168 | MARKET_EOS_USD: ASSET_EOS, 169 | MARKET_ALGO_USD: ASSET_ALGO, 170 | MARKET_ZRX_USD: ASSET_ZRX, 171 | MARKET_XMR_USD: ASSET_XMR, 172 | MARKET_ZEC_USD: ASSET_ZEC, 173 | MARKET_ENJ_USD: ASSET_ENJ, 174 | MARKET_ETC_USD: ASSET_ETC, 175 | MARKET_XLM_USD: ASSET_XLM, 176 | MARKET_TRX_USD: ASSET_TRX, 177 | MARKET_XTZ_USD: ASSET_XTZ, 178 | MARKET_ICP_USD: ASSET_ICP, 179 | MARKET_RUNE_USD: ASSET_RUNE, 180 | MARKET_LUNA_USD: ASSET_LUNA, 181 | MARKET_NEAR_USD: ASSET_NEAR, 182 | MARKET_CELO_USD: ASSET_CELO, 183 | } 184 | 185 | # ------------ Asset IDs ------------ 186 | COLLATERAL_ASSET_ID_BY_NETWORK_ID = { 187 | NETWORK_ID_MAINNET: int( 188 | '0x02893294412a4c8f915f75892b395ebbf6859ec246ec365c3b1f56f47c3a0a5d', 189 | 16, 190 | ), 191 | NETWORK_ID_SEPOLIA: int( 192 | '0x01e70c509c4c6bfafe8b73d2fc1819444b2c0b435d4b82c0f24addff9565ce25', 193 | 16, 194 | ), 195 | } 196 | SYNTHETIC_ASSET_ID_MAP = { 197 | ASSET_BTC: int('0x4254432d3130000000000000000000', 16), 198 | ASSET_ETH: int('0x4554482d3900000000000000000000', 16), 199 | ASSET_LINK: int('0x4c494e4b2d37000000000000000000', 16), 200 | ASSET_AAVE: int('0x414156452d38000000000000000000', 16), 201 | ASSET_UNI: int('0x554e492d3700000000000000000000', 16), 202 | ASSET_SUSHI: int('0x53555348492d370000000000000000', 16), 203 | ASSET_SOL: int('0x534f4c2d3700000000000000000000', 16), 204 | ASSET_YFI: int('0x5946492d3130000000000000000000', 16), 205 | ASSET_ONEINCH: int('0x31494e43482d370000000000000000', 16), 206 | ASSET_AVAX: int('0x415641582d37000000000000000000', 16), 207 | ASSET_SNX: int('0x534e582d3700000000000000000000', 16), 208 | ASSET_CRV: int('0x4352562d3600000000000000000000', 16), 209 | ASSET_UMA: int('0x554d412d3700000000000000000000', 16), 210 | ASSET_DOT: int('0x444f542d3700000000000000000000', 16), 211 | ASSET_DOGE: int('0x444f47452d35000000000000000000', 16), 212 | ASSET_MATIC: int('0x4d415449432d360000000000000000', 16), 213 | ASSET_MKR: int('0x4d4b522d3900000000000000000000', 16), 214 | ASSET_FIL: int('0x46494c2d3700000000000000000000', 16), 215 | ASSET_ADA: int('0x4144412d3600000000000000000000', 16), 216 | ASSET_ATOM: int('0x41544f4d2d37000000000000000000', 16), 217 | ASSET_COMP: int('0x434f4d502d38000000000000000000', 16), 218 | ASSET_BCH: int('0x4243482d3800000000000000000000', 16), 219 | ASSET_LTC: int('0x4c54432d3800000000000000000000', 16), 220 | ASSET_EOS: int('0x454f532d3600000000000000000000', 16), 221 | ASSET_ALGO: int('0x414c474f2d36000000000000000000', 16), 222 | ASSET_ZRX: int('0x5a52582d3600000000000000000000', 16), 223 | ASSET_XMR: int('0x584d522d3800000000000000000000', 16), 224 | ASSET_ZEC: int('0x5a45432d3800000000000000000000', 16), 225 | ASSET_ENJ: int('0x454e4a2d3600000000000000000000', 16), 226 | ASSET_ETC: int('0x4554432d3700000000000000000000', 16), 227 | ASSET_XLM: int('0x584c4d2d3500000000000000000000', 16), 228 | ASSET_TRX: int('0x5452582d3400000000000000000000', 16), 229 | ASSET_XTZ: int('0x58545a2d3600000000000000000000', 16), 230 | ASSET_ICP: int('0x4943502d3700000000000000000000', 16), 231 | ASSET_RUNE: int('0x52554e452d36000000000000000000', 16), 232 | ASSET_LUNA: int('0x4c554e412d36000000000000000000', 16), 233 | ASSET_NEAR: int('0x4e4541522d36000000000000000000', 16), 234 | ASSET_CELO: int('0x43454c4f2d36000000000000000000', 16), 235 | } 236 | 237 | 238 | # ------------ Asset Resolution (Quantum Size) ------------ 239 | # 240 | # The asset resolution is the number of quantums (Starkware units) that fit 241 | # within one "human-readable" unit of the asset. For example, if the asset 242 | # resolution for BTC is 1e10, then the smallest unit representable within 243 | # Starkware is 1e-10 BTC, i.e. 1/100th of a satoshi. 244 | # 245 | # For the collateral asset (USDC), the chosen resolution corresponds to the 246 | # base units of the ERC-20 token. For the other, synthetic, assets, the 247 | # resolutions are chosen such that prices relative to USDC are close to one. 248 | ASSET_RESOLUTION = { 249 | ASSET_USDC: '1e6', 250 | ASSET_BTC: '1e10', 251 | ASSET_ETH: '1e9', 252 | ASSET_LINK: '1e7', 253 | ASSET_AAVE: '1e8', 254 | ASSET_UNI: '1e7', 255 | ASSET_SUSHI: '1e7', 256 | ASSET_SOL: '1e7', 257 | ASSET_YFI: '1e10', 258 | ASSET_ONEINCH: '1e7', 259 | ASSET_AVAX: '1e7', 260 | ASSET_SNX: '1e7', 261 | ASSET_CRV: '1e6', 262 | ASSET_UMA: '1e7', 263 | ASSET_DOT: '1e7', 264 | ASSET_DOGE: '1e5', 265 | ASSET_MATIC: '1e6', 266 | ASSET_MKR: '1e9', 267 | ASSET_FIL: '1e7', 268 | ASSET_ADA: '1e6', 269 | ASSET_ATOM: '1e7', 270 | ASSET_COMP: '1e8', 271 | ASSET_BCH: '1e8', 272 | ASSET_LTC: '1e8', 273 | ASSET_EOS: '1e6', 274 | ASSET_ALGO: '1e6', 275 | ASSET_ZRX: '1e6', 276 | ASSET_XMR: '1e8', 277 | ASSET_ZEC: '1e8', 278 | ASSET_ENJ: '1e6', 279 | ASSET_ETC: '1e7', 280 | ASSET_XLM: '1e5', 281 | ASSET_TRX: '1e4', 282 | ASSET_XTZ: '1e6', 283 | ASSET_ICP: '1e7', 284 | ASSET_RUNE: '1e6', 285 | ASSET_LUNA: '1e6', 286 | ASSET_NEAR: '1e6', 287 | ASSET_CELO: '1e6', 288 | } 289 | 290 | # ------------ Ethereum Transactions ------------ 291 | DEFAULT_GAS_AMOUNT = 250000 292 | DEFAULT_GAS_MULTIPLIER = 1.5 293 | DEFAULT_GAS_PRICE = 4000000000 294 | DEFAULT_GAS_PRICE_ADDITION = 3 295 | MAX_SOLIDITY_UINT = 115792089237316195423570985008687907853269984665640564039457584007913129639935 # noqa: E501 296 | FACT_REGISTRY_CONTRACT = { 297 | NETWORK_ID_MAINNET: '0xBE9a129909EbCb954bC065536D2bfAfBd170d27A', 298 | NETWORK_ID_SEPOLIA: '0xCD828e691cA23b66291ae905491Bb89aEe3Abd82', 299 | } 300 | STARKWARE_PERPETUALS_CONTRACT = { 301 | NETWORK_ID_MAINNET: '0xD54f502e184B6B739d7D27a6410a67dc462D69c8', 302 | NETWORK_ID_SEPOLIA: '0x3D05aaCd0fED84f65dE0D91e4621298E702911E2', 303 | } 304 | TOKEN_CONTRACTS = { 305 | ASSET_USDC: { 306 | NETWORK_ID_MAINNET: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 307 | NETWORK_ID_SEPOLIA: '0x7fC9C132268E0E414991449c003DbdB3E73E2059', 308 | }, 309 | } 310 | COLLATERAL_TOKEN_DECIMALS = 6 311 | 312 | # ------------ Off-Chain Ethereum-Signed Actions ------------ 313 | OFF_CHAIN_ONBOARDING_ACTION = 'dYdX Onboarding' 314 | OFF_CHAIN_KEY_DERIVATION_ACTION = 'dYdX STARK Key' 315 | 316 | 317 | # ------------ API Defaults ------------ 318 | DEFAULT_API_TIMEOUT = 3000 319 | -------------------------------------------------------------------------------- /dydx3/dydx_client.py: -------------------------------------------------------------------------------- 1 | from web3 import Web3 2 | 3 | from dydx3.constants import DEFAULT_API_TIMEOUT, NETWORK_ID_MAINNET 4 | from dydx3.eth_signing import SignWithWeb3 5 | from dydx3.eth_signing import SignWithKey 6 | from dydx3.modules.eth_private import EthPrivate 7 | from dydx3.modules.eth import Eth 8 | from dydx3.modules.private import Private 9 | from dydx3.modules.public import Public 10 | from dydx3.modules.onboarding import Onboarding 11 | from dydx3.starkex.helpers import private_key_to_public_key_pair_hex 12 | from dydx3.starkex.starkex_resources.cpp_signature import ( 13 | get_cpp_lib, 14 | ) 15 | 16 | 17 | class Client(object): 18 | 19 | def __init__( 20 | self, 21 | host, 22 | api_timeout=None, 23 | default_ethereum_address=None, 24 | eth_private_key=None, 25 | eth_send_options=None, 26 | network_id=None, 27 | stark_private_key=None, 28 | stark_public_key=None, 29 | stark_public_key_y_coordinate=None, 30 | web3=None, 31 | web3_account=None, 32 | web3_provider=None, 33 | api_key_credentials=None, 34 | crypto_c_exports_path=None, 35 | ): 36 | # Remove trailing '/' if present, from host. 37 | if host.endswith('/'): 38 | host = host[:-1] 39 | 40 | self.host = host 41 | self.api_timeout = api_timeout or DEFAULT_API_TIMEOUT 42 | self.eth_send_options = eth_send_options or {} 43 | self.stark_private_key = stark_private_key 44 | self.api_key_credentials = api_key_credentials 45 | self.stark_public_key_y_coordinate = stark_public_key_y_coordinate 46 | 47 | self.web3 = None 48 | self.eth_signer = None 49 | self.default_address = None 50 | self.network_id = None 51 | 52 | if crypto_c_exports_path is not None: 53 | get_cpp_lib(crypto_c_exports_path) 54 | 55 | if web3 is not None or web3_provider is not None: 56 | if isinstance(web3_provider, str): 57 | web3_provider = Web3.HTTPProvider( 58 | web3_provider, request_kwargs={'timeout': self.api_timeout} 59 | ) 60 | self.web3 = web3 or Web3(web3_provider) 61 | self.eth_signer = SignWithWeb3(self.web3) 62 | self.default_address = self.web3.eth.defaultAccount or None 63 | self.network_id = self.web3.net.version 64 | 65 | if eth_private_key is not None or web3_account is not None: 66 | # May override web3 or web3_provider configuration. 67 | key = eth_private_key or web3_account.key 68 | self.eth_signer = SignWithKey(key) 69 | self.default_address = self.eth_signer.address 70 | 71 | self.default_address = default_ethereum_address or self.default_address 72 | self.network_id = int( 73 | network_id or self.network_id or NETWORK_ID_MAINNET 74 | ) 75 | 76 | # Initialize the public module. Other modules are initialized on 77 | # demand, if the necessary configuration options were provided. 78 | self._public = Public(host) 79 | self._private = None 80 | self._eth_private = None 81 | self._eth = None 82 | self._onboarding = None 83 | 84 | # Derive the public keys. 85 | if stark_private_key is not None: 86 | self.stark_public_key, self.stark_public_key_y_coordinate = ( 87 | private_key_to_public_key_pair_hex(stark_private_key) 88 | ) 89 | if ( 90 | stark_public_key is not None and 91 | stark_public_key != self.stark_public_key 92 | ): 93 | raise ValueError('STARK public/private key mismatch') 94 | if ( 95 | stark_public_key_y_coordinate is not None and 96 | stark_public_key_y_coordinate != 97 | self.stark_public_key_y_coordinate 98 | ): 99 | raise ValueError('STARK public/private key mismatch (y)') 100 | else: 101 | self.stark_public_key = stark_public_key 102 | self.stark_public_key_y_coordinate = stark_public_key_y_coordinate 103 | 104 | # Generate default API key credentials if needed and possible. 105 | if ( 106 | self.eth_signer and 107 | self.default_address and 108 | not self.api_key_credentials 109 | ): 110 | # This may involve a web3 call, so recover on failure. 111 | try: 112 | self.api_key_credentials = ( 113 | self.onboarding.recover_default_api_key_credentials( 114 | ethereum_address=self.default_address, 115 | ) 116 | ) 117 | except Exception as e: 118 | print( 119 | 'Warning: Failed to derive default API key credentials:', 120 | e, 121 | ) 122 | 123 | @property 124 | def public(self): 125 | ''' 126 | Get the public module, used for interacting with public endpoints. 127 | ''' 128 | return self._public 129 | 130 | @property 131 | def private(self): 132 | ''' 133 | Get the private module, used for interacting with endpoints that 134 | require API-key auth. 135 | ''' 136 | if not self._private: 137 | if self.api_key_credentials: 138 | self._private = Private( 139 | host=self.host, 140 | network_id=self.network_id, 141 | stark_private_key=self.stark_private_key, 142 | default_address=self.default_address, 143 | api_timeout=self.api_timeout, 144 | api_key_credentials=self.api_key_credentials, 145 | ) 146 | else: 147 | raise Exception( 148 | 'Private endpoints not supported ' + 149 | 'since api_key_credentials were not specified', 150 | ) 151 | return self._private 152 | 153 | @property 154 | def eth_private(self): 155 | ''' 156 | Get the eth_private module, used for managing API keys and recovery. 157 | Requires Ethereum key auth. 158 | ''' 159 | if not self._eth_private: 160 | if self.eth_signer: 161 | self._eth_private = EthPrivate( 162 | host=self.host, 163 | eth_signer=self.eth_signer, 164 | network_id=self.network_id, 165 | default_address=self.default_address, 166 | api_timeout=self.api_timeout, 167 | ) 168 | else: 169 | raise Exception( 170 | 'Eth private module is not supported since no Ethereum ' + 171 | 'signing method (web3, web3_account, web3_provider) was ' + 172 | 'provided', 173 | ) 174 | return self._eth_private 175 | 176 | @property 177 | def onboarding(self): 178 | ''' 179 | Get the onboarding module, used to create a new user. Requires 180 | Ethereum key auth. 181 | ''' 182 | if not self._onboarding: 183 | if self.eth_signer: 184 | self._onboarding = Onboarding( 185 | host=self.host, 186 | eth_signer=self.eth_signer, 187 | network_id=self.network_id, 188 | default_address=self.default_address, 189 | api_timeout=self.api_timeout, 190 | stark_public_key=self.stark_public_key, 191 | stark_public_key_y_coordinate=( 192 | self.stark_public_key_y_coordinate 193 | ), 194 | ) 195 | else: 196 | raise Exception( 197 | 'Onboarding is not supported since no Ethereum ' + 198 | 'signing method (web3, web3_account, web3_provider) was ' + 199 | 'provided', 200 | ) 201 | return self._onboarding 202 | 203 | @property 204 | def eth(self): 205 | ''' 206 | Get the eth module, used for interacting with Ethereum smart contracts. 207 | ''' 208 | if not self._eth: 209 | eth_private_key = getattr(self.eth_signer, '_private_key', None) 210 | if self.web3 and eth_private_key: 211 | self._eth = Eth( 212 | web3=self.web3, 213 | network_id=self.network_id, 214 | eth_private_key=eth_private_key, 215 | default_address=self.default_address, 216 | stark_public_key=self.stark_public_key, 217 | send_options=self.eth_send_options, 218 | ) 219 | else: 220 | raise Exception( 221 | 'Eth module is not supported since neither web3 ' + 222 | 'nor web3_provider was provided OR since neither ' + 223 | 'eth_private_key nor web3_account was provided', 224 | ) 225 | return self._eth 226 | -------------------------------------------------------------------------------- /dydx3/errors.py: -------------------------------------------------------------------------------- 1 | class DydxError(Exception): 2 | """Base error class for all exceptions raised in this library. 3 | Will never be raised naked; more specific subclasses of this exception will 4 | be raised when appropriate.""" 5 | 6 | 7 | class DydxApiError(DydxError): 8 | 9 | def __init__(self, response): 10 | self.status_code = response.status_code 11 | try: 12 | self.msg = response.json() 13 | except ValueError: 14 | self.msg = response.text 15 | self.response = response 16 | self.request = getattr(response, 'request', None) 17 | 18 | def __str__(self): 19 | return self.__repr__() 20 | 21 | def __repr__(self): 22 | return 'DydxApiError(status_code={}, response={})'.format( 23 | self.status_code, 24 | self.msg, 25 | ) 26 | 27 | 28 | class TransactionReverted(DydxError): 29 | 30 | def __init__(self, tx_receipt): 31 | self.tx_receipt = tx_receipt 32 | -------------------------------------------------------------------------------- /dydx3/eth_signing/__init__.py: -------------------------------------------------------------------------------- 1 | from dydx3.eth_signing.eth_prive_action import SignEthPrivateAction 2 | from dydx3.eth_signing.onboarding_action import SignOnboardingAction 3 | from dydx3.eth_signing.signers import SignWithKey 4 | from dydx3.eth_signing.signers import SignWithWeb3 5 | -------------------------------------------------------------------------------- /dydx3/eth_signing/eth_prive_action.py: -------------------------------------------------------------------------------- 1 | from web3 import Web3 2 | 3 | from dydx3.eth_signing import util 4 | from dydx3.eth_signing.sign_off_chain_action import SignOffChainAction 5 | 6 | EIP712_ETH_PRIVATE_ACTION_STRUCT_STRING = [ 7 | {'type': 'string', 'name': 'method'}, 8 | {'type': 'string', 'name': 'requestPath'}, 9 | {'type': 'string', 'name': 'body'}, 10 | {'type': 'string', 'name': 'timestamp'}, 11 | ] 12 | EIP712_ETH_PRIVATE_ACTION_STRUCT_STRING_STRING = ( 13 | 'dYdX(' + 14 | 'string method,' + 15 | 'string requestPath,' + 16 | 'string body,' + 17 | 'string timestamp' + 18 | ')' 19 | ) 20 | EIP712_STRUCT_NAME = 'dYdX' 21 | 22 | 23 | class SignEthPrivateAction(SignOffChainAction): 24 | 25 | def get_eip712_struct(self): 26 | return EIP712_ETH_PRIVATE_ACTION_STRUCT_STRING 27 | 28 | def get_eip712_struct_name(self): 29 | return EIP712_STRUCT_NAME 30 | 31 | def get_eip712_message( 32 | self, 33 | method, 34 | request_path, 35 | body, 36 | timestamp, 37 | ): 38 | return super(SignEthPrivateAction, self).get_eip712_message( 39 | method=method, 40 | requestPath=request_path, 41 | body=body, 42 | timestamp=timestamp, 43 | ) 44 | 45 | def get_hash( 46 | self, 47 | method, 48 | request_path, 49 | body, 50 | timestamp, 51 | ): 52 | data = [ 53 | [ 54 | 'bytes32', 55 | 'bytes32', 56 | 'bytes32', 57 | 'bytes32', 58 | 'bytes32', 59 | ], 60 | [ 61 | util.hash_string( 62 | EIP712_ETH_PRIVATE_ACTION_STRUCT_STRING_STRING, 63 | ), 64 | util.hash_string(method), 65 | util.hash_string(request_path), 66 | util.hash_string(body), 67 | util.hash_string(timestamp), 68 | ], 69 | ] 70 | struct_hash = Web3.solidityKeccak(*data) 71 | return self.get_eip712_hash(struct_hash) 72 | -------------------------------------------------------------------------------- /dydx3/eth_signing/onboarding_action.py: -------------------------------------------------------------------------------- 1 | from web3 import Web3 2 | 3 | from dydx3.constants import NETWORK_ID_MAINNET 4 | from dydx3.eth_signing import util 5 | from dydx3.eth_signing.sign_off_chain_action import SignOffChainAction 6 | 7 | # On mainnet, include an extra onlySignOn parameter. 8 | EIP712_ONBOARDING_ACTION_STRUCT = [ 9 | {'type': 'string', 'name': 'action'}, 10 | {'type': 'string', 'name': 'onlySignOn'}, 11 | ] 12 | EIP712_ONBOARDING_ACTION_STRUCT_STRING = ( 13 | 'dYdX(' + 14 | 'string action,' + 15 | 'string onlySignOn' + 16 | ')' 17 | ) 18 | EIP712_ONBOARDING_ACTION_STRUCT_TESTNET = [ 19 | {'type': 'string', 'name': 'action'}, 20 | ] 21 | EIP712_ONBOARDING_ACTION_STRUCT_STRING_TESTNET = ( 22 | 'dYdX(' + 23 | 'string action' + 24 | ')' 25 | ) 26 | EIP712_STRUCT_NAME = 'dYdX' 27 | 28 | ONLY_SIGN_ON_DOMAIN_MAINNET = 'https://trade.dydx.exchange' 29 | 30 | 31 | class SignOnboardingAction(SignOffChainAction): 32 | 33 | def get_eip712_struct(self): 34 | # On mainnet, include an extra onlySignOn parameter. 35 | if self.network_id == NETWORK_ID_MAINNET: 36 | return EIP712_ONBOARDING_ACTION_STRUCT 37 | else: 38 | return EIP712_ONBOARDING_ACTION_STRUCT_TESTNET 39 | 40 | def get_eip712_struct_name(self): 41 | return EIP712_STRUCT_NAME 42 | 43 | def get_eip712_message( 44 | self, 45 | **message, 46 | ): 47 | eip712_message = super(SignOnboardingAction, self).get_eip712_message( 48 | **message, 49 | ) 50 | 51 | # On mainnet, include an extra onlySignOn parameter. 52 | if self.network_id == NETWORK_ID_MAINNET: 53 | eip712_message['message']['onlySignOn'] = ( 54 | 'https://trade.dydx.exchange' 55 | ) 56 | 57 | return eip712_message 58 | 59 | def get_hash( 60 | self, 61 | action, 62 | ): 63 | # On mainnet, include an extra onlySignOn parameter. 64 | if self.network_id == NETWORK_ID_MAINNET: 65 | eip712_struct_str = EIP712_ONBOARDING_ACTION_STRUCT_STRING 66 | else: 67 | eip712_struct_str = EIP712_ONBOARDING_ACTION_STRUCT_STRING_TESTNET 68 | 69 | data = [ 70 | [ 71 | 'bytes32', 72 | 'bytes32', 73 | ], 74 | [ 75 | util.hash_string(eip712_struct_str), 76 | util.hash_string(action), 77 | ], 78 | ] 79 | 80 | # On mainnet, include an extra onlySignOn parameter. 81 | if self.network_id == NETWORK_ID_MAINNET: 82 | data[0].append('bytes32') 83 | data[1].append(util.hash_string(ONLY_SIGN_ON_DOMAIN_MAINNET)) 84 | 85 | struct_hash = Web3.solidityKeccak(*data) 86 | return self.get_eip712_hash(struct_hash) 87 | -------------------------------------------------------------------------------- /dydx3/eth_signing/sign_off_chain_action.py: -------------------------------------------------------------------------------- 1 | from web3 import Web3 2 | 3 | from dydx3.eth_signing import util 4 | 5 | DOMAIN = 'dYdX' 6 | VERSION = '1.0' 7 | EIP712_DOMAIN_STRING_NO_CONTRACT = ( 8 | 'EIP712Domain(' + 9 | 'string name,' + 10 | 'string version,' + 11 | 'uint256 chainId' + 12 | ')' 13 | ) 14 | 15 | 16 | class SignOffChainAction(object): 17 | 18 | def __init__(self, signer, network_id): 19 | self.signer = signer 20 | self.network_id = network_id 21 | 22 | def get_hash(self, **message): 23 | raise NotImplementedError 24 | 25 | def get_eip712_struct(self): 26 | raise NotImplementedError 27 | 28 | def get_eip712_struct_name(self): 29 | raise NotImplementedError 30 | 31 | def sign( 32 | self, 33 | signer_address, 34 | **message, 35 | ): 36 | eip712_message = self.get_eip712_message(**message) 37 | message_hash = self.get_hash(**message) 38 | typed_signature = self.signer.sign( 39 | eip712_message, 40 | message_hash, 41 | signer_address, 42 | ) 43 | return typed_signature 44 | 45 | def verify( 46 | self, 47 | typed_signature, 48 | expected_signer_address, 49 | **message, 50 | ): 51 | message_hash = self.get_hash(**message) 52 | signer = util.ec_recover_typed_signature(message_hash, typed_signature) 53 | return util.addresses_are_equal(signer, expected_signer_address) 54 | 55 | def get_eip712_message( 56 | self, 57 | **message, 58 | ): 59 | struct_name = self.get_eip712_struct_name() 60 | return { 61 | 'types': { 62 | 'EIP712Domain': [ 63 | { 64 | 'name': 'name', 65 | 'type': 'string', 66 | }, 67 | { 68 | 'name': 'version', 69 | 'type': 'string', 70 | }, 71 | { 72 | 'name': 'chainId', 73 | 'type': 'uint256', 74 | }, 75 | ], 76 | struct_name: self.get_eip712_struct(), 77 | }, 78 | 'domain': { 79 | 'name': DOMAIN, 80 | 'version': VERSION, 81 | 'chainId': self.network_id, 82 | }, 83 | 'primaryType': struct_name, 84 | 'message': message, 85 | } 86 | 87 | def get_eip712_hash(self, struct_hash): 88 | return Web3.solidityKeccak( 89 | [ 90 | 'bytes2', 91 | 'bytes32', 92 | 'bytes32', 93 | ], 94 | [ 95 | '0x1901', 96 | self.get_domain_hash(), 97 | struct_hash, 98 | ] 99 | ) 100 | 101 | def get_domain_hash(self): 102 | return Web3.solidityKeccak( 103 | [ 104 | 'bytes32', 105 | 'bytes32', 106 | 'bytes32', 107 | 'uint256', 108 | ], 109 | [ 110 | util.hash_string(EIP712_DOMAIN_STRING_NO_CONTRACT), 111 | util.hash_string(DOMAIN), 112 | util.hash_string(VERSION), 113 | self.network_id, 114 | ], 115 | ) 116 | -------------------------------------------------------------------------------- /dydx3/eth_signing/signers.py: -------------------------------------------------------------------------------- 1 | import eth_account 2 | 3 | from dydx3.constants import SIGNATURE_TYPE_NO_PREPEND 4 | from dydx3.eth_signing import util 5 | 6 | 7 | class Signer(object): 8 | 9 | def sign( 10 | self, 11 | eip712_message, 12 | message_hash, 13 | opt_signer_address, 14 | ): 15 | ''' 16 | Sign an EIP-712 message. 17 | 18 | Returns a “typed signature” whose last byte indicates whether the hash 19 | was prepended before being signed. 20 | 21 | :param eip712_message: required 22 | :type eip712_message: dict 23 | 24 | :param message_hash: required 25 | :type message_hash: HexBytes 26 | 27 | :param opt_signer_address: optional 28 | :type opt_signer_address: str 29 | 30 | :returns: str 31 | ''' 32 | raise NotImplementedError() 33 | 34 | 35 | class SignWithWeb3(Signer): 36 | 37 | def __init__(self, web3): 38 | self.web3 = web3 39 | 40 | def sign( 41 | self, 42 | eip712_message, 43 | message_hash, # Ignored. 44 | opt_signer_address, 45 | ): 46 | signer_address = opt_signer_address or self.web3.eth.defaultAccount 47 | if not signer_address: 48 | raise ValueError( 49 | 'Must set ethereum_address or web3.eth.defaultAccount', 50 | ) 51 | raw_signature = self.web3.eth.signTypedData( 52 | signer_address, 53 | eip712_message, 54 | ) 55 | typed_signature = util.create_typed_signature( 56 | raw_signature.hex(), 57 | SIGNATURE_TYPE_NO_PREPEND, 58 | ) 59 | return typed_signature 60 | 61 | 62 | class SignWithKey(Signer): 63 | 64 | def __init__(self, private_key): 65 | self.address = eth_account.Account.from_key(private_key).address 66 | self._private_key = private_key 67 | 68 | def sign( 69 | self, 70 | eip712_message, # Ignored. 71 | message_hash, 72 | opt_signer_address, 73 | ): 74 | if ( 75 | opt_signer_address is not None and 76 | opt_signer_address != self.address 77 | ): 78 | raise ValueError( 79 | 'signer_address is {} but Ethereum key (eth_private_key / ' 80 | 'web3_account) corresponds to address {}'.format( 81 | opt_signer_address, 82 | self.address, 83 | ), 84 | ) 85 | signed_message = eth_account.Account._sign_hash( 86 | message_hash.hex(), 87 | self._private_key, 88 | ) 89 | typed_signature = util.create_typed_signature( 90 | signed_message.signature.hex(), 91 | SIGNATURE_TYPE_NO_PREPEND, 92 | ) 93 | return typed_signature 94 | -------------------------------------------------------------------------------- /dydx3/eth_signing/util.py: -------------------------------------------------------------------------------- 1 | from web3 import Web3 2 | from web3.auto import w3 3 | from dydx3 import constants 4 | 5 | PREPEND_DEC = '\x19Ethereum Signed Message:\n32' 6 | PREPEND_HEX = '\x19Ethereum Signed Message:\n\x20' 7 | 8 | 9 | def is_valid_sig_type( 10 | sig_type, 11 | ): 12 | return sig_type in [ 13 | constants.SIGNATURE_TYPE_DECIMAL, 14 | constants.SIGNATURE_TYPE_HEXADECIMAL, 15 | constants.SIGNATURE_TYPE_NO_PREPEND, 16 | ] 17 | 18 | 19 | def ec_recover_typed_signature( 20 | hashVal, 21 | typed_signature, 22 | ): 23 | if len(strip_hex_prefix(typed_signature)) != 66 * 2: 24 | raise Exception('Unable to ecrecover signature: ' + typed_signature) 25 | 26 | sig_type = int(typed_signature[-2:], 16) 27 | prepended_hash = '' 28 | if sig_type == constants.SIGNATURE_TYPE_NO_PREPEND: 29 | prepended_hash = hashVal 30 | elif sig_type == constants.SIGNATURE_TYPE_DECIMAL: 31 | prepended_hash = Web3.solidityKeccak( 32 | ['string', 'bytes32'], 33 | [PREPEND_DEC, hashVal], 34 | ) 35 | elif sig_type == constants.SIGNATURE_TYPE_HEXADECIMAL: 36 | prepended_hash = Web3.solidityKeccak( 37 | ['string', 'bytes32'], 38 | [PREPEND_HEX, hashVal], 39 | ) 40 | else: 41 | raise Exception('Invalid signature type: ' + sig_type) 42 | 43 | if not prepended_hash: 44 | raise Exception('Invalid hash: ' + hashVal) 45 | 46 | signature = typed_signature[:-2] 47 | 48 | address = w3.eth.account.recoverHash(prepended_hash, signature=signature) 49 | return address 50 | 51 | 52 | def create_typed_signature( 53 | signature, 54 | sig_type, 55 | ): 56 | if not is_valid_sig_type(sig_type): 57 | raise Exception('Invalid signature type: ' + sig_type) 58 | 59 | return fix_raw_signature(signature) + '0' + str(sig_type) 60 | 61 | 62 | def fix_raw_signature( 63 | signature, 64 | ): 65 | stripped = strip_hex_prefix(signature) 66 | 67 | if len(stripped) != 130: 68 | raise Exception('Invalid raw signature: ' + signature) 69 | 70 | rs = stripped[:128] 71 | v = stripped[128: 130] 72 | 73 | if v == '00': 74 | return '0x' + rs + '1b' 75 | if v == '01': 76 | return '0x' + rs + '1c' 77 | if v in ['1b', '1c']: 78 | return '0x' + stripped 79 | 80 | raise Exception('Invalid v value: ' + v) 81 | 82 | # ============ Byte Helpers ============ 83 | 84 | 85 | def strip_hex_prefix(input): 86 | if input.startswith('0x'): 87 | return input[2:] 88 | 89 | return input 90 | 91 | 92 | def addresses_are_equal( 93 | address_one, 94 | address_two, 95 | ): 96 | if not address_one or not address_two: 97 | return False 98 | 99 | return ( 100 | strip_hex_prefix( 101 | address_one 102 | ).lower() == strip_hex_prefix(address_two).lower() 103 | ) 104 | 105 | 106 | def hash_string(input): 107 | return Web3.solidityKeccak(['string'], [input]) 108 | -------------------------------------------------------------------------------- /dydx3/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dydxprotocol/dydx-v3-python/73252e3141881f7b5366b051c89c42d5ccdaef58/dydx3/helpers/__init__.py -------------------------------------------------------------------------------- /dydx3/helpers/db.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | NAMESPACE = uuid.UUID('0f9da948-a6fb-4c45-9edc-4685c3f3317d') 4 | 5 | 6 | def get_user_id(address): 7 | return str(uuid.uuid5(NAMESPACE, address)) 8 | 9 | 10 | def get_account_id( 11 | address, 12 | accountNumber=0, 13 | ): 14 | return str( 15 | uuid.uuid5( 16 | NAMESPACE, 17 | get_user_id(address.lower()) + str(accountNumber), 18 | ), 19 | ) 20 | -------------------------------------------------------------------------------- /dydx3/helpers/request_helpers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import json 3 | import random 4 | 5 | import dateutil.parser as dp 6 | 7 | 8 | def generate_query_path(url, params): 9 | entries = params.items() 10 | if not entries: 11 | return url 12 | 13 | paramsString = '&'.join('{key}={value}'.format( 14 | key=x[0], value=x[1]) for x in entries if x[1] is not None) 15 | if paramsString: 16 | return url + '?' + paramsString 17 | 18 | return url 19 | 20 | 21 | def json_stringify(data): 22 | return json.dumps(data, separators=(',', ':')) 23 | 24 | 25 | def random_client_id(): 26 | return str(int(float(str(random.random())[2:]))) 27 | 28 | 29 | def generate_now_iso(): 30 | return datetime.utcnow().strftime( 31 | '%Y-%m-%dT%H:%M:%S.%f', 32 | )[:-3] + 'Z' 33 | 34 | 35 | def iso_to_epoch_seconds(iso): 36 | return dp.parse(iso).timestamp() 37 | 38 | 39 | def epoch_seconds_to_iso(epoch): 40 | return datetime.utcfromtimestamp(epoch).strftime( 41 | '%Y-%m-%dT%H:%M:%S.%f', 42 | )[:-3] + 'Z' 43 | 44 | 45 | def remove_nones(original): 46 | return {k: v for k, v in original.items() if v is not None} 47 | -------------------------------------------------------------------------------- /dydx3/helpers/requests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests 4 | 5 | from dydx3.errors import DydxApiError 6 | from dydx3.helpers.request_helpers import remove_nones 7 | 8 | # TODO: Use a separate session per client instance. 9 | session = requests.session() 10 | session.headers.update({ 11 | 'Accept': 'application/json', 12 | 'Content-Type': 'application/json', 13 | 'User-Agent': 'dydx/python', 14 | }) 15 | 16 | 17 | class Response(object): 18 | def __init__(self, data={}, headers=None): 19 | self.data = data 20 | self.headers = headers 21 | 22 | 23 | def request(uri, method, headers=None, data_values={}, api_timeout=None): 24 | response = send_request( 25 | uri, 26 | method, 27 | headers, 28 | data=json.dumps( 29 | remove_nones(data_values) 30 | ), 31 | timeout=api_timeout 32 | ) 33 | if not str(response.status_code).startswith('2'): 34 | raise DydxApiError(response) 35 | 36 | if response.content: 37 | return Response(response.json(), response.headers) 38 | else: 39 | return Response('{}', response.headers) 40 | 41 | 42 | def send_request(uri, method, headers=None, **kwargs): 43 | return getattr(session, method)(uri, headers=headers, **kwargs) 44 | -------------------------------------------------------------------------------- /dydx3/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dydxprotocol/dydx-v3-python/73252e3141881f7b5366b051c89c42d5ccdaef58/dydx3/modules/__init__.py -------------------------------------------------------------------------------- /dydx3/modules/eth.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from web3 import Web3 4 | 5 | from dydx3.constants import ASSET_RESOLUTION 6 | from dydx3.constants import COLLATERAL_ASSET 7 | from dydx3.constants import COLLATERAL_ASSET_ID_BY_NETWORK_ID 8 | from dydx3.constants import DEFAULT_GAS_AMOUNT 9 | from dydx3.constants import DEFAULT_GAS_MULTIPLIER 10 | from dydx3.constants import DEFAULT_GAS_PRICE 11 | from dydx3.constants import DEFAULT_GAS_PRICE_ADDITION 12 | from dydx3.constants import MAX_SOLIDITY_UINT 13 | from dydx3.constants import STARKWARE_PERPETUALS_CONTRACT 14 | from dydx3.constants import TOKEN_CONTRACTS 15 | from dydx3.errors import TransactionReverted 16 | 17 | ERC20_ABI = 'abi/erc20.json' 18 | STARKWARE_PERPETUALS_ABI = 'abi/starkware-perpetuals.json' 19 | COLLATERAL_ASSET_RESOLUTION = float(ASSET_RESOLUTION[COLLATERAL_ASSET]) 20 | 21 | 22 | class Eth(object): 23 | 24 | def __init__( 25 | self, 26 | web3, 27 | network_id, 28 | eth_private_key, 29 | default_address, 30 | stark_public_key, 31 | send_options, 32 | ): 33 | self.web3 = web3 34 | self.network_id = network_id 35 | self.eth_private_key = eth_private_key 36 | self.default_address = default_address 37 | self.stark_public_key = stark_public_key 38 | self.send_options = send_options 39 | 40 | self.cached_contracts = {} 41 | self._next_nonce_for_address = {} 42 | 43 | # ----------------------------------------------------------- 44 | # Helper Functions 45 | # ----------------------------------------------------------- 46 | 47 | def create_contract( 48 | self, 49 | address, 50 | file_path, 51 | ): 52 | dydx_folder = os.path.join( 53 | os.path.dirname(os.path.abspath(__file__)), 54 | '..', 55 | ) 56 | return self.web3.eth.contract( 57 | address=address, 58 | abi=json.load(open(os.path.join(dydx_folder, file_path), 'r')), 59 | ) 60 | 61 | def get_contract( 62 | self, 63 | address, 64 | file_path, 65 | ): 66 | if address not in self.cached_contracts: 67 | self.cached_contracts[address] = self.create_contract( 68 | address, 69 | file_path, 70 | ) 71 | return self.cached_contracts[address] 72 | 73 | def get_exchange_contract( 74 | self, 75 | contract_address=None, 76 | ): 77 | if contract_address is None: 78 | contract_address = STARKWARE_PERPETUALS_CONTRACT.get( 79 | self.network_id, 80 | ) 81 | if contract_address is None: 82 | raise ValueError( 83 | 'Perpetuals exchange contract on network {}'.format( 84 | self.network_id, 85 | ) 86 | ) 87 | contract_address = Web3.toChecksumAddress(contract_address) 88 | return self.get_contract(contract_address, STARKWARE_PERPETUALS_ABI) 89 | 90 | def get_token_contract( 91 | self, 92 | asset, 93 | token_address, 94 | ): 95 | if token_address is None: 96 | token_address = TOKEN_CONTRACTS.get(asset, {}).get(self.network_id) 97 | if token_address is None: 98 | raise ValueError( 99 | 'Token address unknown for asset {} on network {}'.format( 100 | asset, 101 | self.network_id, 102 | ) 103 | ) 104 | token_address = Web3.toChecksumAddress(token_address) 105 | return self.get_contract(token_address, ERC20_ABI) 106 | 107 | def send_eth_transaction( 108 | self, 109 | method=None, 110 | options=None, 111 | ): 112 | options = dict(self.send_options, **(options or {})) 113 | 114 | if 'from' not in options: 115 | options['from'] = self.default_address 116 | if options.get('from') is None: 117 | raise ValueError( 118 | "options['from'] is not set, and no default address is set", 119 | ) 120 | auto_detect_nonce = 'nonce' not in options 121 | if auto_detect_nonce: 122 | options['nonce'] = self.get_next_nonce(options['from']) 123 | if 'gasPrice' not in options: 124 | try: 125 | options['gasPrice'] = ( 126 | self.web3.eth.gasPrice + DEFAULT_GAS_PRICE_ADDITION 127 | ) 128 | except Exception: 129 | options['gasPrice'] = DEFAULT_GAS_PRICE 130 | if 'value' not in options: 131 | options['value'] = 0 132 | gas_multiplier = options.pop('gasMultiplier', DEFAULT_GAS_MULTIPLIER) 133 | if 'gas' not in options: 134 | try: 135 | options['gas'] = int( 136 | method.estimateGas(options) * gas_multiplier 137 | ) 138 | except Exception: 139 | options['gas'] = DEFAULT_GAS_AMOUNT 140 | 141 | signed = self.sign_tx(method, options) 142 | try: 143 | tx_hash = self.web3.eth.sendRawTransaction(signed.rawTransaction) 144 | except ValueError as error: 145 | while ( 146 | auto_detect_nonce and 147 | ( 148 | 'nonce too low' in str(error) or 149 | 'replacement transaction underpriced' in str(error) 150 | ) 151 | ): 152 | try: 153 | options['nonce'] += 1 154 | signed = self.sign_tx(method, options) 155 | tx_hash = self.web3.eth.sendRawTransaction( 156 | signed.rawTransaction, 157 | ) 158 | except ValueError as inner_error: 159 | error = inner_error 160 | else: 161 | break # Break on success... 162 | else: 163 | raise error # ...and raise error otherwise. 164 | 165 | # Update next nonce for the account. 166 | self._next_nonce_for_address[options['from']] = options['nonce'] + 1 167 | 168 | return tx_hash.hex() 169 | 170 | def get_next_nonce( 171 | self, 172 | ethereum_address, 173 | ): 174 | if self._next_nonce_for_address.get(ethereum_address) is None: 175 | self._next_nonce_for_address[ethereum_address] = ( 176 | self.web3.eth.getTransactionCount(ethereum_address) 177 | ) 178 | return self._next_nonce_for_address[ethereum_address] 179 | 180 | def sign_tx( 181 | self, 182 | method, 183 | options, 184 | ): 185 | if method is None: 186 | tx = options 187 | else: 188 | tx = method.buildTransaction(options) 189 | return self.web3.eth.account.sign_transaction( 190 | tx, 191 | self.eth_private_key, 192 | ) 193 | 194 | def wait_for_tx( 195 | self, 196 | tx_hash, 197 | ): 198 | ''' 199 | Wait for a tx to be mined and return the receipt. Raise on revert. 200 | 201 | :param tx_hash: required 202 | :type tx_hash: number 203 | 204 | :returns: transactionReceipt 205 | 206 | :raises: TransactionReverted 207 | ''' 208 | tx_receipt = self.web3.eth.waitForTransactionReceipt(tx_hash) 209 | if tx_receipt['status'] == 0: 210 | raise TransactionReverted(tx_receipt) 211 | 212 | # ----------------------------------------------------------- 213 | # Transactions 214 | # ----------------------------------------------------------- 215 | 216 | def register_user( 217 | self, 218 | registration_signature, 219 | stark_public_key=None, 220 | ethereum_address=None, 221 | send_options=None, 222 | ): 223 | ''' 224 | Register a STARK key, using a signature provided by dYdX. 225 | 226 | :param registration_signature: required 227 | :type registration_signature: string 228 | 229 | :param stark_public_key: optional 230 | :type stark_public_key: string 231 | 232 | :param ethereum_address: optional 233 | :type ethereum_address: string 234 | 235 | :param send_options: optional 236 | :type send_options: dict 237 | 238 | :returns: transactionHash 239 | 240 | :raises: ValueError 241 | ''' 242 | stark_public_key = stark_public_key or self.stark_public_key 243 | if stark_public_key is None: 244 | raise ValueError('No stark_public_key was provided') 245 | 246 | ethereum_address = ethereum_address or self.default_address 247 | if ethereum_address is None: 248 | raise ValueError( 249 | 'ethereum_address was not provided, ' 250 | 'and no default address is set', 251 | ) 252 | 253 | contract = self.get_exchange_contract() 254 | return self.send_eth_transaction( 255 | method=contract.functions.registerUser( 256 | ethereum_address, 257 | int(stark_public_key, 16), 258 | registration_signature, 259 | ), 260 | options=send_options, 261 | ) 262 | 263 | def deposit_to_exchange( 264 | self, 265 | position_id, 266 | human_amount, 267 | stark_public_key=None, 268 | send_options=None, 269 | ): 270 | ''' 271 | Deposit collateral to the L2 perpetuals exchange. 272 | 273 | :param position_id: required 274 | :type position_id: int or string 275 | 276 | :param human_amount: required 277 | :type human_amount: number or string 278 | 279 | :param stark_public_key: optional 280 | :type stark_public_key: string 281 | 282 | :param send_options: optional 283 | :type send_options: dict 284 | 285 | :returns: transactionHash 286 | 287 | :raises: ValueError 288 | ''' 289 | stark_public_key = stark_public_key or self.stark_public_key 290 | if stark_public_key is None: 291 | raise ValueError('No stark_public_key was provided') 292 | 293 | contract = self.get_exchange_contract() 294 | return self.send_eth_transaction( 295 | method=contract.functions.deposit( 296 | int(stark_public_key, 16), 297 | COLLATERAL_ASSET_ID_BY_NETWORK_ID[self.network_id], 298 | int(position_id), 299 | int(float(human_amount) * COLLATERAL_ASSET_RESOLUTION), 300 | ), 301 | options=send_options, 302 | ) 303 | 304 | def withdraw( 305 | self, 306 | stark_public_key=None, 307 | send_options=None, 308 | ): 309 | ''' 310 | Withdraw from exchange. 311 | 312 | :param stark_public_key: optional 313 | :type stark_public_key: string 314 | 315 | :param send_options: optional 316 | :type send_options: dict 317 | 318 | :returns: transactionHash 319 | 320 | :raises: ValueError 321 | ''' 322 | stark_public_key = stark_public_key or self.stark_public_key 323 | if stark_public_key is None: 324 | raise ValueError('No stark_public_key was provided') 325 | 326 | contract = self.get_exchange_contract() 327 | return self.send_eth_transaction( 328 | method=contract.functions.withdraw( 329 | int(stark_public_key, 16), 330 | COLLATERAL_ASSET_ID_BY_NETWORK_ID[self.network_id], 331 | ), 332 | options=send_options, 333 | ) 334 | 335 | def withdraw_to( 336 | self, 337 | recipient, 338 | stark_public_key=None, 339 | send_options=None, 340 | ): 341 | ''' 342 | Withdraw from exchange to address. 343 | 344 | :param recipient: required 345 | :type recipient: string 346 | 347 | :param stark_public_key: optional 348 | :type stark_public_key: string 349 | 350 | :param send_options: optional 351 | :type send_options: dict 352 | 353 | :returns: transactionHash 354 | 355 | :raises: ValueError 356 | ''' 357 | stark_public_key = stark_public_key or self.stark_public_key 358 | if stark_public_key is None: 359 | raise ValueError('No stark_public_key was provided') 360 | 361 | contract = self.get_exchange_contract() 362 | return self.send_eth_transaction( 363 | method=contract.functions.withdrawTo( 364 | int(stark_public_key, 16), 365 | COLLATERAL_ASSET_ID_BY_NETWORK_ID[self.network_id], 366 | recipient, 367 | ), 368 | options=send_options, 369 | ) 370 | 371 | def transfer_eth( 372 | self, 373 | to_address=None, # Require keyword args to avoid confusing the amount. 374 | human_amount=None, 375 | send_options=None, 376 | ): 377 | ''' 378 | Send Ethereum. 379 | 380 | :param to_address: required 381 | :type to_address: number 382 | 383 | :param human_amount: required 384 | :type human_amount: number or string 385 | 386 | :param send_options: optional 387 | :type send_options: dict 388 | 389 | :returns: transactionHash 390 | 391 | :raises: ValueError 392 | ''' 393 | if to_address is None: 394 | raise ValueError('to_address is required') 395 | 396 | if human_amount is None: 397 | raise ValueError('human_amount is required') 398 | 399 | return self.send_eth_transaction( 400 | options=dict( 401 | send_options, 402 | to=to_address, 403 | value=Web3.toWei(human_amount, 'ether'), 404 | ), 405 | ) 406 | 407 | def transfer_token( 408 | self, 409 | to_address=None, # Require keyword args to avoid confusing the amount. 410 | human_amount=None, 411 | asset=COLLATERAL_ASSET, 412 | token_address=None, 413 | send_options=None, 414 | ): 415 | ''' 416 | Send Ethereum. 417 | 418 | :param to_address: required 419 | :type to_address: number 420 | 421 | :param human_amount: required 422 | :type human_amount: number of string 423 | 424 | :param asset: optional 425 | :type asset: string 426 | 427 | :param token_address: optional 428 | :type asset: string 429 | 430 | :param send_options: optional 431 | :type send_options: dict 432 | 433 | :returns: transactionHash 434 | 435 | :raises: ValueError 436 | ''' 437 | if to_address is None: 438 | raise ValueError('to_address is required') 439 | 440 | if human_amount is None: 441 | raise ValueError('human_amount is required') 442 | 443 | if asset not in ASSET_RESOLUTION: 444 | raise ValueError('Unknown asset {}'.format(asset)) 445 | asset_resolution = ASSET_RESOLUTION[asset] 446 | 447 | contract = self.get_token_contract(asset, token_address) 448 | return self.send_eth_transaction( 449 | method=contract.functions.transfer( 450 | to_address, 451 | int(float(human_amount) * float(asset_resolution)), 452 | ), 453 | options=send_options, 454 | ) 455 | 456 | def set_token_max_allowance( 457 | self, 458 | spender, 459 | asset=COLLATERAL_ASSET, 460 | token_address=None, 461 | send_options=None, 462 | ): 463 | ''' 464 | Set max allowance for some spender for some asset or token_address. 465 | 466 | :param spender: required 467 | :type spender: string 468 | 469 | :param asset: optional 470 | :type asset: string 471 | 472 | :param token_address: optional 473 | :type asset: string 474 | 475 | :param send_options: optional 476 | :type send_options: dict 477 | 478 | :returns: transactionHash 479 | 480 | :raises: ValueError 481 | ''' 482 | contract = self.get_token_contract(asset, token_address) 483 | return self.send_eth_transaction( 484 | method=contract.functions.approve( 485 | spender, 486 | MAX_SOLIDITY_UINT, 487 | ), 488 | options=send_options, 489 | ) 490 | 491 | # ----------------------------------------------------------- 492 | # Getters 493 | # ----------------------------------------------------------- 494 | 495 | def get_eth_balance( 496 | self, 497 | owner=None, 498 | ): 499 | ''' 500 | Get the owner's ether balance as a human readable amount. 501 | 502 | :param owner: optional 503 | :type owner: string 504 | 505 | :returns: string 506 | 507 | :raises: ValueError 508 | ''' 509 | owner = owner or self.default_address 510 | if owner is None: 511 | raise ValueError( 512 | 'owner was not provided, and no default address is set', 513 | ) 514 | 515 | wei_balance = self.web3.eth.getBalance(owner) 516 | return Web3.fromWei(wei_balance, 'ether') 517 | 518 | def get_token_balance( 519 | self, 520 | owner=None, 521 | asset=COLLATERAL_ASSET, 522 | token_address=None, 523 | ): 524 | ''' 525 | Get the owner's balance for some asset or token address. 526 | 527 | :param owner: optional 528 | :type owner: string 529 | 530 | :param asset: optional 531 | :type asset: string 532 | 533 | :param token_address: optional 534 | :type asset: string 535 | 536 | :returns: int 537 | ''' 538 | owner = owner or self.default_address 539 | if owner is None: 540 | raise ValueError( 541 | 'owner was not provided, and no default address is set', 542 | ) 543 | 544 | contract = self.get_token_contract(asset, token_address) 545 | return contract.functions.balanceOf(owner).call() 546 | 547 | def get_token_allowance( 548 | self, 549 | spender, 550 | owner=None, 551 | asset=COLLATERAL_ASSET, 552 | token_address=None, 553 | ): 554 | ''' 555 | Get allowance for some spender for some asset or token address. 556 | 557 | :param spender: required 558 | :type spender: string 559 | 560 | :param owner: optional 561 | :type owner: string 562 | 563 | :param asset: optional 564 | :type asset: string 565 | 566 | :param token_address: optional 567 | :type token_address: string 568 | 569 | :returns: int 570 | 571 | :raises: ValueError 572 | ''' 573 | owner = owner or self.default_address 574 | if owner is None: 575 | raise ValueError( 576 | 'owner was not provided, and no default address is set', 577 | ) 578 | 579 | contract = self.get_token_contract(asset, token_address) 580 | return contract.functions.allowance(owner, spender).call() 581 | -------------------------------------------------------------------------------- /dydx3/modules/eth_private.py: -------------------------------------------------------------------------------- 1 | from dydx3.helpers.request_helpers import generate_now_iso 2 | from dydx3.helpers.request_helpers import generate_query_path 3 | from dydx3.helpers.request_helpers import json_stringify 4 | from dydx3.eth_signing import SignEthPrivateAction 5 | from dydx3.helpers.requests import request 6 | 7 | 8 | class EthPrivate(object): 9 | """Module for adding/deleting API keys and recovery.""" 10 | 11 | def __init__( 12 | self, 13 | host, 14 | eth_signer, 15 | network_id, 16 | default_address, 17 | api_timeout, 18 | ): 19 | self.host = host 20 | self.default_address = default_address 21 | self.api_timeout = api_timeout 22 | 23 | self.signer = SignEthPrivateAction(eth_signer, network_id) 24 | 25 | # ============ Request Helpers ============ 26 | 27 | def _request( 28 | self, 29 | method, 30 | endpoint, 31 | opt_ethereum_address, 32 | data={} 33 | ): 34 | ethereum_address = opt_ethereum_address or self.default_address 35 | 36 | request_path = '/'.join(['/v3', endpoint]) 37 | timestamp = generate_now_iso() 38 | signature = self.signer.sign( 39 | ethereum_address, 40 | method=method.upper(), 41 | request_path=request_path, 42 | body=json_stringify(data) if data else '{}', 43 | timestamp=timestamp, 44 | ) 45 | 46 | return request( 47 | self.host + request_path, 48 | method, 49 | { 50 | 'DYDX-SIGNATURE': signature, 51 | 'DYDX-TIMESTAMP': timestamp, 52 | 'DYDX-ETHEREUM-ADDRESS': ethereum_address, 53 | }, 54 | data, 55 | self.api_timeout, 56 | ) 57 | 58 | def _post( 59 | self, 60 | endpoint, 61 | opt_ethereum_address, 62 | ): 63 | return self._request( 64 | 'post', 65 | endpoint, 66 | opt_ethereum_address, 67 | ) 68 | 69 | def _delete( 70 | self, 71 | endpoint, 72 | opt_ethereum_address, 73 | params={}, 74 | ): 75 | url = generate_query_path(endpoint, params) 76 | return self._request( 77 | 'delete', 78 | url, 79 | opt_ethereum_address, 80 | ) 81 | 82 | def _get( 83 | self, 84 | endpoint, 85 | opt_ethereum_address, 86 | params={}, 87 | ): 88 | url = generate_query_path(endpoint, params) 89 | return self._request( 90 | 'get', 91 | url, 92 | opt_ethereum_address, 93 | ) 94 | 95 | # ============ Requests ============ 96 | 97 | def create_api_key( 98 | self, 99 | ethereum_address=None, 100 | ): 101 | ''' 102 | Register an API key. 103 | 104 | :param ethereum_address: optional 105 | :type ethereum_address: str 106 | 107 | :returns: Object containing an apiKey 108 | 109 | :raises: DydxAPIError 110 | ''' 111 | return self._post( 112 | 'api-keys', 113 | ethereum_address, 114 | ) 115 | 116 | def delete_api_key( 117 | self, 118 | api_key, 119 | ethereum_address=None, 120 | ): 121 | ''' 122 | Delete an API key. 123 | 124 | :param api_key: required 125 | :type api_key: str 126 | 127 | :param ethereum_address: optional 128 | :type ethereum_address: str 129 | 130 | :returns: None 131 | 132 | :raises: DydxAPIError 133 | ''' 134 | return self._delete( 135 | 'api-keys', 136 | ethereum_address, 137 | { 138 | 'apiKey': api_key, 139 | }, 140 | ) 141 | 142 | def recovery( 143 | self, 144 | ethereum_address=None 145 | ): 146 | ''' 147 | This is for if you can't recover your starkKey or apiKey and need an 148 | additional way to get your starkKey and balance on our exchange, 149 | both of which are needed to call the L1 solidity function needed to 150 | recover your funds. 151 | 152 | :param ethereum_address: optional 153 | :type ethereum_address: str 154 | 155 | :returns: { 156 | starkKey: str, 157 | positionId: str, 158 | equity: str, 159 | freeCollateral: str, 160 | quoteBalance: str, 161 | positions: array of open positions 162 | } 163 | 164 | :raises: DydxAPIError 165 | ''' 166 | return self._get( 167 | 'recovery', 168 | ethereum_address, 169 | ) 170 | -------------------------------------------------------------------------------- /dydx3/modules/onboarding.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from web3 import Web3 4 | 5 | from dydx3.constants import OFF_CHAIN_ONBOARDING_ACTION 6 | from dydx3.constants import OFF_CHAIN_KEY_DERIVATION_ACTION 7 | from dydx3.eth_signing import SignOnboardingAction 8 | from dydx3.helpers.requests import request 9 | from dydx3.starkex.helpers import private_key_to_public_key_pair_hex 10 | 11 | 12 | class Onboarding(object): 13 | 14 | def __init__( 15 | self, 16 | host, 17 | eth_signer, 18 | network_id, 19 | default_address, 20 | api_timeout, 21 | stark_public_key=None, 22 | stark_public_key_y_coordinate=None, 23 | ): 24 | self.host = host 25 | self.default_address = default_address 26 | self.api_timeout = api_timeout 27 | self.stark_public_key = stark_public_key 28 | self.stark_public_key_y_coordinate = stark_public_key_y_coordinate 29 | 30 | self.signer = SignOnboardingAction(eth_signer, network_id) 31 | 32 | # ============ Request Helpers ============ 33 | 34 | def _post( 35 | self, 36 | endpoint, 37 | data, 38 | opt_ethereum_address, 39 | ): 40 | ethereum_address = opt_ethereum_address or self.default_address 41 | 42 | signature = self.signer.sign( 43 | ethereum_address, 44 | action=OFF_CHAIN_ONBOARDING_ACTION, 45 | ) 46 | 47 | request_path = '/'.join(['/v3', endpoint]) 48 | return request( 49 | self.host + request_path, 50 | 'post', 51 | { 52 | 'DYDX-SIGNATURE': signature, 53 | 'DYDX-ETHEREUM-ADDRESS': ethereum_address, 54 | }, 55 | data, 56 | self.api_timeout, 57 | ) 58 | 59 | # ============ Requests ============ 60 | 61 | def create_user( 62 | self, 63 | stark_public_key=None, 64 | stark_public_key_y_coordinate=None, 65 | ethereum_address=None, 66 | referred_by_affiliate_link=None, 67 | country=None, 68 | ): 69 | ''' 70 | Onboard a user with an Ethereum address and STARK key. 71 | 72 | By default, onboards using the STARK and/or API public keys 73 | corresponding to private keys that the client was initialized with. 74 | 75 | :param stark_public_key: optional 76 | :type stark_public_key: str 77 | 78 | :param stark_public_key_y_coordinate: optional 79 | :type stark_public_key_y_coordinate: str 80 | 81 | :param ethereum_address: optional 82 | :type ethereum_address: str 83 | 84 | :param referred_by_affiliate_link: optional 85 | :type referred_by_affiliate_link: str 86 | 87 | :param country optional 88 | :type country: str (ISO 3166-1 Alpha-2) 89 | 90 | :returns: { apiKey, user, account } 91 | 92 | :raises: DydxAPIError 93 | ''' 94 | stark_key = stark_public_key or self.stark_public_key 95 | stark_key_y = ( 96 | stark_public_key_y_coordinate or self.stark_public_key_y_coordinate 97 | ) 98 | if stark_key is None: 99 | raise ValueError( 100 | 'STARK private key or public key is required' 101 | ) 102 | if stark_key_y is None: 103 | raise ValueError( 104 | 'STARK private key or public key y-coordinate is required' 105 | ) 106 | return self._post( 107 | 'onboarding', 108 | { 109 | 'starkKey': stark_key, 110 | 'starkKeyYCoordinate': stark_key_y, 111 | 'referredByAffiliateLink': referred_by_affiliate_link, 112 | 'country': country, 113 | }, 114 | ethereum_address, 115 | ) 116 | 117 | # ============ Key Derivation ============ 118 | 119 | def derive_stark_key( 120 | self, 121 | ethereum_address=None, 122 | ): 123 | ''' 124 | Derive a STARK key pair deterministically from an Ethereum key. 125 | 126 | This is the function used by the dYdX frontend to derive a user's 127 | STARK key pair in a way that is recoverable. Programmatic traders may 128 | optionally derive their STARK key pair in the same way. 129 | 130 | :param ethereum_address: optional 131 | :type ethereum_address: str 132 | ''' 133 | signature = self.signer.sign( 134 | ethereum_address or self.default_address, 135 | action=OFF_CHAIN_KEY_DERIVATION_ACTION, 136 | ) 137 | signature_int = int(signature, 16) 138 | hashed_signature = Web3.solidityKeccak(['uint256'], [signature_int]) 139 | private_key_int = int(hashed_signature.hex(), 16) >> 5 140 | private_key_hex = hex(private_key_int) 141 | public_x, public_y = private_key_to_public_key_pair_hex( 142 | private_key_hex, 143 | ) 144 | return { 145 | 'public_key': public_x, 146 | 'public_key_y_coordinate': public_y, 147 | 'private_key': private_key_hex 148 | } 149 | 150 | def recover_default_api_key_credentials( 151 | self, 152 | ethereum_address=None, 153 | ): 154 | ''' 155 | Derive API credentials deterministically from an Ethereum key. 156 | 157 | This can be used to recover the default API key credentials, which are 158 | the same set of credentials used in the dYdX frontend. 159 | ''' 160 | signature = self.signer.sign( 161 | ethereum_address or self.default_address, 162 | action=OFF_CHAIN_ONBOARDING_ACTION, 163 | ) 164 | r_hex = signature[2:66] 165 | r_int = int(r_hex, 16) 166 | hashed_r_bytes = bytes(Web3.solidityKeccak(['uint256'], [r_int])) 167 | secret_bytes = hashed_r_bytes[:30] 168 | s_hex = signature[66:130] 169 | s_int = int(s_hex, 16) 170 | hashed_s_bytes = bytes(Web3.solidityKeccak(['uint256'], [s_int])) 171 | key_bytes = hashed_s_bytes[:16] 172 | passphrase_bytes = hashed_s_bytes[16:31] 173 | 174 | key_hex = key_bytes.hex() 175 | key_uuid = '-'.join([ 176 | key_hex[:8], 177 | key_hex[8:12], 178 | key_hex[12:16], 179 | key_hex[16:20], 180 | key_hex[20:], 181 | ]) 182 | 183 | return { 184 | 'secret': base64.urlsafe_b64encode(secret_bytes).decode(), 185 | 'key': key_uuid, 186 | 'passphrase': base64.urlsafe_b64encode(passphrase_bytes).decode(), 187 | } 188 | -------------------------------------------------------------------------------- /dydx3/modules/public.py: -------------------------------------------------------------------------------- 1 | from dydx3.constants import DEFAULT_API_TIMEOUT 2 | from dydx3.helpers.request_helpers import generate_query_path 3 | from dydx3.helpers.requests import request 4 | 5 | 6 | class Public(object): 7 | 8 | def __init__( 9 | self, 10 | host, 11 | api_timeout=None, 12 | ): 13 | self.host = host 14 | self.api_timeout = api_timeout or DEFAULT_API_TIMEOUT 15 | 16 | # ============ Request Helpers ============ 17 | 18 | def _get(self, request_path, params={}): 19 | return request( 20 | generate_query_path(self.host + request_path, params), 21 | 'get', 22 | api_timeout=self.api_timeout, 23 | ) 24 | 25 | def _put(self, endpoint, data): 26 | return request( 27 | self.host + '/v3/' + endpoint, 28 | 'put', 29 | {}, 30 | data, 31 | self.api_timeout, 32 | ) 33 | 34 | # ============ Requests ============ 35 | 36 | def check_if_user_exists(self, ethereum_address): 37 | ''' 38 | Check if user exists 39 | 40 | :param host: required 41 | :type host: str 42 | 43 | :returns: Bool 44 | 45 | :raises: DydxAPIError 46 | ''' 47 | uri = '/v3/users/exists' 48 | return self._get( 49 | uri, 50 | {'ethereumAddress': ethereum_address}, 51 | ) 52 | 53 | def check_if_username_exists(self, username): 54 | ''' 55 | Check if username exists 56 | 57 | :param username: required 58 | :type username: str 59 | 60 | :returns: Bool 61 | 62 | :raises: DydxAPIError 63 | ''' 64 | uri = '/v3/usernames' 65 | return self._get(uri, {'username': username}) 66 | 67 | def get_markets(self, market=None): 68 | ''' 69 | Get one or more markets 70 | 71 | :param market: optional 72 | :type market: str in list [ 73 | "BTC-USD", 74 | "ETH-USD", 75 | "LINK-USD", 76 | ... 77 | ] 78 | 79 | :returns: Market array 80 | 81 | :raises: DydxAPIError 82 | ''' 83 | uri = '/v3/markets' 84 | return self._get(uri, {'market': market}) 85 | 86 | def get_orderbook(self, market): 87 | ''' 88 | Get orderbook for a market 89 | 90 | :param market: required 91 | :type market: str in list [ 92 | "BTC-USD", 93 | "ETH-USD", 94 | "LINK-USD", 95 | ... 96 | ] 97 | 98 | :returns: Object containing bid array and ask array of open orders 99 | for a market 100 | 101 | :raises: DydxAPIError 102 | ''' 103 | uri = '/'.join(['/v3/orderbook', market]) 104 | return self._get(uri) 105 | 106 | def get_stats(self, market=None, days=None): 107 | ''' 108 | Get one or more day statistics for a market 109 | 110 | :param market: optional 111 | :type market: str in list [ 112 | "BTC-USD", 113 | "ETH-USD", 114 | "LINK-USD", 115 | ... 116 | ] 117 | 118 | :param days: optional 119 | :type days: str in list [ 120 | "1", 121 | "7", 122 | "30", 123 | ] 124 | 125 | :returns: Statistic information for a market, either for all time 126 | periods or just one. 127 | 128 | :raises: DydxAPIError 129 | ''' 130 | uri = ( 131 | '/'.join(['/v3/stats', market]) 132 | if market is not None 133 | else '/v3/stats' 134 | ) 135 | 136 | return self._get(uri, {'days': days}) 137 | 138 | def get_trades(self, market, starting_before_or_at=None): 139 | ''' 140 | Get trades for a market 141 | 142 | :param market: required 143 | :type market: str in list [ 144 | "BTC-USD", 145 | "ETH-USD", 146 | "LINK-USD", 147 | ... 148 | ] 149 | 150 | :param starting_before_or_at: optional 151 | :type starting_before_or_at: str 152 | 153 | :returns: Trade array 154 | 155 | :raises: DydxAPIError 156 | ''' 157 | uri = '/'.join(['/v3/trades', market]) 158 | return self._get( 159 | uri, 160 | {'startingBeforeOrAt': starting_before_or_at}, 161 | ) 162 | 163 | def get_historical_funding(self, market, effective_before_or_at=None): 164 | ''' 165 | Get historical funding for a market 166 | 167 | :param market: required 168 | :type market: str in list [ 169 | "BTC-USD", 170 | "ETH-USD", 171 | "LINK-USD", 172 | ... 173 | ] 174 | 175 | :param effective_before_or_at: optional 176 | :type effective_before_or_at: str 177 | 178 | :returns: Array of historical funding for a specific market 179 | 180 | :raises: DydxAPIError 181 | ''' 182 | uri = '/'.join(['/v3/historical-funding', market]) 183 | return self._get( 184 | uri, 185 | {'effectiveBeforeOrAt': effective_before_or_at}, 186 | ) 187 | 188 | def get_fast_withdrawal( 189 | self, 190 | creditAsset=None, 191 | creditAmount=None, 192 | debitAmount=None, 193 | ): 194 | ''' 195 | Get all fast withdrawal account information 196 | 197 | :param creditAsset: optional 198 | :type creditAsset: str 199 | 200 | :param creditAmount: optional 201 | :type creditAmount: str 202 | 203 | :param debitAmount: optional 204 | :type debitAmount: str 205 | 206 | :returns: All fast withdrawal accounts 207 | 208 | :raises: DydxAPIError 209 | ''' 210 | uri = '/v3/fast-withdrawals' 211 | return self._get( 212 | uri, 213 | { 214 | 'creditAsset': creditAsset, 215 | 'creditAmount': creditAmount, 216 | 'debitAmount': debitAmount, 217 | }, 218 | ) 219 | 220 | def get_candles( 221 | self, 222 | market, 223 | resolution=None, 224 | from_iso=None, 225 | to_iso=None, 226 | limit=None, 227 | ): 228 | ''' 229 | Get Candles 230 | 231 | :param market: required 232 | :type market: str in list [ 233 | "BTC-USD", 234 | "ETH-USD", 235 | "LINK-USD", 236 | ... 237 | ] 238 | 239 | :param resolution: optional 240 | :type resolution: str in list [ 241 | "1DAY", 242 | "4HOURS" 243 | "1HOUR", 244 | "30MINS", 245 | "15MINS", 246 | "5MINS", 247 | "1MIN", 248 | ] 249 | 250 | :param from_iso: optional 251 | :type from_iso: str 252 | 253 | :param to_iso: optional 254 | :type to_iso: str 255 | 256 | :param limit: optional 257 | :type limit: str 258 | 259 | :returns: Array of candles 260 | 261 | :raises: DydxAPIError 262 | ''' 263 | uri = '/'.join(['/v3/candles', market]) 264 | return self._get( 265 | uri, 266 | { 267 | 'resolution': resolution, 268 | 'fromISO': from_iso, 269 | 'toISO': to_iso, 270 | 'limit': limit, 271 | }, 272 | ) 273 | 274 | def get_time(self): 275 | ''' 276 | Get api server time as iso and as epoch in seconds with MS 277 | 278 | :returns: ISO string and Epoch number in seconds with MS of server time 279 | 280 | :raises: DydxAPIError 281 | ''' 282 | uri = '/v3/time' 283 | return self._get(uri) 284 | 285 | def verify_email( 286 | self, 287 | token, 288 | ): 289 | ''' 290 | Verify email with token 291 | 292 | :param token: required 293 | :type token: string 294 | 295 | :returns: empty object 296 | 297 | :raises: DydxAPIError 298 | ''' 299 | return self._put( 300 | 'emails/verify-email', 301 | { 302 | 'token': token, 303 | } 304 | ) 305 | 306 | def get_public_retroactive_mining_rewards( 307 | self, 308 | ethereum_address, 309 | ): 310 | ''' 311 | Get public retroactive mining rewards 312 | 313 | :param ethereumAddress: required 314 | :type ethereumAddress: str 315 | 316 | :returns: PublicRetroactiveMiningRewards 317 | 318 | :raises: DydxAPIError 319 | ''' 320 | return self._get( 321 | '/v3/rewards/public-retroactive-mining', 322 | { 323 | 'ethereumAddress': ethereum_address, 324 | }, 325 | ) 326 | 327 | def get_config(self): 328 | ''' 329 | Get global config variables for the exchange as a whole. 330 | This includes (but is not limited to) details on the exchange, 331 | including addresses, fees, transfers, and rate limits. 332 | 333 | :returns: GlobalConfigVariables 334 | 335 | :raises: DydxAPIError 336 | ''' 337 | return self._get('/v3/config') 338 | 339 | def get_insurance_fund_balance(self): 340 | ''' 341 | Get the balance of the dYdX insurance fund 342 | 343 | :returns: Balance of the dYdX insurance fund in USD 344 | 345 | :raises: DydxAPIError 346 | ''' 347 | return self._get('/v3/insurance-fund/balance') 348 | 349 | def get_profile( 350 | self, 351 | publicId, 352 | ): 353 | ''' 354 | Get Public Profile 355 | 356 | :param publicId: required 357 | :type publicId: str 358 | 359 | :returns: PublicProfile 360 | 361 | :raises: DydxAPIError 362 | ''' 363 | uri = '/'.join(['/v3/profile', publicId]) 364 | return self._get(uri) 365 | 366 | def get_historical_leaderboard_pnls( 367 | self, 368 | period, 369 | limit=None, 370 | ): 371 | ''' 372 | Get Historical Leaderboard Pnls 373 | 374 | :param period: required 375 | :type period: str 376 | :type period: str in list [ 377 | "LEAGUES", 378 | "DAILY", 379 | "DAILY_COMPETITION", 380 | ... 381 | ] 382 | 383 | :param limit: optional 384 | :type limit: str 385 | 386 | :returns: HistoricalLeaderboardPnl 387 | 388 | :raises: DydxAPIError 389 | ''' 390 | uri = '/'.join(['/v3/accounts/historical-leaderboard-pnls', period]) 391 | return self._get( 392 | uri, 393 | { 394 | 'limit': limit, 395 | } 396 | ) 397 | -------------------------------------------------------------------------------- /dydx3/starkex/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dydxprotocol/dydx-v3-python/73252e3141881f7b5366b051c89c42d5ccdaef58/dydx3/starkex/__init__.py -------------------------------------------------------------------------------- /dydx3/starkex/conditional_transfer.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import math 3 | 4 | from dydx3.constants import COLLATERAL_ASSET 5 | from dydx3.constants import COLLATERAL_ASSET_ID_BY_NETWORK_ID 6 | from dydx3.starkex.constants import CONDITIONAL_TRANSFER_FEE_ASSET_ID 7 | from dydx3.starkex.constants import CONDITIONAL_TRANSFER_FIELD_BIT_LENGTHS 8 | from dydx3.starkex.constants import CONDITIONAL_TRANSFER_MAX_AMOUNT_FEE 9 | from dydx3.starkex.constants import CONDITIONAL_TRANSFER_PADDING_BITS 10 | from dydx3.starkex.constants import CONDITIONAL_TRANSFER_PREFIX 11 | from dydx3.starkex.constants import ONE_HOUR_IN_SECONDS 12 | from dydx3.starkex.helpers import fact_to_condition 13 | from dydx3.starkex.helpers import nonce_from_client_id 14 | from dydx3.starkex.helpers import to_quantums_exact 15 | from dydx3.starkex.signable import Signable 16 | from dydx3.starkex.starkex_resources.proxy import get_hash 17 | 18 | StarkwareConditionalTransfer = namedtuple( 19 | 'StarkwareConditionalTransfer', 20 | [ 21 | 'sender_position_id', 22 | 'receiver_position_id', 23 | 'receiver_public_key', 24 | 'condition', 25 | 'quantums_amount', 26 | 'nonce', 27 | 'expiration_epoch_hours', 28 | ], 29 | ) 30 | 31 | 32 | class SignableConditionalTransfer(Signable): 33 | 34 | def __init__( 35 | self, 36 | network_id, 37 | sender_position_id, 38 | receiver_position_id, 39 | receiver_public_key, 40 | fact_registry_address, 41 | fact, 42 | human_amount, 43 | client_id, 44 | expiration_epoch_seconds, 45 | ): 46 | receiver_public_key = ( 47 | receiver_public_key 48 | if isinstance(receiver_public_key, int) 49 | else int(receiver_public_key, 16) 50 | ) 51 | quantums_amount = to_quantums_exact(human_amount, COLLATERAL_ASSET) 52 | expiration_epoch_hours = math.ceil( 53 | float(expiration_epoch_seconds) / ONE_HOUR_IN_SECONDS, 54 | ) 55 | message = StarkwareConditionalTransfer( 56 | sender_position_id=int(sender_position_id), 57 | receiver_position_id=int(receiver_position_id), 58 | receiver_public_key=receiver_public_key, 59 | condition=fact_to_condition(fact_registry_address, fact), 60 | quantums_amount=quantums_amount, 61 | nonce=nonce_from_client_id(client_id), 62 | expiration_epoch_hours=expiration_epoch_hours, 63 | ) 64 | super(SignableConditionalTransfer, self).__init__( 65 | network_id, 66 | message, 67 | ) 68 | 69 | def to_starkware(self): 70 | return self._message 71 | 72 | def _calculate_hash(self): 73 | """Calculate the hash of the Starkware order.""" 74 | 75 | # TODO: Check values are in bounds 76 | 77 | # The transfer asset and fee asset are always the collateral asset. 78 | # Fees are not supported for conditional transfers. 79 | asset_ids = get_hash( 80 | COLLATERAL_ASSET_ID_BY_NETWORK_ID[self.network_id], 81 | CONDITIONAL_TRANSFER_FEE_ASSET_ID, 82 | ) 83 | 84 | part_1 = get_hash( 85 | get_hash( 86 | asset_ids, 87 | self._message.receiver_public_key, 88 | ), 89 | self._message.condition, 90 | ) 91 | 92 | part_2 = self._message.sender_position_id 93 | part_2 <<= CONDITIONAL_TRANSFER_FIELD_BIT_LENGTHS['position_id'] 94 | part_2 += self._message.receiver_position_id 95 | part_2 <<= CONDITIONAL_TRANSFER_FIELD_BIT_LENGTHS['position_id'] 96 | part_2 += self._message.sender_position_id 97 | part_2 <<= CONDITIONAL_TRANSFER_FIELD_BIT_LENGTHS['nonce'] 98 | part_2 += self._message.nonce 99 | 100 | part_3 = CONDITIONAL_TRANSFER_PREFIX 101 | part_3 <<= CONDITIONAL_TRANSFER_FIELD_BIT_LENGTHS['quantums_amount'] 102 | part_3 += self._message.quantums_amount 103 | part_3 <<= CONDITIONAL_TRANSFER_FIELD_BIT_LENGTHS['quantums_amount'] 104 | part_3 += CONDITIONAL_TRANSFER_MAX_AMOUNT_FEE 105 | part_3 <<= CONDITIONAL_TRANSFER_FIELD_BIT_LENGTHS[ 106 | 'expiration_epoch_hours' 107 | ] 108 | part_3 += self._message.expiration_epoch_hours 109 | part_3 <<= CONDITIONAL_TRANSFER_PADDING_BITS 110 | 111 | return get_hash( 112 | get_hash( 113 | part_1, 114 | part_2, 115 | ), 116 | part_3, 117 | ) 118 | -------------------------------------------------------------------------------- /dydx3/starkex/constants.py: -------------------------------------------------------------------------------- 1 | """Constants related to creating hashes of Starkware objects.""" 2 | 3 | ONE_HOUR_IN_SECONDS = 60 * 60 4 | ORDER_SIGNATURE_EXPIRATION_BUFFER_HOURS = 24 * 7 # Seven days. 5 | 6 | TRANSFER_PREFIX = 4 7 | TRANSFER_PADDING_BITS = 81 8 | CONDITIONAL_TRANSFER_PADDING_BITS = 81 9 | CONDITIONAL_TRANSFER_PREFIX = 5 10 | ORDER_PREFIX = 3 11 | ORDER_PADDING_BITS = 17 12 | WITHDRAWAL_PADDING_BITS = 49 13 | WITHDRAWAL_PREFIX = 6 14 | 15 | # Note: Fees are not supported for conditional transfers or transfers. 16 | TRANSFER_FEE_ASSET_ID = 0 17 | TRANSFER_MAX_AMOUNT_FEE = 0 18 | 19 | CONDITIONAL_TRANSFER_FEE_ASSET_ID = 0 20 | CONDITIONAL_TRANSFER_MAX_AMOUNT_FEE = 0 21 | 22 | TRANSFER_FIELD_BIT_LENGTHS = { 23 | "asset_id": 250, 24 | "receiver_public_key": 251, 25 | "position_id": 64, 26 | "quantums_amount": 64, 27 | "nonce": 32, 28 | "expiration_epoch_hours": 32, 29 | } 30 | 31 | CONDITIONAL_TRANSFER_FIELD_BIT_LENGTHS = { 32 | "asset_id": 250, 33 | "receiver_public_key": 251, 34 | "position_id": 64, 35 | "condition": 251, 36 | "quantums_amount": 64, 37 | "nonce": 32, 38 | "expiration_epoch_hours": 32, 39 | } 40 | 41 | ORDER_FIELD_BIT_LENGTHS = { 42 | "asset_id_synthetic": 128, 43 | "asset_id_collateral": 250, 44 | "asset_id_fee": 250, 45 | "quantums_amount": 64, 46 | "nonce": 32, 47 | "position_id": 64, 48 | "expiration_epoch_hours": 32, 49 | } 50 | 51 | WITHDRAWAL_FIELD_BIT_LENGTHS = { 52 | "asset_id": 250, 53 | "position_id": 64, 54 | "nonce": 32, 55 | "quantums_amount": 64, 56 | "expiration_epoch_hours": 32, 57 | } 58 | 59 | ORACLE_PRICE_FIELD_BIT_LENGTHS = { 60 | "asset_name": 128, 61 | "oracle_name": 40, 62 | "price": 120, 63 | "timestamp_epoch_seconds": 32, 64 | } 65 | -------------------------------------------------------------------------------- /dydx3/starkex/helpers.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import hashlib 3 | 4 | from web3 import Web3 5 | 6 | from dydx3.constants import ASSET_RESOLUTION 7 | from dydx3.eth_signing.util import strip_hex_prefix 8 | from dydx3.starkex.constants import ORDER_FIELD_BIT_LENGTHS 9 | from dydx3.starkex.starkex_resources.python_signature import ( 10 | get_random_private_key 11 | ) 12 | from dydx3.starkex.starkex_resources.python_signature import ( 13 | private_key_to_ec_point_on_stark_curve, 14 | ) 15 | from dydx3.starkex.starkex_resources.python_signature import ( 16 | private_to_stark_key 17 | ) 18 | 19 | BIT_MASK_250 = (2 ** 250) - 1 20 | NONCE_UPPER_BOUND_EXCLUSIVE = 1 << ORDER_FIELD_BIT_LENGTHS['nonce'] 21 | DECIMAL_CTX_ROUND_DOWN = decimal.Context(rounding=decimal.ROUND_DOWN) 22 | DECIMAL_CTX_ROUND_UP = decimal.Context(rounding=decimal.ROUND_UP) 23 | DECIMAL_CTX_EXACT = decimal.Context( 24 | traps=[ 25 | decimal.Inexact, 26 | decimal.DivisionByZero, 27 | decimal.InvalidOperation, 28 | decimal.Overflow, 29 | ], 30 | ) 31 | 32 | 33 | def bytes_to_int(x): 34 | """Convert a bytestring to an int.""" 35 | return int(x.hex(), 16) 36 | 37 | 38 | def int_to_hex_32(x): 39 | """Normalize to a 32-byte hex string without 0x prefix.""" 40 | padded_hex = hex(x)[2:].rjust(64, '0') 41 | if len(padded_hex) != 64: 42 | raise ValueError('Input does not fit in 32 bytes') 43 | return padded_hex 44 | 45 | 46 | def serialize_signature(r, s): 47 | """Convert a signature from an r, s pair to a 32-byte hex string.""" 48 | return int_to_hex_32(r) + int_to_hex_32(s) 49 | 50 | 51 | def deserialize_signature(signature): 52 | """Convert a signature from a 32-byte hex string to an r, s pair.""" 53 | if len(signature) != 128: 54 | raise ValueError( 55 | 'Invalid serialized signature, expected hex string of length 128', 56 | ) 57 | return int(signature[:64], 16), int(signature[64:], 16) 58 | 59 | 60 | def to_quantums_exact(human_amount, asset): 61 | """Convert a human-readable amount to an integer amount of quantums. 62 | 63 | If the provided human_amount is not a multiple of the quantum size, 64 | an exception will be raised. 65 | """ 66 | return _to_quantums_helper(human_amount, asset, DECIMAL_CTX_EXACT) 67 | 68 | 69 | def to_quantums_round_down(human_amount, asset): 70 | """Convert a human-readable amount to an integer amount of quantums. 71 | 72 | If the provided human_amount is not a multiple of the quantum size, 73 | the result will be rounded down to the nearest integer. 74 | """ 75 | return _to_quantums_helper(human_amount, asset, DECIMAL_CTX_ROUND_DOWN) 76 | 77 | 78 | def to_quantums_round_up(human_amount, asset): 79 | """Convert a human-readable amount to an integer amount of quantums. 80 | 81 | If the provided human_amount is not a multiple of the quantum size, 82 | the result will be rounded up to the nearest integer. 83 | """ 84 | return _to_quantums_helper(human_amount, asset, DECIMAL_CTX_ROUND_UP) 85 | 86 | 87 | def _to_quantums_helper(human_amount, asset, ctx): 88 | try: 89 | amount_dec = ctx.create_decimal(human_amount) 90 | resolution_dec = ctx.create_decimal(ASSET_RESOLUTION[asset]) 91 | quantums = (amount_dec * resolution_dec).to_integral_exact(context=ctx) 92 | except decimal.Inexact: 93 | raise ValueError( 94 | 'Amount {} is not a multiple of the quantum size {}'.format( 95 | human_amount, 96 | 1 / float(ASSET_RESOLUTION[asset]), 97 | ), 98 | ) 99 | return int(quantums) 100 | 101 | 102 | def nonce_from_client_id(client_id): 103 | """Generate a nonce deterministically from an arbitrary string.""" 104 | message = hashlib.sha256() 105 | message.update(client_id.encode()) # Encode as UTF-8. 106 | return int(message.digest().hex(), 16) % NONCE_UPPER_BOUND_EXCLUSIVE 107 | 108 | 109 | def get_transfer_erc20_fact( 110 | recipient, 111 | token_decimals, 112 | human_amount, 113 | token_address, 114 | salt, 115 | ): 116 | token_amount = float(human_amount) * (10 ** token_decimals) 117 | if not token_amount.is_integer(): 118 | raise ValueError( 119 | 'Amount {} has more precision than token decimals {}'.format( 120 | human_amount, 121 | token_decimals, 122 | ) 123 | ) 124 | hex_bytes = Web3.solidityKeccak( 125 | [ 126 | 'address', 127 | 'uint256', 128 | 'address', 129 | 'uint256', 130 | ], 131 | [ 132 | recipient, 133 | int(token_amount), 134 | token_address, 135 | salt, 136 | ], 137 | ) 138 | return bytes(hex_bytes) 139 | 140 | 141 | def fact_to_condition(fact_registry_address, fact): 142 | """Generate the condition, signed as part of a conditional transfer.""" 143 | if not isinstance(fact, bytes): 144 | raise ValueError('fact must be a byte-string') 145 | data = bytes.fromhex(strip_hex_prefix(fact_registry_address)) + fact 146 | return int(Web3.keccak(data).hex(), 16) & BIT_MASK_250 147 | 148 | 149 | def message_to_hash(message_string): 150 | """Generate a hash deterministically from an arbitrary string.""" 151 | message = hashlib.sha256() 152 | message.update(message_string.encode()) # Encode as UTF-8. 153 | return int(message.digest().hex(), 16) >> 5 154 | 155 | 156 | def generate_private_key_hex_unsafe(): 157 | """Generate a STARK key using the Python builtin random module.""" 158 | return hex(get_random_private_key()) 159 | 160 | 161 | def private_key_from_bytes(data): 162 | """Generate a STARK key deterministically from binary data.""" 163 | if not isinstance(data, bytes): 164 | raise ValueError('Input must be a byte-string') 165 | return hex(int(Web3.keccak(data).hex(), 16) >> 5) 166 | 167 | 168 | def private_key_to_public_hex(private_key_hex): 169 | """Given private key as hex string, return the public key as hex string.""" 170 | private_key_int = int(private_key_hex, 16) 171 | return hex(private_to_stark_key(private_key_int)) 172 | 173 | 174 | def private_key_to_public_key_pair_hex(private_key_hex): 175 | """Given private key as hex string, return the public x, y pair as hex.""" 176 | private_key_int = int(private_key_hex, 16) 177 | x, y = private_key_to_ec_point_on_stark_curve(private_key_int) 178 | return [hex(x), hex(y)] 179 | -------------------------------------------------------------------------------- /dydx3/starkex/order.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import math 3 | 4 | from collections import namedtuple 5 | 6 | from dydx3.constants import COLLATERAL_ASSET 7 | from dydx3.constants import COLLATERAL_ASSET_ID_BY_NETWORK_ID 8 | from dydx3.constants import ORDER_SIDE_BUY 9 | from dydx3.constants import SYNTHETIC_ASSET_ID_MAP 10 | from dydx3.constants import SYNTHETIC_ASSET_MAP 11 | from dydx3.starkex.constants import ONE_HOUR_IN_SECONDS 12 | from dydx3.starkex.constants import ORDER_FIELD_BIT_LENGTHS 13 | from dydx3.starkex.constants import ORDER_PADDING_BITS 14 | from dydx3.starkex.constants import ORDER_PREFIX 15 | from dydx3.starkex.constants import ORDER_SIGNATURE_EXPIRATION_BUFFER_HOURS 16 | from dydx3.starkex.helpers import nonce_from_client_id 17 | from dydx3.starkex.helpers import to_quantums_exact 18 | from dydx3.starkex.helpers import to_quantums_round_down 19 | from dydx3.starkex.helpers import to_quantums_round_up 20 | from dydx3.starkex.signable import Signable 21 | from dydx3.starkex.starkex_resources.proxy import get_hash 22 | 23 | DECIMAL_CONTEXT_ROUND_DOWN = decimal.Context(rounding=decimal.ROUND_DOWN) 24 | DECIMAL_CONTEXT_ROUND_UP = decimal.Context(rounding=decimal.ROUND_UP) 25 | 26 | StarkwareOrder = namedtuple( 27 | 'StarkwareOrder', 28 | [ 29 | 'order_type', 30 | 'asset_id_synthetic', 31 | 'asset_id_collateral', 32 | 'asset_id_fee', 33 | 'quantums_amount_synthetic', 34 | 'quantums_amount_collateral', 35 | 'quantums_amount_fee', 36 | 'is_buying_synthetic', 37 | 'position_id', 38 | 'nonce', 39 | 'expiration_epoch_hours', 40 | ], 41 | ) 42 | 43 | 44 | class SignableOrder(Signable): 45 | 46 | def __init__( 47 | self, 48 | network_id, 49 | market, 50 | side, 51 | position_id, 52 | human_size, 53 | human_price, 54 | limit_fee, 55 | client_id, 56 | expiration_epoch_seconds, 57 | ): 58 | synthetic_asset = SYNTHETIC_ASSET_MAP[market] 59 | synthetic_asset_id = SYNTHETIC_ASSET_ID_MAP[synthetic_asset] 60 | collateral_asset_id = COLLATERAL_ASSET_ID_BY_NETWORK_ID[network_id] 61 | is_buying_synthetic = side == ORDER_SIDE_BUY 62 | quantums_amount_synthetic = to_quantums_exact( 63 | human_size, 64 | synthetic_asset, 65 | ) 66 | 67 | # Note: By creating the decimals outside the context and then 68 | # multiplying within the context, we ensure rounding does not occur 69 | # until after the multiplication is computed with full precision. 70 | if is_buying_synthetic: 71 | human_cost = DECIMAL_CONTEXT_ROUND_UP.multiply( 72 | decimal.Decimal(human_size), 73 | decimal.Decimal(human_price) 74 | ) 75 | quantums_amount_collateral = to_quantums_round_up( 76 | human_cost, 77 | COLLATERAL_ASSET, 78 | ) 79 | else: 80 | human_cost = DECIMAL_CONTEXT_ROUND_DOWN.multiply( 81 | decimal.Decimal(human_size), 82 | decimal.Decimal(human_price) 83 | ) 84 | quantums_amount_collateral = to_quantums_round_down( 85 | human_cost, 86 | COLLATERAL_ASSET, 87 | ) 88 | 89 | # The limitFee is a fraction, e.g. 0.01 is a 1 % fee. 90 | # It is always paid in the collateral asset. 91 | # Constrain the limit fee to six decimals of precision. 92 | # The final fee amount must be rounded up. 93 | limit_fee_rounded = DECIMAL_CONTEXT_ROUND_DOWN.quantize( 94 | decimal.Decimal(limit_fee), 95 | decimal.Decimal('0.000001'), 96 | ) 97 | quantums_amount_fee_decimal = DECIMAL_CONTEXT_ROUND_UP.multiply( 98 | limit_fee_rounded, 99 | quantums_amount_collateral, 100 | ).to_integral_value(context=DECIMAL_CONTEXT_ROUND_UP) 101 | 102 | # Orders may have a short time-to-live on the orderbook, but we need 103 | # to ensure their signatures are valid by the time they reach the 104 | # blockchain. Therefore, we enforce that the signed expiration includes 105 | # a buffer relative to the expiration timestamp sent to the dYdX API. 106 | expiration_epoch_hours = math.ceil( 107 | float(expiration_epoch_seconds) / ONE_HOUR_IN_SECONDS, 108 | ) + ORDER_SIGNATURE_EXPIRATION_BUFFER_HOURS 109 | 110 | message = StarkwareOrder( 111 | order_type='LIMIT_ORDER_WITH_FEES', 112 | asset_id_synthetic=synthetic_asset_id, 113 | asset_id_collateral=collateral_asset_id, 114 | asset_id_fee=collateral_asset_id, 115 | quantums_amount_synthetic=quantums_amount_synthetic, 116 | quantums_amount_collateral=quantums_amount_collateral, 117 | quantums_amount_fee=int(quantums_amount_fee_decimal), 118 | is_buying_synthetic=is_buying_synthetic, 119 | position_id=int(position_id), 120 | nonce=nonce_from_client_id(client_id), 121 | expiration_epoch_hours=expiration_epoch_hours, 122 | ) 123 | super(SignableOrder, self).__init__(network_id, message) 124 | 125 | def to_starkware(self): 126 | return self._message 127 | 128 | def _calculate_hash(self): 129 | """Calculate the hash of the Starkware order.""" 130 | 131 | # TODO: Check values are in bounds 132 | 133 | if self._message.is_buying_synthetic: 134 | asset_id_sell = self._message.asset_id_collateral 135 | asset_id_buy = self._message.asset_id_synthetic 136 | quantums_amount_sell = self._message.quantums_amount_collateral 137 | quantums_amount_buy = self._message.quantums_amount_synthetic 138 | else: 139 | asset_id_sell = self._message.asset_id_synthetic 140 | asset_id_buy = self._message.asset_id_collateral 141 | quantums_amount_sell = self._message.quantums_amount_synthetic 142 | quantums_amount_buy = self._message.quantums_amount_collateral 143 | 144 | part_1 = quantums_amount_sell 145 | part_1 <<= ORDER_FIELD_BIT_LENGTHS['quantums_amount'] 146 | part_1 += quantums_amount_buy 147 | part_1 <<= ORDER_FIELD_BIT_LENGTHS['quantums_amount'] 148 | part_1 += self._message.quantums_amount_fee 149 | part_1 <<= ORDER_FIELD_BIT_LENGTHS['nonce'] 150 | part_1 += self._message.nonce 151 | 152 | part_2 = ORDER_PREFIX 153 | for _ in range(3): 154 | part_2 <<= ORDER_FIELD_BIT_LENGTHS['position_id'] 155 | part_2 += self._message.position_id 156 | part_2 <<= ORDER_FIELD_BIT_LENGTHS['expiration_epoch_hours'] 157 | part_2 += self._message.expiration_epoch_hours 158 | part_2 <<= ORDER_PADDING_BITS 159 | 160 | assets_hash = get_hash( 161 | get_hash( 162 | asset_id_sell, 163 | asset_id_buy, 164 | ), 165 | self._message.asset_id_fee, 166 | ) 167 | return get_hash( 168 | get_hash( 169 | assets_hash, 170 | part_1, 171 | ), 172 | part_2, 173 | ) 174 | -------------------------------------------------------------------------------- /dydx3/starkex/signable.py: -------------------------------------------------------------------------------- 1 | from dydx3.constants import COLLATERAL_ASSET_ID_BY_NETWORK_ID 2 | from dydx3.starkex.helpers import deserialize_signature 3 | from dydx3.starkex.helpers import serialize_signature 4 | from dydx3.starkex.starkex_resources.proxy import sign 5 | from dydx3.starkex.starkex_resources.proxy import verify 6 | 7 | 8 | class Signable(object): 9 | """Base class for an object signable with a STARK key.""" 10 | 11 | def __init__(self, network_id, message): 12 | self.network_id = network_id 13 | self._message = message 14 | self._hash = None 15 | 16 | # Sanity check. 17 | if not COLLATERAL_ASSET_ID_BY_NETWORK_ID[self.network_id]: 18 | raise ValueError( 19 | 'Unknown network ID or unknown collateral asset for network: ' 20 | '{}'.format(network_id), 21 | ) 22 | 23 | @property 24 | def hash(self): 25 | """Get the hash of the object.""" 26 | if self._hash is None: 27 | self._hash = self._calculate_hash() 28 | return self._hash 29 | 30 | def sign(self, private_key_hex): 31 | """Sign the hash of the object using the given private key.""" 32 | r, s = sign(self.hash, int(private_key_hex, 16)) 33 | return serialize_signature(r, s) 34 | 35 | def verify_signature(self, signature_hex, public_key_hex): 36 | """Return True if the signature is valid for the given public key.""" 37 | r, s = deserialize_signature(signature_hex) 38 | return verify(self.hash, r, s, int(public_key_hex, 16)) 39 | 40 | def _calculate_hash(self): 41 | raise NotImplementedError 42 | -------------------------------------------------------------------------------- /dydx3/starkex/starkex_resources/__init__.py: -------------------------------------------------------------------------------- 1 | """Cryptographic functions to sign and verify signatures with STARK keys. 2 | 3 | The code in this module is copied from 4 | https://github.com/starkware-libs/starkex-resources 5 | and is used in accordance with the Apache License, Version 2.0. 6 | 7 | https://www.starkware.co/open-source-license/ 8 | """ 9 | -------------------------------------------------------------------------------- /dydx3/starkex/starkex_resources/cpp_signature.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import secrets 3 | import os 4 | from typing import Optional, Tuple 5 | import json 6 | import math 7 | from dydx3.starkex.starkex_resources.python_signature import ( 8 | inv_mod_curve_size, 9 | ) 10 | 11 | PEDERSEN_HASH_POINT_FILENAME = os.path.join( 12 | os.path.dirname(__file__), 'pedersen_params.json') 13 | PEDERSEN_PARAMS = json.load(open(PEDERSEN_HASH_POINT_FILENAME)) 14 | 15 | EC_ORDER = PEDERSEN_PARAMS['EC_ORDER'] 16 | 17 | FIELD_PRIME = PEDERSEN_PARAMS['FIELD_PRIME'] 18 | 19 | N_ELEMENT_BITS_ECDSA = math.floor(math.log(FIELD_PRIME, 2)) 20 | assert N_ELEMENT_BITS_ECDSA == 251 21 | 22 | 23 | CPP_LIB_PATH = None 24 | OUT_BUFFER_SIZE = 251 25 | 26 | def get_cpp_lib(crypto_c_exports_path): 27 | global CPP_LIB_PATH 28 | CPP_LIB_PATH = ctypes.cdll.LoadLibrary(os.path.abspath(crypto_c_exports_path)) 29 | # Configure argument and return types. 30 | CPP_LIB_PATH.Hash.argtypes = [ 31 | ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p] 32 | CPP_LIB_PATH.Verify.argtypes = [ 33 | ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p] 34 | CPP_LIB_PATH.Verify.restype = bool 35 | CPP_LIB_PATH.Sign.argtypes = [ 36 | ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p] 37 | 38 | def check_cpp_lib_path() -> bool: 39 | return CPP_LIB_PATH is not None 40 | 41 | ######### 42 | # ECDSA # 43 | ######### 44 | 45 | # A type for the digital signature. 46 | ECSignature = Tuple[int, int] 47 | 48 | ################# 49 | # CPP WRAPPERS # 50 | ################# 51 | 52 | def cpp_hash(left, right) -> int: 53 | res = ctypes.create_string_buffer(OUT_BUFFER_SIZE) 54 | if CPP_LIB_PATH.Hash( 55 | left.to_bytes(32, 'little', signed=False), 56 | right.to_bytes(32, 'little', signed=False), 57 | res) != 0: 58 | raise ValueError(res.raw.rstrip(b'\00')) 59 | return int.from_bytes(res.raw[:32], 'little', signed=False) 60 | 61 | 62 | def cpp_sign(msg_hash, priv_key, seed: Optional[int] = 32) -> ECSignature: 63 | """ 64 | Note that this uses the secrets module to generate cryptographically strong random numbers. 65 | Note that the same seed will give a different signature compared with the sign function in 66 | signature.py. 67 | """ 68 | res = ctypes.create_string_buffer(OUT_BUFFER_SIZE) 69 | random_bytes = secrets.token_bytes(seed) 70 | if CPP_LIB_PATH.Sign( 71 | priv_key.to_bytes(32, 'little', signed=False), 72 | msg_hash.to_bytes(32, 'little', signed=False), 73 | random_bytes, res) != 0: 74 | raise ValueError(res.raw.rstrip(b'\00')) 75 | w = int.from_bytes(res.raw[32:64], 'little', signed=False) 76 | s = inv_mod_curve_size(w) 77 | return (int.from_bytes(res.raw[:32], 'little', signed=False), s) 78 | 79 | 80 | def cpp_verify(msg_hash, r, s, stark_key) -> bool: 81 | w =inv_mod_curve_size(s) 82 | assert 1 <= stark_key < 2**N_ELEMENT_BITS_ECDSA, 'stark_key = %s' % stark_key 83 | assert 1 <= msg_hash < 2**N_ELEMENT_BITS_ECDSA, 'msg_hash = %s' % msg_hash 84 | assert 1 <= r < 2**N_ELEMENT_BITS_ECDSA, 'r = %s' % r 85 | assert 1 <= w < EC_ORDER, 'w = %s' % w 86 | return CPP_LIB_PATH.Verify( 87 | stark_key.to_bytes(32, 'little', signed=False), 88 | msg_hash.to_bytes(32, 'little', signed=False), 89 | r.to_bytes(32, 'little', signed=False), 90 | w.to_bytes(32, 'little', signed=False)) 91 | -------------------------------------------------------------------------------- /dydx3/starkex/starkex_resources/math_utils.py: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Copyright 2019 StarkWare Industries Ltd. # 3 | # # 4 | # Licensed under the Apache License, Version 2.0 (the "License"). # 5 | # You may not use this file except in compliance with the License. # 6 | # You may obtain a copy of the License at # 7 | # # 8 | # https://www.starkware.co/open-source-license/ # 9 | # # 10 | # Unless required by applicable law or agreed to in writing, # 11 | # software distributed under the License is distributed on an "AS IS" BASIS, # 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 13 | # See the License for the specific language governing permissions # 14 | # and limitations under the License. # 15 | ############################################################################### 16 | 17 | 18 | from typing import Tuple 19 | 20 | import mpmath 21 | import sympy 22 | from sympy.core.numbers import igcdex 23 | 24 | # A type that represents a point (x,y) on an elliptic curve. 25 | ECPoint = Tuple[int, int] 26 | 27 | 28 | def pi_as_string(digits: int) -> str: 29 | """ 30 | Returns pi as a string of decimal digits without the decimal point ("314..."). 31 | """ 32 | mpmath.mp.dps = digits # Set number of digits. 33 | return '3' + str(mpmath.mp.pi)[2:] 34 | 35 | 36 | def is_quad_residue(n: int, p: int) -> bool: 37 | """ 38 | Returns True if n is a quadratic residue mod p. 39 | """ 40 | return sympy.is_quad_residue(n, p) 41 | 42 | 43 | def sqrt_mod(n: int, p: int) -> int: 44 | """ 45 | Finds the minimum positive integer m such that (m*m) % p == n 46 | """ 47 | return min(sympy.sqrt_mod(n, p, all_roots=True)) 48 | 49 | 50 | def div_mod(n: int, m: int, p: int) -> int: 51 | """ 52 | Finds a nonnegative integer 0 <= x < p such that (m * x) % p == n 53 | """ 54 | a, b, c = igcdex(m, p) 55 | assert c == 1 56 | return (n * a) % p 57 | 58 | 59 | def ec_add(point1: ECPoint, point2: ECPoint, p: int) -> ECPoint: 60 | """ 61 | Gets two points on an elliptic curve mod p and returns their sum. 62 | Assumes the points are given in affine form (x, y) and have different x coordinates. 63 | """ 64 | assert (point1[0] - point2[0]) % p != 0 65 | m = div_mod(point1[1] - point2[1], point1[0] - point2[0], p) 66 | x = (m * m - point1[0] - point2[0]) % p 67 | y = (m * (point1[0] - x) - point1[1]) % p 68 | return x, y 69 | 70 | 71 | def ec_neg(point: ECPoint, p: int) -> ECPoint: 72 | """ 73 | Given a point (x,y) return (x, -y) 74 | """ 75 | x, y = point 76 | return (x, (-y) % p) 77 | 78 | 79 | def ec_double(point: ECPoint, alpha: int, p: int) -> ECPoint: 80 | """ 81 | Doubles a point on an elliptic curve with the equation y^2 = x^3 + alpha*x + beta mod p. 82 | Assumes the point is given in affine form (x, y) and has y != 0. 83 | """ 84 | assert point[1] % p != 0 85 | m = div_mod(3 * point[0] * point[0] + alpha, 2 * point[1], p) 86 | x = (m * m - 2 * point[0]) % p 87 | y = (m * (point[0] - x) - point[1]) % p 88 | return x, y 89 | 90 | 91 | def ec_mult(m: int, point: ECPoint, alpha: int, p: int) -> ECPoint: 92 | """ 93 | Multiplies by m a point on the elliptic curve with equation y^2 = x^3 + alpha*x + beta mod p. 94 | Assumes the point is given in affine form (x, y) and that 0 < m < order(point). 95 | """ 96 | if m == 1: 97 | return point 98 | if m % 2 == 0: 99 | return ec_mult(m // 2, ec_double(point, alpha, p), alpha, p) 100 | return ec_add(ec_mult(m - 1, point, alpha, p), point, p) 101 | -------------------------------------------------------------------------------- /dydx3/starkex/starkex_resources/proxy.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from dydx3.starkex.starkex_resources.cpp_signature import check_cpp_lib_path 4 | from dydx3.starkex.starkex_resources.cpp_signature import cpp_hash 5 | from dydx3.starkex.starkex_resources.cpp_signature import cpp_verify 6 | from dydx3.starkex.starkex_resources.python_signature import ECPoint 7 | from dydx3.starkex.starkex_resources.python_signature import ECSignature 8 | from dydx3.starkex.starkex_resources.python_signature import py_pedersen_hash 9 | from dydx3.starkex.starkex_resources.python_signature import py_sign 10 | from dydx3.starkex.starkex_resources.python_signature import py_verify 11 | 12 | 13 | def sign( 14 | msg_hash: int, 15 | priv_key: int, 16 | seed: Optional[int] = None, 17 | ) -> ECSignature: 18 | # Note: cpp_sign() is not optimized and is currently slower than py_sign(). 19 | # So always use py_sign() for now. 20 | return py_sign(msg_hash=msg_hash, priv_key=priv_key, seed=seed) 21 | 22 | 23 | def verify( 24 | msg_hash: int, 25 | r: int, 26 | s: int, 27 | public_key: Union[int, ECPoint], 28 | ) -> bool: 29 | if check_cpp_lib_path(): 30 | return cpp_verify(msg_hash=msg_hash, r=r, s=s, stark_key=public_key) 31 | 32 | return py_verify(msg_hash=msg_hash, r=r, s=s, public_key=public_key) 33 | 34 | 35 | def get_hash(*elements: int) -> int: 36 | if check_cpp_lib_path(): 37 | return cpp_hash(*elements) 38 | 39 | return py_pedersen_hash(*elements) 40 | -------------------------------------------------------------------------------- /dydx3/starkex/starkex_resources/python_signature.py: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Copyright 2019 StarkWare Industries Ltd. # 3 | # # 4 | # Licensed under the Apache License, Version 2.0 (the "License"). # 5 | # You may not use this file except in compliance with the License. # 6 | # You may obtain a copy of the License at # 7 | # # 8 | # https://www.starkware.co/open-source-license/ # 9 | # # 10 | # Unless required by applicable law or agreed to in writing, # 11 | # software distributed under the License is distributed on an "AS IS" BASIS, # 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 13 | # See the License for the specific language governing permissions # 14 | # and limitations under the License. # 15 | ############################################################################### 16 | 17 | import hashlib 18 | import json 19 | import math 20 | import os 21 | import random 22 | from typing import Optional, Tuple, Union 23 | 24 | from ecdsa.rfc6979 import generate_k 25 | 26 | from .math_utils import ECPoint, div_mod, ec_add, ec_double, ec_mult, is_quad_residue, sqrt_mod 27 | 28 | PEDERSEN_HASH_POINT_FILENAME = os.path.join( 29 | os.path.dirname(__file__), 'pedersen_params.json') 30 | PEDERSEN_PARAMS = json.load(open(PEDERSEN_HASH_POINT_FILENAME)) 31 | 32 | FIELD_PRIME = PEDERSEN_PARAMS['FIELD_PRIME'] 33 | FIELD_GEN = PEDERSEN_PARAMS['FIELD_GEN'] 34 | ALPHA = PEDERSEN_PARAMS['ALPHA'] 35 | BETA = PEDERSEN_PARAMS['BETA'] 36 | EC_ORDER = PEDERSEN_PARAMS['EC_ORDER'] 37 | CONSTANT_POINTS = PEDERSEN_PARAMS['CONSTANT_POINTS'] 38 | 39 | N_ELEMENT_BITS_ECDSA = math.floor(math.log(FIELD_PRIME, 2)) 40 | assert N_ELEMENT_BITS_ECDSA == 251 41 | 42 | N_ELEMENT_BITS_HASH = FIELD_PRIME.bit_length() 43 | assert N_ELEMENT_BITS_HASH == 252 44 | 45 | # Elliptic curve parameters. 46 | assert 2**N_ELEMENT_BITS_ECDSA < EC_ORDER < FIELD_PRIME 47 | 48 | SHIFT_POINT = CONSTANT_POINTS[0] 49 | MINUS_SHIFT_POINT = (SHIFT_POINT[0], FIELD_PRIME - SHIFT_POINT[1]) 50 | EC_GEN = CONSTANT_POINTS[1] 51 | 52 | assert SHIFT_POINT == [0x49ee3eba8c1600700ee1b87eb599f16716b0b1022947733551fde4050ca6804, 53 | 0x3ca0cfe4b3bc6ddf346d49d06ea0ed34e621062c0e056c1d0405d266e10268a] 54 | assert EC_GEN == [0x1ef15c18599971b7beced415a40f0c7deacfd9b0d1819e03d723d8bc943cfca, 55 | 0x5668060aa49730b7be4801df46ec62de53ecd11abe43a32873000c36e8dc1f] 56 | 57 | 58 | ######### 59 | # ECDSA # 60 | ######### 61 | 62 | # A type for the digital signature. 63 | ECSignature = Tuple[int, int] 64 | 65 | 66 | class InvalidPublicKeyError(Exception): 67 | def __init__(self): 68 | super().__init__('Given x coordinate does not represent any point on the elliptic curve.') 69 | 70 | 71 | def get_y_coordinate(stark_key_x_coordinate: int) -> int: 72 | """ 73 | Given the x coordinate of a stark_key, returns a possible y coordinate such that together the 74 | point (x,y) is on the curve. 75 | Note that the real y coordinate is either y or -y. 76 | If x is invalid stark_key it throws an error. 77 | """ 78 | 79 | x = stark_key_x_coordinate 80 | y_squared = (x * x * x + ALPHA * x + BETA) % FIELD_PRIME 81 | if not is_quad_residue(y_squared, FIELD_PRIME): 82 | raise InvalidPublicKeyError() 83 | return sqrt_mod(y_squared, FIELD_PRIME) 84 | 85 | 86 | def get_random_private_key() -> int: 87 | # NOTE: It is IMPORTANT to use a strong random function here. 88 | return random.randint(1, EC_ORDER - 1) 89 | 90 | 91 | def private_key_to_ec_point_on_stark_curve(priv_key: int) -> ECPoint: 92 | assert 0 < priv_key < EC_ORDER 93 | return ec_mult(priv_key, EC_GEN, ALPHA, FIELD_PRIME) 94 | 95 | 96 | def private_to_stark_key(priv_key: int) -> int: 97 | return private_key_to_ec_point_on_stark_curve(priv_key)[0] 98 | 99 | 100 | def inv_mod_curve_size(x: int) -> int: 101 | return div_mod(1, x, EC_ORDER) 102 | 103 | 104 | def generate_k_rfc6979(msg_hash: int, priv_key: int, seed: Optional[int] = None) -> int: 105 | # Pad the message hash, for consistency with the elliptic.js library. 106 | if 1 <= msg_hash.bit_length() % 8 <= 4 and msg_hash.bit_length() >= 248: 107 | # Only if we are one-nibble short: 108 | msg_hash *= 16 109 | 110 | if seed is None: 111 | extra_entropy = b'' 112 | else: 113 | extra_entropy = seed.to_bytes(math.ceil(seed.bit_length() / 8), 'big') 114 | 115 | return generate_k(EC_ORDER, priv_key, hashlib.sha256, 116 | msg_hash.to_bytes(math.ceil(msg_hash.bit_length() / 8), 'big'), 117 | extra_entropy=extra_entropy) 118 | 119 | 120 | # Starkware crypto functions implemented in Python. 121 | # 122 | # Copied from: 123 | # https://github.com/starkware-libs/starkex-resources/blob/0f08e6c55ad88c93499f71f2af4a2e7ae0185cdf/crypto/starkware/crypto/signature/signature.py 124 | # 125 | # Changes made by dYdX to function name only. 126 | 127 | def py_sign(msg_hash: int, priv_key: int, seed: Optional[int] = None) -> ECSignature: 128 | # Note: msg_hash must be smaller than 2**N_ELEMENT_BITS_ECDSA. 129 | # Message whose hash is >= 2**N_ELEMENT_BITS_ECDSA cannot be signed. 130 | # This happens with a very small probability. 131 | assert 0 <= msg_hash < 2**N_ELEMENT_BITS_ECDSA, 'Message not signable.' 132 | 133 | # Choose a valid k. In our version of ECDSA not every k value is valid, 134 | # and there is a negligible probability a drawn k cannot be used for signing. 135 | # This is why we have this loop. 136 | while True: 137 | k = generate_k_rfc6979(msg_hash, priv_key, seed) 138 | # Update seed for next iteration in case the value of k is bad. 139 | if seed is None: 140 | seed = 1 141 | else: 142 | seed += 1 143 | 144 | # Cannot fail because 0 < k < EC_ORDER and EC_ORDER is prime. 145 | x = ec_mult(k, EC_GEN, ALPHA, FIELD_PRIME)[0] 146 | 147 | # DIFF: in classic ECDSA, we take int(x) % n. 148 | r = int(x) 149 | if not (1 <= r < 2**N_ELEMENT_BITS_ECDSA): 150 | # Bad value. This fails with negligible probability. 151 | continue 152 | 153 | if (msg_hash + r * priv_key) % EC_ORDER == 0: 154 | # Bad value. This fails with negligible probability. 155 | continue 156 | 157 | w = div_mod(k, msg_hash + r * priv_key, EC_ORDER) 158 | if not (1 <= w < 2**N_ELEMENT_BITS_ECDSA): 159 | # Bad value. This fails with negligible probability. 160 | continue 161 | 162 | s = inv_mod_curve_size(w) 163 | return r, s 164 | 165 | 166 | def mimic_ec_mult_air(m: int, point: ECPoint, shift_point: ECPoint) -> ECPoint: 167 | """ 168 | Computes m * point + shift_point using the same steps like the AIR and throws an exception if 169 | and only if the AIR errors. 170 | """ 171 | assert 0 < m < 2**N_ELEMENT_BITS_ECDSA 172 | partial_sum = shift_point 173 | for _ in range(N_ELEMENT_BITS_ECDSA): 174 | assert partial_sum[0] != point[0] 175 | if m & 1: 176 | partial_sum = ec_add(partial_sum, point, FIELD_PRIME) 177 | point = ec_double(point, ALPHA, FIELD_PRIME) 178 | m >>= 1 179 | assert m == 0 180 | return partial_sum 181 | 182 | 183 | # Starkware crypto functions implemented in Python. 184 | # 185 | # Copied from: 186 | # https://github.com/starkware-libs/starkex-resources/blob/0f08e6c55ad88c93499f71f2af4a2e7ae0185cdf/crypto/starkware/crypto/signature/signature.py 187 | # 188 | # Changes made by dYdX to function name only. 189 | 190 | def py_verify(msg_hash: int, r: int, s: int, public_key: Union[int, ECPoint]) -> bool: 191 | # Compute w = s^-1 (mod EC_ORDER). 192 | assert 1 <= s < EC_ORDER, 's = %s' % s 193 | w = inv_mod_curve_size(s) 194 | 195 | # Preassumptions: 196 | # DIFF: in classic ECDSA, we assert 1 <= r, w <= EC_ORDER-1. 197 | # Since r, w < 2**N_ELEMENT_BITS_ECDSA < EC_ORDER, we only need to verify r, w != 0. 198 | assert 1 <= r < 2**N_ELEMENT_BITS_ECDSA, 'r = %s' % r 199 | assert 1 <= w < 2**N_ELEMENT_BITS_ECDSA, 'w = %s' % w 200 | assert 0 <= msg_hash < 2**N_ELEMENT_BITS_ECDSA, 'msg_hash = %s' % msg_hash 201 | 202 | if isinstance(public_key, int): 203 | # Only the x coordinate of the point is given, check the two possibilities for the y 204 | # coordinate. 205 | try: 206 | y = get_y_coordinate(public_key) 207 | except InvalidPublicKeyError: 208 | return False 209 | assert pow(y, 2, FIELD_PRIME) == ( 210 | pow(public_key, 3, FIELD_PRIME) + ALPHA * public_key + BETA) % FIELD_PRIME 211 | return py_verify(msg_hash, r, s, (public_key, y)) or \ 212 | py_verify(msg_hash, r, s, (public_key, (-y) % FIELD_PRIME)) 213 | else: 214 | # The public key is provided as a point. 215 | # Verify it is on the curve. 216 | assert (public_key[1]**2 - (public_key[0]**3 + ALPHA * 217 | public_key[0] + BETA)) % FIELD_PRIME == 0 218 | 219 | # Signature validation. 220 | # DIFF: original formula is: 221 | # x = (w*msg_hash)*EC_GEN + (w*r)*public_key 222 | # While what we implement is: 223 | # x = w*(msg_hash*EC_GEN + r*public_key). 224 | # While both mathematically equivalent, one might error while the other doesn't, 225 | # given the current implementation. 226 | # This formula ensures that if the verification errors in our AIR, it errors here as well. 227 | try: 228 | zG = mimic_ec_mult_air(msg_hash, EC_GEN, MINUS_SHIFT_POINT) 229 | rQ = mimic_ec_mult_air(r, public_key, SHIFT_POINT) 230 | wB = mimic_ec_mult_air(w, ec_add(zG, rQ, FIELD_PRIME), SHIFT_POINT) 231 | x = ec_add(wB, MINUS_SHIFT_POINT, FIELD_PRIME)[0] 232 | except AssertionError: 233 | return False 234 | 235 | # DIFF: Here we drop the mod n from classic ECDSA. 236 | return r == x 237 | 238 | 239 | ################# 240 | # Pedersen hash # 241 | ################# 242 | 243 | # Starkware crypto functions implemented in Python. 244 | # 245 | # Copied from: 246 | # https://github.com/starkware-libs/starkex-resources/blob/0f08e6c55ad88c93499f71f2af4a2e7ae0185cdf/crypto/starkware/crypto/signature/signature.py 247 | # 248 | # Changes made by dYdX to function name only. 249 | 250 | def py_pedersen_hash(*elements: int) -> int: 251 | return pedersen_hash_as_point(*elements)[0] 252 | 253 | 254 | def pedersen_hash_as_point(*elements: int) -> ECPoint: 255 | """ 256 | Similar to pedersen_hash but also returns the y coordinate of the resulting EC point. 257 | This function is used for testing. 258 | """ 259 | point = SHIFT_POINT 260 | for i, x in enumerate(elements): 261 | assert 0 <= x < FIELD_PRIME 262 | point_list = CONSTANT_POINTS[2 + i * N_ELEMENT_BITS_HASH:2 + (i + 1) * N_ELEMENT_BITS_HASH] 263 | assert len(point_list) == N_ELEMENT_BITS_HASH 264 | for pt in point_list: 265 | assert point[0] != pt[0], 'Unhashable input.' 266 | if x & 1: 267 | point = ec_add(point, pt, FIELD_PRIME) 268 | x >>= 1 269 | assert x == 0 270 | return point 271 | -------------------------------------------------------------------------------- /dydx3/starkex/transfer.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import math 3 | 4 | from dydx3.constants import COLLATERAL_ASSET 5 | from dydx3.constants import COLLATERAL_ASSET_ID_BY_NETWORK_ID 6 | from dydx3.starkex.constants import ONE_HOUR_IN_SECONDS 7 | from dydx3.starkex.constants import TRANSFER_FIELD_BIT_LENGTHS 8 | from dydx3.starkex.constants import TRANSFER_PADDING_BITS 9 | from dydx3.starkex.constants import TRANSFER_PREFIX 10 | from dydx3.starkex.constants import TRANSFER_FEE_ASSET_ID 11 | from dydx3.starkex.constants import TRANSFER_MAX_AMOUNT_FEE 12 | from dydx3.starkex.helpers import nonce_from_client_id 13 | from dydx3.starkex.helpers import to_quantums_exact 14 | from dydx3.starkex.signable import Signable 15 | from dydx3.starkex.starkex_resources.proxy import get_hash 16 | 17 | StarkwareTransfer = namedtuple( 18 | 'StarkwareTransfer', 19 | [ 20 | 'sender_position_id', 21 | 'receiver_position_id', 22 | 'receiver_public_key', 23 | 'quantums_amount', 24 | 'nounce', 25 | 'expiration_epoch_hours', 26 | ], 27 | ) 28 | 29 | 30 | class SignableTransfer(Signable): 31 | """ 32 | Wrapper object to convert a transfer, and hash, sign, and verify its 33 | signature. 34 | """ 35 | 36 | def __init__( 37 | self, 38 | sender_position_id, 39 | receiver_position_id, 40 | receiver_public_key, 41 | human_amount, 42 | client_id, 43 | expiration_epoch_seconds, 44 | network_id, 45 | ): 46 | nounce = nonce_from_client_id(client_id) 47 | 48 | # The transfer asset is always the collateral asset. 49 | quantums_amount = to_quantums_exact( 50 | human_amount, 51 | COLLATERAL_ASSET, 52 | ) 53 | 54 | # Convert to a Unix timestamp (in hours). 55 | expiration_epoch_hours = math.ceil( 56 | float(expiration_epoch_seconds) / ONE_HOUR_IN_SECONDS, 57 | ) 58 | 59 | receiver_public_key = ( 60 | receiver_public_key 61 | if isinstance(receiver_public_key, int) 62 | else int(receiver_public_key, 16) 63 | ) 64 | 65 | message = StarkwareTransfer( 66 | sender_position_id=int(sender_position_id), 67 | receiver_position_id=int(receiver_position_id), 68 | receiver_public_key=receiver_public_key, 69 | quantums_amount=quantums_amount, 70 | nounce=nounce, 71 | expiration_epoch_hours=expiration_epoch_hours 72 | ) 73 | 74 | super(SignableTransfer, self).__init__(network_id, message) 75 | 76 | def to_starkware(self): 77 | return self._message 78 | 79 | def _calculate_hash(self): 80 | """Calculate the hash of the Starkware order.""" 81 | # TODO: Check values are in bounds 82 | 83 | asset_ids = get_hash( 84 | COLLATERAL_ASSET_ID_BY_NETWORK_ID[self.network_id], 85 | TRANSFER_FEE_ASSET_ID, 86 | ) 87 | 88 | part1 = get_hash( 89 | asset_ids, 90 | self._message.receiver_public_key, 91 | ) 92 | 93 | part2 = self._message.sender_position_id 94 | part2 <<= TRANSFER_FIELD_BIT_LENGTHS['position_id'] 95 | part2 += self._message.receiver_position_id 96 | part2 <<= TRANSFER_FIELD_BIT_LENGTHS['position_id'] 97 | part2 += self._message.sender_position_id 98 | part2 <<= TRANSFER_FIELD_BIT_LENGTHS['nonce'] 99 | part2 += self._message.nounce 100 | 101 | part3 = TRANSFER_PREFIX 102 | part3 <<= TRANSFER_FIELD_BIT_LENGTHS['quantums_amount'] 103 | part3 += self._message.quantums_amount 104 | part3 <<= TRANSFER_FIELD_BIT_LENGTHS['quantums_amount'] 105 | part3 += TRANSFER_MAX_AMOUNT_FEE 106 | part3 <<= TRANSFER_FIELD_BIT_LENGTHS['expiration_epoch_hours'] 107 | part3 += self._message.expiration_epoch_hours 108 | part3 <<= TRANSFER_PADDING_BITS 109 | 110 | return get_hash( 111 | get_hash( 112 | part1, 113 | part2, 114 | ), 115 | part3, 116 | ) 117 | -------------------------------------------------------------------------------- /dydx3/starkex/withdrawal.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import math 3 | 4 | from dydx3.constants import COLLATERAL_ASSET 5 | from dydx3.constants import COLLATERAL_ASSET_ID_BY_NETWORK_ID 6 | from dydx3.starkex.constants import ONE_HOUR_IN_SECONDS 7 | from dydx3.starkex.constants import WITHDRAWAL_FIELD_BIT_LENGTHS 8 | from dydx3.starkex.constants import WITHDRAWAL_PADDING_BITS 9 | from dydx3.starkex.constants import WITHDRAWAL_PREFIX 10 | from dydx3.starkex.helpers import nonce_from_client_id 11 | from dydx3.starkex.helpers import to_quantums_exact 12 | from dydx3.starkex.signable import Signable 13 | from dydx3.starkex.starkex_resources.proxy import get_hash 14 | 15 | StarkwareWithdrawal = namedtuple( 16 | 'StarkwareWithdrawal', 17 | [ 18 | 'quantums_amount', 19 | 'position_id', 20 | 'nonce', 21 | 'expiration_epoch_hours', 22 | ], 23 | ) 24 | 25 | 26 | class SignableWithdrawal(Signable): 27 | 28 | def __init__( 29 | self, 30 | network_id, 31 | position_id, 32 | human_amount, 33 | client_id, 34 | expiration_epoch_seconds, 35 | ): 36 | quantums_amount = to_quantums_exact(human_amount, COLLATERAL_ASSET) 37 | expiration_epoch_hours = math.ceil( 38 | float(expiration_epoch_seconds) / ONE_HOUR_IN_SECONDS, 39 | ) 40 | message = StarkwareWithdrawal( 41 | quantums_amount=quantums_amount, 42 | position_id=int(position_id), 43 | nonce=nonce_from_client_id(client_id), 44 | expiration_epoch_hours=expiration_epoch_hours, 45 | ) 46 | super(SignableWithdrawal, self).__init__(network_id, message) 47 | 48 | def to_starkware(self): 49 | return self._message 50 | 51 | def _calculate_hash(self): 52 | """Calculate the hash of the Starkware order.""" 53 | 54 | # TODO: Check values are in bounds 55 | 56 | packed = WITHDRAWAL_PREFIX 57 | packed <<= WITHDRAWAL_FIELD_BIT_LENGTHS['position_id'] 58 | packed += self._message.position_id 59 | packed <<= WITHDRAWAL_FIELD_BIT_LENGTHS['nonce'] 60 | packed += self._message.nonce 61 | packed <<= WITHDRAWAL_FIELD_BIT_LENGTHS['quantums_amount'] 62 | packed += self._message.quantums_amount 63 | packed <<= WITHDRAWAL_FIELD_BIT_LENGTHS['expiration_epoch_hours'] 64 | packed += self._message.expiration_epoch_hours 65 | packed <<= WITHDRAWAL_PADDING_BITS 66 | 67 | return get_hash( 68 | COLLATERAL_ASSET_ID_BY_NETWORK_ID[self.network_id], 69 | packed, 70 | ) 71 | -------------------------------------------------------------------------------- /examples/onboard.py: -------------------------------------------------------------------------------- 1 | '''Example for onboarding an account and accessing private endpoints. 2 | 3 | Usage: python -m examples.onboard 4 | ''' 5 | 6 | from dydx3 import Client 7 | from dydx3.constants import API_HOST_SEPOLIA 8 | from dydx3.constants import NETWORK_ID_SEPOLIA 9 | from web3 import Web3 10 | 11 | # Ganache test address. 12 | ETHEREUM_ADDRESS = '0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b' 13 | 14 | # Ganache node. 15 | WEB_PROVIDER_URL = 'http://localhost:8545' 16 | 17 | client = Client( 18 | network_id=NETWORK_ID_SEPOLIA, 19 | host=API_HOST_SEPOLIA, 20 | default_ethereum_address=ETHEREUM_ADDRESS, 21 | web3=Web3(Web3.HTTPProvider(WEB_PROVIDER_URL)), 22 | ) 23 | 24 | # Set STARK key. 25 | stark_key_pair_with_y_coordinate = client.onboarding.derive_stark_key() 26 | client.stark_private_key = stark_key_pair_with_y_coordinate['private_key'] 27 | (public_x, public_y) = ( 28 | stark_key_pair_with_y_coordinate['public_key'], 29 | stark_key_pair_with_y_coordinate['public_key_y_coordinate'], 30 | ) 31 | 32 | # Onboard the account. 33 | onboarding_response = client.onboarding.create_user( 34 | stark_public_key=public_x, 35 | stark_public_key_y_coordinate=public_y, 36 | ) 37 | print('onboarding_response', onboarding_response) 38 | 39 | # Query a private endpoint. 40 | accounts_response = client.private.get_accounts() 41 | print('accounts_response', accounts_response) 42 | -------------------------------------------------------------------------------- /examples/orders.py: -------------------------------------------------------------------------------- 1 | '''Example for placing, replacing, and canceling orders. 2 | 3 | Usage: python -m examples.orders 4 | ''' 5 | 6 | import time 7 | 8 | from dydx3 import Client 9 | from dydx3.constants import API_HOST_SEPOLIA 10 | from dydx3.constants import MARKET_BTC_USD 11 | from dydx3.constants import NETWORK_ID_SEPOLIA 12 | from dydx3.constants import ORDER_SIDE_BUY 13 | from dydx3.constants import ORDER_STATUS_OPEN 14 | from dydx3.constants import ORDER_TYPE_LIMIT 15 | from web3 import Web3 16 | 17 | # Ganache test address. 18 | ETHEREUM_ADDRESS = '0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b' 19 | 20 | # Ganache node. 21 | WEB_PROVIDER_URL = 'http://localhost:8545' 22 | 23 | client = Client( 24 | network_id=NETWORK_ID_SEPOLIA, 25 | host=API_HOST_SEPOLIA, 26 | default_ethereum_address=ETHEREUM_ADDRESS, 27 | web3=Web3(Web3.HTTPProvider(WEB_PROVIDER_URL)), 28 | ) 29 | 30 | # Set STARK key. 31 | stark_private_key = client.onboarding.derive_stark_key() 32 | client.stark_private_key = stark_private_key 33 | 34 | # Get our position ID. 35 | account_response = client.private.get_account() 36 | position_id = account_response['account']['positionId'] 37 | 38 | # Post an bid at a price that is unlikely to match. 39 | order_params = { 40 | 'position_id': position_id, 41 | 'market': MARKET_BTC_USD, 42 | 'side': ORDER_SIDE_BUY, 43 | 'order_type': ORDER_TYPE_LIMIT, 44 | 'post_only': True, 45 | 'size': '0.0777', 46 | 'price': '20', 47 | 'limit_fee': '0.0015', 48 | 'expiration_epoch_seconds': time.time() + 5, 49 | } 50 | order_response = client.private.create_order(**order_params) 51 | order_id = order_response['order']['id'] 52 | 53 | # Replace the order at a higher price, several times. 54 | # Note that order replacement is done atomically in the matching engine. 55 | for replace_price in range(21, 26): 56 | order_response = client.private.create_order( 57 | **dict( 58 | order_params, 59 | price=str(replace_price), 60 | cancel_id=order_id, 61 | ), 62 | ) 63 | order_id = order_response['order']['id'] 64 | 65 | # Count open orders (there should be exactly one). 66 | orders_response = client.private.get_orders( 67 | market=MARKET_BTC_USD, 68 | status=ORDER_STATUS_OPEN, 69 | ) 70 | assert len(orders_response['orders']) == 1 71 | 72 | # Cancel all orders. 73 | client.private.cancel_all_orders() 74 | 75 | # Count open orders (there should be none). 76 | orders_response = client.private.get_orders( 77 | market=MARKET_BTC_USD, 78 | status=ORDER_STATUS_OPEN, 79 | ) 80 | assert len(orders_response['orders']) == 0 81 | -------------------------------------------------------------------------------- /examples/websockets.py: -------------------------------------------------------------------------------- 1 | '''Example for connecting to private WebSockets with an existing account. 2 | 3 | Usage: python -m examples.websockets 4 | ''' 5 | 6 | import asyncio 7 | import json 8 | import websockets 9 | 10 | from dydx3 import Client 11 | from dydx3.helpers.request_helpers import generate_now_iso 12 | from dydx3.constants import API_HOST_SEPOLIA 13 | from dydx3.constants import NETWORK_ID_SEPOLIA 14 | from dydx3.constants import WS_HOST_SEPOLIA 15 | from web3 import Web3 16 | 17 | # Ganache test address. 18 | ETHEREUM_ADDRESS = '0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b' 19 | 20 | # Ganache node. 21 | WEB_PROVIDER_URL = 'http://localhost:8545' 22 | 23 | client = Client( 24 | network_id=NETWORK_ID_SEPOLIA, 25 | host=API_HOST_SEPOLIA, 26 | default_ethereum_address=ETHEREUM_ADDRESS, 27 | web3=Web3(Web3.HTTPProvider(WEB_PROVIDER_URL)), 28 | ) 29 | 30 | now_iso_string = generate_now_iso() 31 | signature = client.private.sign( 32 | request_path='/ws/accounts', 33 | method='GET', 34 | iso_timestamp=now_iso_string, 35 | data={}, 36 | ) 37 | req = { 38 | 'type': 'subscribe', 39 | 'channel': 'v3_accounts', 40 | 'accountNumber': '0', 41 | 'apiKey': client.api_key_credentials['key'], 42 | 'passphrase': client.api_key_credentials['passphrase'], 43 | 'timestamp': now_iso_string, 44 | 'signature': signature, 45 | } 46 | 47 | 48 | async def main(): 49 | # Note: This doesn't work with Python 3.9. 50 | async with websockets.connect(WS_HOST_SEPOLIA) as websocket: 51 | 52 | await websocket.send(json.dumps(req)) 53 | print(f'> {req}') 54 | 55 | while True: 56 | res = await websocket.recv() 57 | print(f'< {res}') 58 | 59 | asyncio.get_event_loop().run_until_complete(main()) 60 | -------------------------------------------------------------------------------- /integration_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dydxprotocol/dydx-v3-python/73252e3141881f7b5366b051c89c42d5ccdaef58/integration_tests/__init__.py -------------------------------------------------------------------------------- /integration_tests/test_auth_levels.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from web3 import Web3 5 | 6 | from dydx3 import Client 7 | from dydx3 import DydxApiError 8 | from dydx3 import SignableWithdrawal 9 | from dydx3 import constants 10 | from dydx3 import generate_private_key_hex_unsafe 11 | from dydx3 import private_key_to_public_key_pair_hex 12 | from dydx3.helpers.request_helpers import random_client_id 13 | 14 | from tests.constants import DEFAULT_HOST 15 | from tests.constants import DEFAULT_NETWORK_ID 16 | from tests.constants import SEVEN_DAYS_S 17 | 18 | HOST = os.environ.get('V3_API_HOST', DEFAULT_HOST) 19 | NETWORK_ID = int(os.environ.get('NETWORK_ID', DEFAULT_NETWORK_ID)) 20 | 21 | 22 | class TestAuthLevels(): 23 | 24 | def test_public(self): 25 | client = Client( 26 | host=HOST, 27 | network_id=NETWORK_ID, 28 | ) 29 | client.public.get_markets() 30 | 31 | def test_private_with_private_keys(self): 32 | # Generate STARK keys and Ethhereum account. 33 | stark_private_key = generate_private_key_hex_unsafe() 34 | eth_account = Web3(None).eth.account.create() 35 | 36 | # Get public key. 37 | stark_public_key, stark_public_key_y_coordinate = ( 38 | private_key_to_public_key_pair_hex(stark_private_key) 39 | ) 40 | 41 | # Onboard the user. 42 | Client( 43 | host=HOST, 44 | network_id=NETWORK_ID, 45 | eth_private_key=eth_account.key, 46 | ).onboarding.create_user( 47 | stark_public_key=stark_public_key, 48 | stark_public_key_y_coordinate=stark_public_key_y_coordinate, 49 | ) 50 | 51 | # Create a second client WITHOUT eth_private_key. 52 | client = Client( 53 | host=HOST, 54 | network_id=NETWORK_ID, 55 | stark_private_key=stark_private_key, 56 | ) 57 | 58 | # Get the primary account. 59 | get_account_result = client.private.get_account( 60 | ethereum_address=eth_account.address, 61 | ) 62 | account = get_account_result['account'] 63 | 64 | # Initiate a regular (slow) withdrawal. 65 | # 66 | # Expect signature validation to pass, although the collateralization 67 | # check will fail. 68 | expected_error = ( 69 | 'Withdrawal would put account under collateralization minumum' 70 | ) 71 | expiration_epoch_seconds = time.time() + SEVEN_DAYS_S + 60 72 | try: 73 | client.private.create_withdrawal( 74 | position_id=account['positionId'], 75 | amount='1', 76 | asset=constants.ASSET_USDC, 77 | to_address=eth_account.address, 78 | expiration_epoch_seconds=expiration_epoch_seconds, 79 | ) 80 | except DydxApiError as e: 81 | if expected_error not in str(e): 82 | raise 83 | 84 | def test_private_without_stark_private_key(self): 85 | # Generate STARK keys and Ethhereum account. 86 | stark_private_key = generate_private_key_hex_unsafe() 87 | eth_account = Web3(None).eth.account.create() 88 | 89 | # Get public key. 90 | stark_public_key, stark_public_key_y_coordinate = ( 91 | private_key_to_public_key_pair_hex(stark_private_key) 92 | ) 93 | 94 | # Onboard the user. 95 | Client( 96 | host=HOST, 97 | network_id=NETWORK_ID, 98 | eth_private_key=eth_account.key, 99 | ).onboarding.create_user( 100 | stark_public_key=stark_public_key, 101 | stark_public_key_y_coordinate=stark_public_key_y_coordinate, 102 | ) 103 | 104 | # Create a second client WITHOUT eth_private_key or stark_private_key. 105 | client = Client( 106 | host=HOST, 107 | network_id=NETWORK_ID, 108 | ) 109 | 110 | # Get the primary account. 111 | get_account_result = client.private.get_account( 112 | ethereum_address=eth_account.address, 113 | ) 114 | account = get_account_result['account'] 115 | 116 | # Sign a withdrawal. 117 | client_id = random_client_id() 118 | expiration_epoch_seconds = time.time() + SEVEN_DAYS_S + 60 119 | signable_withdrawal = SignableWithdrawal( 120 | network_id=NETWORK_ID, 121 | position_id=account['positionId'], 122 | client_id=client_id, 123 | human_amount='1', 124 | expiration_epoch_seconds=expiration_epoch_seconds, 125 | ) 126 | signature = signable_withdrawal.sign(stark_private_key) 127 | 128 | # Initiate a regular (slow) withdrawal. 129 | # 130 | # Expect signature validation to pass, although the collateralization 131 | # check will fail. 132 | expected_error = ( 133 | 'Withdrawal would put account under collateralization minumum' 134 | ) 135 | try: 136 | client.private.create_withdrawal( 137 | position_id=account['positionId'], 138 | amount='1', 139 | asset=constants.ASSET_USDC, 140 | to_address=eth_account.address, 141 | client_id=client_id, 142 | expiration_epoch_seconds=expiration_epoch_seconds, 143 | signature=signature, 144 | ) 145 | except DydxApiError as e: 146 | if expected_error not in str(e): 147 | raise 148 | 149 | def test_onboard_with_private_keys(self): 150 | # Generate keys. 151 | stark_private_key = generate_private_key_hex_unsafe() 152 | eth_private_key = Web3(None).eth.account.create().key 153 | 154 | # Create client WITH private keys. 155 | client = Client( 156 | host=HOST, 157 | network_id=NETWORK_ID, 158 | stark_private_key=stark_private_key, 159 | eth_private_key=eth_private_key, 160 | ) 161 | 162 | # Onboard the user. 163 | client.onboarding.create_user() 164 | 165 | # Register and then revoke a second API key. 166 | client.eth_private.create_api_key() 167 | client.private.get_api_keys() 168 | 169 | # TODO 170 | # client.eth_private.delete_api_key(api_public_key_2) 171 | 172 | def test_onboard_with_web3_provider(self): 173 | # Generate private key. 174 | stark_private_key = generate_private_key_hex_unsafe() 175 | 176 | # Get public key. 177 | stark_public_key, stark_public_key_y_coordinate = ( 178 | private_key_to_public_key_pair_hex(stark_private_key) 179 | ) 180 | 181 | # Get account address from local Ethereum node. 182 | ethereum_address = Web3().eth.accounts[0] 183 | 184 | # Create client WITHOUT any private keys. 185 | client = Client( 186 | host=HOST, 187 | network_id=NETWORK_ID, 188 | web3_provider=Web3.HTTPProvider('http://localhost:8545'), 189 | ) 190 | 191 | # Onboard the user. 192 | try: 193 | client.onboarding.create_user( 194 | ethereum_address=ethereum_address, 195 | stark_public_key=stark_public_key, 196 | stark_public_key_y_coordinate=stark_public_key_y_coordinate, 197 | ) 198 | 199 | # If the Ethereum address was already onboarded, ignore the error. 200 | except DydxApiError: 201 | pass 202 | 203 | # Register and then revoke a second API key. 204 | client.eth_private.create_api_key( 205 | ethereum_address=ethereum_address, 206 | ) 207 | client.private.get_api_keys() 208 | client.eth_private.delete_api_key( 209 | api_key=client.api_key_credentials['key'], 210 | ethereum_address=ethereum_address, 211 | ) 212 | 213 | def test_onboard_with_web3_default_account(self): 214 | # Generate private key. 215 | stark_private_key = generate_private_key_hex_unsafe() 216 | 217 | # Get public key. 218 | stark_public_key, stark_public_key_y_coordinate = ( 219 | private_key_to_public_key_pair_hex(stark_private_key) 220 | ) 221 | 222 | # Connect to local Ethereum node. 223 | web3 = Web3() 224 | web3.eth.defaultAccount = web3.eth.accounts[1] 225 | 226 | # Create client WITHOUT any private keys. 227 | client = Client( 228 | host=HOST, 229 | network_id=NETWORK_ID, 230 | web3=web3, 231 | ) 232 | 233 | # Onboard the user. 234 | try: 235 | client.onboarding.create_user( 236 | stark_public_key=stark_public_key, 237 | stark_public_key_y_coordinate=stark_public_key_y_coordinate, 238 | ) 239 | 240 | # If the Ethereum address was already onboarded, ignore the error. 241 | except DydxApiError: 242 | pass 243 | 244 | # Register and then revoke a second API key. 245 | client.eth_private.create_api_key() 246 | client.private.get_api_keys() 247 | client.eth_private.delete_api_key( 248 | api_key=client.api_key_credentials['key'], 249 | ) 250 | -------------------------------------------------------------------------------- /integration_tests/test_integration.py: -------------------------------------------------------------------------------- 1 | '''Integration test. 2 | This test can be very slow due to on-chain calls. 3 | Run with `pytest -s` to enable print statements. 4 | ''' 5 | 6 | import re 7 | import os 8 | import time 9 | 10 | from web3 import Web3 11 | 12 | from dydx3 import Client 13 | from dydx3 import DydxApiError 14 | from dydx3 import constants 15 | from dydx3 import epoch_seconds_to_iso 16 | from dydx3 import generate_private_key_hex_unsafe 17 | from dydx3 import private_key_to_public_key_pair_hex 18 | 19 | from tests.constants import DEFAULT_HOST 20 | from tests.constants import DEFAULT_NETWORK_ID 21 | from tests.constants import SEVEN_DAYS_S 22 | 23 | from integration_tests.util import wait_for_condition 24 | 25 | HOST = os.environ.get('V3_API_HOST', DEFAULT_HOST) 26 | NETWORK_ID = os.environ.get('NETWORK_ID', DEFAULT_NETWORK_ID) 27 | LP_POSITION_ID = os.environ.get('LP_POSITION_ID', '2') 28 | LP_PUBLIC_KEY = os.environ.get( 29 | 'LP_PUBLIC_KEY', 30 | '04a9ecd28a67407c3cff8937f329ca24fd631b1d9ca2b9f2df47c7ebf72bf0b0', 31 | ) 32 | 33 | 34 | class TestIntegration(): 35 | 36 | def test_integration_without_funds(self): 37 | # Create an Ethereum account and STARK keys for the new user. 38 | web3_account = Web3(None).eth.account.create() 39 | ethereum_address = web3_account.address 40 | stark_private_key = generate_private_key_hex_unsafe() 41 | 42 | # Create client for the new user. 43 | client = Client( 44 | host=HOST, 45 | network_id=NETWORK_ID, 46 | stark_private_key=stark_private_key, 47 | web3_account=web3_account, 48 | ) 49 | 50 | # Onboard the user. 51 | client.onboarding.create_user() 52 | 53 | # Register a new API key. 54 | client.eth_private.create_api_key() 55 | 56 | # Get the primary account. 57 | get_account_result = client.private.get_account( 58 | ethereum_address=ethereum_address, 59 | ) 60 | account = get_account_result['account'] 61 | assert int(account['starkKey'], 16) == int(client.stark_public_key, 16) 62 | 63 | # Initiate a regular (slow) withdrawal. 64 | # 65 | # Expect signature validation to pass, although the collateralization 66 | # check will fail. 67 | expected_error = ( 68 | 'Withdrawal would put account under collateralization minumum' 69 | ) 70 | expiration_epoch_seconds = time.time() + SEVEN_DAYS_S + 60 71 | try: 72 | client.private.create_withdrawal( 73 | position_id=account['positionId'], 74 | amount='1', 75 | asset=constants.ASSET_USDC, 76 | to_address=ethereum_address, 77 | expiration_epoch_seconds=expiration_epoch_seconds, 78 | ) 79 | except DydxApiError as e: 80 | if expected_error not in str(e): 81 | raise 82 | 83 | # Post an order. 84 | # 85 | # Expect signature validation to pass, although the collateralization 86 | # check will fail. 87 | one_minute_from_now_iso = epoch_seconds_to_iso(time.time() + 60) 88 | try: 89 | client.private.create_order( 90 | position_id=account['positionId'], 91 | market=constants.MARKET_BTC_USD, 92 | side=constants.ORDER_SIDE_BUY, 93 | order_type=constants.ORDER_TYPE_LIMIT, 94 | post_only=False, 95 | size='10', 96 | price='1000', 97 | limit_fee='0.1', 98 | expiration=one_minute_from_now_iso, 99 | ) 100 | except DydxApiError as e: 101 | if expected_error not in str(e): 102 | raise 103 | 104 | def test_integration(self): 105 | source_private_key = os.environ.get('TEST_SOURCE_PRIVATE_KEY') 106 | if source_private_key is None: 107 | raise ValueError('TEST_SOURCE_PRIVATE_KEY must be set') 108 | 109 | web3_provider = os.environ.get('TEST_WEB3_PROVIDER_URL') 110 | if web3_provider is None: 111 | raise ValueError('TEST_WEB3_PROVIDER_URL must be set') 112 | 113 | # Create client that will be used to fund the new user. 114 | source_client = Client( 115 | host='', 116 | eth_private_key=source_private_key, 117 | web3_provider=web3_provider, 118 | ) 119 | 120 | # Create an Ethereum account and STARK keys for the new user. 121 | web3_account = Web3(None).eth.account.create() 122 | ethereum_address = web3_account.address 123 | eth_private_key = web3_account.key 124 | stark_private_key = generate_private_key_hex_unsafe() 125 | 126 | # Fund the new user with ETH and USDC. 127 | fund_eth_hash = source_client.eth.transfer_eth( 128 | to_address=ethereum_address, 129 | human_amount=0.001, 130 | ) 131 | fund_usdc_hash = source_client.eth.transfer_token( 132 | to_address=ethereum_address, 133 | human_amount=2, 134 | ) 135 | print('Waiting for funds...') 136 | source_client.eth.wait_for_tx(fund_eth_hash) 137 | source_client.eth.wait_for_tx(fund_usdc_hash) 138 | print('...done.') 139 | 140 | # Create client for the new user. 141 | client = Client( 142 | host=HOST, 143 | network_id=NETWORK_ID, 144 | stark_private_key=stark_private_key, 145 | eth_private_key=eth_private_key, 146 | web3_provider=web3_provider, 147 | ) 148 | 149 | # Onboard the user. 150 | res = client.onboarding.create_user() 151 | api_key_credentials = res['apiKey'] 152 | 153 | print('eth_private_key', eth_private_key) 154 | print('stark_private_key', stark_private_key) 155 | print('client.api_key_credentials', client.api_key_credentials) 156 | 157 | # Get the user. 158 | get_user_result = client.private.get_user() 159 | assert get_user_result['user'] == { 160 | 'ethereumAddress': ethereum_address.lower(), 161 | 'isRegistered': False, 162 | 'email': None, 163 | 'username': None, 164 | 'userData': {}, 165 | 'makerFeeRate': '0.0005', 166 | 'takerFeeRate': '0.0015', 167 | 'makerVolume30D': '0', 168 | 'takerVolume30D': '0', 169 | 'fees30D': '0', 170 | } 171 | 172 | # Get the registration signature. 173 | registration_result = client.private.get_registration() 174 | signature = registration_result['signature'] 175 | assert re.match('0x[0-9a-f]{130}$', signature) is not None, ( 176 | 'Invalid registration result: {}'.format(registration_result) 177 | ) 178 | 179 | # Register the user on-chain. 180 | registration_tx_hash = client.eth.register_user(signature) 181 | print('Waiting for registration...') 182 | client.eth.wait_for_tx(registration_tx_hash) 183 | print('...done.') 184 | 185 | # Set the user's username. 186 | username = 'integration_user_{}'.format(int(time.time())) 187 | client.private.update_user(username=username) 188 | 189 | # Create a second account under the same user. 190 | # 191 | # NOTE: Support for multiple accounts under the same user is limited. 192 | # The frontend does not currently support mutiple accounts per user. 193 | stark_private_key_2 = generate_private_key_hex_unsafe() 194 | stark_public_key_2, stark_public_key_y_coordinate_2 = ( 195 | private_key_to_public_key_pair_hex(stark_private_key_2) 196 | ) 197 | 198 | client.private.create_account( 199 | stark_public_key=stark_public_key_2, 200 | stark_public_key_y_coordinate=stark_public_key_y_coordinate_2, 201 | ) 202 | 203 | # Get the primary account. 204 | get_account_result = client.private.get_account( 205 | ethereum_address=ethereum_address, 206 | ) 207 | account = get_account_result['account'] 208 | assert int(account['starkKey'], 16) == int(client.stark_public_key, 16) 209 | 210 | # Get all accounts. 211 | get_all_accounts_result = client.private.get_accounts() 212 | get_all_accounts_public_keys = [ 213 | a['starkKey'] for a in get_all_accounts_result['accounts'] 214 | ] 215 | assert int(client.stark_public_key, 16) in [ 216 | int(k, 16) for k in get_all_accounts_public_keys 217 | ] 218 | 219 | # TODO: Fix. 220 | # assert int(stark_public_key_2, 16) in [ 221 | # int(k, 16) for k in get_all_accounts_public_keys 222 | # ] 223 | 224 | # Get positions. 225 | get_positions_result = client.private.get_positions(market='BTC-USD') 226 | assert get_positions_result == {'positions': []} 227 | 228 | # Set allowance on the Starkware perpetual contract, for the deposit. 229 | approve_tx_hash = client.eth.set_token_max_allowance( 230 | client.eth.get_exchange_contract().address, 231 | ) 232 | print('Waiting for allowance...') 233 | client.eth.wait_for_tx(approve_tx_hash) 234 | print('...done.') 235 | 236 | # Send an on-chain deposit. 237 | deposit_tx_hash = client.eth.deposit_to_exchange( 238 | account['positionId'], 239 | 3, 240 | ) 241 | print('Waiting for deposit...') 242 | client.eth.wait_for_tx(deposit_tx_hash) 243 | print('...done.') 244 | 245 | # Wait for the deposit to be processed. 246 | print('Waiting for deposit to be processed on dYdX...') 247 | wait_for_condition( 248 | lambda: len(client.private.get_transfers()['transfers']) > 0, 249 | True, 250 | 60, 251 | ) 252 | print('...transfer was recorded, waiting for confirmation...') 253 | wait_for_condition( 254 | lambda: client.private.get_account()['account']['quoteBalance'], 255 | '2', 256 | 180, 257 | ) 258 | print('...done.') 259 | 260 | # Post an order. 261 | one_minute_from_now_iso = epoch_seconds_to_iso(time.time() + 60) 262 | create_order_result = client.private.create_order( 263 | position_id=account['positionId'], 264 | market=constants.MARKET_BTC_USD, 265 | side=constants.ORDER_SIDE_BUY, 266 | order_type=constants.ORDER_TYPE_LIMIT, 267 | post_only=False, 268 | size='10', 269 | price='1000', 270 | limit_fee='0.1', 271 | expiration=one_minute_from_now_iso, 272 | ) 273 | 274 | # Get the order. 275 | order_id = create_order_result['order']['id'] 276 | get_order_result = client.private.get_order_by_id(order_id) 277 | assert get_order_result['order']['market'] == constants.MARKET_BTC_USD 278 | 279 | # Cancel the order. 280 | client.private.cancel_order(order_id) 281 | 282 | # Cancel all orders. 283 | client.private.cancel_all_orders() 284 | 285 | # Get open orders. 286 | get_orders_result = client.private.get_orders( 287 | market=constants.MARKET_BTC_USD, 288 | status=constants.POSITION_STATUS_OPEN, 289 | ) 290 | assert get_orders_result == {'orders': []} 291 | 292 | # Get fills. 293 | client.private.get_fills( 294 | market=constants.MARKET_BTC_USD, 295 | ) 296 | 297 | # Initiate a regular (slow) withdrawal. 298 | expiration_epoch_seconds = time.time() + SEVEN_DAYS_S + 60 299 | client.private.create_withdrawal( 300 | position_id=account['positionId'], 301 | amount='1', 302 | asset=constants.ASSET_USDC, 303 | to_address=ethereum_address, 304 | expiration_epoch_seconds=expiration_epoch_seconds, 305 | ) 306 | 307 | # Get deposits. 308 | deposits_result = client.private.get_transfers( 309 | transfer_type=constants.ACCOUNT_ACTION_DEPOSIT, 310 | ) 311 | assert len(deposits_result['transfers']) == 1 312 | 313 | # Get withdrawals. 314 | withdrawals_result = client.private.get_transfers( 315 | transfer_type=constants.ACCOUNT_ACTION_WITHDRAWAL, 316 | ) 317 | assert len(withdrawals_result['transfers']) == 1 318 | 319 | # Get funding payments. 320 | client.private.get_funding_payments( 321 | market=constants.MARKET_BTC_USD, 322 | ) 323 | 324 | # Register a new API key. 325 | create_api_key_result = client.eth_private.create_api_key() 326 | new_api_key_credentials = create_api_key_result['apiKey'] 327 | 328 | # Get all API keys. 329 | get_api_keys_result = client.private.get_api_keys() 330 | api_keys_public_keys = [ 331 | a['key'] for a in get_api_keys_result['apiKeys'] 332 | ] 333 | assert api_key_credentials['key'] in api_keys_public_keys 334 | 335 | # Delete an API key. 336 | client.eth_private.delete_api_key( 337 | api_key=new_api_key_credentials['key'], 338 | ethereum_address=ethereum_address, 339 | ) 340 | 341 | # Get all API keys after the deletion. 342 | get_api_keys_result_after = client.private.get_api_keys() 343 | assert len(get_api_keys_result_after['apiKeys']) == 1 344 | 345 | # Initiate a fast withdrawal of USDC. 346 | expiration_epoch_seconds = time.time() + SEVEN_DAYS_S + 60 347 | client.private.create_fast_withdrawal( 348 | position_id=account['positionId'], 349 | credit_asset='USDC', 350 | credit_amount='1', 351 | debit_amount='2', 352 | to_address=ethereum_address, 353 | lp_position_id=LP_POSITION_ID, 354 | lp_stark_public_key=LP_PUBLIC_KEY, 355 | client_id='mock-client-id', 356 | expiration_epoch_seconds=expiration_epoch_seconds, 357 | ) 358 | 359 | # Send an on-chain withdraw. 360 | withdraw_tx_hash = client.eth.withdraw() 361 | print('Waiting for withdraw...') 362 | client.eth.wait_for_tx(withdraw_tx_hash) 363 | print('...done.') 364 | 365 | # Wait for the withdraw to be processed. 366 | print('Waiting for withdraw to be processed on dYdX...') 367 | wait_for_condition( 368 | lambda: len(client.private.get_transfers()['transfers']) > 0, 369 | True, 370 | 60, 371 | ) 372 | print('...transfer was recorded, waiting for confirmation...') 373 | wait_for_condition( 374 | lambda: client.private.get_account()['account']['quoteBalance'], 375 | '2', 376 | 180, 377 | ) 378 | print('...done.') 379 | 380 | # Send an on-chain withdraw_to. 381 | withdraw_to_tx_hash = client.eth.withdraw_to( 382 | recipient=ethereum_address, 383 | ) 384 | print('Waiting for withdraw_to...') 385 | client.eth.wait_for_tx(withdraw_to_tx_hash) 386 | print('...done.') 387 | 388 | # Wait for the withdraw_to to be processed. 389 | print('Waiting for withdraw_to to be processed on dYdX...') 390 | wait_for_condition( 391 | lambda: len(client.private.get_transfers()['transfers']) > 0, 392 | True, 393 | 60, 394 | ) 395 | print('...transfer was recorded, waiting for confirmation...') 396 | wait_for_condition( 397 | lambda: client.private.get_account()['account']['quoteBalance'], 398 | '2', 399 | 180, 400 | ) 401 | print('...done.') 402 | -------------------------------------------------------------------------------- /integration_tests/util.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | class TimedOutWaitingForCondition(Exception): 5 | 6 | def __init__(self, last_value, expected_value): 7 | self.last_value = last_value 8 | self.expected_value = expected_value 9 | 10 | 11 | def wait_for_condition(fn, expected_value, timeout_s, interval_s=1): 12 | start = time.time() 13 | result = fn() 14 | while result != expected_value: 15 | if time.time() - start > timeout_s: 16 | raise TimedOutWaitingForCondition(result, expected_value) 17 | time.sleep(interval_s) 18 | if time.time() - start > timeout_s: 19 | raise TimedOutWaitingForCondition(result, expected_value) 20 | result = fn() 21 | return result 22 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -v --showlocals --durations 10 3 | python_paths = . 4 | testpaths = tests 5 | xfail_strict = true 6 | 7 | [pytest-watch] 8 | runner = pytest --failed-first --maxfail=1 --no-success-flaky-report 9 | -------------------------------------------------------------------------------- /requirements-lint.txt: -------------------------------------------------------------------------------- 1 | autopep8 2 | flake8 3 | -------------------------------------------------------------------------------- /requirements-publish.txt: -------------------------------------------------------------------------------- 1 | setuptools>=60.0.0 2 | wheel==0.38.4 3 | twine==4.0.2 4 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.8.1 2 | cytoolz==0.12.1 3 | dateparser==1.0.0 4 | ecdsa>=0.16.0 5 | eth_keys 6 | eth-account>=0.4.0,<0.6.0 7 | mpmath==1.0.0 8 | pytest>=7.0.0 9 | requests>=2.22.0,<3.0.0 10 | six==1.14 11 | sympy==1.6 12 | tox>=4.3.4 13 | web3>=5.0.0,<6.0.0 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.8.1 2 | cytoolz==0.12.1 3 | dateparser==1.0.0 4 | ecdsa>=0.16.0 5 | eth_keys 6 | eth-account>=0.4.0,<0.6.0 7 | mpmath==1.0.0 8 | requests>=2.22.0,<3.0.0 9 | six==1.14 10 | sympy==1.6 11 | web3>=5.0.0,<6.0.0 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | LONG_DESCRIPTION = open('README.md', 'r').read() 4 | 5 | REQUIREMENTS = [ 6 | 'aiohttp>=3.8.1', 7 | 'cytoolz==0.12.1', 8 | 'dateparser==1.0.0', 9 | 'ecdsa>=0.16.0', 10 | 'eth_keys', 11 | 'eth-account>=0.4.0,<0.6.0', 12 | 'mpmath==1.0.0', 13 | 'requests>=2.22.0,<3.0.0', 14 | 'sympy==1.6', 15 | 'web3>=5.0.0,<6.0.0', 16 | ] 17 | 18 | setup( 19 | name='dydx-v3-python', 20 | version='2.1.0', 21 | packages=find_packages(), 22 | package_data={ 23 | 'dydx3': [ 24 | 'abi/*.json', 25 | 'starkex/starkex_resources/*.json', 26 | ], 27 | }, 28 | description='dYdX Python REST API for Limit Orders', 29 | long_description=LONG_DESCRIPTION, 30 | long_description_content_type='text/markdown', 31 | url='https://github.com/dydxprotocol/dydx-v3-python', 32 | author='dYdX Trading Inc.', 33 | license='Apache 2.0', 34 | author_email='contact@dydx.exchange', 35 | install_requires=REQUIREMENTS, 36 | keywords='dydx exchange rest api defi ethereum eth', 37 | classifiers=[ 38 | 'Intended Audience :: Developers', 39 | 'License :: OSI Approved :: Apache Software License', 40 | 'Operating System :: OS Independent', 41 | 'Programming Language :: Python :: 2', 42 | 'Programming Language :: Python :: 2.7', 43 | 'Programming Language :: Python :: 3', 44 | 'Programming Language :: Python :: 3.4', 45 | 'Programming Language :: Python :: 3.5', 46 | 'Programming Language :: Python :: 3.6', 47 | 'Programming Language :: Python :: 3.9', 48 | 'Programming Language :: Python :: 3.11', 49 | 'Programming Language :: Python', 50 | 'Topic :: Software Development :: Libraries :: Python Modules', 51 | ], 52 | ) 53 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dydxprotocol/dydx-v3-python/73252e3141881f7b5366b051c89c42d5ccdaef58/tests/__init__.py -------------------------------------------------------------------------------- /tests/constants.py: -------------------------------------------------------------------------------- 1 | # ------------ Constants for Testing ------------ 2 | DEFAULT_HOST = 'http://localhost:8080' 3 | DEFAULT_NETWORK_ID = 1001 4 | SEVEN_DAYS_S = 7 * 24 * 60 * 60 5 | -------------------------------------------------------------------------------- /tests/eth_signing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dydxprotocol/dydx-v3-python/73252e3141881f7b5366b051c89c42d5ccdaef58/tests/eth_signing/__init__.py -------------------------------------------------------------------------------- /tests/eth_signing/test_api_key_action.py: -------------------------------------------------------------------------------- 1 | from web3 import Web3 2 | 3 | from dydx3.constants import NETWORK_ID_MAINNET 4 | from dydx3.eth_signing import SignWithWeb3 5 | from dydx3.eth_signing import SignWithKey 6 | from dydx3.eth_signing import SignEthPrivateAction 7 | 8 | GANACHE_ADDRESS = '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1' 9 | GANACHE_PRIVATE_KEY = ( 10 | '0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d' 11 | ) 12 | PARAMS = { 13 | 'method': 'POST', 14 | 'request_path': 'v3/test', 15 | 'body': '{}', 16 | 'timestamp': '2021-01-08T10:06:12.500Z', 17 | } 18 | 19 | EXPECTED_SIGNATURE = ( 20 | '0x3ec5317783b313b0acac1f13a23eaaa2fca1f45c2f395081e9bfc20b4cc1acb17e' 21 | '3d755764f37bf13fa62565c9cb50475e0a987ab0afa74efde0b3926bb7ab9d1b00' 22 | ) 23 | 24 | 25 | class TestApiKeyAction(): 26 | 27 | def test_sign_via_local_node(self): 28 | web3 = Web3() # Connect to a local Ethereum node. 29 | signer = SignWithWeb3(web3) 30 | 31 | action_signer = SignEthPrivateAction(signer, NETWORK_ID_MAINNET) 32 | signature = action_signer.sign(GANACHE_ADDRESS, **PARAMS) 33 | assert action_signer.verify( 34 | signature, 35 | GANACHE_ADDRESS, 36 | **PARAMS, 37 | ) 38 | assert signature == EXPECTED_SIGNATURE 39 | 40 | def test_sign_via_account(self): 41 | web3 = Web3(None) 42 | web3_account = web3.eth.account.create() 43 | signer = SignWithKey(web3_account.key) 44 | 45 | action_signer = SignEthPrivateAction(signer, NETWORK_ID_MAINNET) 46 | signature = action_signer.sign(signer.address, **PARAMS) 47 | assert action_signer.verify( 48 | signature, 49 | signer.address, 50 | **PARAMS, 51 | ) 52 | 53 | def test_sign_via_private_key(self): 54 | signer = SignWithKey(GANACHE_PRIVATE_KEY) 55 | 56 | action_signer = SignEthPrivateAction(signer, NETWORK_ID_MAINNET) 57 | signature = action_signer.sign(GANACHE_ADDRESS, **PARAMS) 58 | assert action_signer.verify( 59 | signature, 60 | GANACHE_ADDRESS, 61 | **PARAMS, 62 | ) 63 | assert signature == EXPECTED_SIGNATURE 64 | -------------------------------------------------------------------------------- /tests/eth_signing/test_onboarding_action.py: -------------------------------------------------------------------------------- 1 | from web3 import Web3 2 | 3 | from dydx3.constants import NETWORK_ID_MAINNET 4 | from dydx3.constants import OFF_CHAIN_ONBOARDING_ACTION 5 | from dydx3.eth_signing import SignWithWeb3 6 | from dydx3.eth_signing import SignWithKey 7 | from dydx3.eth_signing import SignOnboardingAction 8 | 9 | GANACHE_ADDRESS = '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1' 10 | GANACHE_PRIVATE_KEY = ( 11 | '0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d' 12 | ) 13 | 14 | EXPECTED_SIGNATURE = ( 15 | '0x0a30eea502e9805b95bd432fa1952e345dda3e9f72f7732aa00775865352e2b549' 16 | '29803c221e9e63861e4604fbc796a4e1a6ca23d49452338a3d7602aaf6d1841c00' 17 | ) 18 | 19 | 20 | class TestOnboardingAction(): 21 | 22 | def test_sign_via_local_node(self): 23 | web3 = Web3() # Connect to a local Ethereum node. 24 | signer = SignWithWeb3(web3) 25 | 26 | action_signer = SignOnboardingAction(signer, NETWORK_ID_MAINNET) 27 | signature = action_signer.sign( 28 | GANACHE_ADDRESS, 29 | action=OFF_CHAIN_ONBOARDING_ACTION, 30 | ) 31 | assert action_signer.verify( 32 | signature, 33 | GANACHE_ADDRESS, 34 | action=OFF_CHAIN_ONBOARDING_ACTION, 35 | ) 36 | assert signature == EXPECTED_SIGNATURE 37 | 38 | def test_sign_via_account(self): 39 | web3 = Web3(None) 40 | web3_account = web3.eth.account.create() 41 | signer = SignWithKey(web3_account.key) 42 | 43 | action_signer = SignOnboardingAction(signer, NETWORK_ID_MAINNET) 44 | signature = action_signer.sign( 45 | signer.address, 46 | action=OFF_CHAIN_ONBOARDING_ACTION, 47 | ) 48 | assert action_signer.verify( 49 | signature, 50 | signer.address, 51 | action=OFF_CHAIN_ONBOARDING_ACTION, 52 | ) 53 | 54 | def test_sign_via_private_key(self): 55 | signer = SignWithKey(GANACHE_PRIVATE_KEY) 56 | 57 | action_signer = SignOnboardingAction(signer, NETWORK_ID_MAINNET) 58 | signature = action_signer.sign( 59 | GANACHE_ADDRESS, 60 | action=OFF_CHAIN_ONBOARDING_ACTION, 61 | ) 62 | assert action_signer.verify( 63 | signature, 64 | GANACHE_ADDRESS, 65 | action=OFF_CHAIN_ONBOARDING_ACTION, 66 | ) 67 | assert signature == EXPECTED_SIGNATURE 68 | -------------------------------------------------------------------------------- /tests/starkex/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dydxprotocol/dydx-v3-python/73252e3141881f7b5366b051c89c42d5ccdaef58/tests/starkex/__init__.py -------------------------------------------------------------------------------- /tests/starkex/test_conditional_transfer.py: -------------------------------------------------------------------------------- 1 | from dydx3.constants import NETWORK_ID_SEPOLIA 2 | from dydx3.helpers.request_helpers import iso_to_epoch_seconds 3 | from dydx3.starkex.conditional_transfer import SignableConditionalTransfer 4 | 5 | MOCK_PUBLIC_KEY = ( 6 | '3b865a18323b8d147a12c556bfb1d502516c325b1477a23ba6c77af31f020fd' 7 | ) 8 | MOCK_PRIVATE_KEY = ( 9 | '58c7d5a90b1776bde86ebac077e053ed85b0f7164f53b080304a531947f46e3' 10 | ) 11 | MOCK_SIGNATURE = ( 12 | '030044e03ab5efbaeaa43f472aa637bca8542835da60e8dcda8d145a619546d2' + 13 | '03c7f9007fd6b1963de156bfadf6e90fe4fe4b29674013b7de32f61527c70f00' 14 | ) 15 | 16 | # Mock conditional transfer params. 17 | CONDITIONAL_TRANSFER_PARAMS = { 18 | "network_id": NETWORK_ID_SEPOLIA, 19 | 'sender_position_id': 12345, 20 | 'receiver_position_id': 67890, 21 | 'receiver_public_key': ( 22 | '05135ef87716b0faecec3ba672d145a6daad0aa46437c365d490022115aba674' 23 | ), 24 | 'fact_registry_address': '0x12aa12aa12aa12aa12aa12aa12aa12aa12aa12aa', 25 | 'fact': bytes.fromhex( 26 | '12ff12ff12ff12ff12ff12ff12ff12ff12ff12ff12ff12ff12ff12ff12ff12ff' 27 | ), 28 | 'human_amount': '49.478023', 29 | 'expiration_epoch_seconds': iso_to_epoch_seconds( 30 | '2020-09-17T04:15:55.028Z', 31 | ), 32 | 'client_id': ( 33 | 'This is an ID that the client came up with to describe this transfer' 34 | ), 35 | } 36 | 37 | 38 | class TestConditionalTransfer(): 39 | 40 | def test_sign_conditional_transfer(self): 41 | transfer = SignableConditionalTransfer(**CONDITIONAL_TRANSFER_PARAMS) 42 | signature = transfer.sign(MOCK_PRIVATE_KEY) 43 | assert signature == MOCK_SIGNATURE 44 | 45 | def test_verify_signature(self): 46 | transfer = SignableConditionalTransfer(**CONDITIONAL_TRANSFER_PARAMS) 47 | assert transfer.verify_signature(MOCK_SIGNATURE, MOCK_PUBLIC_KEY) 48 | 49 | def test_starkware_representation(self): 50 | transfer = SignableConditionalTransfer(**CONDITIONAL_TRANSFER_PARAMS) 51 | starkware_transfer = transfer.to_starkware() 52 | assert starkware_transfer.quantums_amount == 49478023 53 | 54 | # Order expiration should be rounded up and should have a buffer added. 55 | assert starkware_transfer.expiration_epoch_hours == 444533 56 | -------------------------------------------------------------------------------- /tests/starkex/test_helpers.py: -------------------------------------------------------------------------------- 1 | from dydx3.starkex.helpers import fact_to_condition 2 | from dydx3.starkex.helpers import generate_private_key_hex_unsafe 3 | from dydx3.starkex.helpers import get_transfer_erc20_fact 4 | from dydx3.starkex.helpers import nonce_from_client_id 5 | from dydx3.starkex.helpers import private_key_from_bytes 6 | from dydx3.starkex.helpers import private_key_to_public_hex 7 | from dydx3.starkex.helpers import private_key_to_public_key_pair_hex 8 | 9 | 10 | class TestHelpers(): 11 | 12 | def test_nonce_from_client_id(self): 13 | assert nonce_from_client_id('') == 2018687061 14 | assert nonce_from_client_id('1') == 3079101259 15 | assert nonce_from_client_id('a') == 2951628987 16 | assert nonce_from_client_id( 17 | 'A really long client ID used to identify an order or withdrawal', 18 | ) == 2913863714 19 | assert nonce_from_client_id( 20 | 'A really long client ID used to identify an order or withdrawal!', 21 | ) == 230317226 22 | 23 | def test_get_transfer_erc20_fact(self): 24 | assert get_transfer_erc20_fact( 25 | recipient='0x1234567890123456789012345678901234567890', 26 | token_decimals=3, 27 | human_amount=123.456, 28 | token_address='0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa', 29 | salt=int('0x1234567890abcdef', 16), 30 | ).hex() == ( 31 | '34052387b5efb6132a42b244cff52a85a507ab319c414564d7a89207d4473672' 32 | ) 33 | 34 | def test_fact_to_condition(self): 35 | fact = bytes.fromhex( 36 | 'cf9492ae0554c642b57f5d9cabee36fb512dd6b6629bdc51e60efb3118b8c2d8' 37 | ) 38 | condition = fact_to_condition( 39 | '0xe4a295420b58a4a7aa5c98920d6e8a0ef875b17a', 40 | fact, 41 | ) 42 | assert hex(condition) == ( 43 | '0x4d794792504b063843afdf759534f5ed510a3ca52e7baba2e999e02349dd24' 44 | ) 45 | 46 | def test_generate_private_key_hex_unsafe(self): 47 | assert ( 48 | generate_private_key_hex_unsafe() != 49 | generate_private_key_hex_unsafe() 50 | ) 51 | 52 | def test_private_key_from_bytes(self): 53 | assert ( 54 | private_key_from_bytes(b'0') == 55 | '0x2242959533856f2a03f3c7d9431e28ef4fe5cb2a15038c37f1d76d35dc508b' 56 | ) 57 | assert ( 58 | private_key_from_bytes(b'a') == 59 | '0x1d61128b46faa109512e0e00fe9adf5ff52047ed61718eeeb7c0525dfcd2f8e' 60 | ) 61 | assert ( 62 | private_key_from_bytes( 63 | b'really long input data for key generation with the ' 64 | b'keyPairFromData() function' 65 | ) == 66 | '0x7c4946831bde597b73f1d5721af9c67731eafeb75c1b8e92ac457a61819a29' 67 | ) 68 | 69 | def test_private_key_to_public_hex(self): 70 | assert private_key_to_public_hex( 71 | '0x2242959533856f2a03f3c7d9431e28ef4fe5cb2a15038c37f1d76d35dc508b', 72 | ) == ( 73 | '0x69a33d37101d7089b606f92e4b41553c237a474ad9d6f62eeb6708415f98f4d' 74 | ) 75 | 76 | def test_private_key_to_public_key_pair_hex(self): 77 | x, y = private_key_to_public_key_pair_hex( 78 | '0x2242959533856f2a03f3c7d9431e28ef4fe5cb2a15038c37f1d76d35dc508b', 79 | ) 80 | assert x == ( 81 | '0x69a33d37101d7089b606f92e4b41553c237a474ad9d6f62eeb6708415f98f4d' 82 | ) 83 | assert y == ( 84 | '0x717e78b98a53888aa7685b91137fa01b9336ce7d25f874dbfb8d752c6ac610d' 85 | ) 86 | -------------------------------------------------------------------------------- /tests/starkex/test_order.py: -------------------------------------------------------------------------------- 1 | from dydx3.constants import MARKET_ETH_USD 2 | from dydx3.constants import NETWORK_ID_SEPOLIA 3 | from dydx3.constants import ORDER_SIDE_BUY 4 | from dydx3.helpers.request_helpers import iso_to_epoch_seconds 5 | from dydx3.starkex.order import SignableOrder 6 | 7 | # Test data where the public key y-coordinate is odd. 8 | MOCK_PUBLIC_KEY = ( 9 | '3b865a18323b8d147a12c556bfb1d502516c325b1477a23ba6c77af31f020fd' 10 | ) 11 | MOCK_PRIVATE_KEY = ( 12 | '58c7d5a90b1776bde86ebac077e053ed85b0f7164f53b080304a531947f46e3' 13 | ) 14 | MOCK_SIGNATURE = ( 15 | '0500a22a8c8b14fbb3b7d26366604c446b9d059420d7db2a8f94bc52691d2626' + 16 | '003e38aa083f72c9db89a7a80b98a6eb92edce7294d917d8489767740affc6ed' 17 | ) 18 | 19 | # Test data where the public key y-coordinate is even. 20 | MOCK_PUBLIC_KEY_EVEN_Y = ( 21 | '5c749cd4c44bdc730bc90af9bfbdede9deb2c1c96c05806ce1bc1cb4fed64f7' 22 | ) 23 | MOCK_PRIVATE_KEY_EVEN_Y = ( 24 | '65b7bb244e019b45a521ef990fb8a002f76695d1fc6c1e31911680f2ed78b84' 25 | ) 26 | MOCK_SIGNATURE_EVEN_Y = ( 27 | '06f593fcec14720cd895e7edf0830b668b6104c0de4be6d22befe4ced0868dc3' + 28 | '0507259e9634a140d83a8fcfc43b5a08af6cec7f85d3606cc7a974465aff334e' 29 | ) 30 | 31 | # Mock order params. 32 | ORDER_PARAMS = { 33 | "network_id": NETWORK_ID_SEPOLIA, 34 | "market": MARKET_ETH_USD, 35 | "side": ORDER_SIDE_BUY, 36 | "position_id": 12345, 37 | "human_size": '145.0005', 38 | "human_price": '350.00067', 39 | "limit_fee": '0.125', 40 | "client_id": ( 41 | 'This is an ID that the client came up with ' + 42 | 'to describe this order' 43 | ), 44 | "expiration_epoch_seconds": iso_to_epoch_seconds( 45 | '2020-09-17T04:15:55.028Z', 46 | ), 47 | } 48 | 49 | 50 | class TestOrder(): 51 | 52 | def test_sign_order(self): 53 | order = SignableOrder(**ORDER_PARAMS) 54 | signature = order.sign(MOCK_PRIVATE_KEY) 55 | assert signature == MOCK_SIGNATURE 56 | 57 | def test_verify_signature_odd_y(self): 58 | order = SignableOrder(**ORDER_PARAMS) 59 | assert order.verify_signature(MOCK_SIGNATURE, MOCK_PUBLIC_KEY) 60 | 61 | def test_verify_signature_even_y(self): 62 | order = SignableOrder(**ORDER_PARAMS) 63 | signature = order.sign(MOCK_PRIVATE_KEY_EVEN_Y) 64 | assert signature == MOCK_SIGNATURE_EVEN_Y 65 | assert order.verify_signature( 66 | MOCK_SIGNATURE_EVEN_Y, 67 | MOCK_PUBLIC_KEY_EVEN_Y 68 | ) 69 | 70 | def test_starkware_representation(self): 71 | order = SignableOrder(**ORDER_PARAMS) 72 | starkware_order = order.to_starkware() 73 | assert starkware_order.quantums_amount_synthetic == 145000500000 74 | assert starkware_order.quantums_amount_collateral == 50750272151 75 | assert starkware_order.quantums_amount_fee == 6343784019 76 | 77 | # Order expiration should be rounded up and should have a buffer added. 78 | assert starkware_order.expiration_epoch_hours == 444701 79 | 80 | def test_convert_order_fee_edge_case(self): 81 | order = SignableOrder( 82 | **dict( 83 | ORDER_PARAMS, 84 | limit_fee='0.000001999999999999999999999999999999999999999999', 85 | ), 86 | ) 87 | starkware_order = order.to_starkware() 88 | assert starkware_order.quantums_amount_fee == 50751 89 | 90 | def test_order_expiration_boundary_case(self): 91 | order = SignableOrder( 92 | **dict( 93 | ORDER_PARAMS, 94 | expiration_epoch_seconds=iso_to_epoch_seconds( 95 | # Less than one second after the start of the hour. 96 | '2021-02-24T16:00:00.407Z', 97 | ), 98 | ), 99 | ) 100 | assert order.to_starkware().expiration_epoch_hours == 448553 101 | -------------------------------------------------------------------------------- /tests/starkex/test_transfer.py: -------------------------------------------------------------------------------- 1 | from dydx3.constants import NETWORK_ID_SEPOLIA 2 | from dydx3.helpers.request_helpers import iso_to_epoch_seconds 3 | from dydx3.starkex.transfer import SignableTransfer 4 | 5 | MOCK_PUBLIC_KEY = ( 6 | '3b865a18323b8d147a12c556bfb1d502516c325b1477a23ba6c77af31f020fd' 7 | ) 8 | MOCK_PRIVATE_KEY = ( 9 | '58c7d5a90b1776bde86ebac077e053ed85b0f7164f53b080304a531947f46e3' 10 | ) 11 | MOCK_SIGNATURE = ( 12 | '02b4d393ea955be0f53029e2f8a10d31671eb9d3ada015d973c903417264688a' + 13 | '02ffb6b7f29870208f1f860b125de95b5444142a867be9dcd80128999518ddd3' 14 | ) 15 | 16 | # Mock transfer params. 17 | TRANSFER_PARAMS = { 18 | 'sender_position_id': 12345, 19 | 'receiver_position_id': 67890, 20 | 'receiver_public_key': ( 21 | '05135ef87716b0faecec3ba672d145a6daad0aa46437c365d490022115aba674' 22 | ), 23 | 'human_amount': '49.478023', 24 | 'expiration_epoch_seconds': iso_to_epoch_seconds( 25 | '2020-09-17T04:15:55.028Z', 26 | ), 27 | 'client_id': ( 28 | 'This is an ID that the client came up with to describe this transfer' 29 | ), 30 | } 31 | 32 | 33 | class TestTransfer(): 34 | 35 | def test_sign_transfer(self): 36 | transfer = SignableTransfer( 37 | **TRANSFER_PARAMS, network_id=NETWORK_ID_SEPOLIA) 38 | signature = transfer.sign(MOCK_PRIVATE_KEY) 39 | assert signature == MOCK_SIGNATURE 40 | 41 | def test_sign_transfer_different_client_id(self): 42 | alternative_transfer_params = {**TRANSFER_PARAMS} 43 | alternative_transfer_params['client_id'] += '!' 44 | 45 | transfer = SignableTransfer( 46 | **alternative_transfer_params, 47 | network_id=NETWORK_ID_SEPOLIA 48 | ) 49 | signature = transfer.sign(MOCK_PRIVATE_KEY) 50 | assert signature != MOCK_SIGNATURE 51 | 52 | def test_sign_transfer_different_receiver_position_id(self): 53 | alternative_transfer_params = {**TRANSFER_PARAMS} 54 | alternative_transfer_params['receiver_position_id'] += 1 55 | 56 | transfer = SignableTransfer( 57 | **alternative_transfer_params, 58 | network_id=NETWORK_ID_SEPOLIA 59 | ) 60 | signature = transfer.sign(MOCK_PRIVATE_KEY) 61 | assert signature != MOCK_SIGNATURE 62 | 63 | def test_verify_signature(self): 64 | transfer = SignableTransfer( 65 | **TRANSFER_PARAMS, network_id=NETWORK_ID_SEPOLIA) 66 | assert transfer.verify_signature(MOCK_SIGNATURE, MOCK_PUBLIC_KEY) 67 | 68 | def test_starkware_representation(self): 69 | transfer = SignableTransfer( 70 | **TRANSFER_PARAMS, network_id=NETWORK_ID_SEPOLIA) 71 | starkware_transfer = transfer.to_starkware() 72 | assert starkware_transfer.quantums_amount == 49478023 73 | 74 | # Order expiration should be rounded up and should have a buffer added. 75 | assert starkware_transfer.expiration_epoch_hours == 444533 76 | -------------------------------------------------------------------------------- /tests/starkex/test_withdrawal.py: -------------------------------------------------------------------------------- 1 | from dydx3.constants import NETWORK_ID_SEPOLIA 2 | from dydx3.helpers.request_helpers import iso_to_epoch_seconds 3 | from dydx3.starkex.withdrawal import SignableWithdrawal 4 | 5 | MOCK_PUBLIC_KEY = ( 6 | '3b865a18323b8d147a12c556bfb1d502516c325b1477a23ba6c77af31f020fd' 7 | ) 8 | MOCK_PRIVATE_KEY = ( 9 | '58c7d5a90b1776bde86ebac077e053ed85b0f7164f53b080304a531947f46e3' 10 | ) 11 | MOCK_SIGNATURE = ( 12 | '01af771baee70bea9e5e0a5e600e29fa67171b32ee5d38c67c5a97630bcd8fab' + 13 | '0563d154cd47dcf9c34e4ddf00d8fea353176807ba5f7ab62316133a8976a733' 14 | ) 15 | 16 | # Mock withdrawal params. 17 | WITHDRAWAL_PARAMS = { 18 | "network_id": NETWORK_ID_SEPOLIA, 19 | "position_id": 12345, 20 | "human_amount": '49.478023', 21 | "client_id": ( 22 | 'This is an ID that the client came up with ' + 23 | 'to describe this withdrawal' 24 | ), 25 | "expiration_epoch_seconds": iso_to_epoch_seconds( 26 | '2020-09-17T04:15:55.028Z', 27 | ), 28 | } 29 | 30 | 31 | class TestWithdrawal(): 32 | 33 | def test_sign_withdrawal(self): 34 | withdrawal = SignableWithdrawal(**WITHDRAWAL_PARAMS) 35 | signature = withdrawal.sign(MOCK_PRIVATE_KEY) 36 | assert signature == MOCK_SIGNATURE 37 | 38 | def test_verify_signature(self): 39 | withdrawal = SignableWithdrawal(**WITHDRAWAL_PARAMS) 40 | assert withdrawal.verify_signature(MOCK_SIGNATURE, MOCK_PUBLIC_KEY) 41 | 42 | def test_starkware_representation(self): 43 | withdrawal = SignableWithdrawal(**WITHDRAWAL_PARAMS) 44 | starkware_withdrawal = withdrawal.to_starkware() 45 | assert starkware_withdrawal.quantums_amount == 49478023 46 | 47 | # Order expiration should be rounded up and should have a buffer added. 48 | assert starkware_withdrawal.expiration_epoch_hours == 444533 49 | -------------------------------------------------------------------------------- /tests/test_constants.py: -------------------------------------------------------------------------------- 1 | from dydx3.constants import ( 2 | SYNTHETIC_ASSET_MAP, 3 | SYNTHETIC_ASSET_ID_MAP, 4 | ASSET_RESOLUTION, 5 | COLLATERAL_ASSET, 6 | ) 7 | 8 | 9 | class TestConstants(): 10 | def test_constants_have_regular_structure(self): 11 | for market, asset in SYNTHETIC_ASSET_MAP.items(): 12 | market_parts = market.split('-') 13 | base_token, quote_token = market_parts 14 | assert base_token == asset 15 | assert quote_token == 'USD' 16 | assert len(market_parts) == 2 17 | 18 | assert list(SYNTHETIC_ASSET_MAP.values()) \ 19 | == list(SYNTHETIC_ASSET_ID_MAP.keys()) 20 | 21 | assets = [x for x in ASSET_RESOLUTION.keys() if x != COLLATERAL_ASSET] 22 | assert assets == list(SYNTHETIC_ASSET_MAP.values()) 23 | -------------------------------------------------------------------------------- /tests/test_onboarding.py: -------------------------------------------------------------------------------- 1 | from web3 import Web3 2 | 3 | from dydx3 import Client 4 | from dydx3.constants import NETWORK_ID_MAINNET 5 | from dydx3.constants import NETWORK_ID_SEPOLIA 6 | 7 | from tests.constants import DEFAULT_HOST 8 | 9 | GANACHE_PRIVATE_KEY = ( 10 | '0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d' 11 | ) 12 | 13 | EXPECTED_API_KEY_CREDENTIALS_MAINNET = { 14 | 'key': '50fdcaa0-62b8-e827-02e8-a9520d46cb9f', 15 | 'secret': 'rdHdKDAOCa0B_Mq-Q9kh8Fz6rK3ocZNOhKB4QsR9', 16 | 'passphrase': '12_1LuuJMZUxcj3kGBWc', 17 | } 18 | EXPECTED_STARK_KEY_PAIR_WITH_Y_COORDINATE_MAINNET = { 19 | 'public_key': 20 | '0x39d88860b99b1809a63add01f7dfa59676ae006bbcdf38ff30b6a69dcf55ed3', 21 | 'public_key_y_coordinate': 22 | '0x2bdd58a2c2acb241070bc5d55659a85bba65211890a8c47019a33902aba8400', 23 | 'private_key': 24 | '0x170d807cafe3d8b5758f3f698331d292bf5aeb71f6fd282f0831dee094ee891', 25 | } 26 | EXPECTED_API_KEY_CREDENTIALS_SEPOLIA = { 27 | 'key': '30cb6046-8f4a-5677-a19c-a494ccb7c7e5', 28 | 'secret': '4Yd_6JtH_-I2taoNQKAhkCifnVHQ2Unue88sIeuc', 29 | 'passphrase': 'Db1GQK5KpI_qeddgjF66', 30 | } 31 | EXPECTED_STARK_KEY_PAIR_WITH_Y_COORDINATE_SEPOLIA = { 32 | 'public_key': 33 | '0x15e2e074a7ac9e78edb2ee9f11a0c0c0a080c79758ab81616eea9c032c75265', 34 | 'public_key_y_coordinate': 35 | '0x360408546b64238f80d7a8a336d7304d75f122a7e5bb22cbb7a14f550eac5a8', 36 | 'private_key': 37 | '0x2d21c094fedea3e72bef27fbcdceaafd34e88fc4b7586859e26e98b21e63a60' 38 | } 39 | 40 | 41 | class TestOnboarding(): 42 | 43 | def test_derive_stark_key_on_mainnet_from_web3(self): 44 | web3 = Web3() # Connect to a local Ethereum node. 45 | client = Client( 46 | host=DEFAULT_HOST, 47 | network_id=NETWORK_ID_MAINNET, 48 | web3=web3, 49 | ) 50 | signer_address = web3.eth.accounts[0] 51 | stark_key_pair_with_y_coordinate = client.onboarding.derive_stark_key( 52 | signer_address, 53 | ) 54 | assert stark_key_pair_with_y_coordinate == \ 55 | EXPECTED_STARK_KEY_PAIR_WITH_Y_COORDINATE_MAINNET 56 | 57 | def test_recover_default_api_key_credentials_on_mainnet_from_web3(self): 58 | web3 = Web3() # Connect to a local Ethereum node. 59 | client = Client( 60 | host=DEFAULT_HOST, 61 | network_id=NETWORK_ID_MAINNET, 62 | web3=web3, 63 | ) 64 | signer_address = web3.eth.accounts[0] 65 | api_key_credentials = ( 66 | client.onboarding.recover_default_api_key_credentials( 67 | signer_address, 68 | ) 69 | ) 70 | assert api_key_credentials == EXPECTED_API_KEY_CREDENTIALS_MAINNET 71 | 72 | def test_derive_stark_key_on_SEPOLIA_from_web3(self): 73 | web3 = Web3() # Connect to a local Ethereum node. 74 | client = Client( 75 | host=DEFAULT_HOST, 76 | network_id=NETWORK_ID_SEPOLIA, 77 | web3=web3, 78 | ) 79 | signer_address = web3.eth.accounts[0] 80 | stark_key_pair_with_y_coordinate = client.onboarding.derive_stark_key( 81 | signer_address, 82 | ) 83 | assert stark_key_pair_with_y_coordinate == \ 84 | EXPECTED_STARK_KEY_PAIR_WITH_Y_COORDINATE_SEPOLIA 85 | 86 | def test_recover_default_api_key_credentials_on_SEPOLIA_from_web3(self): 87 | web3 = Web3() # Connect to a local Ethereum node. 88 | client = Client( 89 | host=DEFAULT_HOST, 90 | network_id=NETWORK_ID_SEPOLIA, 91 | web3=web3, 92 | ) 93 | signer_address = web3.eth.accounts[0] 94 | api_key_credentials = ( 95 | client.onboarding.recover_default_api_key_credentials( 96 | signer_address, 97 | ) 98 | ) 99 | assert api_key_credentials == EXPECTED_API_KEY_CREDENTIALS_SEPOLIA 100 | 101 | def test_derive_stark_key_on_mainnet_from_priv(self): 102 | client = Client( 103 | host=DEFAULT_HOST, 104 | network_id=NETWORK_ID_MAINNET, 105 | eth_private_key=GANACHE_PRIVATE_KEY, 106 | api_key_credentials={'key': 'value'}, 107 | ) 108 | signer_address = client.default_address 109 | stark_key_pair_with_y_coordinate = client.onboarding.derive_stark_key( 110 | signer_address, 111 | ) 112 | assert stark_key_pair_with_y_coordinate == \ 113 | EXPECTED_STARK_KEY_PAIR_WITH_Y_COORDINATE_MAINNET 114 | 115 | def test_recover_default_api_key_credentials_on_mainnet_from_priv(self): 116 | client = Client( 117 | host=DEFAULT_HOST, 118 | network_id=NETWORK_ID_MAINNET, 119 | eth_private_key=GANACHE_PRIVATE_KEY, 120 | ) 121 | signer_address = client.default_address 122 | api_key_credentials = ( 123 | client.onboarding.recover_default_api_key_credentials( 124 | signer_address, 125 | ) 126 | ) 127 | assert api_key_credentials == EXPECTED_API_KEY_CREDENTIALS_MAINNET 128 | 129 | def test_derive_stark_key_on_SEPOLIA_from_priv(self): 130 | client = Client( 131 | host=DEFAULT_HOST, 132 | network_id=NETWORK_ID_SEPOLIA, 133 | eth_private_key=GANACHE_PRIVATE_KEY, 134 | ) 135 | signer_address = client.default_address 136 | stark_key_pair_with_y_coordinate = client.onboarding.derive_stark_key( 137 | signer_address, 138 | ) 139 | assert stark_key_pair_with_y_coordinate == \ 140 | EXPECTED_STARK_KEY_PAIR_WITH_Y_COORDINATE_SEPOLIA 141 | 142 | def test_recover_default_api_key_credentials_on_SEPOLIA_from_priv(self): 143 | client = Client( 144 | host=DEFAULT_HOST, 145 | network_id=NETWORK_ID_SEPOLIA, 146 | eth_private_key=GANACHE_PRIVATE_KEY, 147 | ) 148 | signer_address = client.default_address 149 | api_key_credentials = ( 150 | client.onboarding.recover_default_api_key_credentials( 151 | signer_address, 152 | ) 153 | ) 154 | assert api_key_credentials == EXPECTED_API_KEY_CREDENTIALS_SEPOLIA 155 | -------------------------------------------------------------------------------- /tests/test_public.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dydx3 import Client 4 | from dydx3.constants import MARKET_BTC_USD 5 | from dydx3.constants import MARKET_STATISTIC_DAY_ONE 6 | 7 | from tests.constants import DEFAULT_HOST 8 | 9 | ADDRESS_1 = '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C0' 10 | API_HOST = os.environ.get('V3_API_HOST', DEFAULT_HOST) 11 | 12 | 13 | class TestPublic(): 14 | 15 | def test_check_if_user_exists(self): 16 | public = Client(API_HOST).public 17 | resp = public.check_if_user_exists(ADDRESS_1) 18 | expected_data = { 19 | 'exists': False, 20 | 'contractAddress': '', 21 | 'isProxySigner': False 22 | } 23 | assert resp.data == expected_data 24 | assert resp.headers != {} 25 | 26 | def test_check_if_username_exists(self): 27 | public = Client(API_HOST).public 28 | resp = public.check_if_username_exists('foo') 29 | assert resp.data == {'exists': True} 30 | assert resp.headers != {} 31 | 32 | def test_get_markets(self): 33 | public = Client(API_HOST).public 34 | resp = public.get_markets() 35 | assert resp.data != {} 36 | assert resp.headers != {} 37 | 38 | def test_get_orderbook(self): 39 | public = Client(API_HOST).public 40 | resp = public.get_orderbook(MARKET_BTC_USD) 41 | assert resp.data != {} 42 | assert resp.headers != {} 43 | 44 | def test_get_stats(self): 45 | public = Client(API_HOST).public 46 | resp = public.get_stats( 47 | MARKET_BTC_USD, 48 | MARKET_STATISTIC_DAY_ONE, 49 | ) 50 | assert resp.data != {} 51 | assert resp.headers != {} 52 | 53 | def test_get_trades(self): 54 | public = Client(API_HOST).public 55 | resp = public.get_trades(MARKET_BTC_USD) 56 | assert resp.data != {} 57 | assert resp.headers != {} 58 | 59 | def test_get_historical_funding(self): 60 | public = Client(API_HOST).public 61 | resp = public.get_historical_funding(MARKET_BTC_USD) 62 | assert resp.data != {} 63 | assert resp.headers != {} 64 | 65 | def test_get_candles(self): 66 | public = Client(API_HOST).public 67 | resp = public.get_candles(MARKET_BTC_USD, resolution='1HOUR') 68 | assert resp.data != {} 69 | assert resp.headers != {} 70 | 71 | def test_get_fast_withdrawal(self): 72 | public = Client(API_HOST).public 73 | resp = public.get_fast_withdrawal() 74 | assert resp.data != {} 75 | assert resp.headers != {} 76 | 77 | def test_verify_email(self): 78 | try: 79 | public = Client(API_HOST).public 80 | public.verify_email('token') 81 | except Exception as e: 82 | # No userId gotten with token: token so no verification 83 | # has occurred 84 | assert e.status_code == 400 85 | 86 | def test_public_retroactive_mining(self): 87 | public = Client(API_HOST).public 88 | resp = public.get_public_retroactive_mining_rewards(ADDRESS_1) 89 | assert resp.data != {} 90 | assert resp.headers != {} 91 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = dydx3/starkex/starkex_resources 3 | per-file-ignores = __init__.py:F401 4 | 5 | [tox] 6 | envlist = python2.7, python3.4, python3.5, python3.6, python3.9, python3.11 7 | 8 | [testenv] 9 | commands = 10 | pytest {posargs: tests} 11 | deps = 12 | -rrequirements-test.txt 13 | passenv = V3_API_HOST 14 | --------------------------------------------------------------------------------