├── .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 |
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 |
--------------------------------------------------------------------------------