ethereal-sdk 0.1.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ethereal_sdk-0.1.0a1/PKG-INFO +103 -0
- ethereal_sdk-0.1.0a1/README.md +78 -0
- ethereal_sdk-0.1.0a1/ethereal/__init__.py +4 -0
- ethereal_sdk-0.1.0a1/ethereal/__version__.py +1 -0
- ethereal_sdk-0.1.0a1/ethereal/base_client.py +44 -0
- ethereal_sdk-0.1.0a1/ethereal/chain_client.py +278 -0
- ethereal_sdk-0.1.0a1/ethereal/constants.py +24 -0
- ethereal_sdk-0.1.0a1/ethereal/rest_client.py +285 -0
- ethereal_sdk-0.1.0a1/ethereal/ws_client.py +79 -0
- ethereal_sdk-0.1.0a1/ethereal_sdk.egg-info/PKG-INFO +103 -0
- ethereal_sdk-0.1.0a1/ethereal_sdk.egg-info/SOURCES.txt +16 -0
- ethereal_sdk-0.1.0a1/ethereal_sdk.egg-info/dependency_links.txt +1 -0
- ethereal_sdk-0.1.0a1/ethereal_sdk.egg-info/requires.txt +6 -0
- ethereal_sdk-0.1.0a1/ethereal_sdk.egg-info/top_level.txt +1 -0
- ethereal_sdk-0.1.0a1/pyproject.toml +52 -0
- ethereal_sdk-0.1.0a1/setup.cfg +4 -0
- ethereal_sdk-0.1.0a1/tests/test_chain.py +73 -0
- ethereal_sdk-0.1.0a1/tests/test_clients.py +125 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: ethereal-sdk
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Python SDK for interacting with the Ethereal API
|
|
5
|
+
Author: Meridian Labs
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/meridianxyz/ethereal-py
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: eth-account>=0.13.5
|
|
20
|
+
Requires-Dist: pydantic>=2.10.6
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
22
|
+
Requires-Dist: python-socketio>=5.12.1
|
|
23
|
+
Requires-Dist: requests>=2.32.3
|
|
24
|
+
Requires-Dist: web3>=7.8.0
|
|
25
|
+
|
|
26
|
+
# ethereal-py-sdk
|
|
27
|
+
|
|
28
|
+
**Welcome to ethereal-py-sdk!**
|
|
29
|
+
|
|
30
|
+
Python SDK for interacting with the Ethereal API.
|
|
31
|
+
|
|
32
|
+
## Getting started
|
|
33
|
+
|
|
34
|
+
Before you start, make sure you have installed [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then you can install the SDK and run the tests:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Clone the project
|
|
44
|
+
git clone git@github.com:meridianxyz/ethereal-py-sdk.git
|
|
45
|
+
|
|
46
|
+
# Install dependencies
|
|
47
|
+
uv sync
|
|
48
|
+
|
|
49
|
+
# Run tests
|
|
50
|
+
uv run pytest
|
|
51
|
+
|
|
52
|
+
# Run the linter
|
|
53
|
+
uv run ruff check --fix
|
|
54
|
+
|
|
55
|
+
# Run the example CLI
|
|
56
|
+
uv run python -i examples/cli.py
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
Using the SDK using the REPL (example):
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import ethereal
|
|
65
|
+
|
|
66
|
+
rc = ethereal.RESTClient()
|
|
67
|
+
rc.list_products()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or use the provided CLI:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cp .env.test .env
|
|
74
|
+
|
|
75
|
+
uv run python -i examples/cli.py
|
|
76
|
+
|
|
77
|
+
>>> rc.list_products()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Generating Pydantic Type
|
|
81
|
+
|
|
82
|
+
Ethereal uses an OpenAPI spec to represent the API. You can generate Pydantic models from the OpenAPI spec using the `datamodel-codegen` tool:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# place a `spec.json` in the root of the project
|
|
86
|
+
uv run datamodel-codegen --input /path/to/api_spec.json \
|
|
87
|
+
--output ethereal/models/generated.py \
|
|
88
|
+
--input-file-type openapi \
|
|
89
|
+
--openapi-scopes paths schemas parameters \
|
|
90
|
+
--output-model-type pydantic_v2.BaseModel
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
Docs are created using [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/). To run the docs locally:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# serve
|
|
99
|
+
uv run mkdocs serve
|
|
100
|
+
|
|
101
|
+
# build
|
|
102
|
+
uv run mkdocs build
|
|
103
|
+
```
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# ethereal-py-sdk
|
|
2
|
+
|
|
3
|
+
**Welcome to ethereal-py-sdk!**
|
|
4
|
+
|
|
5
|
+
Python SDK for interacting with the Ethereal API.
|
|
6
|
+
|
|
7
|
+
## Getting started
|
|
8
|
+
|
|
9
|
+
Before you start, make sure you have installed [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then you can install the SDK and run the tests:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Clone the project
|
|
19
|
+
git clone git@github.com:meridianxyz/ethereal-py-sdk.git
|
|
20
|
+
|
|
21
|
+
# Install dependencies
|
|
22
|
+
uv sync
|
|
23
|
+
|
|
24
|
+
# Run tests
|
|
25
|
+
uv run pytest
|
|
26
|
+
|
|
27
|
+
# Run the linter
|
|
28
|
+
uv run ruff check --fix
|
|
29
|
+
|
|
30
|
+
# Run the example CLI
|
|
31
|
+
uv run python -i examples/cli.py
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Using the SDK using the REPL (example):
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import ethereal
|
|
40
|
+
|
|
41
|
+
rc = ethereal.RESTClient()
|
|
42
|
+
rc.list_products()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or use the provided CLI:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cp .env.test .env
|
|
49
|
+
|
|
50
|
+
uv run python -i examples/cli.py
|
|
51
|
+
|
|
52
|
+
>>> rc.list_products()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Generating Pydantic Type
|
|
56
|
+
|
|
57
|
+
Ethereal uses an OpenAPI spec to represent the API. You can generate Pydantic models from the OpenAPI spec using the `datamodel-codegen` tool:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# place a `spec.json` in the root of the project
|
|
61
|
+
uv run datamodel-codegen --input /path/to/api_spec.json \
|
|
62
|
+
--output ethereal/models/generated.py \
|
|
63
|
+
--input-file-type openapi \
|
|
64
|
+
--openapi-scopes paths schemas parameters \
|
|
65
|
+
--output-model-type pydantic_v2.BaseModel
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Documentation
|
|
69
|
+
|
|
70
|
+
Docs are created using [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/). To run the docs locally:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# serve
|
|
74
|
+
uv run mkdocs serve
|
|
75
|
+
|
|
76
|
+
# build
|
|
77
|
+
uv run mkdocs build
|
|
78
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0a1"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Union, Dict, Any
|
|
3
|
+
from ethereal.models.config import BaseConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_logger(name):
|
|
7
|
+
"""Get a configured logger instance.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
name (str): Name for the logger
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Logger: Configured logging instance
|
|
14
|
+
"""
|
|
15
|
+
logger = logging.getLogger(name)
|
|
16
|
+
logger.setLevel(logging.INFO)
|
|
17
|
+
|
|
18
|
+
handler = logging.StreamHandler()
|
|
19
|
+
formatter = logging.Formatter(
|
|
20
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S"
|
|
21
|
+
)
|
|
22
|
+
handler.setFormatter(formatter)
|
|
23
|
+
logger.addHandler(handler)
|
|
24
|
+
|
|
25
|
+
return logger
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BaseClient:
|
|
29
|
+
"""Base client with common functionality.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config (Union[Dict[str, Any], BaseConfig]): Base configuration
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: Union[Dict[str, Any], BaseConfig]):
|
|
36
|
+
self.config = BaseConfig.model_validate(config)
|
|
37
|
+
self._setup_logging()
|
|
38
|
+
|
|
39
|
+
def _setup_logging(self):
|
|
40
|
+
"""Set up logging for the client."""
|
|
41
|
+
self.logger = get_logger(self.__class__.__name__)
|
|
42
|
+
if self.config.verbose:
|
|
43
|
+
self.logger.setLevel(logging.DEBUG)
|
|
44
|
+
pass
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from typing import Optional, Dict, Any, Union
|
|
5
|
+
from ethereal.base_client import BaseClient
|
|
6
|
+
from ethereal.models.config import ChainConfig
|
|
7
|
+
from ethereal.models.rest import RpcConfigDto
|
|
8
|
+
from web3 import Web3
|
|
9
|
+
from web3.exceptions import Web3Exception
|
|
10
|
+
from web3.types import TxParams
|
|
11
|
+
from eth_account import Account
|
|
12
|
+
from eth_account.messages import encode_typed_data
|
|
13
|
+
from eth_utils import encode_hex
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# constants
|
|
17
|
+
USDE_ADDRESSES = {996353: "0xa1623E0AA40B142Cf755938b325321fB2c61Cf05"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_contract(contract_name: str):
|
|
21
|
+
contract_dir = os.path.join(
|
|
22
|
+
os.path.dirname(os.path.abspath(__file__)),
|
|
23
|
+
"contracts",
|
|
24
|
+
f"{contract_name}.json",
|
|
25
|
+
)
|
|
26
|
+
with open(contract_dir) as f:
|
|
27
|
+
return json.load(f)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ChainClient(BaseClient):
|
|
31
|
+
"""Client for interacting with the blockchain using Web3 functionality.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
config (Union[Dict[str, Any], ChainConfig]): Chain configuration
|
|
35
|
+
rpc_config (RpcConfigDto, optional): RPC configuration. Defaults to None.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
Exception: If RPC URL or private key is not specified in the configuration
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config: Union[Dict[str, Any], ChainConfig],
|
|
44
|
+
rpc_config: RpcConfigDto = None,
|
|
45
|
+
):
|
|
46
|
+
super().__init__(config)
|
|
47
|
+
self.config = ChainConfig.model_validate(config)
|
|
48
|
+
self.provider = self._setup_provider()
|
|
49
|
+
self.account = self._setup_account()
|
|
50
|
+
self.address = self.account.address
|
|
51
|
+
self.private_key = self.config.private_key
|
|
52
|
+
|
|
53
|
+
self.chain_id = self.provider.eth.chain_id
|
|
54
|
+
self.usde = self.provider.eth.contract(
|
|
55
|
+
address=USDE_ADDRESSES[self.chain_id], abi=read_contract("ERC20")
|
|
56
|
+
)
|
|
57
|
+
self.rpc_config = rpc_config
|
|
58
|
+
|
|
59
|
+
def _setup_provider(self):
|
|
60
|
+
"""Set up the Web3 provider.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Web3: The Web3 provider instance
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
Exception: If RPC URL is not specified in the configuration
|
|
67
|
+
"""
|
|
68
|
+
# TODO: Support other provider types (e.g. WebSocket)
|
|
69
|
+
if self.config.rpc_url is None:
|
|
70
|
+
raise Exception("RPC URL must be specified in the configuration")
|
|
71
|
+
return Web3(Web3.HTTPProvider(self.config.rpc_url))
|
|
72
|
+
|
|
73
|
+
def _setup_account(self):
|
|
74
|
+
"""Set up the account.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Account: The Web3 account instance
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
Exception: If private key is not specified in the configuration
|
|
81
|
+
"""
|
|
82
|
+
if self.config.private_key is None:
|
|
83
|
+
raise Exception("Private key must be specified in the configuration")
|
|
84
|
+
return self.provider.eth.account.from_key(self.config.private_key)
|
|
85
|
+
|
|
86
|
+
def _get_tx(self, value=0, to=None) -> TxParams:
|
|
87
|
+
"""Get default transaction parameters.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
value (int, optional): The value to send. Defaults to 0.
|
|
91
|
+
to (str, optional): The recipient address. Defaults to None.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
TxParams: The transaction parameters
|
|
95
|
+
"""
|
|
96
|
+
params: TxParams = {
|
|
97
|
+
"from": self.address,
|
|
98
|
+
"chainId": self.chain_id,
|
|
99
|
+
"value": value,
|
|
100
|
+
"nonce": self.get_nonce(self.address),
|
|
101
|
+
}
|
|
102
|
+
if to is not None:
|
|
103
|
+
params["to"] = to
|
|
104
|
+
return params
|
|
105
|
+
|
|
106
|
+
def add_gas_fees(self, tx: TxParams) -> TxParams:
|
|
107
|
+
"""Add gas fee parameters to a transaction.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
tx (TxParams): The transaction parameters
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
TxParams: The transaction parameters with gas fee parameters added
|
|
114
|
+
"""
|
|
115
|
+
if "maxFeePerGas" in tx and "maxPriorityFeePerGas" in tx:
|
|
116
|
+
return tx
|
|
117
|
+
try:
|
|
118
|
+
gas_price = self.provider.eth.gas_price
|
|
119
|
+
max_priority_fee = self.provider.eth.max_priority_fee
|
|
120
|
+
tx["maxFeePerGas"] = gas_price
|
|
121
|
+
tx["maxPriorityFeePerGas"] = max_priority_fee
|
|
122
|
+
return tx
|
|
123
|
+
except Web3Exception as e:
|
|
124
|
+
self.logger.error(f"Failed to add gas: {e}")
|
|
125
|
+
return tx
|
|
126
|
+
|
|
127
|
+
def add_gas_limit(self, tx: TxParams) -> TxParams:
|
|
128
|
+
"""Add gas limit to a transaction.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
tx (TxParams): The transaction parameters
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
TxParams: The transaction parameters with gas limit added
|
|
135
|
+
"""
|
|
136
|
+
if "gas" in tx:
|
|
137
|
+
return tx
|
|
138
|
+
try:
|
|
139
|
+
gas = self.provider.eth.estimate_gas(tx)
|
|
140
|
+
tx["gas"] = gas
|
|
141
|
+
return tx
|
|
142
|
+
except Web3Exception as e:
|
|
143
|
+
self.logger.error(f"Failed to add gas limit: {e}")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
def submit_tx(self, tx: TxParams) -> str:
|
|
147
|
+
"""Submit a transaction.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
tx (TxParams): The transaction parameters
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
str: The transaction hash
|
|
154
|
+
"""
|
|
155
|
+
tx = self.add_gas_fees(tx)
|
|
156
|
+
tx = self.add_gas_limit(tx)
|
|
157
|
+
try:
|
|
158
|
+
signed_tx = self.provider.eth.account.sign_transaction(
|
|
159
|
+
tx, private_key=self.private_key
|
|
160
|
+
)
|
|
161
|
+
tx_hash = self.provider.eth.send_raw_transaction(signed_tx.raw_transaction)
|
|
162
|
+
return encode_hex(tx_hash)
|
|
163
|
+
except Web3Exception as e:
|
|
164
|
+
self.logger.error(f"Failed to submit transaction: {e}")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
def get_nonce(self, address: str) -> int:
|
|
168
|
+
"""Get the nonce for a given address.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
address (str): The address to get the nonce for
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
int: The nonce, or -1 if failed
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
return self.provider.eth.get_transaction_count(address)
|
|
178
|
+
except Web3Exception as e:
|
|
179
|
+
self.logger.error(f"Failed to get nonce: {e}")
|
|
180
|
+
return -1
|
|
181
|
+
|
|
182
|
+
def get_balance(self, address: str) -> int:
|
|
183
|
+
"""Get the balance for a given address.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
address (str): The address to get the balance for
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
int: The balance, or -1 if failed
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
return self.provider.eth.get_balance(address)
|
|
193
|
+
except Web3Exception as e:
|
|
194
|
+
self.logger.error(f"Failed to get balance: {e}")
|
|
195
|
+
return -1
|
|
196
|
+
|
|
197
|
+
def get_token_balance(self, address: str, token_address: str) -> int:
|
|
198
|
+
"""Get the token balance for a given address.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
address (str): The address to get the token balance for
|
|
202
|
+
token_address (str): The token address
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
int: The token balance, or -1 if failed
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
contract = self.provider.eth.contract(
|
|
209
|
+
address=token_address, abi=read_contract("ERC20")
|
|
210
|
+
)
|
|
211
|
+
return contract.functions.balanceOf(address).call()
|
|
212
|
+
except Web3Exception as e:
|
|
213
|
+
self.logger.error(f"Failed to get token balance: {e}")
|
|
214
|
+
return -1
|
|
215
|
+
|
|
216
|
+
def sign_message(self, private_key, domain, types, primary_type, message):
|
|
217
|
+
# A type fix for the domain
|
|
218
|
+
domain["chainId"] = int(domain["chainId"])
|
|
219
|
+
|
|
220
|
+
# Preparing the full message as per EIP-712
|
|
221
|
+
full_message = {
|
|
222
|
+
"types": types,
|
|
223
|
+
"primaryType": primary_type,
|
|
224
|
+
"domain": domain,
|
|
225
|
+
"message": message,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
encoded_message = encode_typed_data(full_message=full_message)
|
|
229
|
+
|
|
230
|
+
# Signing the message
|
|
231
|
+
signed_message = Account.sign_message(encoded_message, private_key)
|
|
232
|
+
return "0x" + signed_message.signature.hex()
|
|
233
|
+
|
|
234
|
+
def deposit_usde(
|
|
235
|
+
self,
|
|
236
|
+
amount: float,
|
|
237
|
+
address: Optional[str] = None,
|
|
238
|
+
submit: Optional[bool] = False,
|
|
239
|
+
account_name: Optional[str] = "primary",
|
|
240
|
+
) -> Union[TxParams, str]:
|
|
241
|
+
"""Submit a deposit transaction.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
amount (float): The amount to deposit
|
|
245
|
+
address (str, optional): The address to deposit to. Defaults to None.
|
|
246
|
+
submit (bool, optional): Whether to submit the transaction. Defaults to False.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Union[TxParams, str]: The transaction parameters or transaction hash if submit=True
|
|
250
|
+
"""
|
|
251
|
+
if address is None:
|
|
252
|
+
address = self.address
|
|
253
|
+
try:
|
|
254
|
+
contract_address = self.rpc_config.domain.verifyingContract
|
|
255
|
+
contract = self.provider.eth.contract(
|
|
256
|
+
address=contract_address, abi=read_contract("EtherealProxy")
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# params
|
|
260
|
+
subaccount = self.provider.to_hex(text=account_name).ljust(66, "0")
|
|
261
|
+
deposit_token = self.usde.address
|
|
262
|
+
amount = self.provider.to_wei(amount, "ether")
|
|
263
|
+
referral_code = self.provider.to_hex(0).ljust(66, "0")
|
|
264
|
+
|
|
265
|
+
# prepare the tx
|
|
266
|
+
tx = self._get_tx(to=contract_address)
|
|
267
|
+
tx["data"] = contract.encode_abi(
|
|
268
|
+
"deposit", args=[subaccount, deposit_token, amount, referral_code]
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if submit:
|
|
272
|
+
return self.submit_tx(tx)
|
|
273
|
+
else:
|
|
274
|
+
return tx
|
|
275
|
+
|
|
276
|
+
except Web3Exception as e:
|
|
277
|
+
self.logger.error(f"Failed to get token balance: {e}")
|
|
278
|
+
return -1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from ethereal.__version__ import __version__
|
|
2
|
+
|
|
3
|
+
USER_AGENT = f"ethereal-py-sdk/{__version__}"
|
|
4
|
+
PRIVATE_KEY = "PRIVATE_KEY"
|
|
5
|
+
RPC_URL = "RPC_URL"
|
|
6
|
+
|
|
7
|
+
BASE_URL = "https://api.etherealtest.net"
|
|
8
|
+
API_PREFIX = "/v1"
|
|
9
|
+
|
|
10
|
+
WS_BASE_URL = "wss://ws.etherealtest.net"
|
|
11
|
+
WS_NAMESPACES = [
|
|
12
|
+
"/",
|
|
13
|
+
"/v1/stream",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
X_RATELIMIT_LIMIT = "x-ratelimit-limit"
|
|
17
|
+
X_RATELIMIT_REMAINING = "x-ratelimit-remaining"
|
|
18
|
+
RETRY_AFTER = "retry-after"
|
|
19
|
+
RATE_LIMIT_HEADERS = {X_RATELIMIT_LIMIT, X_RATELIMIT_REMAINING, RETRY_AFTER}
|
|
20
|
+
REST_COMMON_FIELDS = {
|
|
21
|
+
X_RATELIMIT_LIMIT: "rate_limit_limit",
|
|
22
|
+
X_RATELIMIT_REMAINING: "rate_limit_remaining",
|
|
23
|
+
RETRY_AFTER: "retry_after",
|
|
24
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Union, Dict, Any, Optional
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from ethereal.constants import API_PREFIX
|
|
5
|
+
from ethereal.rest.http_client import HTTPClient
|
|
6
|
+
from ethereal.chain_client import ChainClient
|
|
7
|
+
from ethereal.models.config import RESTConfig, ChainConfig
|
|
8
|
+
from ethereal.models.rest import (
|
|
9
|
+
TimeInForce,
|
|
10
|
+
RpcConfigDto,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RESTClient(HTTPClient):
|
|
15
|
+
"""REST client for interacting with the Ethereal API.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
config (Union[Dict[str, Any], RESTConfig]): Configuration dictionary or RESTConfig object.
|
|
19
|
+
Optional fields include:
|
|
20
|
+
- private_key (str): The private key
|
|
21
|
+
- base_url (str): Base URL for REST requests, defaults to "https://api.etherealtest.net"
|
|
22
|
+
- timeout (int): Timeout in seconds for REST requests
|
|
23
|
+
- verbose (bool): Enables debug logging, defaults to False
|
|
24
|
+
- rate_limit_headers (bool): Enables rate limit headers, defaults to False
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from ethereal.rest.funding import list_funding_rates
|
|
28
|
+
from ethereal.rest.linked_signer import (
|
|
29
|
+
get_active_signer,
|
|
30
|
+
get_signer,
|
|
31
|
+
get_signer_quota,
|
|
32
|
+
list_signers,
|
|
33
|
+
link_signer,
|
|
34
|
+
)
|
|
35
|
+
from ethereal.rest.order import (
|
|
36
|
+
get_order,
|
|
37
|
+
list_fills,
|
|
38
|
+
list_orders,
|
|
39
|
+
list_trades,
|
|
40
|
+
submit_order as _submit_order,
|
|
41
|
+
cancel_order,
|
|
42
|
+
)
|
|
43
|
+
from ethereal.rest.position import list_positions, get_position
|
|
44
|
+
from ethereal.rest.product import (
|
|
45
|
+
get_market_liquidity,
|
|
46
|
+
list_market_prices,
|
|
47
|
+
list_products,
|
|
48
|
+
)
|
|
49
|
+
from ethereal.rest.rpc import get_rpc_config
|
|
50
|
+
from ethereal.rest.subaccount import (
|
|
51
|
+
list_subaccounts,
|
|
52
|
+
get_subaccount,
|
|
53
|
+
get_subaccount_balances,
|
|
54
|
+
)
|
|
55
|
+
from ethereal.rest.token import (
|
|
56
|
+
get_token,
|
|
57
|
+
list_token_withdraws,
|
|
58
|
+
list_tokens,
|
|
59
|
+
list_token_transfers,
|
|
60
|
+
withdraw_token,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def __init__(self, config: Union[Dict[str, Any], RESTConfig] = {}):
|
|
64
|
+
super().__init__(config)
|
|
65
|
+
self.config = RESTConfig.model_validate(config)
|
|
66
|
+
|
|
67
|
+
# fetch RPC configuration
|
|
68
|
+
self.rpc_config = self.get_rpc_config()
|
|
69
|
+
|
|
70
|
+
self.chain = None
|
|
71
|
+
if self.config.chain_config:
|
|
72
|
+
self._init_chain_client(self.config.chain_config, self.rpc_config)
|
|
73
|
+
self.private_key = self.chain.private_key
|
|
74
|
+
self.provider = self.chain.provider
|
|
75
|
+
else:
|
|
76
|
+
self.private_key = None
|
|
77
|
+
self.provider = None
|
|
78
|
+
|
|
79
|
+
# TODO: Find a better way to set these default
|
|
80
|
+
self.default_time_in_force = TimeInForce.IOC
|
|
81
|
+
self.default_post_only = False
|
|
82
|
+
|
|
83
|
+
def _init_chain_client(
|
|
84
|
+
self,
|
|
85
|
+
config: Union[Dict[str, Any], ChainConfig],
|
|
86
|
+
rpc_config: RpcConfigDto = None,
|
|
87
|
+
):
|
|
88
|
+
"""Initialize the ChainClient.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
config (Union[Dict[str, Any], ChainConfig]): The chain configuration.
|
|
92
|
+
rpc_config (RpcConfigDto, optional): RPC configuration. Defaults to None.
|
|
93
|
+
"""
|
|
94
|
+
config = ChainConfig.model_validate(config)
|
|
95
|
+
try:
|
|
96
|
+
self.chain = ChainClient(config, rpc_config)
|
|
97
|
+
self.logger.info("Chain client initialized successfully")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
self.logger.warning(f"Failed to initialize chain client: {e}")
|
|
100
|
+
|
|
101
|
+
def _get_pages(
|
|
102
|
+
self,
|
|
103
|
+
endpoint: str,
|
|
104
|
+
request_model: BaseModel,
|
|
105
|
+
response_model: BaseModel,
|
|
106
|
+
paginate: bool = False,
|
|
107
|
+
**kwargs,
|
|
108
|
+
) -> BaseModel:
|
|
109
|
+
"""Make a GET request with validated parameters and response and handling for pagination.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
endpoint (str): API endpoint path (e.g. "order" will be appended to the base URL and prefix to form "/v1/order")
|
|
113
|
+
request_model (BaseModel): Pydantic model for request validation
|
|
114
|
+
response_model (BaseModel): Pydantic model for response validation
|
|
115
|
+
paginate (bool): Whether to fetch additional pages of data
|
|
116
|
+
**kwargs: Parameters to validate and include in the request
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
response (BaseModel): Validated response object
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
orders = client.validated_get(
|
|
123
|
+
endpoint="order",
|
|
124
|
+
request_model=V1OrderGetParametersQuery,
|
|
125
|
+
response_model=ListOfOrderDtos,
|
|
126
|
+
subaccountId="abc123",
|
|
127
|
+
limit=50
|
|
128
|
+
)
|
|
129
|
+
"""
|
|
130
|
+
result = self.get_validated(
|
|
131
|
+
url_path=f"{API_PREFIX}/{endpoint}",
|
|
132
|
+
request_model=request_model,
|
|
133
|
+
response_model=response_model,
|
|
134
|
+
**kwargs,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# If pagination is requested, fetch additional pages
|
|
138
|
+
is_paginated = all(
|
|
139
|
+
[
|
|
140
|
+
hasattr(result, "data"),
|
|
141
|
+
hasattr(result, "hasNext"),
|
|
142
|
+
hasattr(result, "nextCursor"),
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
if not is_paginated:
|
|
146
|
+
raise ValueError("Response does not support pagination")
|
|
147
|
+
elif paginate:
|
|
148
|
+
all_data = list(result.data)
|
|
149
|
+
|
|
150
|
+
# Continue fetching while there are more pages
|
|
151
|
+
current_result = result
|
|
152
|
+
while current_result.hasNext and current_result.nextCursor:
|
|
153
|
+
current_result = self.get_validated(
|
|
154
|
+
url_path=f"{API_PREFIX}/{endpoint}",
|
|
155
|
+
request_model=request_model,
|
|
156
|
+
response_model=response_model,
|
|
157
|
+
cursor=current_result.nextCursor,
|
|
158
|
+
**kwargs,
|
|
159
|
+
)
|
|
160
|
+
# Add data from this page
|
|
161
|
+
all_data.extend(current_result.data)
|
|
162
|
+
|
|
163
|
+
# Update the result with the combined data
|
|
164
|
+
result.data = all_data
|
|
165
|
+
result.hasNext = False
|
|
166
|
+
result.nextCursor = None
|
|
167
|
+
return result.data
|
|
168
|
+
|
|
169
|
+
@cached_property
|
|
170
|
+
def subaccounts(self):
|
|
171
|
+
"""Get the list of subaccounts.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
subaccounts (List): List of subaccount objects.
|
|
175
|
+
"""
|
|
176
|
+
return self.list_subaccounts(self.chain.address)
|
|
177
|
+
|
|
178
|
+
@cached_property
|
|
179
|
+
def products(self):
|
|
180
|
+
"""Get the list of products.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
products (List): List of product objects.
|
|
184
|
+
"""
|
|
185
|
+
return self.list_products()
|
|
186
|
+
|
|
187
|
+
@cached_property
|
|
188
|
+
def products_by_ticker(self):
|
|
189
|
+
"""Get the products indexed by ticker.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
products_by_ticker (Dict[str, ProductDto]): Dictionary of products keyed by ticker.
|
|
193
|
+
"""
|
|
194
|
+
return {p.ticker: p for p in self.products}
|
|
195
|
+
|
|
196
|
+
@cached_property
|
|
197
|
+
def products_by_id(self):
|
|
198
|
+
"""Get the products indexed by ID.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
products_by_id (Dict[str, ProductDto]): Dictionary of products keyed by ID.
|
|
202
|
+
"""
|
|
203
|
+
return {p.id: p for p in self.products}
|
|
204
|
+
|
|
205
|
+
def create_order(
|
|
206
|
+
self,
|
|
207
|
+
order_type: str,
|
|
208
|
+
quantity: float,
|
|
209
|
+
side: int,
|
|
210
|
+
price: Optional[float] = None,
|
|
211
|
+
ticker: Optional[str] = None,
|
|
212
|
+
product_id: Optional[str] = None,
|
|
213
|
+
sender: Optional[str] = None,
|
|
214
|
+
subaccount: Optional[str] = None,
|
|
215
|
+
time_in_force: Optional[str] = None,
|
|
216
|
+
post_only: Optional[bool] = None,
|
|
217
|
+
):
|
|
218
|
+
"""Create and submit an order.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
order_type (str): The type of order (market or limit)
|
|
222
|
+
quantity (float): The quantity of the order
|
|
223
|
+
side (int): The side of the order (0 = BUY, 1 = SELL)
|
|
224
|
+
price (float, optional): The price of the order (for limit orders)
|
|
225
|
+
ticker (str, optional): The ticker of the product
|
|
226
|
+
product_id (str, optional): The ID of the product
|
|
227
|
+
sender (str, optional): The sender address
|
|
228
|
+
subaccount (str, optional): The subaccount name
|
|
229
|
+
time_in_force (str, optional): The time in force for limit orders
|
|
230
|
+
post_only (bool, optional): Whether the order is post-only (for limit orders)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
order (OrderDto): The response data from the API
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: If neither product_id nor ticker is provided or if order type is invalid
|
|
237
|
+
"""
|
|
238
|
+
# get the sender and account info
|
|
239
|
+
if sender is None:
|
|
240
|
+
sender = self.chain.address
|
|
241
|
+
if subaccount is None:
|
|
242
|
+
subaccount = self.subaccounts[0].name
|
|
243
|
+
|
|
244
|
+
# get the product info
|
|
245
|
+
if product_id is not None:
|
|
246
|
+
onchain_id = self.products_by_id[product_id].onchainId
|
|
247
|
+
elif ticker is not None:
|
|
248
|
+
onchain_id = self.products_by_ticker[ticker].onchainId
|
|
249
|
+
else:
|
|
250
|
+
raise ValueError("Either product_id or ticker must be provided")
|
|
251
|
+
|
|
252
|
+
# prepare the order params
|
|
253
|
+
quantity = str(quantity)
|
|
254
|
+
if order_type == "MARKET":
|
|
255
|
+
order_params = {
|
|
256
|
+
"sender": sender,
|
|
257
|
+
"subaccount": subaccount,
|
|
258
|
+
"side": side,
|
|
259
|
+
"price": "0",
|
|
260
|
+
"quantity": quantity,
|
|
261
|
+
"onchainId": onchain_id,
|
|
262
|
+
"orderType": order_type,
|
|
263
|
+
}
|
|
264
|
+
elif order_type == "LIMIT":
|
|
265
|
+
time_in_force = (
|
|
266
|
+
self.default_time_in_force if time_in_force is None else time_in_force
|
|
267
|
+
)
|
|
268
|
+
post_only = self.default_post_only if post_only is None else post_only
|
|
269
|
+
price = "{:.9f}".format(price)
|
|
270
|
+
|
|
271
|
+
order_params = {
|
|
272
|
+
"sender": sender,
|
|
273
|
+
"subaccount": subaccount,
|
|
274
|
+
"side": side,
|
|
275
|
+
"price": price,
|
|
276
|
+
"quantity": quantity,
|
|
277
|
+
"onchainId": onchain_id,
|
|
278
|
+
"orderType": order_type,
|
|
279
|
+
"timeInForce": time_in_force,
|
|
280
|
+
"postOnly": post_only,
|
|
281
|
+
}
|
|
282
|
+
else:
|
|
283
|
+
raise ValueError("Invalid order type")
|
|
284
|
+
|
|
285
|
+
return self._submit_order(**order_params)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from typing import Union, Dict, Any, Callable, Optional
|
|
2
|
+
|
|
3
|
+
from ethereal.models.config import WSConfig
|
|
4
|
+
from ethereal.ws.ws_base import WSBase
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class WSClient(WSBase):
|
|
8
|
+
"""Ethereal websocket client.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
config (Union[Dict[str, Any], WSConfig]): Configuration dictionary or WSConfig object.
|
|
12
|
+
Required fields include:
|
|
13
|
+
- base_url (str): Base URL for websocket requests
|
|
14
|
+
Optional fields include:
|
|
15
|
+
- verbose (bool): Enables debug logging, defaults to False
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config: Union[Dict[str, Any], WSConfig]):
|
|
19
|
+
super().__init__(config)
|
|
20
|
+
|
|
21
|
+
def subscribe(
|
|
22
|
+
self,
|
|
23
|
+
stream_type: str,
|
|
24
|
+
product_id: str,
|
|
25
|
+
subaccount_id: Optional[str] = None,
|
|
26
|
+
callback: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
27
|
+
namespace: Optional[str] = "/v1/stream",
|
|
28
|
+
) -> Dict[str, Any]:
|
|
29
|
+
"""Subscribe to a specific stream.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
stream_type (str): Type of stream to subscribe to
|
|
33
|
+
product_id (str): Product ID to subscribe to
|
|
34
|
+
subaccount_id (Optional[str]): Subaccount ID, optional
|
|
35
|
+
callback (Optional[Callable]): Callback function to handle incoming messages
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dict[str, Any]: Subscription response
|
|
39
|
+
"""
|
|
40
|
+
subscription_data = {"type": stream_type, "productId": product_id}
|
|
41
|
+
|
|
42
|
+
if subaccount_id:
|
|
43
|
+
subscription_data["subaccountId"] = subaccount_id
|
|
44
|
+
|
|
45
|
+
# Register callback if provided
|
|
46
|
+
if callback:
|
|
47
|
+
event_key = stream_type
|
|
48
|
+
if event_key not in self.callbacks:
|
|
49
|
+
self.callbacks[event_key] = []
|
|
50
|
+
|
|
51
|
+
self.callbacks[event_key].append(callback)
|
|
52
|
+
|
|
53
|
+
# Send subscription request
|
|
54
|
+
return self._emit("subscribe", subscription_data, namespace=namespace)
|
|
55
|
+
|
|
56
|
+
def unsubscribe(
|
|
57
|
+
self,
|
|
58
|
+
stream_type: str,
|
|
59
|
+
product_id: str,
|
|
60
|
+
subaccount_id: Optional[str] = None,
|
|
61
|
+
namespace: Optional[str] = "/v1/stream",
|
|
62
|
+
) -> Dict[str, Any]:
|
|
63
|
+
"""Unsubscribe from a specific stream.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
stream_type (str): Type of stream to unsubscribe from
|
|
67
|
+
product_id (str): Product ID to unsubscribe from
|
|
68
|
+
subaccount_id (Optional[str]): Subaccount ID, optional
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Dict[str, Any]: Unsubscription response
|
|
72
|
+
"""
|
|
73
|
+
unsubscription_data = {"type": stream_type, "productId": product_id}
|
|
74
|
+
|
|
75
|
+
if subaccount_id:
|
|
76
|
+
unsubscription_data["subaccountId"] = subaccount_id
|
|
77
|
+
|
|
78
|
+
# Send unsubscription request
|
|
79
|
+
return self._emit("unsubscribe", unsubscription_data, namespace=namespace)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: ethereal-sdk
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Python SDK for interacting with the Ethereal API
|
|
5
|
+
Author: Meridian Labs
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/meridianxyz/ethereal-py
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: eth-account>=0.13.5
|
|
20
|
+
Requires-Dist: pydantic>=2.10.6
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
22
|
+
Requires-Dist: python-socketio>=5.12.1
|
|
23
|
+
Requires-Dist: requests>=2.32.3
|
|
24
|
+
Requires-Dist: web3>=7.8.0
|
|
25
|
+
|
|
26
|
+
# ethereal-py-sdk
|
|
27
|
+
|
|
28
|
+
**Welcome to ethereal-py-sdk!**
|
|
29
|
+
|
|
30
|
+
Python SDK for interacting with the Ethereal API.
|
|
31
|
+
|
|
32
|
+
## Getting started
|
|
33
|
+
|
|
34
|
+
Before you start, make sure you have installed [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then you can install the SDK and run the tests:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Clone the project
|
|
44
|
+
git clone git@github.com:meridianxyz/ethereal-py-sdk.git
|
|
45
|
+
|
|
46
|
+
# Install dependencies
|
|
47
|
+
uv sync
|
|
48
|
+
|
|
49
|
+
# Run tests
|
|
50
|
+
uv run pytest
|
|
51
|
+
|
|
52
|
+
# Run the linter
|
|
53
|
+
uv run ruff check --fix
|
|
54
|
+
|
|
55
|
+
# Run the example CLI
|
|
56
|
+
uv run python -i examples/cli.py
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
Using the SDK using the REPL (example):
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import ethereal
|
|
65
|
+
|
|
66
|
+
rc = ethereal.RESTClient()
|
|
67
|
+
rc.list_products()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or use the provided CLI:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cp .env.test .env
|
|
74
|
+
|
|
75
|
+
uv run python -i examples/cli.py
|
|
76
|
+
|
|
77
|
+
>>> rc.list_products()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Generating Pydantic Type
|
|
81
|
+
|
|
82
|
+
Ethereal uses an OpenAPI spec to represent the API. You can generate Pydantic models from the OpenAPI spec using the `datamodel-codegen` tool:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# place a `spec.json` in the root of the project
|
|
86
|
+
uv run datamodel-codegen --input /path/to/api_spec.json \
|
|
87
|
+
--output ethereal/models/generated.py \
|
|
88
|
+
--input-file-type openapi \
|
|
89
|
+
--openapi-scopes paths schemas parameters \
|
|
90
|
+
--output-model-type pydantic_v2.BaseModel
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
Docs are created using [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/). To run the docs locally:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# serve
|
|
99
|
+
uv run mkdocs serve
|
|
100
|
+
|
|
101
|
+
# build
|
|
102
|
+
uv run mkdocs build
|
|
103
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
ethereal/__init__.py
|
|
4
|
+
ethereal/__version__.py
|
|
5
|
+
ethereal/base_client.py
|
|
6
|
+
ethereal/chain_client.py
|
|
7
|
+
ethereal/constants.py
|
|
8
|
+
ethereal/rest_client.py
|
|
9
|
+
ethereal/ws_client.py
|
|
10
|
+
ethereal_sdk.egg-info/PKG-INFO
|
|
11
|
+
ethereal_sdk.egg-info/SOURCES.txt
|
|
12
|
+
ethereal_sdk.egg-info/dependency_links.txt
|
|
13
|
+
ethereal_sdk.egg-info/requires.txt
|
|
14
|
+
ethereal_sdk.egg-info/top_level.txt
|
|
15
|
+
tests/test_chain.py
|
|
16
|
+
tests/test_clients.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ethereal
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ethereal-sdk"
|
|
3
|
+
dynamic = ["version", "readme"]
|
|
4
|
+
description = "Python SDK for interacting with the Ethereal API"
|
|
5
|
+
authors = [{ name = "Meridian Labs" }]
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.8"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Intended Audience :: Developers",
|
|
10
|
+
"Programming Language :: Python :: 3",
|
|
11
|
+
"Programming Language :: Python :: 3.8",
|
|
12
|
+
"Programming Language :: Python :: 3.9",
|
|
13
|
+
"Programming Language :: Python :: 3.10",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"eth-account>=0.13.5",
|
|
21
|
+
"pydantic>=2.10.6",
|
|
22
|
+
"python-dotenv>=1.0.1",
|
|
23
|
+
"python-socketio>=5.12.1",
|
|
24
|
+
"requests>=2.32.3",
|
|
25
|
+
"web3>=7.8.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/meridianxyz/ethereal-py"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
packages = ["ethereal"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.dynamic]
|
|
35
|
+
version = { attr = "ethereal.__version__.__version__" }
|
|
36
|
+
readme = { file = ["README.md"], content-type = "text/markdown" }
|
|
37
|
+
dependencies = { file = ["requirements.txt"] }
|
|
38
|
+
|
|
39
|
+
[tool.uv]
|
|
40
|
+
dev-dependencies = [
|
|
41
|
+
"datamodel-code-generator>=0.27.3",
|
|
42
|
+
"ipykernel>=6.29.5",
|
|
43
|
+
"mkdocs-material>=9.6.4",
|
|
44
|
+
"mkdocstrings-python>=1.11.1",
|
|
45
|
+
"pytest>=8.3.4",
|
|
46
|
+
"pytest-asyncio>=0.24.0",
|
|
47
|
+
"ruff>=0.9.3",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[build-system]
|
|
51
|
+
requires = ["setuptools>=61.0"]
|
|
52
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from web3 import Web3
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_provider(rc):
|
|
5
|
+
rc.logger.info(f"Chain ID: {rc.chain.chain_id}")
|
|
6
|
+
assert rc.provider is not None
|
|
7
|
+
assert isinstance(rc.provider, Web3)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_block(rc):
|
|
11
|
+
"""Test block method."""
|
|
12
|
+
block = rc.provider.eth.get_block("latest")
|
|
13
|
+
assert block is not None
|
|
14
|
+
assert block.get("number") is not None
|
|
15
|
+
assert block.get("hash") is not None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_nonce(rc):
|
|
19
|
+
"""Test nonce method."""
|
|
20
|
+
nonce = rc.chain.get_nonce(rc.chain.address)
|
|
21
|
+
rc.logger.info(f"Nonce: {nonce}")
|
|
22
|
+
assert nonce is not None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_gas(rc):
|
|
26
|
+
"""Test gas methods."""
|
|
27
|
+
gas_price = rc.provider.eth.gas_price
|
|
28
|
+
rc.logger.info(f"Gas Price: {gas_price}")
|
|
29
|
+
assert gas_price is not None
|
|
30
|
+
assert gas_price > 0
|
|
31
|
+
|
|
32
|
+
max_priority_fee = rc.provider.eth.max_priority_fee
|
|
33
|
+
rc.logger.info(f"Max Priority Fee: {max_priority_fee}")
|
|
34
|
+
assert max_priority_fee is not None
|
|
35
|
+
assert max_priority_fee > 0
|
|
36
|
+
|
|
37
|
+
gas_limit = rc.provider.eth.estimate_gas(
|
|
38
|
+
{"from": rc.chain.address, "to": rc.chain.address, "value": 1}
|
|
39
|
+
)
|
|
40
|
+
rc.logger.info(f"Gas Limit: {gas_limit}")
|
|
41
|
+
assert gas_limit is not None
|
|
42
|
+
assert gas_limit > 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_eth_balance(rc):
|
|
46
|
+
"""Test eth balance method."""
|
|
47
|
+
balance = rc.chain.get_balance(rc.chain.address)
|
|
48
|
+
rc.logger.info(f"Balance: {balance}")
|
|
49
|
+
assert balance is not None
|
|
50
|
+
assert balance >= 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_usde_balance(rc):
|
|
54
|
+
"""Test token balance method."""
|
|
55
|
+
balance = rc.chain.get_token_balance(rc.chain.address, rc.chain.usde.address)
|
|
56
|
+
rc.logger.info(f"Balance: {balance}")
|
|
57
|
+
assert balance is not None
|
|
58
|
+
assert balance >= 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_deposit_usde(rc):
|
|
62
|
+
"""Test USDe deposit."""
|
|
63
|
+
deposit_tx = rc.chain.deposit_usde(100)
|
|
64
|
+
rc.logger.info(f"Deposit Tx: {deposit_tx}")
|
|
65
|
+
|
|
66
|
+
assert deposit_tx is not None
|
|
67
|
+
assert deposit_tx.get("data") is not None
|
|
68
|
+
assert rc.provider.is_checksum_address(deposit_tx.get("from"))
|
|
69
|
+
assert rc.provider.is_checksum_address(deposit_tx.get("to"))
|
|
70
|
+
|
|
71
|
+
# submit the transaction
|
|
72
|
+
# tx_hash = rc.chain.submit_tx(deposit_tx)
|
|
73
|
+
# rc.logger.info(f"Tx Hash: {tx_hash}")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from web3 import Web3
|
|
2
|
+
from ethereal.models.config import (
|
|
3
|
+
BaseConfig,
|
|
4
|
+
HTTPConfig,
|
|
5
|
+
WSConfig,
|
|
6
|
+
ChainConfig,
|
|
7
|
+
RESTConfig,
|
|
8
|
+
)
|
|
9
|
+
from ethereal.base_client import BaseClient
|
|
10
|
+
from ethereal.rest.http_client import HTTPClient
|
|
11
|
+
from ethereal.ws.ws_base import WSBase
|
|
12
|
+
from ethereal.chain_client import ChainClient
|
|
13
|
+
from ethereal.rest_client import RESTClient
|
|
14
|
+
|
|
15
|
+
BASE_URL = "https://api.etherealtest.net"
|
|
16
|
+
RPC_URL = "https://rpc.etherealtest.net"
|
|
17
|
+
WS_URL = "wss://ws.etherealtest.net"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_base_client_with_dict():
|
|
21
|
+
bc = BaseClient({"verbose": True})
|
|
22
|
+
assert bc is not None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_base_client_with_class():
|
|
26
|
+
config = BaseConfig(verbose=True)
|
|
27
|
+
bc = BaseClient(config)
|
|
28
|
+
assert bc is not None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_http_client_with_dict():
|
|
32
|
+
hc = HTTPClient({"base_url": BASE_URL, "verbose": True})
|
|
33
|
+
assert hc is not None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_ws_client_with_class():
|
|
37
|
+
config = WSConfig(base_url=WS_URL, verbose=True)
|
|
38
|
+
hc = WSBase(config)
|
|
39
|
+
assert hc is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_ws_client_with_dict():
|
|
43
|
+
wsc = WSBase({"base_url": WS_URL, "verbose": True})
|
|
44
|
+
assert wsc is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_http_client_with_class():
|
|
48
|
+
config = HTTPConfig(base_url=BASE_URL, timeout=60, verbose=True)
|
|
49
|
+
wsc = HTTPClient(config)
|
|
50
|
+
assert wsc is not None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_chain_client_with_dict():
|
|
54
|
+
test_account = Web3().eth.account.create()
|
|
55
|
+
private_key = test_account.key.hex()
|
|
56
|
+
|
|
57
|
+
cc = ChainClient(
|
|
58
|
+
{
|
|
59
|
+
"rpc_url": RPC_URL,
|
|
60
|
+
"private_key": private_key,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
assert cc is not None
|
|
64
|
+
assert cc.chain_id == 996353
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_chain_client_with_class():
|
|
68
|
+
test_account = Web3().eth.account.create()
|
|
69
|
+
private_key = test_account.key.hex()
|
|
70
|
+
|
|
71
|
+
config = ChainConfig(
|
|
72
|
+
rpc_url=RPC_URL,
|
|
73
|
+
private_key=private_key,
|
|
74
|
+
)
|
|
75
|
+
cc = ChainClient(config)
|
|
76
|
+
assert cc is not None
|
|
77
|
+
assert cc.chain_id == 996353
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_rest_chain_client_with_dict():
|
|
81
|
+
test_account = Web3().eth.account.create()
|
|
82
|
+
private_key = test_account.key.hex()
|
|
83
|
+
|
|
84
|
+
rc = RESTClient(
|
|
85
|
+
{
|
|
86
|
+
"base_url": BASE_URL,
|
|
87
|
+
"chain_config": {
|
|
88
|
+
"private_key": private_key,
|
|
89
|
+
"rpc_url": RPC_URL,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
assert rc is not None
|
|
94
|
+
assert rc.chain.chain_id == 996353
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_rest_chain_client_with_class():
|
|
98
|
+
test_account = Web3().eth.account.create()
|
|
99
|
+
private_key = test_account.key.hex()
|
|
100
|
+
|
|
101
|
+
chain_config = ChainConfig(
|
|
102
|
+
rpc_url=RPC_URL,
|
|
103
|
+
private_key=private_key,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
config = RESTConfig(
|
|
107
|
+
base_url=BASE_URL,
|
|
108
|
+
chain_config=chain_config,
|
|
109
|
+
)
|
|
110
|
+
rc = RESTClient(config)
|
|
111
|
+
assert rc is not None
|
|
112
|
+
assert rc.chain.chain_id == 996353
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_rest_client_with_dict():
|
|
116
|
+
rc = RESTClient()
|
|
117
|
+
assert rc is not None
|
|
118
|
+
assert rc.chain is None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_rest_client_with_class():
|
|
122
|
+
config = RESTConfig()
|
|
123
|
+
rc = RESTClient(config)
|
|
124
|
+
assert rc is not None
|
|
125
|
+
assert rc.chain is None
|