hyperliquid-cli-python 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 @@
1
+ """Core application services."""
hl_cli/core/context.py ADDED
@@ -0,0 +1,156 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ from eth_account import Account as EthAccount
6
+ from hyperliquid.exchange import Exchange
7
+ from hyperliquid.info import Info
8
+ from hyperliquid.utils.constants import MAINNET_API_URL, TESTNET_API_URL
9
+ import requests
10
+
11
+ from ..infra.db import Account, get_default_account
12
+
13
+
14
+ @dataclass
15
+ class Config:
16
+ private_key: Optional[str]
17
+ wallet_address: Optional[str]
18
+ testnet: bool
19
+ account: Optional[Account]
20
+
21
+
22
+ class CLIContext:
23
+ def __init__(self, config: Config):
24
+ self.config = config
25
+ self._info: Optional[Info] = None
26
+ self._multi_perp_info: Optional[Info] = None
27
+ self._exchange_clients: dict[tuple[str, ...], Exchange] = {}
28
+ self._perp_dexs: Optional[list[str]] = None
29
+
30
+ @property
31
+ def base_url(self) -> str:
32
+ return TESTNET_API_URL if self.config.testnet else MAINNET_API_URL
33
+
34
+ def get_public_client(self) -> Info:
35
+ if self._info is None:
36
+ self._info = _build_info_client(self.base_url, skip_ws=True)
37
+ return self._info
38
+
39
+ def get_multi_perp_public_client(self) -> Info:
40
+ if self._multi_perp_info is None:
41
+ self._multi_perp_info = _build_info_client(
42
+ self.base_url,
43
+ skip_ws=True,
44
+ perp_dexs=self.get_perp_dexs(),
45
+ )
46
+ return self._multi_perp_info
47
+
48
+ def get_wallet_client(self, perp_dexs: Optional[list[str]] = None) -> Exchange:
49
+ key = tuple(perp_dexs or [])
50
+ if key not in self._exchange_clients:
51
+ if not self.config.private_key:
52
+ if self.config.account and self.config.account.type == "readonly":
53
+ raise RuntimeError(
54
+ f'Account "{self.config.account.alias}" is read-only and cannot trade. '
55
+ "Use 'hl account add' to add an API wallet."
56
+ )
57
+ raise RuntimeError("No account configured. Run 'hl account add'.")
58
+ wallet = EthAccount.from_key(self.config.private_key)
59
+ kwargs = {
60
+ "wallet": wallet,
61
+ "base_url": self.base_url,
62
+ "account_address": self.get_wallet_address(),
63
+ }
64
+ if perp_dexs is not None:
65
+ kwargs["perp_dexs"] = perp_dexs
66
+ try:
67
+ self._exchange_clients[key] = Exchange(**kwargs)
68
+ except IndexError:
69
+ self._exchange_clients[key] = Exchange(
70
+ **kwargs,
71
+ spot_meta=_load_safe_spot_meta(self.base_url),
72
+ )
73
+ return self._exchange_clients[key]
74
+
75
+ def get_perp_dexs(self) -> list[str]:
76
+ if self._perp_dexs is not None:
77
+ return self._perp_dexs
78
+ try:
79
+ temp = _build_info_client(self.base_url, skip_ws=True)
80
+ raw = temp.perp_dexs()
81
+ names = [str(x.get("name")) for x in raw if isinstance(x, dict) and x.get("name")]
82
+ self._perp_dexs = ["", *names] if names else [""]
83
+ except Exception:
84
+ self._perp_dexs = [""]
85
+ return self._perp_dexs
86
+
87
+ def get_wallet_address(self) -> str:
88
+ if self.config.wallet_address:
89
+ return self.config.wallet_address
90
+ if self.config.private_key:
91
+ return EthAccount.from_key(self.config.private_key).address
92
+ raise RuntimeError("No account configured. Run 'hl account add'.")
93
+
94
+
95
+ def load_config(testnet: bool) -> Config:
96
+ network = "testnet" if testnet else "mainnet"
97
+ default = None
98
+ try:
99
+ default = get_default_account(network)
100
+ except Exception:
101
+ default = None
102
+
103
+ if default:
104
+ return Config(
105
+ private_key=default.api_wallet_private_key,
106
+ wallet_address=default.user_address,
107
+ testnet=testnet,
108
+ account=default,
109
+ )
110
+
111
+ private_key = os.getenv("HYPERLIQUID_PRIVATE_KEY")
112
+ wallet_address = os.getenv("HYPERLIQUID_WALLET_ADDRESS")
113
+
114
+ if private_key and not wallet_address:
115
+ wallet_address = EthAccount.from_key(private_key).address
116
+
117
+ return Config(
118
+ private_key=private_key,
119
+ wallet_address=wallet_address,
120
+ testnet=testnet,
121
+ account=None,
122
+ )
123
+
124
+
125
+ def _load_safe_spot_meta(base_url: str) -> dict:
126
+ # Testnet currently returns some spot pairs with invalid token indexes.
127
+ # Filter those out before constructing the SDK client.
128
+ response = requests.post(f"{base_url}/info", json={"type": "spotMeta"}, timeout=20)
129
+ response.raise_for_status()
130
+ spot_meta = response.json()
131
+ tokens = spot_meta.get("tokens", [])
132
+ max_idx = len(tokens) - 1
133
+
134
+ safe_universe = []
135
+ for pair in spot_meta.get("universe", []):
136
+ refs = pair.get("tokens")
137
+ if not isinstance(refs, list) or len(refs) < 2:
138
+ continue
139
+ if any(not isinstance(ref, int) or ref < 0 or ref > max_idx for ref in refs[:2]):
140
+ continue
141
+ safe_universe.append(pair)
142
+
143
+ return {
144
+ **spot_meta,
145
+ "universe": safe_universe,
146
+ }
147
+
148
+
149
+ def _build_info_client(base_url: str, **kwargs: object) -> Info:
150
+ try:
151
+ return Info(base_url, **kwargs)
152
+ except IndexError:
153
+ # Testnet-only defensive fallback for malformed spot metadata.
154
+ if "spot_meta" in kwargs:
155
+ raise
156
+ return Info(base_url, spot_meta=_load_safe_spot_meta(base_url), **kwargs)
@@ -0,0 +1,23 @@
1
+ import json
2
+
3
+ from ..infra.paths import HL_DIR, ORDER_CONFIG_PATH
4
+
5
+ DEFAULT_CONFIG = {"slippage": 1.0}
6
+
7
+
8
+ def get_order_config() -> dict:
9
+ if not ORDER_CONFIG_PATH.exists():
10
+ return dict(DEFAULT_CONFIG)
11
+ try:
12
+ data = json.loads(ORDER_CONFIG_PATH.read_text())
13
+ return {**DEFAULT_CONFIG, **data}
14
+ except Exception:
15
+ return dict(DEFAULT_CONFIG)
16
+
17
+
18
+ def update_order_config(**updates: object) -> dict:
19
+ HL_DIR.mkdir(parents=True, exist_ok=True)
20
+ cfg = get_order_config()
21
+ cfg.update(updates)
22
+ ORDER_CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
23
+ return cfg
@@ -0,0 +1 @@
1
+ """Infrastructure adapters and persistence."""
hl_cli/infra/db.py ADDED
@@ -0,0 +1,277 @@
1
+ import base64
2
+ import hashlib
3
+ import sqlite3
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ import secrets
7
+ import shutil
8
+ import sys
9
+ from typing import Optional
10
+
11
+ from Crypto.Cipher import ChaCha20
12
+
13
+ from .paths import DB_PATH, HL_DIR
14
+
15
+
16
+ @dataclass
17
+ class Account:
18
+ id: int
19
+ alias: str
20
+ network: str
21
+ user_address: str
22
+ type: str
23
+ source: str
24
+ api_wallet_private_key: Optional[str]
25
+ api_wallet_public_key: Optional[str]
26
+ is_default: bool
27
+ created_at: int
28
+ updated_at: int
29
+
30
+
31
+ _ENC_PREFIX = "enc_v1:"
32
+
33
+
34
+ def _conn() -> sqlite3.Connection:
35
+ HL_DIR.mkdir(parents=True, exist_ok=True)
36
+ conn = sqlite3.connect(DB_PATH)
37
+ conn.row_factory = sqlite3.Row
38
+ _migrate(conn)
39
+ # TODO: Remove this compatibility migration after all existing plaintext
40
+ # account rows have been rewritten to encrypted storage.
41
+ _migrate_encrypted_account_fields(conn)
42
+ return conn
43
+
44
+
45
+ def _migrate(conn: sqlite3.Connection) -> None:
46
+ conn.execute(
47
+ """
48
+ CREATE TABLE IF NOT EXISTS accounts (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ alias TEXT NOT NULL UNIQUE,
51
+ network TEXT NOT NULL DEFAULT 'mainnet' CHECK (network IN ('mainnet', 'testnet')),
52
+ user_address TEXT NOT NULL,
53
+ type TEXT NOT NULL CHECK (type IN ('readonly', 'api_wallet')),
54
+ source TEXT NOT NULL DEFAULT 'cli_import',
55
+ api_wallet_private_key TEXT,
56
+ api_wallet_public_key TEXT,
57
+ is_default INTEGER NOT NULL DEFAULT 0,
58
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
59
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
60
+ )
61
+ """
62
+ )
63
+ columns = {
64
+ row["name"]
65
+ for row in conn.execute("PRAGMA table_info(accounts)").fetchall()
66
+ }
67
+ # TODO: Remove this compatibility migration after all existing databases have
68
+ # been migrated to include the network column.
69
+ if "network" not in columns:
70
+ conn.execute("ALTER TABLE accounts ADD COLUMN network TEXT NOT NULL DEFAULT 'mainnet'")
71
+ conn.commit()
72
+
73
+
74
+ def _to_account(row: sqlite3.Row) -> Account:
75
+ return Account(
76
+ id=row["id"],
77
+ alias=row["alias"],
78
+ network=row["network"],
79
+ user_address=_decrypt_value(row["user_address"]),
80
+ type=row["type"],
81
+ source=row["source"],
82
+ api_wallet_private_key=_decrypt_optional_value(row["api_wallet_private_key"]),
83
+ api_wallet_public_key=_decrypt_optional_value(row["api_wallet_public_key"]),
84
+ is_default=bool(row["is_default"]),
85
+ created_at=row["created_at"],
86
+ updated_at=row["updated_at"],
87
+ )
88
+
89
+
90
+ def get_all_accounts(network: str) -> list[Account]:
91
+ conn = _conn()
92
+ rows = conn.execute(
93
+ "SELECT * FROM accounts WHERE network = ? ORDER BY is_default DESC, created_at ASC",
94
+ (network,),
95
+ ).fetchall()
96
+ conn.close()
97
+ return [_to_account(r) for r in rows]
98
+
99
+
100
+ def get_account_by_alias(alias: str, network: str) -> Optional[Account]:
101
+ conn = _conn()
102
+ row = conn.execute("SELECT * FROM accounts WHERE alias = ? AND network = ?", (alias, network)).fetchone()
103
+ conn.close()
104
+ return _to_account(row) if row else None
105
+
106
+
107
+ def get_default_account(network: str) -> Optional[Account]:
108
+ conn = _conn()
109
+ row = conn.execute("SELECT * FROM accounts WHERE network = ? AND is_default = 1 LIMIT 1", (network,)).fetchone()
110
+ conn.close()
111
+ return _to_account(row) if row else None
112
+
113
+
114
+ def get_account_count(network: str) -> int:
115
+ conn = _conn()
116
+ count = conn.execute("SELECT COUNT(*) AS c FROM accounts WHERE network = ?", (network,)).fetchone()["c"]
117
+ conn.close()
118
+ return int(count)
119
+
120
+
121
+ def is_alias_taken(alias: str, network: str) -> bool:
122
+ return get_account_by_alias(alias, network) is not None
123
+
124
+
125
+ def create_account(
126
+ *,
127
+ alias: str,
128
+ network: str,
129
+ user_address: str,
130
+ account_type: str,
131
+ source: str = "cli_import",
132
+ api_wallet_private_key: str | None = None,
133
+ api_wallet_public_key: str | None = None,
134
+ set_as_default: bool = False,
135
+ ) -> Account:
136
+ conn = _conn()
137
+ count = conn.execute("SELECT COUNT(*) AS c FROM accounts WHERE network = ?", (network,)).fetchone()["c"]
138
+ should_be_default = count == 0 or set_as_default
139
+ if should_be_default:
140
+ conn.execute("UPDATE accounts SET is_default = 0 WHERE network = ? AND is_default = 1", (network,))
141
+ conn.execute(
142
+ """
143
+ INSERT INTO accounts (
144
+ alias, network, user_address, type, source,
145
+ api_wallet_private_key, api_wallet_public_key, is_default
146
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
147
+ """,
148
+ (
149
+ alias,
150
+ network,
151
+ _encrypt_value(user_address),
152
+ account_type,
153
+ source,
154
+ _encrypt_optional_value(api_wallet_private_key),
155
+ _encrypt_optional_value(api_wallet_public_key),
156
+ 1 if should_be_default else 0,
157
+ ),
158
+ )
159
+ conn.commit()
160
+ row = conn.execute("SELECT * FROM accounts WHERE alias = ? AND network = ?", (alias, network)).fetchone()
161
+ conn.close()
162
+ return _to_account(row)
163
+
164
+
165
+ def set_default_account(alias: str, network: str) -> Account:
166
+ conn = _conn()
167
+ conn.execute("UPDATE accounts SET is_default = 0 WHERE network = ? AND is_default = 1", (network,))
168
+ conn.execute(
169
+ "UPDATE accounts SET is_default = 1, updated_at = strftime('%s', 'now') WHERE alias = ? AND network = ?",
170
+ (alias, network),
171
+ )
172
+ conn.commit()
173
+ row = conn.execute("SELECT * FROM accounts WHERE alias = ? AND network = ?", (alias, network)).fetchone()
174
+ conn.close()
175
+ if not row:
176
+ raise ValueError(f'Account with alias "{alias}" not found')
177
+ return _to_account(row)
178
+
179
+
180
+ def delete_account(alias: str, network: str) -> bool:
181
+ conn = _conn()
182
+ row = conn.execute("SELECT * FROM accounts WHERE alias = ? AND network = ?", (alias, network)).fetchone()
183
+ if not row:
184
+ conn.close()
185
+ return False
186
+ was_default = bool(row["is_default"])
187
+ conn.execute("DELETE FROM accounts WHERE alias = ? AND network = ?", (alias, network))
188
+ if was_default:
189
+ first = conn.execute(
190
+ "SELECT id FROM accounts WHERE network = ? ORDER BY created_at ASC LIMIT 1",
191
+ (network,),
192
+ ).fetchone()
193
+ if first:
194
+ conn.execute("UPDATE accounts SET is_default = 1 WHERE id = ?", (first["id"],))
195
+ conn.commit()
196
+ conn.close()
197
+ return True
198
+
199
+
200
+ def _command_path_for_key() -> str:
201
+ argv0 = sys.argv[0]
202
+ resolved = shutil.which(argv0) if argv0 and "/" not in argv0 else argv0
203
+ if not resolved:
204
+ resolved = argv0
205
+ return str(Path(resolved or "hl").resolve())
206
+
207
+
208
+ def _chacha20_key() -> bytes:
209
+ # Derive the encryption key from the current command path so only the same
210
+ # installed command path can transparently decrypt the stored account data.
211
+ return hashlib.sha256(_command_path_for_key().encode("utf-8")).digest()
212
+
213
+
214
+ def _encrypt_value(value: str) -> str:
215
+ nonce = secrets.token_bytes(12)
216
+ cipher = ChaCha20.new(key=_chacha20_key(), nonce=nonce)
217
+ encrypted = cipher.encrypt(value.encode("utf-8"))
218
+ return f"{_ENC_PREFIX}{base64.urlsafe_b64encode(nonce).decode()}:{base64.urlsafe_b64encode(encrypted).decode()}"
219
+
220
+
221
+ def _decrypt_value(value: str) -> str:
222
+ if not value.startswith(_ENC_PREFIX):
223
+ return value
224
+ payload = value[len(_ENC_PREFIX):]
225
+ nonce_b64, encrypted_b64 = payload.split(":", 1)
226
+ nonce = base64.urlsafe_b64decode(nonce_b64.encode())
227
+ encrypted = base64.urlsafe_b64decode(encrypted_b64.encode())
228
+ cipher = ChaCha20.new(key=_chacha20_key(), nonce=nonce)
229
+ return cipher.decrypt(encrypted).decode("utf-8")
230
+
231
+
232
+ def _encrypt_optional_value(value: Optional[str]) -> Optional[str]:
233
+ if value is None:
234
+ return None
235
+ return _encrypt_value(value)
236
+
237
+
238
+ def _decrypt_optional_value(value: Optional[str]) -> Optional[str]:
239
+ if value is None:
240
+ return None
241
+ return _decrypt_value(value)
242
+
243
+
244
+ def _migrate_encrypted_account_fields(conn: sqlite3.Connection) -> None:
245
+ # TODO: Remove this whole function after all existing plaintext account rows
246
+ # have been migrated to encrypted storage.
247
+ rows = conn.execute(
248
+ """
249
+ SELECT id, user_address, api_wallet_private_key, api_wallet_public_key
250
+ FROM accounts
251
+ """
252
+ ).fetchall()
253
+ for row in rows:
254
+ user_address = row["user_address"]
255
+ api_wallet_private_key = row["api_wallet_private_key"]
256
+ api_wallet_public_key = row["api_wallet_public_key"]
257
+ if (
258
+ isinstance(user_address, str)
259
+ and user_address.startswith(_ENC_PREFIX)
260
+ and (api_wallet_private_key is None or str(api_wallet_private_key).startswith(_ENC_PREFIX))
261
+ and (api_wallet_public_key is None or str(api_wallet_public_key).startswith(_ENC_PREFIX))
262
+ ):
263
+ continue
264
+ conn.execute(
265
+ """
266
+ UPDATE accounts
267
+ SET user_address = ?, api_wallet_private_key = ?, api_wallet_public_key = ?
268
+ WHERE id = ?
269
+ """,
270
+ (
271
+ _encrypt_value(user_address),
272
+ _encrypt_optional_value(api_wallet_private_key),
273
+ _encrypt_optional_value(api_wallet_public_key),
274
+ row["id"],
275
+ ),
276
+ )
277
+ conn.commit()
hl_cli/infra/paths.py ADDED
@@ -0,0 +1,5 @@
1
+ from pathlib import Path
2
+
3
+ HL_DIR = Path.home() / ".hl"
4
+ DB_PATH = HL_DIR / "hl.db"
5
+ ORDER_CONFIG_PATH = HL_DIR / "order-config.json"
@@ -0,0 +1 @@
1
+ """Utility helpers."""
@@ -0,0 +1,66 @@
1
+ from typing import Any, Callable
2
+
3
+ from rich.table import Table
4
+
5
+
6
+ def market_table_columns(*, include_category: bool, show_perp_only_fields: bool) -> list[str]:
7
+ columns = ["Coin", "Pair", "Price", "24h%", "Vol"]
8
+ if include_category:
9
+ columns.insert(1, "Category")
10
+ if show_perp_only_fields:
11
+ columns.extend(["Funding", "OI"])
12
+ return columns
13
+
14
+
15
+ def market_table_row_values(
16
+ row: dict[str, Any],
17
+ *,
18
+ include_category: bool,
19
+ show_perp_only_fields: bool,
20
+ format_price: Callable[[Any], str],
21
+ format_usd: Callable[[Any], str],
22
+ format_rate_pct: Callable[[Any], str],
23
+ ) -> list[str]:
24
+ values = [
25
+ str(row.get("coin", "")),
26
+ str(row.get("pairName", "")),
27
+ format_price(row.get("price")),
28
+ "-" if row.get("priceChange") is None else f"{float(row['priceChange']):.2f}%",
29
+ format_usd(row.get("volumeUsd")),
30
+ ]
31
+ if include_category:
32
+ values.insert(1, str(row.get("category") or "-"))
33
+ if show_perp_only_fields:
34
+ values.extend(
35
+ [
36
+ format_rate_pct(row.get("funding")),
37
+ format_usd(row.get("openInterestUsd")),
38
+ ]
39
+ )
40
+ return values
41
+
42
+
43
+ def market_table_widths(columns: list[str], rendered_rows: list[list[str]]) -> list[int]:
44
+ widths = [len(column) for column in columns]
45
+ for row in rendered_rows:
46
+ for idx, value in enumerate(row):
47
+ widths[idx] = max(widths[idx], len(value))
48
+ return widths
49
+
50
+
51
+ def build_market_table(
52
+ *,
53
+ title: str,
54
+ columns: list[str],
55
+ rendered_rows: list[list[str]],
56
+ widths: list[int],
57
+ highlighted_index: int = -1,
58
+ ) -> Table:
59
+ table = Table(title=title)
60
+ for idx, column in enumerate(columns):
61
+ justify = "right" if column in {"Price", "24h%", "Vol", "Funding", "OI"} else "left"
62
+ table.add_column(column, width=widths[idx], no_wrap=True, overflow="ellipsis", justify=justify)
63
+ for idx, row in enumerate(rendered_rows):
64
+ style = "bold reverse" if idx == highlighted_index else ""
65
+ table.add_row(*row, style=style)
66
+ return table