cartha-cli 1.0.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.
- cartha_cli/__init__.py +34 -0
- cartha_cli/bt.py +206 -0
- cartha_cli/commands/__init__.py +25 -0
- cartha_cli/commands/common.py +76 -0
- cartha_cli/commands/config.py +294 -0
- cartha_cli/commands/health.py +463 -0
- cartha_cli/commands/help.py +49 -0
- cartha_cli/commands/miner_password.py +283 -0
- cartha_cli/commands/miner_status.py +524 -0
- cartha_cli/commands/pair_status.py +484 -0
- cartha_cli/commands/pools.py +121 -0
- cartha_cli/commands/prove_lock.py +1260 -0
- cartha_cli/commands/register.py +274 -0
- cartha_cli/commands/shared_options.py +235 -0
- cartha_cli/commands/version.py +15 -0
- cartha_cli/config.py +75 -0
- cartha_cli/display.py +62 -0
- cartha_cli/eth712.py +7 -0
- cartha_cli/main.py +237 -0
- cartha_cli/pair.py +201 -0
- cartha_cli/utils.py +274 -0
- cartha_cli/verifier.py +342 -0
- cartha_cli/wallet.py +59 -0
- cartha_cli-1.0.0.dist-info/METADATA +180 -0
- cartha_cli-1.0.0.dist-info/RECORD +28 -0
- cartha_cli-1.0.0.dist-info/WHEEL +4 -0
- cartha_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cartha_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
cartha_cli/main.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Primary Typer application for the Cartha CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .commands import (
|
|
8
|
+
config,
|
|
9
|
+
health,
|
|
10
|
+
miner_status,
|
|
11
|
+
pair_status,
|
|
12
|
+
pools,
|
|
13
|
+
prove_lock,
|
|
14
|
+
register,
|
|
15
|
+
version,
|
|
16
|
+
)
|
|
17
|
+
from .commands.common import log_endpoint_banner, set_trace_enabled
|
|
18
|
+
from .commands.help import print_root_help
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
help="Miner-facing tooling for registering on the Cartha subnet and managing lock positions. Cartha is the Liquidity Provider for 0xMarkets DEX.",
|
|
22
|
+
add_completion=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Create command groups
|
|
26
|
+
miner_app = typer.Typer(
|
|
27
|
+
help="Miner management commands: register, check status, and manage passwords.",
|
|
28
|
+
name="miner",
|
|
29
|
+
invoke_without_command=True,
|
|
30
|
+
)
|
|
31
|
+
miner_app_alias = typer.Typer(
|
|
32
|
+
help="Miner management commands: register, check status, and manage passwords.",
|
|
33
|
+
name="m",
|
|
34
|
+
invoke_without_command=True,
|
|
35
|
+
)
|
|
36
|
+
vault_app = typer.Typer(
|
|
37
|
+
help="Vault management commands: lock funds and claim deposits.",
|
|
38
|
+
name="vault",
|
|
39
|
+
invoke_without_command=True,
|
|
40
|
+
)
|
|
41
|
+
vault_app_alias = typer.Typer(
|
|
42
|
+
help="Vault management commands: lock funds and claim deposits.",
|
|
43
|
+
name="v",
|
|
44
|
+
invoke_without_command=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Define callbacks for groups (show help when invoked without subcommand)
|
|
49
|
+
def miner_group_callback(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
help_option: bool = typer.Option(
|
|
52
|
+
False,
|
|
53
|
+
"--help",
|
|
54
|
+
"-h",
|
|
55
|
+
help="Show this message and exit.",
|
|
56
|
+
is_eager=True,
|
|
57
|
+
),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Miner management commands."""
|
|
60
|
+
if ctx.invoked_subcommand is None or help_option:
|
|
61
|
+
ctx.get_help()
|
|
62
|
+
raise typer.Exit()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def vault_group_callback(
|
|
66
|
+
ctx: typer.Context,
|
|
67
|
+
help_option: bool = typer.Option(
|
|
68
|
+
False,
|
|
69
|
+
"--help",
|
|
70
|
+
"-h",
|
|
71
|
+
help="Show this message and exit.",
|
|
72
|
+
is_eager=True,
|
|
73
|
+
),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Vault management commands."""
|
|
76
|
+
if ctx.invoked_subcommand is None or help_option:
|
|
77
|
+
ctx.get_help()
|
|
78
|
+
raise typer.Exit()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Register callbacks for both miner apps (main and alias)
|
|
82
|
+
miner_app.callback(invoke_without_command=True)(miner_group_callback)
|
|
83
|
+
miner_app_alias.callback(invoke_without_command=True)(miner_group_callback)
|
|
84
|
+
|
|
85
|
+
# Register callbacks for both vault apps (main and alias)
|
|
86
|
+
vault_app.callback(invoke_without_command=True)(vault_group_callback)
|
|
87
|
+
vault_app_alias.callback(invoke_without_command=True)(vault_group_callback)
|
|
88
|
+
|
|
89
|
+
# Register commands in both miner apps (main and alias)
|
|
90
|
+
for miner_group in [miner_app, miner_app_alias]:
|
|
91
|
+
miner_group.command("status")(miner_status.miner_status)
|
|
92
|
+
miner_group.command("register")(register.register)
|
|
93
|
+
|
|
94
|
+
# Register commands in both vault apps (main and alias)
|
|
95
|
+
for vault_group in [vault_app, vault_app_alias]:
|
|
96
|
+
vault_group.command("lock")(prove_lock.prove_lock)
|
|
97
|
+
vault_group.command("pools")(pools.pools)
|
|
98
|
+
|
|
99
|
+
# Add groups with short aliases (after callbacks and commands are registered)
|
|
100
|
+
app.add_typer(miner_app, name="miner")
|
|
101
|
+
app.add_typer(miner_app_alias, name="m") # Short alias
|
|
102
|
+
app.add_typer(vault_app, name="vault")
|
|
103
|
+
app.add_typer(vault_app_alias, name="v") # Short alias
|
|
104
|
+
|
|
105
|
+
# Keep pair_app for backward compatibility (deprecated)
|
|
106
|
+
pair_app = typer.Typer(
|
|
107
|
+
help="Pair status commands (deprecated - use 'cartha miner status')."
|
|
108
|
+
)
|
|
109
|
+
app.add_typer(pair_app, name="pair")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.callback(invoke_without_command=True)
|
|
113
|
+
def cli_root(
|
|
114
|
+
ctx: typer.Context,
|
|
115
|
+
help_option: bool = typer.Option(
|
|
116
|
+
False,
|
|
117
|
+
"--help",
|
|
118
|
+
"-h",
|
|
119
|
+
help="Show this message and exit.",
|
|
120
|
+
is_eager=True,
|
|
121
|
+
),
|
|
122
|
+
trace: bool = typer.Option(
|
|
123
|
+
False,
|
|
124
|
+
"--trace",
|
|
125
|
+
help="Show full stack traces when errors occur.",
|
|
126
|
+
),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Top-level callback to provide rich help and endpoint banner."""
|
|
129
|
+
set_trace_enabled(trace)
|
|
130
|
+
if ctx.obj is None:
|
|
131
|
+
ctx.obj = {}
|
|
132
|
+
ctx.obj["trace"] = trace
|
|
133
|
+
|
|
134
|
+
if help_option:
|
|
135
|
+
print_root_help()
|
|
136
|
+
raise typer.Exit()
|
|
137
|
+
|
|
138
|
+
if ctx.invoked_subcommand is None:
|
|
139
|
+
print_root_help()
|
|
140
|
+
raise typer.Exit()
|
|
141
|
+
|
|
142
|
+
log_endpoint_banner()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Register top-level commands
|
|
146
|
+
app.command("version")(version.version_command)
|
|
147
|
+
|
|
148
|
+
# Create utils command group
|
|
149
|
+
utils_app = typer.Typer(
|
|
150
|
+
help="Utility commands: health checks and configuration management.",
|
|
151
|
+
name="utils",
|
|
152
|
+
invoke_without_command=True,
|
|
153
|
+
)
|
|
154
|
+
utils_app_alias = typer.Typer(
|
|
155
|
+
help="Utility commands: health checks and configuration management.",
|
|
156
|
+
name="u",
|
|
157
|
+
invoke_without_command=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def utils_group_callback(
|
|
161
|
+
ctx: typer.Context,
|
|
162
|
+
help_option: bool = typer.Option(
|
|
163
|
+
False,
|
|
164
|
+
"--help",
|
|
165
|
+
"-h",
|
|
166
|
+
help="Show this message and exit.",
|
|
167
|
+
is_eager=True,
|
|
168
|
+
),
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Utility commands."""
|
|
171
|
+
if ctx.invoked_subcommand is None or help_option:
|
|
172
|
+
ctx.get_help()
|
|
173
|
+
raise typer.Exit()
|
|
174
|
+
|
|
175
|
+
# Register callbacks for both utils apps (main and alias)
|
|
176
|
+
utils_app.callback(invoke_without_command=True)(utils_group_callback)
|
|
177
|
+
utils_app_alias.callback(invoke_without_command=True)(utils_group_callback)
|
|
178
|
+
|
|
179
|
+
# Register commands in both utils apps (main and alias)
|
|
180
|
+
for utils_group in [utils_app, utils_app_alias]:
|
|
181
|
+
utils_group.command("health")(health.health_check)
|
|
182
|
+
|
|
183
|
+
# Create config subcommand group under utils
|
|
184
|
+
config_app = typer.Typer(
|
|
185
|
+
help="Configuration commands: view and set environment variables.",
|
|
186
|
+
name="config",
|
|
187
|
+
invoke_without_command=True,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def config_group_callback(
|
|
191
|
+
ctx: typer.Context,
|
|
192
|
+
help_option: bool = typer.Option(
|
|
193
|
+
False,
|
|
194
|
+
"--help",
|
|
195
|
+
"-h",
|
|
196
|
+
help="Show this message and exit.",
|
|
197
|
+
is_eager=True,
|
|
198
|
+
),
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Configuration commands."""
|
|
201
|
+
if ctx.invoked_subcommand is None or help_option:
|
|
202
|
+
if ctx.invoked_subcommand is None:
|
|
203
|
+
# Show list by default
|
|
204
|
+
config.config_list()
|
|
205
|
+
else:
|
|
206
|
+
ctx.get_help()
|
|
207
|
+
raise typer.Exit()
|
|
208
|
+
|
|
209
|
+
config_app.callback(invoke_without_command=True)(config_group_callback)
|
|
210
|
+
config_app.command("list")(config.config_list)
|
|
211
|
+
config_app.command("set")(config.config_set)
|
|
212
|
+
config_app.command("get")(config.config_get)
|
|
213
|
+
config_app.command("unset")(config.config_unset)
|
|
214
|
+
|
|
215
|
+
# Add config to both utils apps (main and alias)
|
|
216
|
+
for utils_group in [utils_app, utils_app_alias]:
|
|
217
|
+
utils_group.add_typer(config_app)
|
|
218
|
+
|
|
219
|
+
# Add groups with short aliases (after callbacks and commands are registered)
|
|
220
|
+
app.add_typer(utils_app, name="utils")
|
|
221
|
+
app.add_typer(utils_app_alias, name="u") # Short alias
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def help_command() -> None:
|
|
225
|
+
"""Show help message."""
|
|
226
|
+
print_root_help()
|
|
227
|
+
raise typer.Exit()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
app.command("help")(help_command)
|
|
231
|
+
|
|
232
|
+
# Keep deprecated commands for backward compatibility
|
|
233
|
+
pair_app.command("status")(pair_status.pair_status)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__": # pragma: no cover
|
|
237
|
+
app()
|
cartha_cli/pair.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Pair authentication and status utilities for the Cartha CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import bittensor as bt
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from .bt import get_subtensor
|
|
13
|
+
from .utils import format_timestamp
|
|
14
|
+
from .verifier import VerifierError, fetch_pair_status
|
|
15
|
+
from .wallet import CHALLENGE_PREFIX, CHALLENGE_TTL_SECONDS, load_wallet
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_uid_from_hotkey(
|
|
21
|
+
*,
|
|
22
|
+
network: str,
|
|
23
|
+
netuid: int,
|
|
24
|
+
hotkey: str,
|
|
25
|
+
) -> int | None:
|
|
26
|
+
"""Get the UID for a hotkey on the subnet.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
network: Bittensor network name
|
|
30
|
+
netuid: Subnet netuid
|
|
31
|
+
hotkey: Hotkey SS58 address
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
UID if registered, None if not registered or deregistered
|
|
35
|
+
"""
|
|
36
|
+
subtensor = None
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
subtensor = get_subtensor(network)
|
|
40
|
+
|
|
41
|
+
# Try to get UID directly - if successful, they're registered
|
|
42
|
+
try:
|
|
43
|
+
uid = subtensor.get_uid_for_hotkey_on_subnet(
|
|
44
|
+
hotkey_ss58=hotkey, netuid=netuid
|
|
45
|
+
)
|
|
46
|
+
if uid is not None and uid >= 0:
|
|
47
|
+
return int(uid)
|
|
48
|
+
except AttributeError:
|
|
49
|
+
# Method doesn't exist in this bittensor version
|
|
50
|
+
return None
|
|
51
|
+
except Exception:
|
|
52
|
+
# Any other error means not registered or network issue
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
return None
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
error_msg = str(exc)
|
|
58
|
+
if "nodename" in error_msg.lower() or "servname" in error_msg.lower():
|
|
59
|
+
console.print(
|
|
60
|
+
f"[bold red]Network error[/]: Unable to connect to Bittensor {network} network: {error_msg}"
|
|
61
|
+
)
|
|
62
|
+
console.print(
|
|
63
|
+
"[yellow]This might be a DNS/network connectivity issue. Please check your internet connection.[/]"
|
|
64
|
+
)
|
|
65
|
+
raise typer.Exit(code=1) from None
|
|
66
|
+
# Re-raise other exceptions as-is
|
|
67
|
+
raise
|
|
68
|
+
finally:
|
|
69
|
+
# Clean up connections
|
|
70
|
+
try:
|
|
71
|
+
if subtensor is not None:
|
|
72
|
+
if hasattr(subtensor, "close"):
|
|
73
|
+
subtensor.close()
|
|
74
|
+
del subtensor
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ensure_pair_registered(
|
|
80
|
+
*,
|
|
81
|
+
network: str,
|
|
82
|
+
netuid: int,
|
|
83
|
+
slot: str,
|
|
84
|
+
hotkey: str,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Ensure a pair is registered on the subnet.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
network: Bittensor network name
|
|
90
|
+
netuid: Subnet netuid
|
|
91
|
+
slot: Slot UID
|
|
92
|
+
hotkey: Hotkey SS58 address
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
typer.Exit: If pair is not registered or UID mismatch
|
|
96
|
+
"""
|
|
97
|
+
subtensor = None
|
|
98
|
+
metagraph = None
|
|
99
|
+
try:
|
|
100
|
+
subtensor = get_subtensor(network)
|
|
101
|
+
metagraph = subtensor.metagraph(netuid)
|
|
102
|
+
slot_index = int(slot)
|
|
103
|
+
if slot_index < 0 or slot_index >= len(metagraph.hotkeys):
|
|
104
|
+
console.print(
|
|
105
|
+
f"[bold red]UID {slot} not found[/] in the metagraph (netuid {netuid})."
|
|
106
|
+
)
|
|
107
|
+
raise typer.Exit(code=1)
|
|
108
|
+
registered_hotkey = metagraph.hotkeys[slot_index]
|
|
109
|
+
if registered_hotkey != hotkey:
|
|
110
|
+
console.print(
|
|
111
|
+
f"[bold red]UID mismatch[/]: slot {slot} belongs to a different hotkey, not {hotkey}. Please verify your inputs."
|
|
112
|
+
)
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
error_msg = str(exc)
|
|
116
|
+
if "nodename" in error_msg.lower() or "servname" in error_msg.lower():
|
|
117
|
+
console.print(
|
|
118
|
+
f"[bold red]Network error[/]: Unable to connect to Bittensor {network} network: {error_msg}"
|
|
119
|
+
)
|
|
120
|
+
console.print(
|
|
121
|
+
"[yellow]This might be a DNS/network connectivity issue. Please check your internet connection.[/]"
|
|
122
|
+
)
|
|
123
|
+
raise typer.Exit(code=1) from None
|
|
124
|
+
# Re-raise other exceptions as-is
|
|
125
|
+
raise
|
|
126
|
+
finally:
|
|
127
|
+
# Clean up connections
|
|
128
|
+
try:
|
|
129
|
+
if subtensor is not None:
|
|
130
|
+
if hasattr(subtensor, "close"):
|
|
131
|
+
subtensor.close()
|
|
132
|
+
del subtensor
|
|
133
|
+
if metagraph is not None:
|
|
134
|
+
del metagraph
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_pair_auth_payload(
|
|
140
|
+
*,
|
|
141
|
+
network: str,
|
|
142
|
+
netuid: int,
|
|
143
|
+
slot: str,
|
|
144
|
+
hotkey: str,
|
|
145
|
+
wallet_name: str,
|
|
146
|
+
wallet_hotkey: str,
|
|
147
|
+
skip_metagraph_check: bool = False,
|
|
148
|
+
challenge_prefix: str | None = None,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Build authentication payload for pair status/password requests or lock flow.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
network: Bittensor network name
|
|
154
|
+
netuid: Subnet netuid
|
|
155
|
+
slot: Slot UID
|
|
156
|
+
hotkey: Hotkey SS58 address
|
|
157
|
+
wallet_name: Coldkey wallet name
|
|
158
|
+
wallet_hotkey: Hotkey name
|
|
159
|
+
skip_metagraph_check: Skip metagraph validation check
|
|
160
|
+
challenge_prefix: Challenge prefix (defaults to CHALLENGE_PREFIX, use "cartha-lock" for lock flow)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Dictionary with message, signature, and expires_at
|
|
164
|
+
"""
|
|
165
|
+
wallet = load_wallet(wallet_name, wallet_hotkey, hotkey)
|
|
166
|
+
if not skip_metagraph_check:
|
|
167
|
+
ensure_pair_registered(
|
|
168
|
+
network=network, netuid=netuid, slot=slot, hotkey=hotkey
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
timestamp = int(time.time())
|
|
172
|
+
prefix = challenge_prefix or CHALLENGE_PREFIX
|
|
173
|
+
message = (
|
|
174
|
+
f"{prefix}|network:{network}|netuid:{netuid}|slot:{slot}|"
|
|
175
|
+
f"hotkey:{hotkey}|ts:{timestamp}"
|
|
176
|
+
)
|
|
177
|
+
message_bytes = message.encode("utf-8")
|
|
178
|
+
signature_bytes = wallet.hotkey.sign(message_bytes)
|
|
179
|
+
|
|
180
|
+
verifier_keypair = bt.Keypair(ss58_address=hotkey)
|
|
181
|
+
if not verifier_keypair.verify(message_bytes, signature_bytes):
|
|
182
|
+
console.print("[bold red]Unable to verify the ownership signature locally.[/]")
|
|
183
|
+
raise typer.Exit(code=1)
|
|
184
|
+
|
|
185
|
+
expires_at = timestamp + CHALLENGE_TTL_SECONDS
|
|
186
|
+
expiry_time = format_timestamp(expires_at)
|
|
187
|
+
console.print(
|
|
188
|
+
"[bold green]Ownership challenge signed[/] "
|
|
189
|
+
f"(expires in {CHALLENGE_TTL_SECONDS}s at {expiry_time})."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"message": message,
|
|
194
|
+
"signature": "0x" + signature_bytes.hex(),
|
|
195
|
+
"expires_at": expires_at,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# REMOVED: request_pair_status_or_password - replaced by new lock flow
|
|
200
|
+
# Old function removed as part of new lock flow implementation
|
|
201
|
+
|
cartha_cli/utils.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Utility functions for the Cartha CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from decimal import ROUND_DOWN, Decimal, InvalidOperation
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich import box
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from .config import settings
|
|
15
|
+
|
|
16
|
+
console = typer.get_current().console if hasattr(typer, "get_current") else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_hex(value: str, prefix: str = "0x") -> str:
|
|
20
|
+
"""Normalize hex string to ensure it has the correct prefix.
|
|
21
|
+
|
|
22
|
+
Handles common mistakes like "Ox" (capital O) -> "0x" (zero).
|
|
23
|
+
"""
|
|
24
|
+
value = value.strip()
|
|
25
|
+
# Fix common mistake: "Ox" (capital O) -> "0x" (zero)
|
|
26
|
+
if value.startswith("Ox") or value.startswith("OX"):
|
|
27
|
+
value = "0x" + value[2:]
|
|
28
|
+
if not value.startswith(prefix):
|
|
29
|
+
value = prefix + value
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_timestamp(ts: int | float | str | None) -> str:
|
|
34
|
+
"""Format a timestamp showing both UTC and local time.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
ts: Unix timestamp (seconds) as int, float, or string, or None for current time
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Formatted string like "2024-01-01 12:00:00 UTC (2024-01-01 07:00:00 EST)"
|
|
41
|
+
"""
|
|
42
|
+
if ts is None:
|
|
43
|
+
ts = time.time()
|
|
44
|
+
elif isinstance(ts, str):
|
|
45
|
+
try:
|
|
46
|
+
ts = float(ts)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return str(ts) # Return as-is if not parseable
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
ts_float = float(ts)
|
|
52
|
+
utc_dt = datetime.fromtimestamp(ts_float, tz=UTC)
|
|
53
|
+
|
|
54
|
+
# Get local timezone
|
|
55
|
+
try:
|
|
56
|
+
local_tz: ZoneInfo = ZoneInfo("local")
|
|
57
|
+
except Exception:
|
|
58
|
+
# Fallback if zoneinfo fails (shouldn't happen on Python 3.11+)
|
|
59
|
+
fallback_tz = datetime.now().astimezone().tzinfo
|
|
60
|
+
if fallback_tz is None:
|
|
61
|
+
# Ultimate fallback to UTC
|
|
62
|
+
local_tz = ZoneInfo("UTC")
|
|
63
|
+
else:
|
|
64
|
+
# Type ignore: mypy doesn't understand that ZoneInfo is compatible with tzinfo
|
|
65
|
+
local_tz = fallback_tz # type: ignore[assignment]
|
|
66
|
+
|
|
67
|
+
local_dt = utc_dt.astimezone(local_tz)
|
|
68
|
+
|
|
69
|
+
# Format both times
|
|
70
|
+
utc_str = utc_dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
71
|
+
local_str = local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
72
|
+
|
|
73
|
+
return f"{utc_str} ({local_str})"
|
|
74
|
+
except (ValueError, OSError, OverflowError):
|
|
75
|
+
# Fallback to simple ISO format if anything fails
|
|
76
|
+
try:
|
|
77
|
+
return datetime.fromtimestamp(float(ts), tz=UTC).isoformat()
|
|
78
|
+
except Exception:
|
|
79
|
+
return str(ts)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_timestamp_multiline(ts: int | float | str | None) -> str:
|
|
83
|
+
"""Format a timestamp showing UTC and local time on separate lines.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
ts: Unix timestamp (seconds) as int, float, or string, or None for current time
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Formatted string with UTC on first line, local time on second line (if different from UTC),
|
|
90
|
+
or just UTC if user is in UTC timezone.
|
|
91
|
+
"""
|
|
92
|
+
if ts is None:
|
|
93
|
+
ts = time.time()
|
|
94
|
+
elif isinstance(ts, str):
|
|
95
|
+
try:
|
|
96
|
+
ts = float(ts)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return str(ts) # Return as-is if not parseable
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
ts_float = float(ts)
|
|
102
|
+
utc_dt = datetime.fromtimestamp(ts_float, tz=UTC)
|
|
103
|
+
|
|
104
|
+
# Get local timezone
|
|
105
|
+
try:
|
|
106
|
+
local_tz: ZoneInfo = ZoneInfo("local")
|
|
107
|
+
except Exception:
|
|
108
|
+
fallback_tz = datetime.now().astimezone().tzinfo
|
|
109
|
+
if fallback_tz is None:
|
|
110
|
+
local_tz = ZoneInfo("UTC")
|
|
111
|
+
else:
|
|
112
|
+
local_tz = fallback_tz # type: ignore[assignment]
|
|
113
|
+
|
|
114
|
+
local_dt = utc_dt.astimezone(local_tz)
|
|
115
|
+
|
|
116
|
+
# Format UTC time
|
|
117
|
+
utc_str = utc_dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
118
|
+
|
|
119
|
+
# Check if local timezone is UTC (compare timezone objects, not just hours/minutes)
|
|
120
|
+
# Also check if the offset is zero (handles cases where timezone name differs but offset is UTC)
|
|
121
|
+
is_utc_timezone = (
|
|
122
|
+
str(local_tz) == "UTC"
|
|
123
|
+
or local_tz.utcoffset(utc_dt) == timedelta(0)
|
|
124
|
+
or (hasattr(local_tz, 'key') and local_tz.key == 'UTC')
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if is_utc_timezone:
|
|
128
|
+
# User is in UTC timezone, only show UTC
|
|
129
|
+
return utc_str
|
|
130
|
+
else:
|
|
131
|
+
# Show both UTC and local time on separate lines
|
|
132
|
+
local_str = local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
133
|
+
return f"{utc_str}\n({local_str})"
|
|
134
|
+
except (ValueError, OSError, OverflowError):
|
|
135
|
+
try:
|
|
136
|
+
return datetime.fromtimestamp(float(ts), tz=UTC).isoformat()
|
|
137
|
+
except Exception:
|
|
138
|
+
return str(ts)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def format_evm_address(address: str) -> str:
|
|
142
|
+
"""Format an EVM address in standard crypto wallet display format.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
address: EVM address (e.g., "0x86997f52073317659B25aA622C5d93ed77444DeE")
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Formatted address like "0x8699...44DeE" (first 6 chars after 0x + last 4 chars)
|
|
149
|
+
"""
|
|
150
|
+
if not address or len(address) < 10:
|
|
151
|
+
return address
|
|
152
|
+
|
|
153
|
+
# Remove 0x prefix if present for processing
|
|
154
|
+
if address.startswith("0x"):
|
|
155
|
+
prefix = "0x"
|
|
156
|
+
addr_without_prefix = address[2:]
|
|
157
|
+
else:
|
|
158
|
+
prefix = ""
|
|
159
|
+
addr_without_prefix = address
|
|
160
|
+
|
|
161
|
+
if len(addr_without_prefix) <= 10:
|
|
162
|
+
# Address is too short, return as-is
|
|
163
|
+
return address
|
|
164
|
+
|
|
165
|
+
# Format: 0x + first 4 chars + ... + last 4 chars
|
|
166
|
+
return f"{prefix}{addr_without_prefix[:4]}...{addr_without_prefix[-4:]}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def usdc_to_base_units(value: str) -> int:
|
|
170
|
+
"""Convert USDC amount string to base units (micro-USDC).
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
value: USDC amount as string (e.g., "250.5")
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Amount in base units (e.g., 250500000)
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
typer.Exit: If value is invalid or non-positive
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
decimal_value = Decimal(value.strip())
|
|
183
|
+
except (InvalidOperation, AttributeError) as exc:
|
|
184
|
+
raise typer.Exit(code=1) from exc
|
|
185
|
+
if decimal_value <= 0:
|
|
186
|
+
raise typer.Exit(code=1)
|
|
187
|
+
quantized = decimal_value.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
|
|
188
|
+
return int(quantized * Decimal(10**6))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_current_epoch_start(reference: datetime | None = None) -> datetime:
|
|
192
|
+
"""Calculate the start of the current epoch (Friday 00:00 UTC).
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
reference: Reference datetime (defaults to now in UTC)
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Datetime of the current epoch start (Friday 00:00 UTC)
|
|
199
|
+
"""
|
|
200
|
+
reference = reference or datetime.now(tz=UTC)
|
|
201
|
+
weekday = reference.weekday() # Monday=0, Friday=4
|
|
202
|
+
days_since_friday = (weekday - 4) % 7
|
|
203
|
+
candidate = datetime(
|
|
204
|
+
year=reference.year,
|
|
205
|
+
month=reference.month,
|
|
206
|
+
day=reference.day,
|
|
207
|
+
hour=0,
|
|
208
|
+
minute=0,
|
|
209
|
+
second=0,
|
|
210
|
+
microsecond=0,
|
|
211
|
+
tzinfo=UTC,
|
|
212
|
+
)
|
|
213
|
+
return candidate - timedelta(days=days_since_friday)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_next_epoch_freeze_time(reference: datetime | None = None) -> datetime:
|
|
217
|
+
"""Calculate the next epoch freeze time (next Friday 00:00 UTC).
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
reference: Reference datetime (defaults to now in UTC)
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Datetime of the next epoch freeze (next Friday 00:00 UTC)
|
|
224
|
+
"""
|
|
225
|
+
current_start = get_current_epoch_start(reference)
|
|
226
|
+
# If we're exactly at epoch start, next is in 7 days
|
|
227
|
+
# Otherwise, next is current + 7 days
|
|
228
|
+
return current_start + timedelta(days=7)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def format_countdown(seconds: float) -> str:
|
|
232
|
+
"""Format seconds into a human-readable countdown string.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
seconds: Number of seconds remaining
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Formatted string like "2d 5h 30m 15s"
|
|
239
|
+
"""
|
|
240
|
+
if seconds < 0:
|
|
241
|
+
return "0s"
|
|
242
|
+
|
|
243
|
+
days = int(seconds // 86400)
|
|
244
|
+
hours = int((seconds % 86400) // 3600)
|
|
245
|
+
minutes = int((seconds % 3600) // 60)
|
|
246
|
+
secs = int(seconds % 60)
|
|
247
|
+
|
|
248
|
+
parts = []
|
|
249
|
+
if days > 0:
|
|
250
|
+
parts.append(f"{days}d")
|
|
251
|
+
if hours > 0:
|
|
252
|
+
parts.append(f"{hours}h")
|
|
253
|
+
if minutes > 0:
|
|
254
|
+
parts.append(f"{minutes}m")
|
|
255
|
+
if secs > 0 or not parts:
|
|
256
|
+
parts.append(f"{secs}s")
|
|
257
|
+
|
|
258
|
+
return " ".join(parts)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_local_timezone() -> ZoneInfo:
|
|
262
|
+
"""Get the local timezone, with fallbacks.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
ZoneInfo object for local timezone
|
|
266
|
+
"""
|
|
267
|
+
try:
|
|
268
|
+
return ZoneInfo("local")
|
|
269
|
+
except Exception:
|
|
270
|
+
fallback_tz = datetime.now().astimezone().tzinfo
|
|
271
|
+
if fallback_tz is None:
|
|
272
|
+
return ZoneInfo("UTC")
|
|
273
|
+
return fallback_tz # type: ignore[return-value]
|
|
274
|
+
|