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,1260 @@
1
+ """Lock command - create new lock positions with verifier-signed EIP-712 LockRequest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import webbrowser
7
+ from decimal import Decimal
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import urlencode
11
+
12
+ import typer
13
+ from rich import box
14
+ from rich.panel import Panel
15
+ from rich.prompt import Confirm
16
+ from rich.status import Status
17
+ from rich.table import Table
18
+ from web3 import Web3
19
+
20
+ from ..config import settings
21
+ from ..pair import build_pair_auth_payload, get_uid_from_hotkey
22
+ from ..utils import normalize_hex, usdc_to_base_units
23
+ from ..verifier import (
24
+ VerifierError,
25
+ get_lock_status,
26
+ process_lock_transaction,
27
+ request_lock_signature,
28
+ verify_hotkey,
29
+ )
30
+ from ..wallet import load_wallet
31
+ from .common import console, exit_with_error, handle_unexpected_exception
32
+ from .shared_options import (
33
+ wallet_name_option,
34
+ wallet_hotkey_option,
35
+ network_option,
36
+ pool_id_option,
37
+ chain_id_option,
38
+ vault_address_option,
39
+ owner_evm_option,
40
+ amount_option,
41
+ lock_days_option,
42
+ json_output_option,
43
+ )
44
+
45
+ # Import pool helpers for pool_id conversion
46
+ try:
47
+ from ...testnet.pool_ids import (
48
+ format_pool_id,
49
+ list_pools,
50
+ pool_id_to_chain_id,
51
+ pool_id_to_name,
52
+ pool_id_to_vault_address,
53
+ pool_name_to_id,
54
+ vault_address_to_chain_id,
55
+ vault_address_to_pool_id,
56
+ )
57
+ except ImportError:
58
+ # Fallback if running from different context
59
+ import sys
60
+ from pathlib import Path
61
+
62
+ # Try adding parent directory to path
63
+ testnet_dir = Path(__file__).parent.parent.parent / "testnet"
64
+ if testnet_dir.exists():
65
+ sys.path.insert(0, str(testnet_dir.parent))
66
+ try:
67
+ from testnet.pool_ids import (
68
+ format_pool_id,
69
+ list_pools,
70
+ pool_id_to_chain_id,
71
+ pool_id_to_name,
72
+ pool_id_to_vault_address,
73
+ pool_name_to_id,
74
+ vault_address_to_chain_id,
75
+ vault_address_to_pool_id,
76
+ )
77
+ except ImportError:
78
+ # Final fallback
79
+ def pool_name_to_id(pool_name: str) -> str:
80
+ """Fallback: encode pool name as hex."""
81
+ name_bytes = pool_name.encode("utf-8")
82
+ padded = name_bytes.ljust(32, b"\x00")
83
+ return "0x" + padded.hex()
84
+
85
+ def pool_id_to_name(pool_id: str) -> str | None:
86
+ """Fallback: try to decode."""
87
+ try:
88
+ hex_str = pool_id.lower().removeprefix("0x")
89
+ pool_bytes = bytes.fromhex(hex_str)
90
+ name = pool_bytes.rstrip(b"\x00").decode("utf-8", errors="ignore")
91
+ return name if name and name.isprintable() else None
92
+ except Exception:
93
+ return None
94
+
95
+ def format_pool_id(pool_id: str) -> str:
96
+ """Fallback: return pool_id as-is."""
97
+ return pool_id
98
+
99
+ def list_pools() -> dict[str, str]:
100
+ """Fallback: return empty dict."""
101
+ return {}
102
+
103
+ def pool_id_to_vault_address(pool_id: str) -> str | None:
104
+ """Fallback: return None."""
105
+ return None
106
+
107
+ def vault_address_to_pool_id(vault_address: str) -> str | None:
108
+ """Fallback: return None."""
109
+ return None
110
+
111
+ def pool_id_to_chain_id(pool_id: str) -> int | None:
112
+ """Fallback: return None."""
113
+ return None
114
+
115
+ def vault_address_to_chain_id(vault_address: str) -> int | None:
116
+ """Fallback: return None."""
117
+ return None
118
+
119
+
120
+ def prove_lock(
121
+ coldkey: str | None = wallet_name_option(required=False),
122
+ hotkey: str | None = wallet_hotkey_option(required=False),
123
+ network: str = network_option(),
124
+ chain: int | None = chain_id_option(),
125
+ vault: str | None = vault_address_option(),
126
+ pool_id: str | None = pool_id_option(),
127
+ amount: str | None = amount_option(),
128
+ lock_days: int | None = lock_days_option(),
129
+ owner: str | None = owner_evm_option(),
130
+ json_output: bool = json_output_option(),
131
+ ) -> None:
132
+ """Create a new lock position with verifier-signed EIP-712 LockRequest.
133
+
134
+ USAGE:
135
+ ------
136
+ Interactive mode (recommended): Just run 'cartha vault lock' and follow prompts
137
+ With arguments: Provide flags to skip prompts (e.g., -w cold -wh hot -p BTCUSD -a 100 -d 30 -e 0xEVM...)
138
+
139
+ ALIASES:
140
+ --------
141
+ Wallet: --wallet-name, --coldkey, -w | --wallet-hotkey, --hotkey, -wh
142
+ Network: --network, -n (auto-maps: test=netuid 78, finney=netuid 35)
143
+ Pool: --pool-id, --pool, --poolid, -p (accepts names like BTCUSD or hex IDs)
144
+ Amount: --amount, -a | Lock days: --lock-days, --days, -d
145
+ Owner: --owner-evm, --owner, --evm, -e
146
+ Chain/Vault: auto-detected from pool (can override with --chain-id, --vault-address)
147
+
148
+ FLOW:
149
+ -----
150
+ 1. Check registration on subnet (netuid auto-mapped from network)
151
+ 2. Authenticate with Bittensor hotkey signature
152
+ 3. Check for duplicate positions (rejects early if exists)
153
+ 4. Request EIP-712 LockRequest signature from verifier
154
+ 5. Open Cartha Lock UI for approval and lock transactions
155
+ 6. Auto-detect transaction completion
156
+ 7. Verifier automatically processes and adds to upcoming epoch
157
+ """
158
+ try:
159
+ # Step 1: Determine netuid and verifier URL based on network
160
+ if network == "test":
161
+ netuid = 78
162
+ elif network == "finney":
163
+ netuid = 35
164
+ # Warn that mainnet is not live yet
165
+ console.print()
166
+ console.print("[bold yellow]⚠️ MAINNET NOT AVAILABLE YET[/]")
167
+ console.print()
168
+ console.print("[yellow]Cartha subnet is currently in testnet phase (subnet 78 on test network).[/]")
169
+ console.print("[yellow]Mainnet (subnet 35 on finney network) has not been announced yet.[/]")
170
+ console.print()
171
+ console.print("[bold cyan]To use testnet:[/]")
172
+ console.print(" cartha vault lock --network test ...")
173
+ console.print()
174
+ console.print("[dim]If you continue with finney network, the CLI will attempt to connect[/]")
175
+ console.print("[dim]but the subnet may not be operational yet.[/]")
176
+ console.print()
177
+ if not Confirm.ask("[yellow]Continue with finney network anyway?[/]", default=False):
178
+ console.print("[yellow]Cancelled. Use --network test for testnet.[/]")
179
+ raise typer.Exit(code=0)
180
+ else:
181
+ # Default to finney settings if unknown network
182
+ netuid = 35
183
+
184
+ # Auto-map verifier URL based on network (if not explicitly set via env var)
185
+ from ..config import get_verifier_url_for_network
186
+ expected_verifier_url = get_verifier_url_for_network(network)
187
+ if settings.verifier_url != expected_verifier_url:
188
+ console.print(
189
+ f"[dim]Using verifier for {network} network: {expected_verifier_url}[/]"
190
+ )
191
+ # Update settings for this session
192
+ settings.verifier_url = expected_verifier_url
193
+
194
+ # Step 2: Collect coldkey and hotkey
195
+ if coldkey is None:
196
+ coldkey = typer.prompt("Coldkey wallet name", default="default")
197
+ if hotkey is None:
198
+ hotkey = typer.prompt("Hotkey name", default="default")
199
+
200
+ # Load wallet to get hotkey SS58 address
201
+ wallet = load_wallet(coldkey, hotkey)
202
+ hotkey_ss58 = wallet.hotkey.ss58_address
203
+
204
+ console.print(f"\n[bold cyan]Checking registration...[/]")
205
+ console.print(f"[dim]Hotkey:[/] {hotkey_ss58}")
206
+ console.print(f"[dim]Network:[/] {network} (netuid: {netuid})")
207
+
208
+ # Step 3: Check registration via Bittensor network (same as other commands)
209
+ try:
210
+ with console.status(
211
+ "[bold cyan]Checking miner registration status...[/]",
212
+ spinner="dots",
213
+ ):
214
+ uid = get_uid_from_hotkey(
215
+ network=network,
216
+ netuid=netuid,
217
+ hotkey=hotkey_ss58,
218
+ )
219
+
220
+ if uid is None:
221
+ console.print(
222
+ "[bold red]Error:[/] Hotkey is not registered or has been deregistered "
223
+ f"on netuid {netuid} ({network} network)."
224
+ )
225
+ console.print(
226
+ "[yellow]Please register your hotkey first using 'cartha miner register'.[/]"
227
+ )
228
+ raise typer.Exit(code=1)
229
+
230
+ console.print(f"[bold green]✓ Registered[/] - UID: {uid}")
231
+ except typer.Exit:
232
+ raise
233
+ except Exception as exc:
234
+ handle_unexpected_exception("Registration check failed", exc)
235
+
236
+ # Step 4: Generate Bittensor signature for authentication
237
+ console.print(f"\n[bold cyan]Authenticating with Bittensor hotkey...[/]")
238
+ try:
239
+ auth_payload = build_pair_auth_payload(
240
+ network=network,
241
+ netuid=netuid,
242
+ slot=str(uid),
243
+ hotkey=hotkey_ss58,
244
+ wallet_name=coldkey,
245
+ wallet_hotkey=hotkey,
246
+ skip_metagraph_check=True, # Already checked via verifier
247
+ challenge_prefix="cartha-lock",
248
+ )
249
+ except Exception as exc:
250
+ handle_unexpected_exception("Failed to generate Bittensor signature", exc)
251
+
252
+ # Step 5: Verify hotkey and get session token
253
+ try:
254
+ auth_result = verify_hotkey(
255
+ hotkey=hotkey_ss58,
256
+ signature=auth_payload["signature"],
257
+ message=auth_payload["message"],
258
+ )
259
+ session_token = auth_result["session_token"]
260
+ expires_at = auth_result["expires_at"]
261
+ console.print(
262
+ f"[bold green]✓ Authenticated[/] - Session expires at {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(expires_at))}"
263
+ )
264
+ except VerifierError as exc:
265
+ exit_with_error(f"Authentication failed: {exc}")
266
+ except Exception as exc:
267
+ handle_unexpected_exception("Authentication failed", exc)
268
+
269
+ # Step 6: Collect lock parameters
270
+ console.print(f"\n[bold cyan]Collecting lock parameters...[/]")
271
+
272
+ # Chain ID - will be auto-detected after pool_id/vault is selected
273
+ # We'll set it after vault matching
274
+
275
+ # Pool ID (collect first, then auto-match vault)
276
+ available_pools = list_pools()
277
+ if pool_id is None:
278
+ # Show available pools if we have them
279
+ if available_pools:
280
+ console.print("\n[bold cyan]Available pools:[/]")
281
+ for pool_name, pool_id_hex in sorted(available_pools.items()):
282
+ vault_addr = pool_id_to_vault_address(pool_id_hex)
283
+ if vault_addr:
284
+ console.print(
285
+ f" - {pool_name}: {format_pool_id(pool_id_hex)} "
286
+ f"[dim](Vault: {vault_addr})[/]"
287
+ )
288
+ else:
289
+ console.print(f" - {pool_name}: {format_pool_id(pool_id_hex)}")
290
+ console.print()
291
+
292
+ while True:
293
+ pool_input = typer.prompt(
294
+ "Pool ID (name or hex string)", show_default=False
295
+ )
296
+ pool_id_clean = pool_input.strip()
297
+
298
+ # Check if it's a readable pool name
299
+ pool_id_upper = pool_id_clean.upper()
300
+ if available_pools and pool_id_upper in available_pools:
301
+ pool_id = pool_name_to_id(pool_id_upper).lower()
302
+ console.print(
303
+ f"[dim]Converted pool name to ID:[/] {pool_id_upper} → {format_pool_id(pool_id)}"
304
+ )
305
+ break
306
+ # Check if it's a hex string
307
+ elif pool_id_clean.startswith("0x") and len(pool_id_clean) == 66:
308
+ pool_id = pool_id_clean.lower()
309
+ break
310
+ else:
311
+ # Try to normalize
312
+ pool_id_normalized = normalize_hex(pool_id_clean).lower()
313
+ if len(pool_id_normalized) == 66:
314
+ pool_id = pool_id_normalized
315
+ break
316
+ console.print(
317
+ "[bold red]Error:[/] Pool ID must be a recognized pool name or a 66-character hex string (0x...)"
318
+ )
319
+
320
+ # Normalize pool_id - handle both pool names and hex strings
321
+ else:
322
+ # Pool ID was provided via command line, check if it's a pool name
323
+ pool_id_clean = pool_id.strip()
324
+ pool_id_upper = pool_id_clean.upper()
325
+
326
+ # Check if it's a readable pool name
327
+ if available_pools and pool_id_upper in available_pools:
328
+ pool_id = pool_name_to_id(pool_id_upper).lower()
329
+ console.print(
330
+ f"[dim]Converted pool name to ID:[/] {pool_id_upper} → {format_pool_id(pool_id)}"
331
+ )
332
+ # Check if it's already a hex string
333
+ elif pool_id_clean.startswith("0x") and len(pool_id_clean) == 66:
334
+ pool_id = pool_id_clean.lower()
335
+ else:
336
+ # Try to normalize as hex
337
+ pool_id_normalized = normalize_hex(pool_id_clean).lower()
338
+ if len(pool_id_normalized) == 66:
339
+ pool_id = pool_id_normalized
340
+ else:
341
+ # If not a valid hex and not a known pool name, just normalize it
342
+ if not pool_id.startswith("0x"):
343
+ pool_id = "0x" + pool_id
344
+ pool_id = pool_id.lower()
345
+
346
+ # Auto-match vault address from pool ID
347
+ if vault is None:
348
+ auto_vault = pool_id_to_vault_address(pool_id)
349
+ if auto_vault:
350
+ vault = Web3.to_checksum_address(auto_vault)
351
+ pool_name = pool_id_to_name(pool_id)
352
+ console.print(
353
+ f"[bold green]✓ Auto-matched vault[/] - {pool_name or 'Pool'} → {vault}"
354
+ )
355
+ else:
356
+ # Fallback: prompt for vault if no mapping found
357
+ console.print(
358
+ "[yellow]⚠ No vault mapping found for this pool ID. Please provide vault address.[/]"
359
+ )
360
+ while True:
361
+ vault = typer.prompt("Vault contract address", show_default=False)
362
+ if Web3.is_address(vault):
363
+ vault = Web3.to_checksum_address(vault)
364
+ break
365
+ console.print(
366
+ "[bold red]Error:[/] Vault address must be a valid EVM address (0x...)"
367
+ )
368
+ else:
369
+ # Vault was provided, verify it matches pool ID if possible
370
+ if Web3.is_address(vault):
371
+ vault = Web3.to_checksum_address(vault)
372
+ expected_pool_id = vault_address_to_pool_id(vault)
373
+ if expected_pool_id and expected_pool_id.lower() != pool_id.lower():
374
+ pool_name = pool_id_to_name(pool_id)
375
+ expected_pool_name = pool_id_to_name(expected_pool_id)
376
+ console.print(
377
+ f"[bold yellow]⚠ Warning:[/] Vault {vault} is mapped to pool "
378
+ f"{expected_pool_name or expected_pool_id}, but you selected "
379
+ f"{pool_name or pool_id}"
380
+ )
381
+ if not Confirm.ask(
382
+ "[yellow]Continue anyway?[/]", default=False
383
+ ):
384
+ raise typer.Exit(code=1)
385
+ else:
386
+ exit_with_error("Invalid vault address format")
387
+
388
+ # Auto-match chain ID from pool ID or vault address
389
+ if chain is None:
390
+ # Try to get chain ID from pool ID first
391
+ auto_chain_id = None
392
+ try:
393
+ auto_chain_id = pool_id_to_chain_id(pool_id)
394
+ except NameError:
395
+ # Function not available - this shouldn't happen if imports worked
396
+ # But handle gracefully by trying to import it
397
+ try:
398
+ from testnet.pool_ids import pool_id_to_chain_id
399
+ auto_chain_id = pool_id_to_chain_id(pool_id)
400
+ except ImportError:
401
+ pass
402
+
403
+ if not auto_chain_id:
404
+ # Fallback: try to get from vault address
405
+ try:
406
+ auto_chain_id = vault_address_to_chain_id(vault)
407
+ except NameError:
408
+ try:
409
+ from testnet.pool_ids import vault_address_to_chain_id
410
+ auto_chain_id = vault_address_to_chain_id(vault)
411
+ except ImportError:
412
+ pass
413
+
414
+ if auto_chain_id:
415
+ chain = auto_chain_id
416
+ chain_name = "Base Sepolia" if chain == 84532 else f"Chain {chain}"
417
+ console.print(
418
+ f"[bold green]✓ Auto-matched chain ID[/] - {chain_name} (chain ID: {chain})"
419
+ )
420
+ else:
421
+ # Fallback: prompt for chain ID if no mapping found
422
+ console.print(
423
+ "[yellow]⚠ No chain ID mapping found. Please provide chain ID.[/]"
424
+ )
425
+ while True:
426
+ try:
427
+ chain_input = typer.prompt("Chain ID", show_default=False)
428
+ chain = int(chain_input)
429
+ if chain <= 0:
430
+ console.print(
431
+ "[bold red]Error:[/] Chain ID must be a positive integer"
432
+ )
433
+ continue
434
+ break
435
+ except ValueError:
436
+ console.print("[bold red]Error:[/] Chain ID must be a valid integer")
437
+ else:
438
+ # Chain ID was provided, verify it matches vault if possible
439
+ expected_chain_id = None
440
+ try:
441
+ expected_chain_id = vault_address_to_chain_id(vault)
442
+ except NameError:
443
+ try:
444
+ from testnet.pool_ids import vault_address_to_chain_id
445
+ expected_chain_id = vault_address_to_chain_id(vault)
446
+ except ImportError:
447
+ pass
448
+
449
+ if expected_chain_id and expected_chain_id != chain:
450
+ chain_name = "Base Sepolia" if expected_chain_id == 84532 else f"Chain {expected_chain_id}"
451
+ console.print(
452
+ f"[bold yellow]⚠ Warning:[/] Vault {vault} is on {chain_name} (chain ID: {expected_chain_id}), "
453
+ f"but you specified chain ID {chain}"
454
+ )
455
+ if not Confirm.ask(
456
+ "[yellow]Continue anyway?[/]", default=False
457
+ ):
458
+ raise typer.Exit(code=1)
459
+
460
+ # Amount
461
+ amount_base_units: int | None = None
462
+ if amount is None:
463
+ while True:
464
+ try:
465
+ amount_input = typer.prompt(
466
+ "Lock amount in USDC (e.g. 250.5)", show_default=False
467
+ )
468
+ amount_base_units = usdc_to_base_units(amount_input)
469
+ if amount_base_units <= 0:
470
+ console.print("[bold red]Error:[/] Amount must be positive")
471
+ continue
472
+ break
473
+ except Exception as exc:
474
+ console.print(f"[bold red]Error:[/] Invalid amount: {exc}")
475
+ else:
476
+ try:
477
+ amount_as_int = int(float(amount))
478
+ if amount_as_int >= 1_000_000_000:
479
+ amount_base_units = amount_as_int
480
+ else:
481
+ amount_base_units = usdc_to_base_units(amount)
482
+ except (ValueError, Exception):
483
+ amount_base_units = usdc_to_base_units(amount)
484
+
485
+ # Lock days
486
+ if lock_days is None:
487
+ while True:
488
+ try:
489
+ lock_days_input = typer.prompt(
490
+ "Lock duration in days (e.g., 365)", show_default=False
491
+ )
492
+ lock_days = int(lock_days_input)
493
+ if lock_days <= 0:
494
+ console.print(
495
+ "[bold red]Error:[/] Lock days must be positive"
496
+ )
497
+ continue
498
+ if lock_days > 1825: # 5 years max
499
+ console.print(
500
+ "[bold red]Error:[/] Lock days cannot exceed 1825 (5 years)"
501
+ )
502
+ continue
503
+ break
504
+ except ValueError:
505
+ console.print("[bold red]Error:[/] Lock days must be a valid integer")
506
+
507
+ # Owner (EVM address)
508
+ if owner is None:
509
+ while True:
510
+ owner = typer.prompt("EVM address (owner)", show_default=False)
511
+ if Web3.is_address(owner):
512
+ owner = Web3.to_checksum_address(owner)
513
+ break
514
+ console.print(
515
+ "[bold red]Error:[/] EVM address must be a valid address (0x...)"
516
+ )
517
+ else:
518
+ if not Web3.is_address(owner):
519
+ exit_with_error("Invalid EVM address format")
520
+ owner = Web3.to_checksum_address(owner)
521
+
522
+ # Step 7: Check for existing position to avoid wasting user's time
523
+ console.print(f"\n[bold cyan]Checking for existing positions...[/]")
524
+ try:
525
+ from ..verifier import fetch_miner_status
526
+
527
+ existing_status = fetch_miner_status(hotkey=hotkey_ss58, slot=str(uid))
528
+
529
+ # Check if position already exists with same pool + EVM
530
+ if existing_status.get("pools"):
531
+ for pool in existing_status["pools"]:
532
+ pool_id_existing = pool.get("pool_id", "").lower()
533
+ evm_existing = pool.get("evm_address", "").lower()
534
+
535
+ if pool_id_existing == pool_id.lower() and evm_existing == owner.lower():
536
+ # Found duplicate!
537
+ pool_name_display = pool.get("pool_name", "").upper() or "Pool"
538
+ console.print(
539
+ f"\n[bold red]Error: Position already exists![/]\n"
540
+ )
541
+ console.print(
542
+ f"[yellow]You already have a lock position on {pool_name_display} with this EVM address:[/]"
543
+ )
544
+ console.print(f" • Amount: [bold]{pool.get('amount_usdc', 0):.2f} USDC[/]")
545
+ console.print(f" • Lock days: [bold]{pool.get('lock_days', 0)}[/]")
546
+ console.print(f" • EVM: [dim]{pool.get('evm_address')}[/]")
547
+
548
+ expires_at = pool.get('expires_at')
549
+ if expires_at:
550
+ console.print(f" • Expires: [bold]{expires_at}[/]")
551
+
552
+ console.print()
553
+ console.print(
554
+ f"[bold cyan]To add more USDC or extend your lock period:[/]\n"
555
+ f" Visit: [bold]https://cartha.finance/manage[/]"
556
+ )
557
+ console.print()
558
+ console.print(
559
+ f"[dim]Note: You can create a new position on the same pool using a different EVM address.[/]"
560
+ )
561
+ raise typer.Exit(code=1)
562
+
563
+ console.print("[dim]✓ No existing position found - proceeding...[/]")
564
+ except typer.Exit:
565
+ raise
566
+ except Exception as check_exc:
567
+ # If status check fails, log warning but continue (don't block users if verifier is down)
568
+ console.print(
569
+ f"[yellow]Warning: Could not check existing positions ({check_exc})[/]"
570
+ )
571
+ console.print("[dim]Continuing anyway...[/]")
572
+
573
+ # Step 8: Request EIP-712 LockRequest signature from verifier
574
+ console.print(f"\n[bold cyan]Requesting signature from verifier...[/]")
575
+ try:
576
+ sig_result = request_lock_signature(
577
+ session_token=session_token,
578
+ pool_id=pool_id,
579
+ amount=amount_base_units,
580
+ lock_days=lock_days,
581
+ hotkey=hotkey_ss58,
582
+ miner_slot=str(uid),
583
+ uid=str(uid),
584
+ owner=owner,
585
+ chain_id=chain,
586
+ vault_address=vault,
587
+ )
588
+ signature = sig_result["signature"]
589
+ timestamp = sig_result["timestamp"]
590
+ nonce = sig_result["nonce"]
591
+ expires_at_sig = sig_result["expiresAt"]
592
+ approve_tx = sig_result["approveTx"]
593
+ lock_tx = sig_result["lockTx"]
594
+
595
+ console.print(f"[bold green]✓ Signature received[/]")
596
+ console.print(f"[dim]Expires at:[/] {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(expires_at_sig))}")
597
+ except VerifierError as exc:
598
+ exit_with_error(f"Failed to request signature: {exc}")
599
+ except Exception as exc:
600
+ handle_unexpected_exception("Signature request failed", exc)
601
+
602
+ # Step 9: Display lock details and get confirmation
603
+ console.print(f"\n[bold cyan]Lock Details:[/]")
604
+ summary_table = Table(show_header=False, box=box.SIMPLE)
605
+ summary_table.add_column(style="cyan")
606
+ summary_table.add_column(style="yellow")
607
+
608
+ # Show pool name if available
609
+ pool_name = pool_id_to_name(pool_id)
610
+ pool_display = (
611
+ pool_name.upper() if pool_name else format_pool_id(pool_id)
612
+ )
613
+ summary_table.add_row("Pool", pool_display)
614
+
615
+ human_amount = Decimal(amount_base_units) / Decimal(10**6)
616
+ amount_str = f"{human_amount:.6f}".rstrip("0").rstrip(".")
617
+ summary_table.add_row("Amount", f"{amount_str} USDC ({amount_base_units} base units)")
618
+
619
+ summary_table.add_row("Lock Days", str(lock_days))
620
+ summary_table.add_row("Owner (EVM)", owner)
621
+ summary_table.add_row("Hotkey", hotkey_ss58)
622
+ summary_table.add_row("UID", str(uid))
623
+ summary_table.add_row("Chain ID", str(chain))
624
+ summary_table.add_row("Vault", vault)
625
+
626
+ # Calculate unlock date
627
+ unlock_timestamp = int(time.time()) + (lock_days * 24 * 60 * 60)
628
+ unlock_date = time.strftime("%Y-%m-%d", time.gmtime(unlock_timestamp))
629
+ summary_table.add_row("Unlock Date", unlock_date)
630
+
631
+ console.print(summary_table)
632
+ console.print()
633
+
634
+ # LP Risk Disclosure
635
+ console.print(Panel(
636
+ "[bold yellow]⚠️ LIQUIDITY PROVIDER RISK DISCLOSURE[/]\n\n"
637
+ "By locking USDC, you agree that:\n\n"
638
+ "• Your funds will be used as DEX liquidity for leveraged trading\n"
639
+ "• Liquidation events may result in partial loss of capital\n"
640
+ "• Lost funds are NOT reimbursed - this is the LP risk model\n"
641
+ "• You earn subnet rewards + liquidation fees in return\n"
642
+ "• Minimum collateral: 100k USDC to maintain full emission scoring\n"
643
+ "• If your withdrawable balance falls below 100k USDC, your emission scoring will be reduced\n\n"
644
+ "[bold red]Only commit funds you can afford to lose.[/]\n\n"
645
+ "[dim]This disclosure is required for all liquidity providers.[/]\n"
646
+ "[dim]more information: https://docs.0xmarkets.io/legal-and-risk[/]",
647
+ title="[bold red]Important - Read Carefully[/]",
648
+ border_style="red",
649
+ padding=(1, 2),
650
+ ))
651
+ console.print()
652
+
653
+ if not Confirm.ask(
654
+ "[bold yellow]I understand the risks and wish to proceed[/]",
655
+ default=False,
656
+ ):
657
+ console.print("[bold red]Lock cancelled. Your funds remain safe.[/]")
658
+ raise typer.Exit(code=0)
659
+
660
+ console.print()
661
+ if not Confirm.ask(
662
+ "[bold yellow]Proceed with lock creation?[/]", default=True
663
+ ):
664
+ console.print("[bold yellow]Cancelled.[/]")
665
+ raise typer.Exit(code=0)
666
+
667
+ # Step 10: Display transaction data - Phase 1: Approve
668
+ console.print(f"\n[bold cyan]Transaction Data[/]")
669
+ console.print(
670
+ "\n[bold yellow]⚠️ Execute these transactions to complete your lock:[/]\n"
671
+ )
672
+
673
+ # USDC contract address for Base Sepolia
674
+ usdc_contract_address = "0x2340D09c348930A76c8c2783EDa8610F699A51A8"
675
+
676
+ console.print("[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]")
677
+ console.print("[bold]Phase 1: Approve USDC[/]")
678
+ console.print("[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]")
679
+ console.print()
680
+ console.print("[bold]Fields for Frontend Approve Page:[/]")
681
+ console.print()
682
+ console.print(f" [yellow]Contract Address (USDC)[/]: {usdc_contract_address}")
683
+ console.print(f" [yellow]spender[/] (address): {vault}")
684
+ console.print(f" [yellow]amount[/] (uint256): {amount_base_units}")
685
+ console.print(f" [yellow]owner[/] (address): {owner}")
686
+ console.print()
687
+
688
+ # Open browser with frontend URL for Phase 1
689
+ frontend_url = settings.lock_ui_url
690
+ phase1_params = {
691
+ "phase": "1",
692
+ "chainId": str(chain),
693
+ "usdcAddress": "0x2340D09c348930A76c8c2783EDa8610F699A51A8",
694
+ "vaultAddress": vault,
695
+ "spender": vault,
696
+ "amount": str(amount_base_units),
697
+ "owner": owner,
698
+ }
699
+ phase1_url = f"{frontend_url}?{urlencode(phase1_params)}"
700
+ console.print(f"\n[bold cyan]Opening lock interface...[/]")
701
+ console.print(f"URL: [cyan]{phase1_url}[/]")
702
+ try:
703
+ webbrowser.open(phase1_url)
704
+ except Exception as e:
705
+ console.print(f"[bold yellow]Warning:[/] Could not open browser automatically: {e}")
706
+ console.print(f"Please manually open: [cyan]{phase1_url}[/]")
707
+
708
+ # Auto-detect approval by polling USDC allowance
709
+ console.print(f"\n[bold cyan]Waiting for approval transaction...[/]")
710
+ console.print("[dim]The CLI will automatically detect when the approval is complete.[/]")
711
+ console.print("[dim]You can also press Ctrl+C to skip and continue manually.[/]")
712
+
713
+ # Base Sepolia RPC endpoint
714
+ base_sepolia_rpc = "https://sepolia.base.org"
715
+
716
+ # ERC20 ABI for allowance function and Approval event
717
+ erc20_abi = [
718
+ {
719
+ "constant": True,
720
+ "inputs": [
721
+ {"name": "_owner", "type": "address"},
722
+ {"name": "_spender", "type": "address"}
723
+ ],
724
+ "name": "allowance",
725
+ "outputs": [{"name": "", "type": "uint256"}],
726
+ "type": "function"
727
+ },
728
+ {
729
+ "anonymous": False,
730
+ "inputs": [
731
+ {"indexed": True, "name": "owner", "type": "address"},
732
+ {"indexed": True, "name": "spender", "type": "address"},
733
+ {"indexed": False, "name": "value", "type": "uint256"}
734
+ ],
735
+ "name": "Approval",
736
+ "type": "event"
737
+ }
738
+ ]
739
+
740
+ max_polls = 60 # Poll for up to 5 minutes (60 * 5 seconds)
741
+ poll_interval = 5 # Check every 5 seconds
742
+
743
+ approval_detected = False
744
+ try:
745
+ w3 = Web3(Web3.HTTPProvider(base_sepolia_rpc))
746
+ usdc_contract = w3.eth.contract(
747
+ address=Web3.to_checksum_address(usdc_contract_address),
748
+ abi=erc20_abi
749
+ )
750
+
751
+ # Also check for Approval events to detect which address actually approved
752
+ # This helps if user approves with different wallet than specified
753
+ approval_event_signature = Web3.keccak(text="Approval(address,address,uint256)").hex()
754
+
755
+ with Status(
756
+ "[bold cyan]Waiting for approval transaction...[/]",
757
+ console=console,
758
+ spinner="dots",
759
+ ) as status:
760
+ for poll_num in range(max_polls):
761
+ try:
762
+ # Check current allowance for the specified owner
763
+ current_allowance = usdc_contract.functions.allowance(
764
+ Web3.to_checksum_address(owner),
765
+ Web3.to_checksum_address(vault)
766
+ ).call()
767
+
768
+ if current_allowance >= amount_base_units:
769
+ approval_detected = True
770
+ status.stop()
771
+ console.print("\n[bold green]✓ Approval detected![/]")
772
+ console.print(f"[dim]Current allowance: {current_allowance} (required: {amount_base_units})[/]")
773
+ break
774
+
775
+ # Also check recent Approval events to see if approval happened with different address
776
+ # Get latest block number
777
+ latest_block = w3.eth.block_number
778
+ # Check last 10 blocks for Approval events
779
+ from_block = max(0, latest_block - 10)
780
+
781
+ try:
782
+ # Filter for Approval events where spender is the vault
783
+ events = usdc_contract.events.Approval.get_logs(
784
+ fromBlock=from_block,
785
+ toBlock=latest_block,
786
+ argument_filters={'spender': Web3.to_checksum_address(vault)}
787
+ )
788
+
789
+ # Check if any recent approval has sufficient amount
790
+ for event in events:
791
+ if event.args.value >= amount_base_units:
792
+ # Found approval with sufficient amount, but from different owner
793
+ actual_owner = event.args.owner
794
+ if actual_owner.lower() != owner.lower():
795
+ status.stop()
796
+ console.print(f"\n[yellow]⚠ Approval detected, but from different address![/]")
797
+ console.print(f"[dim]Expected owner: {owner}[/]")
798
+ console.print(f"[dim]Actual approver: {actual_owner}[/]")
799
+ console.print(f"[dim]Approval amount: {event.args.value} (required: {amount_base_units})[/]")
800
+ console.print(f"\n[yellow]Please approve with the correct wallet address: {owner}[/]")
801
+ console.print("[dim]Or update the owner address in the CLI to match the wallet you used.[/]")
802
+ approval_detected = False
803
+ break
804
+ except Exception:
805
+ # If event filtering fails, continue with normal polling
806
+ pass
807
+
808
+ # Update status message
809
+ status.update(
810
+ f"[bold cyan]Waiting for approval... (checking {poll_num + 1}/{max_polls})[/] "
811
+ f"[dim]Current allowance: {current_allowance} / {amount_base_units}[/]"
812
+ )
813
+
814
+ time.sleep(poll_interval)
815
+ except KeyboardInterrupt:
816
+ status.stop()
817
+ console.print("\n[yellow]Polling interrupted by user.[/]")
818
+ break
819
+ except Exception as e:
820
+ # Network errors - continue polling
821
+ if poll_num < max_polls - 1:
822
+ status.update(
823
+ f"[yellow]Network error, retrying... (check {poll_num + 1}/{max_polls})[/]"
824
+ )
825
+ time.sleep(poll_interval)
826
+ else:
827
+ status.stop()
828
+ console.print(f"\n[yellow]Could not verify approval automatically: {e}[/]")
829
+ break
830
+ except Exception as e:
831
+ console.print(f"[yellow]Warning:[/] Could not set up approval detection: {e}")
832
+ console.print("[dim]Falling back to manual confirmation...[/]")
833
+
834
+ # If approval not detected, ask user
835
+ if not approval_detected:
836
+ if not Confirm.ask(
837
+ "\n[bold yellow]Have you completed the approve transaction?[/] (Type 'yes' to continue to Phase 2)",
838
+ default=False
839
+ ):
840
+ console.print("[bold yellow]You can continue with Phase 2 later. The signature will expire in 5 minutes.[/]")
841
+ raise typer.Exit(code=0)
842
+
843
+ # Calculate hotkey bytes32 (keccak256 of SS58 string) - moved before Phase 1 display
844
+ hotkey_bytes = hotkey_ss58.encode("utf-8")
845
+ hotkey_bytes32 = Web3.keccak(hotkey_bytes)
846
+ # Ensure single 0x prefix (hex() doesn't include 0x)
847
+ hotkey_hex = hotkey_bytes32.hex()
848
+ if not hotkey_hex.startswith("0x"):
849
+ hotkey_hex = "0x" + hotkey_hex
850
+
851
+ # Convert pool_id to bytes32 hex if needed
852
+ pool_id_normalized = pool_id.lower().strip()
853
+ if not pool_id_normalized.startswith("0x"):
854
+ pool_id_normalized = "0x" + pool_id_normalized
855
+ if len(pool_id_normalized) == 42:
856
+ # Legacy address format: pad to bytes32
857
+ hex_part = pool_id_normalized[2:]
858
+ padded_hex = "0" * 24 + hex_part
859
+ pool_id_normalized = "0x" + padded_hex
860
+
861
+ # Ensure signature has single 0x prefix
862
+ signature_normalized = signature.strip()
863
+ if signature_normalized.startswith("0x0x"):
864
+ # Remove double 0x prefix
865
+ signature_normalized = signature_normalized[2:]
866
+ elif not signature_normalized.startswith("0x"):
867
+ signature_normalized = "0x" + signature_normalized
868
+
869
+ # Step 11: Display Phase 2: Lock Position
870
+ console.print()
871
+ console.print("[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]")
872
+ console.print("[bold]Phase 2: Lock Position[/]")
873
+ console.print("[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]")
874
+ console.print()
875
+ # Verify vault address matches what we expect
876
+ if lock_tx['to'].lower() != vault.lower():
877
+ console.print(
878
+ f"[bold yellow]⚠ Warning:[/] Transaction vault address ({lock_tx['to']}) "
879
+ f"does not match selected vault ({vault})"
880
+ )
881
+ console.print("[bold]Fields for Frontend Lock Page:[/]")
882
+ console.print()
883
+ console.print(f" [yellow]Contract Address (Vault)[/]: {lock_tx['to']}")
884
+ console.print(f" [yellow]poolId_[/] (bytes32): {pool_id_normalized}")
885
+ console.print(f" [yellow]amount[/] (uint256): {amount_base_units}")
886
+ console.print(f" [yellow]lockDays[/] (uint64): {lock_days}")
887
+ console.print(f" [yellow]hotkey[/] (bytes32): {hotkey_hex}")
888
+ console.print(f" [yellow]timestamp[/] (uint256): {timestamp}")
889
+ console.print(f" [yellow]signature[/] (bytes): {signature_normalized}")
890
+ console.print(f" [yellow]owner[/] (address): {owner}")
891
+ console.print()
892
+
893
+ # Open browser with frontend URL for Phase 2
894
+ frontend_url = settings.lock_ui_url
895
+ phase2_params = {
896
+ "phase": "2",
897
+ "chainId": str(chain),
898
+ "vaultAddress": lock_tx['to'],
899
+ "poolId": pool_id_normalized,
900
+ "amount": str(amount_base_units),
901
+ "lockDays": str(lock_days),
902
+ "hotkey": hotkey_hex,
903
+ "timestamp": str(timestamp),
904
+ "signature": signature_normalized,
905
+ "owner": owner,
906
+ }
907
+ phase2_url = f"{frontend_url}?{urlencode(phase2_params)}"
908
+ console.print(f"\n[bold cyan]Opening lock interface for Phase 2...[/]")
909
+ console.print(f"URL: [cyan]{phase2_url}[/]")
910
+ try:
911
+ webbrowser.open(phase2_url)
912
+ except Exception as e:
913
+ console.print(f"[bold yellow]Warning:[/] Could not open browser automatically: {e}")
914
+ console.print(f"Please manually open: [cyan]{phase2_url}[/]")
915
+ console.print()
916
+ console.print(
917
+ "[dim]The CLI will automatically detect when the lock transaction is complete.[/]"
918
+ )
919
+ console.print("[dim]You can also press Ctrl+C to skip and continue manually.[/]")
920
+
921
+ # Step 9: Auto-detect lock transaction and check status
922
+ # Vault ABI for LockCreated event
923
+ vault_abi = [
924
+ {
925
+ "anonymous": False,
926
+ "inputs": [
927
+ {"indexed": True, "internalType": "bytes32", "name": "lockId", "type": "bytes32"},
928
+ {"indexed": True, "internalType": "address", "name": "owner", "type": "address"},
929
+ {"indexed": True, "internalType": "bytes32", "name": "poolId", "type": "bytes32"},
930
+ {"indexed": False, "internalType": "address", "name": "vault", "type": "address"},
931
+ {"indexed": False, "internalType": "uint256", "name": "amount", "type": "uint256"},
932
+ {"indexed": False, "internalType": "uint64", "name": "start", "type": "uint64"},
933
+ {"indexed": False, "internalType": "uint64", "name": "lockDays", "type": "uint64"}
934
+ ],
935
+ "name": "LockCreated",
936
+ "type": "event"
937
+ }
938
+ ]
939
+
940
+ # Get RPC endpoint for Base Sepolia
941
+ rpc_url = None
942
+ if chain == 84532: # Base Sepolia
943
+ rpc_url = "https://sepolia.base.org"
944
+ else:
945
+ # Only Base Sepolia is supported for auto-detection
946
+ console.print(f"[yellow]Warning:[/] Auto-detection only supports Base Sepolia (chain ID 84532), but chain ID {chain} was specified.")
947
+ console.print("[dim]You'll need to enter the transaction hash manually.[/]")
948
+ rpc_url = None
949
+
950
+ lock_detected = False
951
+ tx_hash_normalized = None
952
+
953
+ if rpc_url:
954
+ try:
955
+ w3 = Web3(Web3.HTTPProvider(rpc_url))
956
+ vault_contract = w3.eth.contract(
957
+ address=Web3.to_checksum_address(vault),
958
+ abi=vault_abi
959
+ )
960
+
961
+ # Calculate expected pool_id bytes32
962
+ pool_id_bytes = Web3.to_bytes(hexstr=pool_id_normalized)
963
+
964
+ max_polls = 60 # Poll for up to 5 minutes (60 * 5 seconds)
965
+ poll_interval = 5 # Check every 5 seconds
966
+
967
+ with Status(
968
+ "[bold cyan]Waiting for lock transaction...[/]",
969
+ console=console,
970
+ spinner="dots",
971
+ ) as status:
972
+ start_block = w3.eth.block_number
973
+ for poll_num in range(max_polls):
974
+ try:
975
+ latest_block = w3.eth.block_number
976
+ # Check last 20 blocks for LockCreated events
977
+ from_block = max(start_block - 1, latest_block - 20)
978
+
979
+ # Filter for LockCreated events matching owner and poolId
980
+ events = vault_contract.events.LockCreated().get_logs(
981
+ fromBlock=from_block,
982
+ toBlock=latest_block,
983
+ argument_filters={
984
+ 'owner': Web3.to_checksum_address(owner),
985
+ 'poolId': pool_id_bytes
986
+ }
987
+ )
988
+
989
+ if events:
990
+ # Found matching lock event
991
+ event = events[-1] # Get the most recent one
992
+ tx_hash_normalized = event['transactionHash'].hex()
993
+ lock_detected = True
994
+ status.stop()
995
+ console.print("\n[bold green]✓ Lock transaction detected![/]")
996
+ console.print(f"[dim]Transaction hash: {tx_hash_normalized}[/]")
997
+ break
998
+
999
+ # Update status message
1000
+ status.update(
1001
+ f"[bold cyan]Waiting for lock transaction... (checking {poll_num + 1}/{max_polls})[/]"
1002
+ )
1003
+
1004
+ time.sleep(poll_interval)
1005
+ except KeyboardInterrupt:
1006
+ status.stop()
1007
+ console.print("\n[yellow]Polling interrupted by user.[/]")
1008
+ break
1009
+ except Exception as e:
1010
+ # Network errors - continue polling
1011
+ if poll_num < max_polls - 1:
1012
+ status.update(
1013
+ f"[yellow]Network error, retrying... (check {poll_num + 1}/{max_polls})[/]"
1014
+ )
1015
+ time.sleep(poll_interval)
1016
+ else:
1017
+ status.stop()
1018
+ console.print(f"\n[yellow]Could not detect lock transaction automatically: {e}[/]")
1019
+ break
1020
+ except Exception as e:
1021
+ console.print(f"[yellow]Warning:[/] Could not set up lock detection: {e}")
1022
+ console.print("[dim]Falling back to manual transaction hash entry...[/]")
1023
+
1024
+ # If lock not detected automatically, prompt for transaction hash
1025
+ if not lock_detected:
1026
+ console.print()
1027
+ while True:
1028
+ tx_hash = typer.prompt(
1029
+ "[bold cyan]Transaction hash (0x...)[/] (Press Enter to skip)",
1030
+ show_default=False,
1031
+ default=""
1032
+ )
1033
+ if not tx_hash.strip():
1034
+ # User skipped - show instructions
1035
+ console.print()
1036
+ console.print("[bold green]✓ Lock flow complete![/]")
1037
+ console.print()
1038
+ console.print(
1039
+ "[dim]The verifier will automatically detect the lock after you execute the transaction.[/]"
1040
+ )
1041
+ console.print(
1042
+ f"[dim]Check your lock status with: [bold]cartha miner status --wallet-name {coldkey} --wallet-hotkey {hotkey}[/][/]"
1043
+ )
1044
+ console.print(
1045
+ f"[dim]Or visit the frontend: [bold]{frontend_url}/manage[/][/]"
1046
+ )
1047
+ console.print()
1048
+ tx_hash_normalized = None
1049
+ break
1050
+
1051
+ tx_hash_normalized = normalize_hex(tx_hash)
1052
+ if len(tx_hash_normalized) == 66:
1053
+ break
1054
+ console.print(
1055
+ "[bold red]Error:[/] Transaction hash must be 32 bytes (0x + 64 hex characters)"
1056
+ )
1057
+
1058
+ # If we have a transaction hash (either auto-detected or manually entered), check status
1059
+ if tx_hash_normalized:
1060
+ console.print()
1061
+ console.print("[dim]Verification can take up to 1 minute. Please wait...[/]")
1062
+ console.print()
1063
+
1064
+ max_status_polls = 8 # Poll for up to 40 seconds (8 * 5 seconds)
1065
+ status_poll_interval = 5 # Check every 5 seconds
1066
+ verified = False
1067
+ status_result = None
1068
+
1069
+ with Status(
1070
+ "[bold cyan]Waiting for verifier to process lock...[/]",
1071
+ console=console,
1072
+ spinner="dots",
1073
+ ) as status:
1074
+ for poll_num in range(max_status_polls):
1075
+ try:
1076
+ status.update(
1077
+ f"[bold cyan]Checking lock status... ({poll_num + 1}/{max_status_polls})[/] "
1078
+ f"[dim](This can take up to 1 minute)[/]"
1079
+ )
1080
+ status_result = get_lock_status(tx_hash=tx_hash_normalized)
1081
+
1082
+ if status_result.get("verified"):
1083
+ verified = True
1084
+ status.stop()
1085
+ break
1086
+
1087
+ # If not verified yet, continue polling
1088
+ if poll_num < max_status_polls - 1:
1089
+ time.sleep(status_poll_interval)
1090
+ except KeyboardInterrupt:
1091
+ status.stop()
1092
+ console.print("\n[yellow]Status check interrupted.[/]")
1093
+ break
1094
+ except Exception as exc:
1095
+ # Network errors - continue polling
1096
+ if poll_num < max_status_polls - 1:
1097
+ status.update(
1098
+ f"[yellow]Network error, retrying... ({poll_num + 1}/{max_status_polls})[/]"
1099
+ )
1100
+ time.sleep(status_poll_interval)
1101
+ else:
1102
+ status.stop()
1103
+ console.print(f"\n[yellow]Could not check lock status: {exc}[/]")
1104
+ break
1105
+
1106
+ if verified and status_result:
1107
+ # Position is processed - show success message
1108
+ console.print("\n[bold green]✓ Lock successful![/]")
1109
+ console.print()
1110
+ console.print(
1111
+ f"[bold cyan]Lock ID:[/] {status_result.get('lockId', 'N/A')}"
1112
+ )
1113
+ console.print(
1114
+ f"[bold cyan]Added to epoch:[/] {status_result.get('addedToEpoch', 'N/A')}"
1115
+ )
1116
+ console.print()
1117
+ console.print("[bold]Check your lock status:[/]")
1118
+ console.print(
1119
+ f" • CLI: [bold]cartha miner status --wallet-name {coldkey} --wallet-hotkey {hotkey}[/]"
1120
+ )
1121
+ console.print(
1122
+ f" • Frontend: [bold]{frontend_url}/manage[/]"
1123
+ )
1124
+ console.print()
1125
+ else:
1126
+ # Position not yet processed after polling - ask if user wants to manually trigger
1127
+ message = status_result.get("message", "") if status_result else ""
1128
+ console.print("\n[yellow]Position not yet processed by verifier after waiting.[/]")
1129
+ if message:
1130
+ console.print(f"[dim]{message}[/]")
1131
+ console.print()
1132
+
1133
+ if Confirm.ask(
1134
+ "[bold cyan]Would you like to manually trigger processing?[/] (This will nudge the verifier to check)",
1135
+ default=False,
1136
+ ):
1137
+ try:
1138
+ with Status(
1139
+ "[bold cyan]Triggering verifier processing...[/]",
1140
+ console=console,
1141
+ spinner="dots",
1142
+ ) as process_status:
1143
+ process_result = process_lock_transaction(tx_hash=tx_hash_normalized)
1144
+ process_status.stop()
1145
+
1146
+ if process_result.get("success"):
1147
+ # Give database a moment to commit
1148
+ time.sleep(1.5)
1149
+
1150
+ # Check status again
1151
+ verified = False
1152
+ status_result = None
1153
+ for retry in range(3):
1154
+ try:
1155
+ status_result = get_lock_status(tx_hash=tx_hash_normalized)
1156
+ if status_result.get("verified"):
1157
+ verified = True
1158
+ break
1159
+ elif retry < 2:
1160
+ time.sleep(0.5)
1161
+ except Exception:
1162
+ if retry < 2:
1163
+ time.sleep(0.5)
1164
+ continue
1165
+
1166
+ if verified and status_result:
1167
+ console.print("\n[bold green]✓ Lock successful![/]")
1168
+ console.print()
1169
+ console.print(
1170
+ f"[bold cyan]Lock ID:[/] {status_result.get('lockId', 'N/A')}"
1171
+ )
1172
+ console.print(
1173
+ f"[bold cyan]Added to epoch:[/] {status_result.get('addedToEpoch', 'N/A')}"
1174
+ )
1175
+ console.print()
1176
+ console.print("[bold]Check your lock status:[/]")
1177
+ console.print(
1178
+ f" • CLI: [bold]cartha miner status --wallet-name {coldkey} --wallet-hotkey {hotkey}[/]"
1179
+ )
1180
+ console.print(
1181
+ f" • Frontend: [bold]{frontend_url}/manage[/]"
1182
+ )
1183
+ console.print()
1184
+ else:
1185
+ console.print("\n[yellow]Processing triggered but not yet verified.[/]")
1186
+ console.print(
1187
+ "[dim]The verifier will process it automatically. Check status later.[/]"
1188
+ )
1189
+ console.print()
1190
+ console.print("[bold]Check your lock status:[/]")
1191
+ console.print(
1192
+ f" • CLI: [bold]cartha miner status --wallet-name {coldkey} --wallet-hotkey {hotkey}[/]"
1193
+ )
1194
+ console.print(
1195
+ f" • Frontend: [bold]{frontend_url}/manage[/]"
1196
+ )
1197
+ console.print()
1198
+ else:
1199
+ console.print("\n[yellow]Could not trigger processing.[/]")
1200
+ console.print(
1201
+ "[dim]The verifier will process it automatically. Check status later.[/]"
1202
+ )
1203
+ console.print()
1204
+ console.print("[bold]Check your lock status:[/]")
1205
+ console.print(
1206
+ f" • CLI: [bold]cartha miner status --wallet-name {coldkey} --wallet-hotkey {hotkey}[/]"
1207
+ )
1208
+ console.print(
1209
+ f" • Frontend: [bold]{frontend_url}/manage[/]"
1210
+ )
1211
+ console.print()
1212
+ except VerifierError as process_exc:
1213
+ console.print(f"\n[yellow]Error triggering processing: {process_exc}[/]")
1214
+ console.print(
1215
+ "[dim]The verifier will process it automatically. Check status later.[/]"
1216
+ )
1217
+ console.print()
1218
+ console.print("[bold]Check your lock status:[/]")
1219
+ console.print(
1220
+ f" • CLI: [bold]cartha miner status --wallet-name {coldkey} --wallet-hotkey {hotkey}[/]"
1221
+ )
1222
+ console.print(
1223
+ f" • Frontend: [bold]{frontend_url}/manage[/]"
1224
+ )
1225
+ console.print()
1226
+ except Exception as process_exc:
1227
+ console.print(f"\n[yellow]Error: {process_exc}[/]")
1228
+ console.print(
1229
+ "[dim]The verifier will process it automatically. Check status later.[/]"
1230
+ )
1231
+ console.print()
1232
+ console.print("[bold]Check your lock status:[/]")
1233
+ console.print(
1234
+ f" • CLI: [bold]cartha miner status --wallet-name {coldkey} --wallet-hotkey {hotkey}[/]"
1235
+ )
1236
+ console.print(
1237
+ f" • Frontend: [bold]{frontend_url}/manage[/]"
1238
+ )
1239
+ console.print()
1240
+ else:
1241
+ # User declined manual processing
1242
+ console.print()
1243
+ console.print("[bold]Check your lock status:[/]")
1244
+ console.print(
1245
+ f" • CLI: [bold]cartha miner status --wallet-name {coldkey} --wallet-hotkey {hotkey}[/]"
1246
+ )
1247
+ console.print(
1248
+ f" • Frontend: [bold]{frontend_url}/manage[/]"
1249
+ )
1250
+ console.print()
1251
+ console.print("[dim]If the verifier doesn't detect your position automatically, you can manually trigger processing later:[/]")
1252
+ console.print(
1253
+ f" [bold]cartha miner status --wallet-name {coldkey} --wallet-hotkey {hotkey} --refresh --tx-hash {tx_hash_normalized}[/]"
1254
+ )
1255
+ console.print()
1256
+
1257
+ except typer.Exit:
1258
+ raise
1259
+ except Exception as exc:
1260
+ handle_unexpected_exception("Lock creation failed", exc)