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.
@@ -0,0 +1,484 @@
1
+ """Pair status command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ import bittensor as bt
9
+ import typer
10
+ from rich.json import JSON
11
+ from rich.table import Table
12
+
13
+ from ..config import settings
14
+ from ..display import display_clock_and_countdown
15
+ from ..pair import (
16
+ build_pair_auth_payload,
17
+ get_uid_from_hotkey,
18
+ )
19
+ from ..utils import format_timestamp, format_timestamp_multiline, format_evm_address
20
+ from ..verifier import VerifierError, fetch_pair_status
21
+ from ..wallet import load_wallet
22
+ from .common import (
23
+ console,
24
+ handle_unexpected_exception,
25
+ handle_wallet_exception,
26
+ )
27
+ from .shared_options import (
28
+ wallet_name_option,
29
+ wallet_hotkey_option,
30
+ slot_option,
31
+ auto_fetch_uid_option,
32
+ network_option,
33
+ netuid_option,
34
+ json_output_option,
35
+ )
36
+
37
+ # Import pool name helper
38
+ try:
39
+ from ...testnet.pool_ids import pool_id_to_name
40
+ except ImportError:
41
+ # Fallback if running from different context
42
+ def pool_id_to_name(pool_id: str) -> str | None:
43
+ """Simple fallback to decode pool ID."""
44
+ try:
45
+ hex_str = pool_id.lower().removeprefix("0x")
46
+ pool_bytes = bytes.fromhex(hex_str)
47
+ name = pool_bytes.rstrip(b"\x00").decode("utf-8", errors="ignore")
48
+ if name and name.isprintable():
49
+ return name
50
+ except Exception:
51
+ pass
52
+ return None
53
+
54
+
55
+ def pair_status(
56
+ wallet_name: str = wallet_name_option(required=True),
57
+ wallet_hotkey: str = wallet_hotkey_option(required=True),
58
+ slot: int | None = slot_option(),
59
+ auto_fetch_uid: bool = auto_fetch_uid_option(),
60
+ network: str = network_option(),
61
+ netuid: int = netuid_option(),
62
+ json_output: bool = json_output_option(),
63
+ ) -> None:
64
+ """Show the verifier state for a miner pair (legacy - use 'cartha miner status' instead).
65
+
66
+ USAGE:
67
+ ------
68
+ Interactive mode: 'cartha pair status' (will prompt for wallet)
69
+ With arguments: 'cartha pair status -w cold -wh hot'
70
+
71
+ ALIASES:
72
+ --------
73
+ Wallet: --wallet-name, --coldkey, -w | --wallet-hotkey, --hotkey, -wh
74
+ Slot: --slot, --uid, -u | Network: --network, -n
75
+
76
+ DEPRECATED: Use 'cartha miner status' instead for faster status checks without authentication.
77
+
78
+ State Legend:
79
+ ════════════════════════════════════════════════════════════════
80
+
81
+ 1. active - In current frozen epoch, earning rewards
82
+
83
+ 2. verified - Has lock proof, not in current active epoch
84
+
85
+ 3. pending - Registered, no lock proof submitted yet
86
+
87
+ 4. revoked - Revoked (deregistered or evicted)
88
+
89
+ 5. unknown - No pair record found
90
+
91
+ ════════════════════════════════════════════════════════════════
92
+ """
93
+ try:
94
+ # Auto-map netuid and verifier URL based on network
95
+ if network == "test":
96
+ netuid = 78
97
+ elif network == "finney":
98
+ netuid = 35
99
+ # Warn that mainnet is not live yet
100
+ console.print()
101
+ console.print("[bold yellow]⚠️ MAINNET NOT AVAILABLE YET[/]")
102
+ console.print("[yellow]Cartha subnet is currently in testnet phase (subnet 78).[/]")
103
+ console.print("[yellow]Mainnet (subnet 35) has not been announced yet.[/]")
104
+ console.print("[dim]Use --network test to access testnet.[/]")
105
+ console.print()
106
+ # Note: netuid parameter is kept for backwards compatibility / explicit override
107
+
108
+ from ..config import get_verifier_url_for_network
109
+ expected_verifier_url = get_verifier_url_for_network(network)
110
+ if settings.verifier_url != expected_verifier_url:
111
+ settings.verifier_url = expected_verifier_url
112
+
113
+ console.print("[bold cyan]Loading wallet...[/]")
114
+ wallet = load_wallet(wallet_name, wallet_hotkey, None)
115
+ hotkey = wallet.hotkey.ss58_address
116
+
117
+ # Fetch UID automatically by default, prompt if disabled
118
+ if slot is None:
119
+ if auto_fetch_uid:
120
+ # Auto-fetch enabled (default) - try to fetch from network
121
+ console.print("[bold cyan]Fetching UID from subnet...[/]")
122
+ try:
123
+ slot = get_uid_from_hotkey(
124
+ network=network, netuid=netuid, hotkey=hotkey
125
+ )
126
+ if slot is None:
127
+ console.print(
128
+ "[bold yellow]Hotkey is not registered or has been deregistered[/] "
129
+ f"on netuid {netuid} ({network} network)."
130
+ )
131
+ console.print(
132
+ "[yellow]You do not belong to any UID at the moment.[/] "
133
+ "Please register your hotkey first using 'cartha miner register'."
134
+ )
135
+ raise typer.Exit(code=0)
136
+ console.print(f"[bold green]Found UID: {slot}[/]")
137
+ except typer.Exit:
138
+ raise
139
+ except Exception as exc:
140
+ console.print(
141
+ "[bold red]Failed to fetch UID automatically[/]: This may be due to Bittensor network issues."
142
+ )
143
+ console.print("[yellow]Falling back to manual input...[/]")
144
+ try:
145
+ slot_input = typer.prompt("Enter your slot UID", type=int)
146
+ slot = slot_input
147
+ console.print(f"[bold green]Using UID: {slot}[/]")
148
+ except (ValueError, KeyboardInterrupt):
149
+ console.print("[bold red]Invalid UID or cancelled.[/]")
150
+ raise typer.Exit(code=1) from exc
151
+ else:
152
+ # Auto-fetch disabled (--no-auto-fetch-uid) - prompt for UID
153
+ console.print(
154
+ "[bold cyan]UID not provided.[/] "
155
+ "[yellow]Auto-fetch disabled. Enter UID manually.[/]"
156
+ )
157
+ try:
158
+ slot_input = typer.prompt(
159
+ "Enter your slot UID (from 'cartha miner register' output)",
160
+ type=int,
161
+ )
162
+ slot = slot_input
163
+ console.print(f"[bold green]Using UID: {slot}[/]")
164
+ except (ValueError, KeyboardInterrupt):
165
+ console.print("[bold red]Invalid UID or cancelled.[/]")
166
+ raise typer.Exit(code=1)
167
+
168
+ slot_id = str(slot)
169
+ # Skip metagraph check - verifier will validate the pair anyway
170
+ # This avoids slow metagraph() calls that cause timeouts
171
+
172
+ console.print("[bold cyan]Signing hotkey ownership challenge...[/]")
173
+ auth_payload = build_pair_auth_payload(
174
+ network=network,
175
+ netuid=netuid,
176
+ slot=slot_id,
177
+ hotkey=hotkey,
178
+ wallet_name=wallet_name,
179
+ wallet_hotkey=wallet_hotkey,
180
+ )
181
+ with console.status(
182
+ "[bold cyan]Verifying ownership with Cartha verifier...[/]",
183
+ spinner="dots",
184
+ ):
185
+ status = fetch_pair_status(
186
+ hotkey=hotkey,
187
+ slot=slot_id,
188
+ network=network,
189
+ netuid=netuid,
190
+ message=auth_payload["message"],
191
+ signature=auth_payload["signature"],
192
+ )
193
+ except bt.KeyFileError as exc:
194
+ handle_wallet_exception(
195
+ wallet_name=wallet_name, wallet_hotkey=wallet_hotkey, exc=exc
196
+ )
197
+ except typer.Exit:
198
+ raise
199
+ except VerifierError as exc:
200
+ # VerifierError handling
201
+ error_msg = str(exc)
202
+ if "timed out" in error_msg.lower() or "timeout" in error_msg.lower():
203
+ console.print(f"[bold red]Request timed out[/]")
204
+ # Print the full error message (may be multi-line)
205
+ console.print(f"[yellow]{error_msg}[/]")
206
+ else:
207
+ console.print(f"[bold red]Verifier request failed[/]: {exc}")
208
+ raise typer.Exit(code=1) from exc
209
+ except Exception as exc:
210
+ # Check if it's a timeout-related error (even if wrapped)
211
+ error_msg = str(exc)
212
+ error_type = type(exc).__name__
213
+
214
+ # Check for timeout indicators in the exception
215
+ is_timeout = (
216
+ "timed out" in error_msg.lower()
217
+ or "timeout" in error_msg.lower()
218
+ or error_type == "Timeout"
219
+ or (
220
+ hasattr(exc, "__cause__")
221
+ and exc.__cause__ is not None
222
+ and (
223
+ "timeout" in str(exc.__cause__).lower()
224
+ or "Timeout" in type(exc.__cause__).__name__
225
+ )
226
+ )
227
+ )
228
+
229
+ if is_timeout:
230
+ console.print(f"[bold red]Request timed out[/]")
231
+ console.print(
232
+ f"[yellow]CLI failed to reach Cartha verifier\n"
233
+ f"Possible causes: Network latency or the verifier is receiving too many requests\n"
234
+ f"Tip: Try again in a moment\n"
235
+ f"Error details: {error_msg}[/]"
236
+ )
237
+ raise typer.Exit(code=1) from exc
238
+
239
+ handle_unexpected_exception("Unable to fetch pair status", exc)
240
+
241
+ initial_status = dict(status)
242
+ password_payload: dict[str, Any] | None = None
243
+
244
+ existing_pwd = initial_status.get("pwd")
245
+ state = initial_status.get("state") or "unknown"
246
+ has_pwd_flag = initial_status.get("has_pwd") or bool(existing_pwd)
247
+
248
+ # Note: Password registration removed - new lock flow uses session tokens instead
249
+ # The has_pwd flag is kept for backward compatibility but passwords are no longer used
250
+
251
+ sanitized = dict(status)
252
+ sanitized.setdefault("state", "unknown")
253
+ sanitized["hotkey"] = hotkey
254
+ sanitized["slot"] = slot_id
255
+ password = sanitized.get("pwd")
256
+
257
+ if json_output:
258
+ console.print(JSON.from_data(sanitized))
259
+ if password:
260
+ console.print(
261
+ "[bold yellow]Keep it safe[/] — for your eyes only. Exposure might allow others to steal your locked USDC rewards."
262
+ )
263
+ return
264
+
265
+ # Display clock and countdown
266
+ display_clock_and_countdown()
267
+
268
+ table = Table(title="Pair Status", show_header=False)
269
+ table.add_row("Hotkey", hotkey)
270
+ table.add_row("Slot UID", slot_id)
271
+ table.add_row("State", sanitized["state"])
272
+
273
+ # Show lock amounts for verified/active states
274
+ state = sanitized.get("state", "").lower()
275
+ if state in ("verified", "active"):
276
+ # Show EVM addresses used - display all addresses
277
+ evm_addresses = sanitized.get("miner_evm_addresses")
278
+ if evm_addresses:
279
+ if len(evm_addresses) == 1:
280
+ table.add_row("EVM Address", evm_addresses[0])
281
+ elif len(evm_addresses) <= 3:
282
+ # Show up to 3 addresses with line breaks
283
+ evm_display = "\n".join(evm_addresses)
284
+ table.add_row("EVM Addresses", evm_display)
285
+ else:
286
+ # Show count for many addresses
287
+ table.add_row("EVM Addresses", f"{len(evm_addresses)} addresses (see pool details below)")
288
+
289
+ table.add_row("Password issued", "yes" if sanitized.get("has_pwd") else "no")
290
+ issued_at = sanitized.get("issued_at")
291
+ if issued_at:
292
+ # Try to parse and format the timestamp
293
+ try:
294
+ if isinstance(issued_at, (int, float)) or (
295
+ isinstance(issued_at, str) and issued_at.isdigit()
296
+ ):
297
+ # Numeric timestamp
298
+ formatted_time = format_timestamp(issued_at)
299
+ elif isinstance(issued_at, str):
300
+ # Try parsing as ISO format datetime string
301
+ try:
302
+ dt = datetime.fromisoformat(issued_at.replace("Z", "+00:00"))
303
+ timestamp = dt.timestamp()
304
+ formatted_time = format_timestamp(timestamp)
305
+ except (ValueError, AttributeError):
306
+ # If parsing fails, display as-is
307
+ formatted_time = issued_at
308
+ else:
309
+ formatted_time = str(issued_at)
310
+ table.add_row("Password issued at", formatted_time)
311
+ except Exception:
312
+ table.add_row("Password issued at", str(issued_at))
313
+ if password:
314
+ table.add_row("Pair password", password)
315
+ console.print(table)
316
+
317
+ # Show warnings and reminders
318
+ if password:
319
+ console.print()
320
+ console.print(
321
+ "[bold yellow]🔐 Keep your password safe[/] — Exposure might allow others to steal your locked USDC rewards."
322
+ )
323
+
324
+ # Show detailed status information for verified/active pairs
325
+ if state in ("verified", "active"):
326
+ pools = sanitized.get("pools", [])
327
+ in_upcoming_epoch = sanitized.get("in_upcoming_epoch")
328
+ expires_at = sanitized.get("expires_at")
329
+
330
+ # Display per-pool table (primary display for lock information)
331
+ if pools:
332
+ console.print()
333
+ console.print("[bold cyan]━━━ Active Pools ━━━[/]")
334
+
335
+ pool_table = Table(show_header=True, header_style="bold cyan")
336
+ pool_table.add_column("Pool Name", style="cyan", no_wrap=True)
337
+ pool_table.add_column("Amount Locked", style="green", justify="right")
338
+ pool_table.add_column("Lock Days", justify="center")
339
+ pool_table.add_column("Expires At", style="yellow")
340
+ pool_table.add_column("Status", justify="center")
341
+ pool_table.add_column("EVM Address", style="dim")
342
+
343
+ for pool in pools:
344
+ # Format pool name - use human-readable name if available
345
+ pool_name = pool.get("pool_name")
346
+ if not pool_name:
347
+ # Fallback to pool_id if name not available
348
+ pool_id = pool.get("pool_id", "")
349
+ if pool_id:
350
+ # Try to convert pool_id to name
351
+ pool_name = pool_id_to_name(pool_id) or pool_id[:10] + "..."
352
+ else:
353
+ pool_name = "Unknown"
354
+ pool_display = pool_name
355
+
356
+ # Format amount locked
357
+ amount_usdc = pool.get("amount_usdc", 0)
358
+ amount_str = f"{amount_usdc:.2f}"
359
+
360
+ # Format lock days
361
+ lock_days = pool.get("lock_days", 0)
362
+ lock_days_str = str(lock_days)
363
+
364
+ # Format expiration
365
+ pool_expires_at = pool.get("expires_at")
366
+ expires_str = "N/A"
367
+ if pool_expires_at:
368
+ try:
369
+ if isinstance(pool_expires_at, str):
370
+ exp_dt = datetime.fromisoformat(
371
+ pool_expires_at.replace("Z", "+00:00")
372
+ )
373
+ elif isinstance(pool_expires_at, datetime):
374
+ exp_dt = pool_expires_at
375
+ else:
376
+ exp_dt = None
377
+ if exp_dt:
378
+ expires_str = format_timestamp(exp_dt.timestamp())
379
+ except Exception:
380
+ expires_str = str(pool_expires_at)
381
+
382
+ # Format status - show which pools are active, verified, and included in next epoch
383
+ is_active = pool.get("is_active", False)
384
+ is_verified = pool.get("is_verified", False)
385
+ pool_in_upcoming = pool.get("in_upcoming_epoch", False)
386
+
387
+ status_parts = []
388
+ if is_active:
389
+ status_parts.append("[green]Active[/]")
390
+ if is_verified:
391
+ status_parts.append("[cyan]Verified[/]")
392
+ if pool_in_upcoming:
393
+ status_parts.append("[bold green]In Next Epoch[/]")
394
+ if not status_parts:
395
+ status_parts.append("[dim]None[/]")
396
+
397
+ status_str = " / ".join(status_parts)
398
+
399
+ # EVM address (full address for clarity)
400
+ evm_addr = pool.get("evm_address", "")
401
+ evm_display = (
402
+ evm_addr
403
+ if len(evm_addr) <= 42
404
+ else (evm_addr[:20] + "..." + evm_addr[-6:])
405
+ )
406
+
407
+ pool_table.add_row(
408
+ pool_display,
409
+ f"{amount_str} USDC",
410
+ lock_days_str,
411
+ expires_str,
412
+ status_str,
413
+ evm_display,
414
+ )
415
+
416
+ console.print(pool_table)
417
+
418
+ console.print()
419
+ console.print("[bold cyan]━━━ Epoch Status ━━━[/]")
420
+
421
+ # Upcoming epoch inclusion status
422
+ if in_upcoming_epoch:
423
+ console.print(
424
+ "[bold green]✓ Included in upcoming epoch[/] — You will receive rewards for the next epoch."
425
+ )
426
+ elif in_upcoming_epoch is False:
427
+ console.print(
428
+ "[bold yellow]⚠ Not included in upcoming epoch[/] — Use [bold]cartha vault lock[/] to be included."
429
+ )
430
+
431
+ # Expiration date information and warnings (aggregated)
432
+ if expires_at:
433
+ try:
434
+ # Parse expiration datetime
435
+ if isinstance(expires_at, str):
436
+ exp_dt = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
437
+ elif isinstance(expires_at, datetime):
438
+ exp_dt = expires_at
439
+ else:
440
+ exp_dt = None
441
+
442
+ if exp_dt:
443
+ now = datetime.now(UTC)
444
+ time_until_expiry = (exp_dt - now).total_seconds()
445
+ days_until_expiry = time_until_expiry / 86400
446
+
447
+ console.print()
448
+ console.print("[bold cyan]━━━ Lock Expiration ━━━[/]")
449
+
450
+ if days_until_expiry < 0:
451
+ console.print(
452
+ "[bold red]⚠ EXPIRED[/] — Some locks expired. USDC will be returned. No more emissions for expired pools."
453
+ )
454
+ elif days_until_expiry <= 7:
455
+ console.print(
456
+ f"[bold red]⚠ Expiring in {days_until_expiry:.1f} days[/] — Make a new lock transaction on-chain to continue receiving emissions."
457
+ )
458
+ elif days_until_expiry <= 30:
459
+ console.print(
460
+ f"[bold yellow]⚠ Expiring in {days_until_expiry:.0f} days[/] — Consider making a new lock transaction on-chain soon."
461
+ )
462
+ else:
463
+ console.print(
464
+ f"[bold green]✓ Valid for {days_until_expiry:.0f} days[/]"
465
+ )
466
+ except Exception:
467
+ pass
468
+
469
+ # Concise reminder
470
+ console.print()
471
+ console.print("[bold cyan]━━━ Reminders ━━━[/]")
472
+ console.print(
473
+ "• Lock expiration: USDC returned automatically, emissions stop for that pool."
474
+ )
475
+ console.print(
476
+ "• Top-ups/extensions: Happen automatically on-chain. No CLI action needed."
477
+ )
478
+ if pools and len(pools) > 1:
479
+ console.print(
480
+ "• Multiple pools: Each pool is tracked separately. Expired pools stop earning, others continue."
481
+ )
482
+
483
+ # Explicitly return to ensure clean exit
484
+ return
@@ -0,0 +1,121 @@
1
+ """Pools command - show current available pools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .common import console
8
+
9
+ # Import pool helpers for pool_id conversion
10
+ try:
11
+ from ...testnet.pool_ids import (
12
+ list_pools,
13
+ pool_id_to_chain_id,
14
+ pool_id_to_vault_address,
15
+ )
16
+ except ImportError:
17
+ # Fallback if running from different context
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ # Try adding parent directory to path
22
+ testnet_dir = Path(__file__).parent.parent.parent / "testnet"
23
+ if testnet_dir.exists():
24
+ sys.path.insert(0, str(testnet_dir.parent))
25
+ try:
26
+ from testnet.pool_ids import (
27
+ list_pools,
28
+ pool_id_to_chain_id,
29
+ pool_id_to_vault_address,
30
+ )
31
+ except ImportError:
32
+ # Final fallback
33
+ def list_pools() -> dict[str, str]:
34
+ """Fallback: return empty dict."""
35
+ return {}
36
+
37
+ def pool_id_to_vault_address(pool_id: str) -> str | None:
38
+ """Fallback: return None."""
39
+ return None
40
+
41
+ def pool_id_to_chain_id(pool_id: str) -> int | None:
42
+ """Fallback: return None."""
43
+ return None
44
+
45
+
46
+ def pools(
47
+ json_output: bool = typer.Option(
48
+ False, "--json", help="Emit responses as JSON."
49
+ ),
50
+ ) -> None:
51
+ """Show all available pools with their names, IDs, vault addresses, and chain IDs.
52
+
53
+ USAGE:
54
+ ------
55
+ cartha vault pools (or: cartha v pools)
56
+ cartha vault pools --json (for JSON output)
57
+
58
+ OUTPUT:
59
+ -------
60
+ - Pool names: BTCUSD, ETHUSD, EURUSD, etc.
61
+ - Pool IDs: Full hex identifiers (0x...)
62
+ - Vault addresses: Contract addresses for each pool
63
+ - Chain IDs: Which blockchain network (e.g., 84532 for Base Sepolia)
64
+
65
+ Use these pool names directly in 'cartha vault lock -p BTCUSD ...'
66
+ """
67
+ try:
68
+ available_pools = list_pools()
69
+
70
+ if json_output:
71
+ # JSON output format
72
+ import json
73
+
74
+ pools_data = []
75
+ for pool_name, pool_id_hex in sorted(available_pools.items()):
76
+ vault_addr = pool_id_to_vault_address(pool_id_hex)
77
+ chain_id = pool_id_to_chain_id(pool_id_hex)
78
+ pool_data = {
79
+ "name": pool_name,
80
+ "pool_id": pool_id_hex,
81
+ }
82
+ if vault_addr:
83
+ pool_data["vault_address"] = vault_addr
84
+ if chain_id:
85
+ pool_data["chain_id"] = chain_id
86
+ pools_data.append(pool_data)
87
+
88
+ console.print(json.dumps(pools_data, indent=2))
89
+ return
90
+
91
+ # Multi-line text output
92
+ if not available_pools:
93
+ console.print("[yellow]No pools available.[/]")
94
+ return
95
+
96
+ console.print("\n[bold cyan]Available Pools[/]\n")
97
+
98
+ for idx, (pool_name, pool_id_hex) in enumerate(sorted(available_pools.items()), 1):
99
+ vault_addr = pool_id_to_vault_address(pool_id_hex)
100
+ chain_id = pool_id_to_chain_id(pool_id_hex)
101
+
102
+ # Ensure full pool ID is displayed (normalize to ensure 0x prefix)
103
+ pool_id_display = pool_id_hex if pool_id_hex.startswith("0x") else f"0x{pool_id_hex}"
104
+
105
+ # Ensure full vault address is displayed
106
+ vault_display = vault_addr if vault_addr else "[dim]N/A[/]"
107
+
108
+ chain_display = str(chain_id) if chain_id else "[dim]N/A[/]"
109
+
110
+ console.print(f"[bold cyan]Pool {idx}:[/] {pool_name}")
111
+ console.print(f" [yellow]Pool ID:[/] {pool_id_display}")
112
+ console.print(f" [green]Vault Address:[/] {vault_display}")
113
+ console.print(f" [dim]Chain ID:[/] {chain_display}")
114
+
115
+ # Add spacing between pools except for the last one
116
+ if idx < len(available_pools):
117
+ console.print()
118
+
119
+ except Exception as exc:
120
+ console.print(f"[bold red]Error:[/] Failed to list pools: {exc}")
121
+ raise typer.Exit(code=1)