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.
@@ -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,4 @@
1
+ from ethereal.rest_client import RESTClient
2
+ from ethereal.ws_client import WSClient
3
+
4
+ __all__ = ["RESTClient", "WSClient"]
@@ -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,6 @@
1
+ eth-account>=0.13.5
2
+ pydantic>=2.10.6
3
+ python-dotenv>=1.0.1
4
+ python-socketio>=5.12.1
5
+ requests>=2.32.3
6
+ web3>=7.8.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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