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.
@@ -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