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.
- hl_cli/__init__.py +1 -0
- hl_cli/cli/__init__.py +1 -0
- hl_cli/cli/argparse_main.py +814 -0
- hl_cli/cli/markets_tui.py +399 -0
- hl_cli/cli/runtime.py +82 -0
- hl_cli/commands/__init__.py +1 -0
- hl_cli/commands/app.py +1081 -0
- hl_cli/commands/order.py +918 -0
- hl_cli/core/__init__.py +1 -0
- hl_cli/core/context.py +156 -0
- hl_cli/core/order_config.py +23 -0
- hl_cli/infra/__init__.py +1 -0
- hl_cli/infra/db.py +277 -0
- hl_cli/infra/paths.py +5 -0
- hl_cli/utils/__init__.py +1 -0
- hl_cli/utils/market_table.py +66 -0
- hl_cli/utils/output.py +476 -0
- hl_cli/utils/validators.py +45 -0
- hl_cli/utils/watch.py +28 -0
- hyperliquid_cli_python-0.1.0.dist-info/METADATA +269 -0
- hyperliquid_cli_python-0.1.0.dist-info/RECORD +25 -0
- hyperliquid_cli_python-0.1.0.dist-info/WHEEL +5 -0
- hyperliquid_cli_python-0.1.0.dist-info/entry_points.txt +2 -0
- hyperliquid_cli_python-0.1.0.dist-info/licenses/LICENSE +32 -0
- hyperliquid_cli_python-0.1.0.dist-info/top_level.txt +1 -0
hl_cli/core/__init__.py
ADDED
|
@@ -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
|
hl_cli/infra/__init__.py
ADDED
|
@@ -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
hl_cli/utils/__init__.py
ADDED
|
@@ -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
|