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/auth.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Authentication configuration and utilities for Silhouette API client."""
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, cast
|
|
13
|
+
|
|
14
|
+
import eth_account
|
|
15
|
+
from eth_account.messages import encode_defunct
|
|
16
|
+
from eth_account.signers.local import LocalAccount
|
|
17
|
+
from siwe import SiweMessage
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from silhouette.api.client import SilhouetteApiClient
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AuthConfig:
|
|
25
|
+
"""Configuration for automatic authentication."""
|
|
26
|
+
|
|
27
|
+
auto_auth: bool = True
|
|
28
|
+
"""Enable automatic authentication when API calls require it."""
|
|
29
|
+
|
|
30
|
+
max_login_retries: int = 3
|
|
31
|
+
"""Maximum number of login attempts before giving up."""
|
|
32
|
+
|
|
33
|
+
login_retry_base_delay: float = 1.0
|
|
34
|
+
"""Base delay in seconds between login retries (doubled each attempt)."""
|
|
35
|
+
|
|
36
|
+
login_retry_max_delay: float = 10.0
|
|
37
|
+
"""Maximum delay in seconds between login retries (caps exponential backoff)."""
|
|
38
|
+
|
|
39
|
+
chain_id: int = 1
|
|
40
|
+
"""Chain ID for SIWE message signing (1 for mainnet, 421614 for Arbitrum Sepolia testnet)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthManager:
|
|
44
|
+
"""Manages authentication state and automatic login for the API client."""
|
|
45
|
+
|
|
46
|
+
# Token TTL - assume 1 hour until server provides expiresAt
|
|
47
|
+
TOKEN_TTL_SECONDS = 3600
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
client: "SilhouetteApiClient",
|
|
52
|
+
config: AuthConfig,
|
|
53
|
+
private_key: str | None = None,
|
|
54
|
+
keystore_path: str | None = None,
|
|
55
|
+
):
|
|
56
|
+
"""
|
|
57
|
+
Initialize the authentication manager.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
client: The SilhouetteApiClient instance
|
|
61
|
+
config: Authentication configuration
|
|
62
|
+
private_key: Private key for auto-authentication (hex string with or without 0x prefix)
|
|
63
|
+
keystore_path: Path to encrypted keystore file (alternative to private_key)
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If both private_key and keystore_path are provided
|
|
67
|
+
"""
|
|
68
|
+
self.client = client
|
|
69
|
+
self.config = config
|
|
70
|
+
self._jwt_token: str | None = None
|
|
71
|
+
self._token_expires_at: float | None = None
|
|
72
|
+
self._wallet: LocalAccount | None = None
|
|
73
|
+
self._login_lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
# Load wallet if auto_auth is enabled
|
|
76
|
+
if self.config.auto_auth:
|
|
77
|
+
if private_key and keystore_path:
|
|
78
|
+
raise ValueError("Specify either private_key or keystore_path, not both")
|
|
79
|
+
if private_key:
|
|
80
|
+
self._wallet = eth_account.Account.from_key(private_key)
|
|
81
|
+
elif keystore_path:
|
|
82
|
+
self._wallet = self._load_keystore(keystore_path)
|
|
83
|
+
|
|
84
|
+
def _load_keystore(self, keystore_path: str) -> LocalAccount:
|
|
85
|
+
"""
|
|
86
|
+
Load wallet from encrypted keystore file.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
keystore_path: Path to keystore JSON file
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Decrypted LocalAccount
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
FileNotFoundError: If keystore file doesn't exist
|
|
96
|
+
ValueError: If keystore path is not a file
|
|
97
|
+
"""
|
|
98
|
+
path = Path(keystore_path).expanduser()
|
|
99
|
+
if not path.is_absolute():
|
|
100
|
+
# Make relative paths relative to the caller's directory
|
|
101
|
+
path = Path.cwd() / path
|
|
102
|
+
|
|
103
|
+
if not path.exists():
|
|
104
|
+
raise FileNotFoundError(f"Keystore file not found: {path}")
|
|
105
|
+
if not path.is_file():
|
|
106
|
+
raise ValueError(f"Keystore path is not a file: {path}")
|
|
107
|
+
|
|
108
|
+
with path.open() as f:
|
|
109
|
+
keystore = json.load(f)
|
|
110
|
+
|
|
111
|
+
password = getpass.getpass("Enter keystore password: ")
|
|
112
|
+
secret_key = eth_account.Account.decrypt(keystore, password)
|
|
113
|
+
return cast(LocalAccount, eth_account.Account.from_key(secret_key))
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def wallet(self) -> LocalAccount | None:
|
|
117
|
+
"""Get the wallet used for authentication."""
|
|
118
|
+
return self._wallet
|
|
119
|
+
|
|
120
|
+
def get_token(self) -> str | None:
|
|
121
|
+
"""Get the current JWT token."""
|
|
122
|
+
return self._jwt_token
|
|
123
|
+
|
|
124
|
+
def set_token(self, token: str, expires_at: float | None = None) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Set the JWT token and expiry time.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
token: JWT token string
|
|
130
|
+
expires_at: Unix timestamp when token expires (defaults to now + TOKEN_TTL_SECONDS)
|
|
131
|
+
"""
|
|
132
|
+
self._jwt_token = token
|
|
133
|
+
if expires_at is None:
|
|
134
|
+
expires_at = time.time() + self.TOKEN_TTL_SECONDS
|
|
135
|
+
self._token_expires_at = expires_at
|
|
136
|
+
|
|
137
|
+
def ensure_authenticated(self) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Ensure the client is authenticated, performing auto-login if necessary.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ValueError: If auto_auth is disabled and no token is present
|
|
143
|
+
RuntimeError: If login fails after max retries
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
# Currently the only thing that unsets the token is handle_auth_error after a 401
|
|
147
|
+
# We can improve this later to proactively refresh before expiry if needed
|
|
148
|
+
if self._jwt_token is not None:
|
|
149
|
+
return # Already authenticated
|
|
150
|
+
|
|
151
|
+
if not self.config.auto_auth:
|
|
152
|
+
raise ValueError("Not authenticated and auto_auth is disabled. Call login() explicitly.")
|
|
153
|
+
|
|
154
|
+
if self._wallet is None:
|
|
155
|
+
raise ValueError("Cannot auto-authenticate without private_key or keystore_path")
|
|
156
|
+
|
|
157
|
+
# Use lock to prevent concurrent login attempts
|
|
158
|
+
with self._login_lock:
|
|
159
|
+
# Double-checked locking: another thread may have logged in while we waited for the lock
|
|
160
|
+
# This prevents redundant login attempts in multi-threaded scenarios
|
|
161
|
+
if self._jwt_token is not None:
|
|
162
|
+
return # type: ignore[unreachable] # Reachable when multiple threads call this concurrently
|
|
163
|
+
|
|
164
|
+
self._perform_login_with_retry()
|
|
165
|
+
|
|
166
|
+
def _perform_login_with_retry(self) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Perform login with exponential backoff and jitter.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
RuntimeError: If all retry attempts fail
|
|
172
|
+
"""
|
|
173
|
+
last_error = None
|
|
174
|
+
|
|
175
|
+
for attempt in range(self.config.max_login_retries):
|
|
176
|
+
try:
|
|
177
|
+
self._perform_login()
|
|
178
|
+
return # Success
|
|
179
|
+
except Exception as e:
|
|
180
|
+
last_error = e
|
|
181
|
+
if attempt < self.config.max_login_retries - 1:
|
|
182
|
+
# Calculate delay with exponential backoff and jitter
|
|
183
|
+
delay = min(
|
|
184
|
+
self.config.login_retry_base_delay * (2**attempt),
|
|
185
|
+
self.config.login_retry_max_delay,
|
|
186
|
+
)
|
|
187
|
+
# 10% jitter
|
|
188
|
+
jitter = random.uniform(0, delay * 0.1) # noqa: S311
|
|
189
|
+
|
|
190
|
+
time.sleep(delay + jitter)
|
|
191
|
+
|
|
192
|
+
raise RuntimeError(f"Login failed after {self.config.max_login_retries} attempts: {last_error}") from last_error
|
|
193
|
+
|
|
194
|
+
def _extract_domain_from_url(self, url: str) -> str:
|
|
195
|
+
"""
|
|
196
|
+
Extract domain (host:port) from a URL for SIWE message.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
url: Full URL like "https://api.example.com:8081/path"
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Domain string like "api.example.com:8081" or "localhost:8081"
|
|
203
|
+
"""
|
|
204
|
+
from urllib.parse import urlparse
|
|
205
|
+
|
|
206
|
+
parsed = urlparse(url)
|
|
207
|
+
# netloc includes host and port (e.g., "localhost:8081" or "api.example.com")
|
|
208
|
+
domain = parsed.netloc
|
|
209
|
+
if not domain:
|
|
210
|
+
# Fallback if URL parsing fails
|
|
211
|
+
domain = "localhost"
|
|
212
|
+
return domain
|
|
213
|
+
|
|
214
|
+
def _perform_login(self) -> None:
|
|
215
|
+
"""
|
|
216
|
+
Perform the SIWE login flow.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
Various exceptions from SIWE message creation/signing or API calls
|
|
220
|
+
"""
|
|
221
|
+
if self._wallet is None:
|
|
222
|
+
raise ValueError("No wallet configured for login")
|
|
223
|
+
|
|
224
|
+
# Extract domain from base_url for SIWE message
|
|
225
|
+
domain = self._extract_domain_from_url(self.client.base_url)
|
|
226
|
+
|
|
227
|
+
# Generate SIWE message
|
|
228
|
+
message = SiweMessage(
|
|
229
|
+
domain=domain,
|
|
230
|
+
address=self._wallet.address,
|
|
231
|
+
statement="Sign in with Ethereum to the app.",
|
|
232
|
+
uri=f"{self.client.base_url}/login",
|
|
233
|
+
version="1",
|
|
234
|
+
chain_id=self.config.chain_id,
|
|
235
|
+
nonce=secrets.token_hex(12),
|
|
236
|
+
issued_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
237
|
+
)
|
|
238
|
+
prepared_message_str = message.prepare_message()
|
|
239
|
+
|
|
240
|
+
# Sign the message
|
|
241
|
+
message_to_sign = encode_defunct(text=prepared_message_str)
|
|
242
|
+
signed_message = self._wallet.sign_message(message_to_sign)
|
|
243
|
+
|
|
244
|
+
# Verify signature client-side
|
|
245
|
+
message.verify(signed_message.signature)
|
|
246
|
+
|
|
247
|
+
# Call login API
|
|
248
|
+
signature_hex = "0x" + signed_message.signature.hex()
|
|
249
|
+
token = self.client._raw_login(prepared_message_str, signature_hex)
|
|
250
|
+
self.set_token(token)
|
|
251
|
+
|
|
252
|
+
def handle_auth_error(self, response_status: int) -> bool:
|
|
253
|
+
"""
|
|
254
|
+
Handle authentication errors by attempting to re-login.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
response_status: HTTP status code from failed request
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if login was attempted and should retry the request, False otherwise
|
|
261
|
+
"""
|
|
262
|
+
if response_status != 401:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
if not self.config.auto_auth:
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
if self._wallet is None:
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
# Clear expired token and re-login
|
|
272
|
+
with self._login_lock:
|
|
273
|
+
# Clear the expired token
|
|
274
|
+
self._jwt_token = None
|
|
275
|
+
self._token_expires_at = None
|
|
276
|
+
|
|
277
|
+
# Attempt login
|
|
278
|
+
try:
|
|
279
|
+
self._perform_login_with_retry()
|
|
280
|
+
return True # Retry the original request
|
|
281
|
+
except Exception:
|
|
282
|
+
return False # Login failed, don't retry
|