silhouette-python-sdk 0.1.0__py3-none-any.whl
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.
- silhouette/__init__.py +88 -0
- silhouette/abi/silhouette.json +1 -0
- silhouette/api/__init__.py +5 -0
- silhouette/api/auth.py +282 -0
- silhouette/api/client.py +442 -0
- silhouette/hyperliquid/__init__.py +30 -0
- silhouette/hyperliquid/api.py +31 -0
- silhouette/hyperliquid/exchange.py +130 -0
- silhouette/hyperliquid/info.py +110 -0
- silhouette/hyperliquid/utils/__init__.py +22 -0
- silhouette/hyperliquid/utils/constants.py +4 -0
- silhouette/hyperliquid/utils/error.py +4 -0
- silhouette/hyperliquid/utils/signing.py +5 -0
- silhouette/hyperliquid/utils/types.py +4 -0
- silhouette/hyperliquid/websocket_manager.py +34 -0
- silhouette/py.typed +0 -0
- silhouette/utils/__init__.py +8 -0
- silhouette/utils/conversions.py +96 -0
- silhouette/utils/types.py +369 -0
- silhouette_python_sdk-0.1.0.dist-info/LICENSE.md +21 -0
- silhouette_python_sdk-0.1.0.dist-info/METADATA +291 -0
- silhouette_python_sdk-0.1.0.dist-info/RECORD +23 -0
- silhouette_python_sdk-0.1.0.dist-info/WHEEL +4 -0
silhouette/api/client.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Silhouette API client for integrating with the enclave API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from typing_extensions import Unpack
|
|
8
|
+
|
|
9
|
+
from silhouette.api.auth import AuthConfig, AuthManager
|
|
10
|
+
from silhouette.utils.types import (
|
|
11
|
+
CancelOrderRequest,
|
|
12
|
+
CancelOrderResponse,
|
|
13
|
+
CreateOrderRequest,
|
|
14
|
+
CreateOrderResponse,
|
|
15
|
+
GetBalancesResponse,
|
|
16
|
+
GetFeaturesResponse,
|
|
17
|
+
GetHealthInfoResponse,
|
|
18
|
+
GetHealthReadyResponse,
|
|
19
|
+
GetHealthResponse,
|
|
20
|
+
GetHistoryRequest,
|
|
21
|
+
GetHistoryResponse,
|
|
22
|
+
GetSessionResponse,
|
|
23
|
+
GetStatsResponse,
|
|
24
|
+
GetUserOrdersRequest,
|
|
25
|
+
GetUserOrdersResponse,
|
|
26
|
+
GetUserWithdrawalsResponse,
|
|
27
|
+
GetWithdrawalStatusResponse,
|
|
28
|
+
InitiateWithdrawalResponse,
|
|
29
|
+
ResetSessionResponse,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SilhouetteApiError(Exception):
|
|
34
|
+
"""Custom exception for Silhouette API errors."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, code: str, message: str, status: int | None):
|
|
37
|
+
super().__init__(f"API Error [{code}]: {message} (HTTP {status})")
|
|
38
|
+
self.code = code
|
|
39
|
+
self.message = message
|
|
40
|
+
self.status = status
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HealthOperations:
|
|
44
|
+
"""Health endpoint operations for the enclave API."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, client: "SilhouetteApiClient"):
|
|
47
|
+
self._client = client
|
|
48
|
+
|
|
49
|
+
def get_health(self) -> GetHealthResponse:
|
|
50
|
+
"""Get basic health check information."""
|
|
51
|
+
return cast(GetHealthResponse, self._client._request_operation("getHealth"))
|
|
52
|
+
|
|
53
|
+
def get_features(self) -> GetFeaturesResponse:
|
|
54
|
+
"""Get health features discovery information."""
|
|
55
|
+
return cast(GetFeaturesResponse, self._client._request_operation("getHealthFeatures"))
|
|
56
|
+
|
|
57
|
+
def get_info(self) -> GetHealthInfoResponse:
|
|
58
|
+
"""Get detailed system info (debug mode only)."""
|
|
59
|
+
return cast(GetHealthInfoResponse, self._client._request_operation("getHealthInfo"))
|
|
60
|
+
|
|
61
|
+
def get_ready(self) -> GetHealthReadyResponse:
|
|
62
|
+
"""Get health readiness check."""
|
|
63
|
+
return cast(GetHealthReadyResponse, self._client._request_operation("getHealthReady"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class HistoryOperations:
|
|
67
|
+
"""History endpoint operations for the enclave API."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, client: "SilhouetteApiClient"):
|
|
70
|
+
self._client = client
|
|
71
|
+
|
|
72
|
+
def get_history(self, **params: Unpack[GetHistoryRequest]) -> GetHistoryResponse:
|
|
73
|
+
"""Get transaction history with optional filtering parameters."""
|
|
74
|
+
return cast(GetHistoryResponse, self._client._request_operation("getHistory", dict(params)))
|
|
75
|
+
|
|
76
|
+
def get_session(self) -> GetSessionResponse:
|
|
77
|
+
"""Get current session information."""
|
|
78
|
+
return cast(GetSessionResponse, self._client._request_operation("getSession"))
|
|
79
|
+
|
|
80
|
+
def get_stats(self) -> GetStatsResponse:
|
|
81
|
+
"""Get history statistics for current session."""
|
|
82
|
+
return cast(GetStatsResponse, self._client._request_operation("getStats"))
|
|
83
|
+
|
|
84
|
+
def reset_session(self) -> ResetSessionResponse:
|
|
85
|
+
"""Reset session (feature flag protected)."""
|
|
86
|
+
return cast(ResetSessionResponse, self._client._request_operation("resetSession"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestOperations:
|
|
90
|
+
"""Test endpoint operations for the enclave API."""
|
|
91
|
+
|
|
92
|
+
__test__ = False
|
|
93
|
+
|
|
94
|
+
def __init__(self, client: "SilhouetteApiClient"):
|
|
95
|
+
self._client = client
|
|
96
|
+
|
|
97
|
+
def get_test_balance(self, address: str) -> dict[str, Any]:
|
|
98
|
+
"""Get test balances for a given address."""
|
|
99
|
+
return self._client._request_operation("getTestBalance", {"address": address})
|
|
100
|
+
|
|
101
|
+
def spoof_deposit(self, user_address: str, token_symbol: str, amount: str) -> dict[str, Any]:
|
|
102
|
+
"""Spoof a deposit for a user."""
|
|
103
|
+
params = {
|
|
104
|
+
"userAddress": user_address,
|
|
105
|
+
"tokenSymbol": token_symbol,
|
|
106
|
+
"amount": amount,
|
|
107
|
+
}
|
|
108
|
+
return self._client._request_operation("spoofDeposit", params)
|
|
109
|
+
|
|
110
|
+
def spoof_withdrawal(self, user_address: str, token_symbol: str, amount: str) -> dict[str, Any]:
|
|
111
|
+
"""Simulates a withdrawal for a user."""
|
|
112
|
+
params = {
|
|
113
|
+
"userAddress": user_address,
|
|
114
|
+
"tokenSymbol": token_symbol,
|
|
115
|
+
"amount": amount,
|
|
116
|
+
}
|
|
117
|
+
return self._client._request_operation("spoofWithdrawal", params)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class UserOperations:
|
|
121
|
+
"""Authenticated user endpoint operations for the enclave API."""
|
|
122
|
+
|
|
123
|
+
__test__ = False
|
|
124
|
+
|
|
125
|
+
def __init__(self, client: "SilhouetteApiClient"):
|
|
126
|
+
self._client = client
|
|
127
|
+
|
|
128
|
+
def get_balances(self) -> GetBalancesResponse:
|
|
129
|
+
"""Get user balances."""
|
|
130
|
+
return cast(GetBalancesResponse, self._client._request_operation("getBalances"))
|
|
131
|
+
|
|
132
|
+
def get_balance(self, token_symbol: str) -> int:
|
|
133
|
+
"""
|
|
134
|
+
Get the available balance for a specific token from the Silhouette enclave.
|
|
135
|
+
|
|
136
|
+
This safely handles cases where the user or token is not found, returning 0
|
|
137
|
+
instead of raising an error.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
token_symbol: The symbol of the token (e.g., "USDC")
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The available balance as an integer in Silhouette's fixed-point format.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
SilhouetteApiError: For unexpected API errors (not USER_NOT_FOUND)
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
balances_data = self.get_balances()
|
|
150
|
+
balance_info = next(
|
|
151
|
+
(b for b in balances_data.get("balances", []) if b["token"] == token_symbol),
|
|
152
|
+
None,
|
|
153
|
+
)
|
|
154
|
+
if balance_info:
|
|
155
|
+
return int(balance_info["available"])
|
|
156
|
+
except SilhouetteApiError as e:
|
|
157
|
+
if e.code == "USER_NOT_FOUND":
|
|
158
|
+
return 0 # User doesn't exist yet, so balance is 0
|
|
159
|
+
raise # Re-raise other unexpected API errors
|
|
160
|
+
else:
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
def get_balance_float(self, token_symbol: str) -> float:
|
|
164
|
+
"""
|
|
165
|
+
Get the available balance for a specific token as a float.
|
|
166
|
+
|
|
167
|
+
This uses the `availableFloat` field from the API, returning 0.0 if the
|
|
168
|
+
user or token is not found.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
token_symbol: The symbol of the token (e.g., "USDC")
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The available balance as a float.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
SilhouetteApiError: For unexpected API errors (not USER_NOT_FOUND)
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
balances_data = self.get_balances()
|
|
181
|
+
balance_info = next(
|
|
182
|
+
(b for b in balances_data.get("balances", []) if b["token"] == token_symbol),
|
|
183
|
+
None,
|
|
184
|
+
)
|
|
185
|
+
if balance_info and "availableFloat" in balance_info:
|
|
186
|
+
return float(balance_info["availableFloat"])
|
|
187
|
+
return 0.0
|
|
188
|
+
except SilhouetteApiError as e:
|
|
189
|
+
if e.code == "USER_NOT_FOUND":
|
|
190
|
+
return 0.0 # User doesn't exist yet, so balance is 0
|
|
191
|
+
raise # Re-raise other unexpected API errors
|
|
192
|
+
|
|
193
|
+
def initiate_withdrawal(self, token_symbol: str, amount: str) -> InitiateWithdrawalResponse:
|
|
194
|
+
"""Initiate a withdrawal for the authenticated user."""
|
|
195
|
+
params = {"tokenSymbol": token_symbol, "amount": amount}
|
|
196
|
+
return cast(InitiateWithdrawalResponse, self._client._request_operation("initiateWithdrawal", params))
|
|
197
|
+
|
|
198
|
+
def get_withdrawal_status(self, withdrawal_id: str) -> GetWithdrawalStatusResponse:
|
|
199
|
+
"""Get the status of a specific withdrawal by ID."""
|
|
200
|
+
params = {"withdrawalId": withdrawal_id}
|
|
201
|
+
return cast(GetWithdrawalStatusResponse, self._client._request_operation("getWithdrawalStatus", params))
|
|
202
|
+
|
|
203
|
+
def get_user_withdrawals(self) -> GetUserWithdrawalsResponse:
|
|
204
|
+
"""Get all withdrawals for the authenticated user."""
|
|
205
|
+
return cast(GetUserWithdrawalsResponse, self._client._request_operation("getUserWithdrawals"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class OrderOperations:
|
|
209
|
+
"""Authenticated order endpoint operations for the enclave API."""
|
|
210
|
+
|
|
211
|
+
def __init__(self, client: "SilhouetteApiClient"):
|
|
212
|
+
self._client = client
|
|
213
|
+
|
|
214
|
+
def create_order(self, **params: Unpack[CreateOrderRequest]) -> CreateOrderResponse:
|
|
215
|
+
"""Create a new order."""
|
|
216
|
+
return cast(CreateOrderResponse, self._client._request_operation("createOrder", dict(params)))
|
|
217
|
+
|
|
218
|
+
def cancel_order(self, **params: Unpack[CancelOrderRequest]) -> CancelOrderResponse:
|
|
219
|
+
"""Cancel an existing order."""
|
|
220
|
+
return cast(CancelOrderResponse, self._client._request_operation("cancelOrder", dict(params)))
|
|
221
|
+
|
|
222
|
+
def get_user_orders(self, **params: Unpack[GetUserOrdersRequest]) -> GetUserOrdersResponse:
|
|
223
|
+
"""Get all orders for the authenticated user."""
|
|
224
|
+
return cast(GetUserOrdersResponse, self._client._request_operation("getUserOrders", dict(params)))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class SilhouetteApiClient:
|
|
228
|
+
"""
|
|
229
|
+
Silhouette API client for interacting with the enclave API.
|
|
230
|
+
|
|
231
|
+
Provides access to health and history operations through a simple,
|
|
232
|
+
synchronous interface that follows Hyperliquid SDK patterns.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
base_url: str = "http://localhost:8081",
|
|
238
|
+
timeout: float = 30.0,
|
|
239
|
+
private_key: str | None = None,
|
|
240
|
+
keystore_path: str | None = None,
|
|
241
|
+
auto_auth: bool = True,
|
|
242
|
+
max_login_retries: int = 3,
|
|
243
|
+
login_retry_base_delay: float = 1.0,
|
|
244
|
+
login_retry_max_delay: float = 10.0,
|
|
245
|
+
verify_ssl: bool = True,
|
|
246
|
+
chain_id: int = 1,
|
|
247
|
+
):
|
|
248
|
+
"""
|
|
249
|
+
Initialize the Silhouette API client.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
base_url: Base URL for the enclave API
|
|
253
|
+
timeout: Request timeout in seconds
|
|
254
|
+
private_key: Private key for auto-authentication (hex string with or without 0x prefix)
|
|
255
|
+
keystore_path: Path to encrypted keystore file (alternative to private_key)
|
|
256
|
+
auto_auth: Enable automatic authentication and token refresh on 401
|
|
257
|
+
max_login_retries: Maximum login retry attempts on failure
|
|
258
|
+
login_retry_base_delay: Base delay for exponential backoff (seconds)
|
|
259
|
+
login_retry_max_delay: Maximum delay for exponential backoff (seconds)
|
|
260
|
+
verify_ssl: Enable SSL certificate verification (defaults to True for production)
|
|
261
|
+
chain_id: Chain ID for SIWE authentication (1 for mainnet, 421614 for Arbitrum Sepolia)
|
|
262
|
+
"""
|
|
263
|
+
self.base_url = base_url
|
|
264
|
+
self.timeout = timeout
|
|
265
|
+
self.verify_ssl = verify_ssl
|
|
266
|
+
|
|
267
|
+
# Initialize auth manager
|
|
268
|
+
auth_config = AuthConfig(
|
|
269
|
+
auto_auth=auto_auth,
|
|
270
|
+
max_login_retries=max_login_retries,
|
|
271
|
+
login_retry_base_delay=login_retry_base_delay,
|
|
272
|
+
login_retry_max_delay=login_retry_max_delay,
|
|
273
|
+
chain_id=chain_id,
|
|
274
|
+
)
|
|
275
|
+
self._auth = AuthManager(
|
|
276
|
+
client=self,
|
|
277
|
+
config=auth_config,
|
|
278
|
+
private_key=private_key,
|
|
279
|
+
keystore_path=keystore_path,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Backwards compatibility: expose _jwt_token property
|
|
283
|
+
self._jwt_token: str | None = None
|
|
284
|
+
|
|
285
|
+
# Initialize operation groups
|
|
286
|
+
self.health = HealthOperations(self)
|
|
287
|
+
self.history = HistoryOperations(self)
|
|
288
|
+
self.test = TestOperations(self)
|
|
289
|
+
self.user = UserOperations(self)
|
|
290
|
+
self.order = OrderOperations(self)
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def wallet(self):
|
|
294
|
+
"""Get the wallet used for authentication."""
|
|
295
|
+
return self._auth.wallet
|
|
296
|
+
|
|
297
|
+
def login(self, message: str, signature: str) -> str:
|
|
298
|
+
"""
|
|
299
|
+
Authenticate with the enclave using a SIWE message and signature.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
message: The SIWE message.
|
|
303
|
+
signature: The signature of the message.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
The JWT token string.
|
|
307
|
+
"""
|
|
308
|
+
token = self._raw_login(message, signature)
|
|
309
|
+
self._auth.set_token(token)
|
|
310
|
+
self._jwt_token = token # Backwards compatibility
|
|
311
|
+
return token
|
|
312
|
+
|
|
313
|
+
def _raw_login(self, message: str, signature: str) -> str:
|
|
314
|
+
"""
|
|
315
|
+
Perform raw login API call without updating auth state.
|
|
316
|
+
|
|
317
|
+
This is used internally by AuthManager to avoid circular dependencies.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
message: The SIWE message.
|
|
321
|
+
signature: The signature of the message.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
The JWT token string.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
SilhouetteApiError: If login fails
|
|
328
|
+
"""
|
|
329
|
+
params = {"message": message, "signature": signature}
|
|
330
|
+
# Use _raw_request to bypass auth logic
|
|
331
|
+
response = self._raw_request("login", params, token=None)
|
|
332
|
+
token_value = response.get("token")
|
|
333
|
+
if not isinstance(token_value, str) or not token_value:
|
|
334
|
+
raise SilhouetteApiError("LOGIN_FAILED", "No token in response", None)
|
|
335
|
+
return token_value
|
|
336
|
+
|
|
337
|
+
def _request_operation(
|
|
338
|
+
self, operation: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
339
|
+
) -> dict[str, Any]:
|
|
340
|
+
"""
|
|
341
|
+
Make a request to the enclave API with the envelope pattern.
|
|
342
|
+
|
|
343
|
+
This method handles automatic authentication and 401 retry logic.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
operation: The operation name to execute
|
|
347
|
+
params: Optional parameters for the operation
|
|
348
|
+
token: Optional JWT token to use for this specific request
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Response data with responseMetadata excluded
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
requests.Timeout: On request timeout
|
|
355
|
+
requests.ConnectionError: On connection failure
|
|
356
|
+
SilhouetteApiError: On API error responses (JSON with code/error)
|
|
357
|
+
ValueError: On malformed or non-JSON responses
|
|
358
|
+
"""
|
|
359
|
+
# Operations that don't require authentication
|
|
360
|
+
unauthenticated_ops = {
|
|
361
|
+
"login",
|
|
362
|
+
"getHealth",
|
|
363
|
+
"getHealthFeatures",
|
|
364
|
+
"getHealthInfo",
|
|
365
|
+
"getHealthReady",
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
requires_auth = operation not in unauthenticated_ops
|
|
369
|
+
|
|
370
|
+
# Auto-login if needed and no explicit token provided
|
|
371
|
+
if requires_auth and token is None:
|
|
372
|
+
# Check for existing token (backwards compatibility)
|
|
373
|
+
token = self._auth.get_token() or self._jwt_token
|
|
374
|
+
if token is None:
|
|
375
|
+
# No token exists, try to auto-authenticate
|
|
376
|
+
self._auth.ensure_authenticated()
|
|
377
|
+
token = self._auth.get_token()
|
|
378
|
+
|
|
379
|
+
# Make the request
|
|
380
|
+
try:
|
|
381
|
+
return self._raw_request(operation, params, token)
|
|
382
|
+
except SilhouetteApiError as e:
|
|
383
|
+
# Handle 401 by attempting re-login and retry
|
|
384
|
+
if e.status == 401 and requires_auth:
|
|
385
|
+
if self._auth.handle_auth_error(e.status):
|
|
386
|
+
# Login succeeded, retry with new token
|
|
387
|
+
token = self._auth.get_token()
|
|
388
|
+
return self._raw_request(operation, params, token)
|
|
389
|
+
# Re-raise if not 401 or if login failed
|
|
390
|
+
raise
|
|
391
|
+
|
|
392
|
+
def _raw_request(
|
|
393
|
+
self, operation: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
394
|
+
) -> dict[str, Any]:
|
|
395
|
+
"""
|
|
396
|
+
Make a raw HTTP request to the enclave API without auth logic.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
operation: The operation name to execute
|
|
400
|
+
params: Optional parameters for the operation
|
|
401
|
+
token: Optional JWT token to use for this request
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Response data with responseMetadata excluded
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
requests.Timeout: On request timeout
|
|
408
|
+
requests.ConnectionError: On connection failure
|
|
409
|
+
SilhouetteApiError: On API error responses (JSON with code/error)
|
|
410
|
+
ValueError: On malformed or non-JSON responses
|
|
411
|
+
"""
|
|
412
|
+
# Build request payload with envelope pattern
|
|
413
|
+
payload = {"operation": operation}
|
|
414
|
+
if params:
|
|
415
|
+
payload.update(params)
|
|
416
|
+
|
|
417
|
+
url = f"{self.base_url}/v0"
|
|
418
|
+
headers = {}
|
|
419
|
+
if token:
|
|
420
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
421
|
+
|
|
422
|
+
# Make the HTTP request
|
|
423
|
+
response = requests.post(url, json=payload, headers=headers, timeout=self.timeout, verify=self.verify_ssl)
|
|
424
|
+
|
|
425
|
+
# Parse JSON response (best-effort)
|
|
426
|
+
try:
|
|
427
|
+
response_data = response.json()
|
|
428
|
+
except json.JSONDecodeError as e:
|
|
429
|
+
# If HTTP error with non-JSON body, surface concise status
|
|
430
|
+
if not response.ok:
|
|
431
|
+
raise ValueError(f"HTTP {response.status_code} error with non-JSON response") from e
|
|
432
|
+
raise ValueError("Failed to parse response JSON") from e
|
|
433
|
+
|
|
434
|
+
# Handle error responses
|
|
435
|
+
if not response.ok:
|
|
436
|
+
error_msg = response_data.get("error", f"HTTP {response.status_code}")
|
|
437
|
+
error_code = response_data.get("code", "UNKNOWN_ERROR")
|
|
438
|
+
raise SilhouetteApiError(error_code, error_msg, response.status_code)
|
|
439
|
+
|
|
440
|
+
# Extract data from envelope, excluding responseMetadata
|
|
441
|
+
response_data.pop("responseMetadata", None)
|
|
442
|
+
return dict(response_data)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hyperliquid API clients - enhanced wrappers around the official SDK.
|
|
3
|
+
|
|
4
|
+
This is a drop-in replacement for the official Hyperliquid Python SDK.
|
|
5
|
+
After installing silhouette-python-sdk, users must import silhouette first,
|
|
6
|
+
then use standard hyperliquid imports:
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
import silhouette # Required: triggers sys.modules injection
|
|
10
|
+
|
|
11
|
+
from hyperliquid.info import Info
|
|
12
|
+
from hyperliquid.exchange import Exchange
|
|
13
|
+
from hyperliquid.websocket_manager import WebsocketManager
|
|
14
|
+
from hyperliquid.utils.constants import MAINNET_API_URL
|
|
15
|
+
from hyperliquid.utils.types import Meta
|
|
16
|
+
|
|
17
|
+
# Market data - includes enhanced methods
|
|
18
|
+
info = Info()
|
|
19
|
+
balance = info.get_balance(wallet_address, "USDC")
|
|
20
|
+
|
|
21
|
+
# Trading - includes enhanced methods
|
|
22
|
+
exchange = Exchange(wallet)
|
|
23
|
+
exchange.deposit_to_silhouette(contract_address, "USDC", "100.0", converter)
|
|
24
|
+
|
|
25
|
+
# Real-time data
|
|
26
|
+
ws_manager = WebsocketManager()
|
|
27
|
+
|
|
28
|
+
# Access utilities
|
|
29
|
+
print(MAINNET_API_URL)
|
|
30
|
+
"""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Hyperliquid API client - direct pass-through to Hyperliquid Python SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
# Re-export everything from official hyperliquid.api for compatibility
|
|
6
|
+
from hyperliquid.api import * # noqa: F403, F401
|
|
7
|
+
|
|
8
|
+
# Alias API so we can wrap it in our own class with the same name
|
|
9
|
+
# This allows us to add extra methods while maintaining compatibility
|
|
10
|
+
from hyperliquid.api import API as HyperliquidAPI
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class API:
|
|
14
|
+
"""
|
|
15
|
+
Hyperliquid API client base class.
|
|
16
|
+
|
|
17
|
+
This class is a direct pass-through to the Hyperliquid API client,
|
|
18
|
+
maintaining exact compatibility with the official Hyperliquid Python SDK.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args, **kwargs):
|
|
22
|
+
"""Initialize the API client with the same parameters as Hyperliquid SDK."""
|
|
23
|
+
self._hyperliquid_api = HyperliquidAPI(*args, **kwargs)
|
|
24
|
+
|
|
25
|
+
def __getattr__(self, name: str) -> Any:
|
|
26
|
+
"""Delegate all method calls to the underlying Hyperliquid API client."""
|
|
27
|
+
return getattr(self._hyperliquid_api, name)
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
"""String representation."""
|
|
31
|
+
return f"Hyperliquid API client (pass-through to {repr(self._hyperliquid_api)})"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Hyperliquid Exchange client - direct pass-through to Hyperliquid Python SDK."""
|
|
2
|
+
|
|
3
|
+
from decimal import ROUND_DOWN, Decimal
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from eth_account.signers.local import LocalAccount
|
|
7
|
+
from hyperliquid.api import API
|
|
8
|
+
|
|
9
|
+
# Re-export everything from official hyperliquid.exchange for compatibility
|
|
10
|
+
from hyperliquid.exchange import * # noqa: F403, F401
|
|
11
|
+
|
|
12
|
+
# Alias Exchange so we can wrap it in our own class with the same name
|
|
13
|
+
# This allows us to add extra methods while maintaining compatibility
|
|
14
|
+
from hyperliquid.exchange import Exchange as HyperliquidExchange
|
|
15
|
+
from hyperliquid.utils.types import Meta, SpotMeta
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from silhouette.utils.conversions import TokenConverter
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HyperliquidDepositError(RuntimeError):
|
|
22
|
+
"""Raised when a Hyperliquid spot transfer used for deposits fails."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Exchange(API):
|
|
26
|
+
"""
|
|
27
|
+
Silhouette's wrapper of the Hyperliquid Exchange client for trading operations.
|
|
28
|
+
|
|
29
|
+
This class is a direct pass-through to the Hyperliquid Exchange client,
|
|
30
|
+
maintaining exact compatibility with the official Hyperliquid Python SDK
|
|
31
|
+
with enhancements provided by Silhouette.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
wallet: LocalAccount,
|
|
37
|
+
base_url: str | None = None,
|
|
38
|
+
meta: Meta | None = None,
|
|
39
|
+
vault_address: str | None = None,
|
|
40
|
+
account_address: str | None = None,
|
|
41
|
+
spot_meta: SpotMeta | None = None,
|
|
42
|
+
perp_dexs: list[str] | None = None,
|
|
43
|
+
timeout: float | None = None,
|
|
44
|
+
):
|
|
45
|
+
"""Initialize the Exchange client with the same parameters as Hyperliquid SDK."""
|
|
46
|
+
self._hyperliquid_exchange = HyperliquidExchange(
|
|
47
|
+
wallet=wallet,
|
|
48
|
+
base_url=base_url,
|
|
49
|
+
meta=meta,
|
|
50
|
+
vault_address=vault_address,
|
|
51
|
+
account_address=account_address,
|
|
52
|
+
spot_meta=spot_meta,
|
|
53
|
+
perp_dexs=perp_dexs,
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def __getattr__(self, name: str) -> Any:
|
|
58
|
+
"""Delegate all method calls to the underlying Hyperliquid Exchange client."""
|
|
59
|
+
return getattr(self._hyperliquid_exchange, name)
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
"""String representation."""
|
|
63
|
+
return f"Silhouette's wrapper of the Hyperliquid Exchange client (pass-through to {repr(self._hyperliquid_exchange)})"
|
|
64
|
+
|
|
65
|
+
### Silhouette-enhanced methods below ###
|
|
66
|
+
|
|
67
|
+
def deposit_to_silhouette(
|
|
68
|
+
self,
|
|
69
|
+
contract_address: str,
|
|
70
|
+
token_symbol: str,
|
|
71
|
+
amount: float,
|
|
72
|
+
converter: "TokenConverter",
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""
|
|
75
|
+
Deposit tokens from Hyperliquid to Silhouette contract via spot transfer.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
contract_address: Silhouette contract address to deposit to
|
|
79
|
+
token_symbol: The symbol of the token to deposit (e.g., "USDC", "HYPE")
|
|
80
|
+
amount: The amount to deposit (automatically quantized to token precision)
|
|
81
|
+
converter: TokenConverter instance for looking up token metadata
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The response from the spot_transfer call
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If the token symbol is not supported or amount is invalid
|
|
88
|
+
HyperliquidDepositError: If the Hyperliquid deposit fails
|
|
89
|
+
"""
|
|
90
|
+
# Only allow supported tokens
|
|
91
|
+
supported_tokens = {"USDC", "HYPE"}
|
|
92
|
+
if token_symbol not in supported_tokens:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Token '{token_symbol}' is not supported. "
|
|
95
|
+
f"Silhouette currently only supports: {', '.join(sorted(supported_tokens))}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Get asset info from converter
|
|
99
|
+
asset = converter.asset_info.get(token_symbol)
|
|
100
|
+
if not asset:
|
|
101
|
+
raise ValueError(f"Unsupported token symbol: {token_symbol}")
|
|
102
|
+
|
|
103
|
+
# Quantize amount to token precision to avoid floating-point errors
|
|
104
|
+
try:
|
|
105
|
+
decimals = int(asset.get("weiDecimals", 0))
|
|
106
|
+
quant = Decimal(1) / (Decimal(10) ** decimals)
|
|
107
|
+
amount_dec = Decimal(str(amount)).quantize(quant, rounding=ROUND_DOWN)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
raise ValueError(f"Invalid amount {amount} for {token_symbol}") from e
|
|
110
|
+
|
|
111
|
+
if amount_dec <= 0:
|
|
112
|
+
raise ValueError("Amount must be positive")
|
|
113
|
+
|
|
114
|
+
# Prefer including tokenId when available to disambiguate tokens
|
|
115
|
+
token_to_transfer = token_symbol
|
|
116
|
+
if token_symbol != "USDC": # noqa: S105
|
|
117
|
+
token_id = asset.get("tokenId")
|
|
118
|
+
if token_id:
|
|
119
|
+
token_to_transfer = f"{token_symbol}:{token_id}"
|
|
120
|
+
|
|
121
|
+
response: dict[str, Any] = self.spot_transfer(
|
|
122
|
+
destination=contract_address,
|
|
123
|
+
token=token_to_transfer,
|
|
124
|
+
amount=float(amount_dec),
|
|
125
|
+
)
|
|
126
|
+
status = response.get("status")
|
|
127
|
+
if status != "ok":
|
|
128
|
+
raise HyperliquidDepositError(f"Hyperliquid deposit failed: {response}")
|
|
129
|
+
|
|
130
|
+
return response
|