bittensor-cli 9.0.0rc1__py3-none-any.whl → 9.0.0rc3__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,1146 @@
1
+ import asyncio
2
+ from functools import partial
3
+
4
+ from typing import TYPE_CHECKING, Optional
5
+ import typer
6
+
7
+ from bittensor_wallet import Wallet
8
+ from bittensor_wallet.errors import KeyFileError
9
+ from rich.prompt import Confirm, Prompt
10
+ from rich.table import Table
11
+
12
+ from async_substrate_interface.errors import SubstrateRequestException
13
+ from bittensor_cli.src import COLOR_PALETTE
14
+ from bittensor_cli.src.bittensor.balances import Balance
15
+ from bittensor_cli.src.bittensor.utils import (
16
+ console,
17
+ err_console,
18
+ print_verbose,
19
+ print_error,
20
+ get_hotkey_wallets_for_wallet,
21
+ is_valid_ss58_address,
22
+ format_error_message,
23
+ group_subnets,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface
28
+
29
+
30
+ # Commands
31
+ async def unstake(
32
+ wallet: Wallet,
33
+ subtensor: "SubtensorInterface",
34
+ hotkey_ss58_address: str,
35
+ all_hotkeys: bool,
36
+ include_hotkeys: list[str],
37
+ exclude_hotkeys: list[str],
38
+ amount: float,
39
+ prompt: bool,
40
+ interactive: bool,
41
+ netuid: Optional[int],
42
+ safe_staking: bool,
43
+ rate_tolerance: float,
44
+ allow_partial_stake: bool,
45
+ ):
46
+ """Unstake from hotkey(s)."""
47
+ unstake_all_from_hk = False
48
+ with console.status(
49
+ f"Retrieving subnet data & identities from {subtensor.network}...",
50
+ spinner="earth",
51
+ ):
52
+ all_sn_dynamic_info_, ck_hk_identities, old_identities = await asyncio.gather(
53
+ subtensor.all_subnets(),
54
+ subtensor.fetch_coldkey_hotkey_identities(),
55
+ subtensor.get_delegate_identities(),
56
+ )
57
+ all_sn_dynamic_info = {info.netuid: info for info in all_sn_dynamic_info_}
58
+
59
+ if interactive:
60
+ hotkeys_to_unstake_from, unstake_all_from_hk = await _unstake_selection(
61
+ subtensor,
62
+ wallet,
63
+ all_sn_dynamic_info,
64
+ ck_hk_identities,
65
+ old_identities,
66
+ netuid=netuid,
67
+ )
68
+ if unstake_all_from_hk:
69
+ hotkey_to_unstake_all = hotkeys_to_unstake_from[0]
70
+ unstake_all_alpha = Confirm.ask(
71
+ "\nUnstake [blue]all alpha stakes[/blue] and stake back to [blue]root[/blue]? (No will unstake everything)",
72
+ default=True,
73
+ )
74
+ return await unstake_all(
75
+ wallet=wallet,
76
+ subtensor=subtensor,
77
+ hotkey_ss58_address=hotkey_to_unstake_all[1],
78
+ unstake_all_alpha=unstake_all_alpha,
79
+ prompt=prompt,
80
+ )
81
+
82
+ if not hotkeys_to_unstake_from:
83
+ console.print("[red]No unstake operations to perform.[/red]")
84
+ return False
85
+ netuids = list({netuid for _, _, netuid in hotkeys_to_unstake_from})
86
+
87
+ else:
88
+ netuids = (
89
+ [int(netuid)]
90
+ if netuid is not None
91
+ else await subtensor.get_all_subnet_netuids()
92
+ )
93
+ hotkeys_to_unstake_from = _get_hotkeys_to_unstake(
94
+ wallet=wallet,
95
+ hotkey_ss58_address=hotkey_ss58_address,
96
+ all_hotkeys=all_hotkeys,
97
+ include_hotkeys=include_hotkeys,
98
+ exclude_hotkeys=exclude_hotkeys,
99
+ )
100
+
101
+ with console.status(
102
+ f"Retrieving stake data from {subtensor.network}...",
103
+ spinner="earth",
104
+ ):
105
+ # Fetch stake balances
106
+ chain_head = await subtensor.substrate.get_chain_head()
107
+ stake_info_list = await subtensor.get_stake_for_coldkey(
108
+ coldkey_ss58=wallet.coldkeypub.ss58_address,
109
+ block_hash=chain_head,
110
+ )
111
+ stake_in_netuids = {}
112
+ for stake_info in stake_info_list:
113
+ if stake_info.hotkey_ss58 not in stake_in_netuids:
114
+ stake_in_netuids[stake_info.hotkey_ss58] = {}
115
+ stake_in_netuids[stake_info.hotkey_ss58][stake_info.netuid] = (
116
+ stake_info.stake
117
+ )
118
+
119
+ # Flag to check if user wants to quit
120
+ skip_remaining_subnets = False
121
+ if len(netuids) > 1 and not amount:
122
+ console.print(
123
+ "[dark_sea_green3]Tip: Enter 'q' any time to stop going over remaining subnets and process current unstakes.\n"
124
+ )
125
+
126
+ # Iterate over hotkeys and netuids to collect unstake operations
127
+ unstake_operations = []
128
+ total_received_amount = Balance.from_tao(0)
129
+ max_float_slippage = 0
130
+ table_rows = []
131
+ for hotkey in hotkeys_to_unstake_from:
132
+ if skip_remaining_subnets:
133
+ break
134
+
135
+ if interactive:
136
+ staking_address_name, staking_address_ss58, netuid = hotkey
137
+ netuids_to_process = [netuid]
138
+ else:
139
+ staking_address_name, staking_address_ss58 = hotkey
140
+ netuids_to_process = netuids
141
+
142
+ initial_amount = amount
143
+
144
+ for netuid in netuids_to_process:
145
+ if skip_remaining_subnets:
146
+ break # Exit the loop over netuids
147
+
148
+ subnet_info = all_sn_dynamic_info.get(netuid)
149
+ if staking_address_ss58 not in stake_in_netuids:
150
+ print_error(
151
+ f"No stake found for hotkey: {staking_address_ss58} on netuid: {netuid}"
152
+ )
153
+ continue # Skip to next hotkey
154
+
155
+ current_stake_balance = stake_in_netuids[staking_address_ss58].get(netuid)
156
+ if current_stake_balance is None or current_stake_balance.tao == 0:
157
+ print_error(
158
+ f"No stake to unstake from {staking_address_ss58} on netuid: {netuid}"
159
+ )
160
+ continue # No stake to unstake
161
+
162
+ # Determine the amount we are unstaking.
163
+ if initial_amount:
164
+ amount_to_unstake_as_balance = Balance.from_tao(initial_amount)
165
+ else:
166
+ amount_to_unstake_as_balance = _ask_unstake_amount(
167
+ current_stake_balance,
168
+ netuid,
169
+ staking_address_name
170
+ if staking_address_name
171
+ else staking_address_ss58,
172
+ staking_address_ss58,
173
+ interactive,
174
+ )
175
+ if amount_to_unstake_as_balance is None:
176
+ skip_remaining_subnets = True
177
+ break
178
+
179
+ # Check enough stake to remove.
180
+ amount_to_unstake_as_balance.set_unit(netuid)
181
+ if amount_to_unstake_as_balance > current_stake_balance:
182
+ err_console.print(
183
+ f"[red]Not enough stake to remove[/red]:\n Stake balance: [dark_orange]{current_stake_balance}[/dark_orange]"
184
+ f" < Unstaking amount: [dark_orange]{amount_to_unstake_as_balance}[/dark_orange] on netuid: {netuid}"
185
+ )
186
+ continue # Skip to the next subnet - useful when single amount is specified for all subnets
187
+
188
+ received_amount, slippage_pct, slippage_pct_float = _calculate_slippage(
189
+ subnet_info=subnet_info, amount=amount_to_unstake_as_balance
190
+ )
191
+ total_received_amount += received_amount
192
+ max_float_slippage = max(max_float_slippage, slippage_pct_float)
193
+
194
+ base_unstake_op = {
195
+ "netuid": netuid,
196
+ "hotkey_name": staking_address_name
197
+ if staking_address_name
198
+ else staking_address_ss58,
199
+ "hotkey_ss58": staking_address_ss58,
200
+ "amount_to_unstake": amount_to_unstake_as_balance,
201
+ "current_stake_balance": current_stake_balance,
202
+ "received_amount": received_amount,
203
+ "slippage_pct": slippage_pct,
204
+ "slippage_pct_float": slippage_pct_float,
205
+ "dynamic_info": subnet_info,
206
+ }
207
+
208
+ base_table_row = [
209
+ str(netuid), # Netuid
210
+ staking_address_name, # Hotkey Name
211
+ str(amount_to_unstake_as_balance), # Amount to Unstake
212
+ str(subnet_info.price.tao)
213
+ + f"({Balance.get_unit(0)}/{Balance.get_unit(netuid)})", # Rate
214
+ str(received_amount), # Received Amount
215
+ slippage_pct, # Slippage Percent
216
+ ]
217
+
218
+ # Additional fields for safe unstaking
219
+ if safe_staking:
220
+ if subnet_info.is_dynamic:
221
+ rate = subnet_info.price.tao or 1
222
+ rate_with_tolerance = rate * (
223
+ 1 - rate_tolerance
224
+ ) # Rate only for display
225
+ price_with_tolerance = subnet_info.price.rao * (
226
+ 1 - rate_tolerance
227
+ ) # Actual price to pass to extrinsic
228
+ else:
229
+ rate_with_tolerance = 1
230
+ price_with_tolerance = 1
231
+
232
+ base_unstake_op["price_with_tolerance"] = price_with_tolerance
233
+ base_table_row.extend(
234
+ [
235
+ f"{rate_with_tolerance:.4f} {Balance.get_unit(0)}/{Balance.get_unit(netuid)}", # Rate with tolerance
236
+ f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", # Partial unstake
237
+ ]
238
+ )
239
+
240
+ unstake_operations.append(base_unstake_op)
241
+ table_rows.append(base_table_row)
242
+
243
+ if not unstake_operations:
244
+ console.print("[red]No unstake operations to perform.[/red]")
245
+ return False
246
+
247
+ table = _create_unstake_table(
248
+ wallet_name=wallet.name,
249
+ wallet_coldkey_ss58=wallet.coldkeypub.ss58_address,
250
+ network=subtensor.network,
251
+ total_received_amount=total_received_amount,
252
+ safe_staking=safe_staking,
253
+ rate_tolerance=rate_tolerance,
254
+ )
255
+ for row in table_rows:
256
+ table.add_row(*row)
257
+
258
+ _print_table_and_slippage(table, max_float_slippage, safe_staking)
259
+ if prompt:
260
+ if not Confirm.ask("Would you like to continue?"):
261
+ raise typer.Exit()
262
+
263
+ # Execute extrinsics
264
+ try:
265
+ wallet.unlock_coldkey()
266
+ except KeyFileError:
267
+ err_console.print("Error decrypting coldkey (possibly incorrect password)")
268
+ return False
269
+
270
+ with console.status("\n:satellite: Performing unstaking operations...") as status:
271
+ if safe_staking:
272
+ for op in unstake_operations:
273
+ if op["netuid"] == 0:
274
+ await _unstake_extrinsic(
275
+ wallet=wallet,
276
+ subtensor=subtensor,
277
+ netuid=op["netuid"],
278
+ amount=op["amount_to_unstake"],
279
+ current_stake=op["current_stake_balance"],
280
+ hotkey_ss58=op["hotkey_ss58"],
281
+ status=status,
282
+ )
283
+ else:
284
+ await _safe_unstake_extrinsic(
285
+ wallet=wallet,
286
+ subtensor=subtensor,
287
+ netuid=op["netuid"],
288
+ amount=op["amount_to_unstake"],
289
+ current_stake=op["current_stake_balance"],
290
+ hotkey_ss58=op["hotkey_ss58"],
291
+ price_limit=op["price_with_tolerance"],
292
+ allow_partial_stake=allow_partial_stake,
293
+ status=status,
294
+ )
295
+ else:
296
+ for op in unstake_operations:
297
+ await _unstake_extrinsic(
298
+ wallet=wallet,
299
+ subtensor=subtensor,
300
+ netuid=op["netuid"],
301
+ amount=op["amount_to_unstake"],
302
+ current_stake=op["current_stake_balance"],
303
+ hotkey_ss58=op["hotkey_ss58"],
304
+ status=status,
305
+ )
306
+ console.print(
307
+ f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]Unstaking operations completed."
308
+ )
309
+
310
+
311
+ async def unstake_all(
312
+ wallet: Wallet,
313
+ subtensor: "SubtensorInterface",
314
+ hotkey_ss58_address: str,
315
+ unstake_all_alpha: bool = False,
316
+ prompt: bool = True,
317
+ ) -> bool:
318
+ """Unstakes all stakes from all hotkeys in all subnets."""
319
+
320
+ with console.status(
321
+ f"Retrieving stake information & identities from {subtensor.network}...",
322
+ spinner="earth",
323
+ ):
324
+ (
325
+ stake_info,
326
+ ck_hk_identities,
327
+ old_identities,
328
+ all_sn_dynamic_info_,
329
+ current_wallet_balance,
330
+ ) = await asyncio.gather(
331
+ subtensor.get_stake_for_coldkey(wallet.coldkeypub.ss58_address),
332
+ subtensor.fetch_coldkey_hotkey_identities(),
333
+ subtensor.get_delegate_identities(),
334
+ subtensor.all_subnets(),
335
+ subtensor.get_balance(wallet.coldkeypub.ss58_address),
336
+ )
337
+ if not hotkey_ss58_address:
338
+ hotkey_ss58_address = wallet.hotkey.ss58_address
339
+ stake_info = [
340
+ stake for stake in stake_info if stake.hotkey_ss58 == hotkey_ss58_address
341
+ ]
342
+
343
+ if unstake_all_alpha:
344
+ stake_info = [stake for stake in stake_info if stake.netuid != 0]
345
+
346
+ if not stake_info:
347
+ console.print("[red]No stakes found to unstake[/red]")
348
+ return False
349
+
350
+ all_sn_dynamic_info = {info.netuid: info for info in all_sn_dynamic_info_}
351
+
352
+ # Create table for unstaking all
353
+ table_title = (
354
+ "Unstaking Summary - All Stakes"
355
+ if not unstake_all_alpha
356
+ else "Unstaking Summary - All Alpha Stakes"
357
+ )
358
+ table = Table(
359
+ title=(
360
+ f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]{table_title}[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n"
361
+ f"Wallet: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.name}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}], "
362
+ f"Coldkey ss58: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\n"
363
+ f"Network: [{COLOR_PALETTE['GENERAL']['HEADER']}]{subtensor.network}[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n"
364
+ ),
365
+ show_footer=True,
366
+ show_edge=False,
367
+ header_style="bold white",
368
+ border_style="bright_black",
369
+ style="bold",
370
+ title_justify="center",
371
+ show_lines=False,
372
+ pad_edge=True,
373
+ )
374
+ table.add_column("Netuid", justify="center", style="grey89")
375
+ table.add_column(
376
+ "Hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"]
377
+ )
378
+ table.add_column(
379
+ f"Current Stake ({Balance.get_unit(1)})",
380
+ justify="center",
381
+ style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"],
382
+ )
383
+ table.add_column(
384
+ f"Rate ({Balance.unit}/{Balance.get_unit(1)})",
385
+ justify="center",
386
+ style=COLOR_PALETTE["POOLS"]["RATE"],
387
+ )
388
+ table.add_column(
389
+ f"Recieved ({Balance.unit})",
390
+ justify="center",
391
+ style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"],
392
+ )
393
+ table.add_column(
394
+ "Slippage",
395
+ justify="center",
396
+ style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"],
397
+ )
398
+
399
+ # Calculate slippage and total received
400
+ max_slippage = 0.0
401
+ total_received_value = Balance(0)
402
+ for stake in stake_info:
403
+ if stake.stake.rao == 0:
404
+ continue
405
+
406
+ # Get hotkey identity
407
+ if hk_identity := ck_hk_identities["hotkeys"].get(stake.hotkey_ss58):
408
+ hotkey_name = hk_identity.get("identity", {}).get(
409
+ "name", ""
410
+ ) or hk_identity.get("display", "~")
411
+ hotkey_display = f"{hotkey_name}"
412
+ elif old_identity := old_identities.get(stake.hotkey_ss58):
413
+ hotkey_name = old_identity.display
414
+ hotkey_display = f"{hotkey_name}"
415
+ else:
416
+ hotkey_display = stake.hotkey_ss58
417
+
418
+ subnet_info = all_sn_dynamic_info.get(stake.netuid)
419
+ stake_amount = stake.stake
420
+ received_amount, slippage_pct, slippage_pct_float = _calculate_slippage(
421
+ subnet_info=subnet_info, amount=stake_amount
422
+ )
423
+ max_slippage = max(max_slippage, slippage_pct_float)
424
+ total_received_value += received_amount
425
+
426
+ table.add_row(
427
+ str(stake.netuid),
428
+ hotkey_display,
429
+ str(stake_amount),
430
+ str(float(subnet_info.price))
431
+ + f"({Balance.get_unit(0)}/{Balance.get_unit(stake.netuid)})",
432
+ str(received_amount),
433
+ slippage_pct,
434
+ )
435
+ console.print(table)
436
+ message = ""
437
+ if max_slippage > 5:
438
+ message += f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]-------------------------------------------------------------------------------------------------------------------\n"
439
+ message += f"[bold]WARNING:[/bold] The slippage on one of your operations is high: [{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]{max_slippage:.4f}%[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}], this may result in a loss of funds.\n"
440
+ message += "-------------------------------------------------------------------------------------------------------------------\n"
441
+ console.print(message)
442
+
443
+ console.print(
444
+ f"Expected return after slippage: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{total_received_value}"
445
+ )
446
+
447
+ if prompt and not Confirm.ask(
448
+ "\nDo you want to proceed with unstaking everything?"
449
+ ):
450
+ return False
451
+
452
+ try:
453
+ wallet.unlock_coldkey()
454
+ except KeyFileError:
455
+ err_console.print("Error decrypting coldkey (possibly incorrect password)")
456
+ return False
457
+
458
+ console_status = (
459
+ ":satellite: Unstaking all Alpha stakes..."
460
+ if unstake_all_alpha
461
+ else ":satellite: Unstaking all stakes..."
462
+ )
463
+ previous_root_stake = await subtensor.get_stake(
464
+ hotkey_ss58=hotkey_ss58_address,
465
+ coldkey_ss58=wallet.coldkeypub.ss58_address,
466
+ netuid=0,
467
+ )
468
+ with console.status(console_status):
469
+ call_function = "unstake_all_alpha" if unstake_all_alpha else "unstake_all"
470
+ call = await subtensor.substrate.compose_call(
471
+ call_module="SubtensorModule",
472
+ call_function=call_function,
473
+ call_params={"hotkey": hotkey_ss58_address},
474
+ )
475
+ success, error_message = await subtensor.sign_and_send_extrinsic(
476
+ call=call,
477
+ wallet=wallet,
478
+ wait_for_inclusion=True,
479
+ wait_for_finalization=False,
480
+ )
481
+
482
+ if success:
483
+ success_message = (
484
+ ":white_heavy_check_mark: [green]Successfully unstaked all stakes[/green]"
485
+ if not unstake_all_alpha
486
+ else ":white_heavy_check_mark: [green]Successfully unstaked all Alpha stakes[/green]"
487
+ )
488
+ console.print(success_message)
489
+ new_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address)
490
+ console.print(
491
+ f"Balance:\n [blue]{current_wallet_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}"
492
+ )
493
+ if unstake_all_alpha:
494
+ root_stake = await subtensor.get_stake(
495
+ hotkey_ss58=hotkey_ss58_address,
496
+ coldkey_ss58=wallet.coldkeypub.ss58_address,
497
+ netuid=0,
498
+ )
499
+ console.print(
500
+ f"Root Stake:\n [blue]{previous_root_stake}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{root_stake}"
501
+ )
502
+ return True
503
+ else:
504
+ err_console.print(
505
+ f":cross_mark: [red]Failed to unstake[/red]: {error_message}"
506
+ )
507
+ return False
508
+
509
+
510
+ # Extrinsics
511
+ async def _unstake_extrinsic(
512
+ wallet: Wallet,
513
+ subtensor: "SubtensorInterface",
514
+ netuid: int,
515
+ amount: Balance,
516
+ current_stake: Balance,
517
+ hotkey_ss58: str,
518
+ status=None,
519
+ ) -> None:
520
+ """Execute a standard unstake extrinsic.
521
+
522
+ Args:
523
+ netuid: The subnet ID
524
+ amount: Amount to unstake
525
+ current_stake: Current stake balance
526
+ hotkey_ss58: Hotkey SS58 address
527
+ wallet: Wallet instance
528
+ subtensor: Subtensor interface
529
+ status: Optional status for console updates
530
+ """
531
+ err_out = partial(print_error, status=status)
532
+ failure_prelude = (
533
+ f":cross_mark: [red]Failed[/red] to unstake {amount} on Netuid {netuid}"
534
+ )
535
+
536
+ if status:
537
+ status.update(
538
+ f"\n:satellite: Unstaking {amount} from {hotkey_ss58} on netuid: {netuid} ..."
539
+ )
540
+
541
+ current_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address)
542
+ call = await subtensor.substrate.compose_call(
543
+ call_module="SubtensorModule",
544
+ call_function="remove_stake",
545
+ call_params={
546
+ "hotkey": hotkey_ss58,
547
+ "netuid": netuid,
548
+ "amount_unstaked": amount.rao,
549
+ },
550
+ )
551
+ extrinsic = await subtensor.substrate.create_signed_extrinsic(
552
+ call=call, keypair=wallet.coldkey
553
+ )
554
+
555
+ try:
556
+ response = await subtensor.substrate.submit_extrinsic(
557
+ extrinsic, wait_for_inclusion=True, wait_for_finalization=False
558
+ )
559
+ await response.process_events()
560
+
561
+ if not await response.is_success:
562
+ err_out(
563
+ f"{failure_prelude} with error: "
564
+ f"{format_error_message(await response.error_message, subtensor.substrate)}"
565
+ )
566
+ return
567
+
568
+ # Fetch latest balance and stake
569
+ block_hash = await subtensor.substrate.get_chain_head()
570
+ new_balance, new_stake = await asyncio.gather(
571
+ subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash),
572
+ subtensor.get_stake(
573
+ hotkey_ss58=hotkey_ss58,
574
+ coldkey_ss58=wallet.coldkeypub.ss58_address,
575
+ netuid=netuid,
576
+ block_hash=block_hash,
577
+ ),
578
+ )
579
+
580
+ console.print(":white_heavy_check_mark: [green]Finalized[/green]")
581
+ console.print(
582
+ f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}"
583
+ )
584
+ console.print(
585
+ f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]"
586
+ f" Stake:\n [blue]{current_stake}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}"
587
+ )
588
+
589
+ except Exception as e:
590
+ err_out(f"{failure_prelude} with error: {str(e)}")
591
+
592
+
593
+ async def _safe_unstake_extrinsic(
594
+ wallet: Wallet,
595
+ subtensor: "SubtensorInterface",
596
+ netuid: int,
597
+ amount: Balance,
598
+ current_stake: Balance,
599
+ hotkey_ss58: str,
600
+ price_limit: Balance,
601
+ allow_partial_stake: bool,
602
+ status=None,
603
+ ) -> None:
604
+ """Execute a safe unstake extrinsic with price limit.
605
+
606
+ Args:
607
+ netuid: The subnet ID
608
+ amount: Amount to unstake
609
+ current_stake: Current stake balance
610
+ hotkey_ss58: Hotkey SS58 address
611
+ price_limit: Maximum acceptable price
612
+ wallet: Wallet instance
613
+ subtensor: Subtensor interface
614
+ allow_partial_stake: Whether to allow partial unstaking
615
+ status: Optional status for console updates
616
+ """
617
+ err_out = partial(print_error, status=status)
618
+ failure_prelude = (
619
+ f":cross_mark: [red]Failed[/red] to unstake {amount} on Netuid {netuid}"
620
+ )
621
+
622
+ if status:
623
+ status.update(
624
+ f"\n:satellite: Unstaking {amount} from {hotkey_ss58} on netuid: {netuid} ..."
625
+ )
626
+
627
+ block_hash = await subtensor.substrate.get_chain_head()
628
+
629
+ current_balance, next_nonce, current_stake = await asyncio.gather(
630
+ subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash),
631
+ subtensor.substrate.get_account_next_index(wallet.coldkeypub.ss58_address),
632
+ subtensor.get_stake(
633
+ hotkey_ss58=hotkey_ss58,
634
+ coldkey_ss58=wallet.coldkeypub.ss58_address,
635
+ netuid=netuid,
636
+ ),
637
+ )
638
+
639
+ call = await subtensor.substrate.compose_call(
640
+ call_module="SubtensorModule",
641
+ call_function="remove_stake_limit",
642
+ call_params={
643
+ "hotkey": hotkey_ss58,
644
+ "netuid": netuid,
645
+ "amount_unstaked": amount.rao,
646
+ "limit_price": price_limit,
647
+ "allow_partial": allow_partial_stake,
648
+ },
649
+ )
650
+
651
+ extrinsic = await subtensor.substrate.create_signed_extrinsic(
652
+ call=call, keypair=wallet.coldkey, nonce=next_nonce
653
+ )
654
+
655
+ try:
656
+ response = await subtensor.substrate.submit_extrinsic(
657
+ extrinsic, wait_for_inclusion=True, wait_for_finalization=False
658
+ )
659
+ except SubstrateRequestException as e:
660
+ if "Custom error: 8" in str(e):
661
+ print_error(
662
+ f"\n{failure_prelude}: Price exceeded tolerance limit. "
663
+ f"Transaction rejected because partial unstaking is disabled. "
664
+ f"Either increase price tolerance or enable partial unstaking.",
665
+ status=status,
666
+ )
667
+ return
668
+ else:
669
+ err_out(
670
+ f"\n{failure_prelude} with error: {format_error_message(e, subtensor.substrate)}"
671
+ )
672
+ return
673
+
674
+ await response.process_events()
675
+ if not await response.is_success:
676
+ err_out(
677
+ f"\n{failure_prelude} with error: {format_error_message(await response.error_message, subtensor.substrate)}"
678
+ )
679
+ return
680
+
681
+ block_hash = await subtensor.substrate.get_chain_head()
682
+ new_balance, new_stake = await asyncio.gather(
683
+ subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash),
684
+ subtensor.get_stake(
685
+ hotkey_ss58=hotkey_ss58,
686
+ coldkey_ss58=wallet.coldkeypub.ss58_address,
687
+ netuid=netuid,
688
+ block_hash=block_hash,
689
+ ),
690
+ )
691
+
692
+ console.print(":white_heavy_check_mark: [green]Finalized[/green]")
693
+ console.print(
694
+ f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}"
695
+ )
696
+
697
+ amount_unstaked = current_stake - new_stake
698
+ if allow_partial_stake and (amount_unstaked != amount):
699
+ console.print(
700
+ "Partial unstake transaction. Unstaked:\n"
701
+ f" [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_unstaked.set_unit(netuid=netuid)}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] "
702
+ f"instead of "
703
+ f"[blue]{amount}[/blue]"
704
+ )
705
+
706
+ console.print(
707
+ f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] "
708
+ f"Stake:\n [blue]{current_stake}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}"
709
+ )
710
+
711
+
712
+ # Helpers
713
+ def _calculate_slippage(subnet_info, amount: Balance) -> tuple[Balance, str, float]:
714
+ """Calculate slippage and received amount for unstaking operation.
715
+
716
+ Args:
717
+ dynamic_info: Subnet information containing price data
718
+ amount: Amount being unstaked
719
+
720
+ Returns:
721
+ tuple containing:
722
+ - received_amount: Balance after slippage
723
+ - slippage_pct: Formatted string of slippage percentage
724
+ - slippage_pct_float: Float value of slippage percentage
725
+ """
726
+ received_amount, _, slippage_pct_float = subnet_info.alpha_to_tao_with_slippage(
727
+ amount
728
+ )
729
+
730
+ if subnet_info.is_dynamic:
731
+ slippage_pct = f"{slippage_pct_float:.4f} %"
732
+ else:
733
+ slippage_pct_float = 0
734
+ slippage_pct = "[red]N/A[/red]"
735
+
736
+ return received_amount, slippage_pct, slippage_pct_float
737
+
738
+
739
+ async def _unstake_selection(
740
+ subtensor: "SubtensorInterface",
741
+ wallet: Wallet,
742
+ dynamic_info,
743
+ identities,
744
+ old_identities,
745
+ netuid: Optional[int] = None,
746
+ ):
747
+ stake_infos = await subtensor.get_stake_for_coldkey(
748
+ coldkey_ss58=wallet.coldkeypub.ss58_address
749
+ )
750
+
751
+ if not stake_infos:
752
+ print_error("You have no stakes to unstake.")
753
+ raise typer.Exit()
754
+
755
+ hotkey_stakes = {}
756
+ for stake_info in stake_infos:
757
+ if netuid is not None and stake_info.netuid != netuid:
758
+ continue
759
+ hotkey_ss58 = stake_info.hotkey_ss58
760
+ netuid_ = stake_info.netuid
761
+ stake_amount = stake_info.stake
762
+ if stake_amount.tao > 0:
763
+ hotkey_stakes.setdefault(hotkey_ss58, {})[netuid_] = stake_amount
764
+
765
+ if not hotkey_stakes:
766
+ if netuid is not None:
767
+ print_error(f"You have no stakes to unstake in subnet {netuid}.")
768
+ else:
769
+ print_error("You have no stakes to unstake.")
770
+ raise typer.Exit()
771
+
772
+ hotkeys_info = []
773
+ for idx, (hotkey_ss58, netuid_stakes) in enumerate(hotkey_stakes.items()):
774
+ if hk_identity := identities["hotkeys"].get(hotkey_ss58):
775
+ hotkey_name = hk_identity.get("identity", {}).get(
776
+ "name", ""
777
+ ) or hk_identity.get("display", "~")
778
+ elif old_identity := old_identities.get(hotkey_ss58):
779
+ hotkey_name = old_identity.display
780
+ else:
781
+ hotkey_name = "~"
782
+ # TODO: Add wallet ids here.
783
+
784
+ hotkeys_info.append(
785
+ {
786
+ "index": idx,
787
+ "identity": hotkey_name,
788
+ "netuids": list(netuid_stakes.keys()),
789
+ "hotkey_ss58": hotkey_ss58,
790
+ }
791
+ )
792
+
793
+ # Display existing hotkeys, id, and staked netuids.
794
+ subnet_filter = f" for Subnet {netuid}" if netuid is not None else ""
795
+ table = Table(
796
+ title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Hotkeys with Stakes{subnet_filter}\n",
797
+ show_footer=True,
798
+ show_edge=False,
799
+ header_style="bold white",
800
+ border_style="bright_black",
801
+ style="bold",
802
+ title_justify="center",
803
+ show_lines=False,
804
+ pad_edge=True,
805
+ )
806
+ table.add_column("Index", justify="right")
807
+ table.add_column("Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"])
808
+ table.add_column("Netuids", style=COLOR_PALETTE["GENERAL"]["NETUID"])
809
+ table.add_column("Hotkey Address", style=COLOR_PALETTE["GENERAL"]["HOTKEY"])
810
+
811
+ for hotkey_info in hotkeys_info:
812
+ index = str(hotkey_info["index"])
813
+ identity = hotkey_info["identity"]
814
+ netuids = group_subnets([n for n in hotkey_info["netuids"]])
815
+ hotkey_ss58 = hotkey_info["hotkey_ss58"]
816
+ table.add_row(index, identity, netuids, hotkey_ss58)
817
+
818
+ console.print("\n", table)
819
+
820
+ # Prompt to select hotkey to unstake.
821
+ hotkey_options = [str(hotkey_info["index"]) for hotkey_info in hotkeys_info]
822
+ hotkey_idx = Prompt.ask(
823
+ "\nEnter the index of the hotkey you want to unstake from",
824
+ choices=hotkey_options,
825
+ )
826
+ selected_hotkey_info = hotkeys_info[int(hotkey_idx)]
827
+ selected_hotkey_ss58 = selected_hotkey_info["hotkey_ss58"]
828
+ selected_hotkey_name = selected_hotkey_info["identity"]
829
+ netuid_stakes = hotkey_stakes[selected_hotkey_ss58]
830
+
831
+ # Display hotkey's staked netuids with amount.
832
+ table = Table(
833
+ title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Stakes for hotkey \n[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{selected_hotkey_name}\n{selected_hotkey_ss58}\n",
834
+ show_footer=True,
835
+ show_edge=False,
836
+ header_style="bold white",
837
+ border_style="bright_black",
838
+ style="bold",
839
+ title_justify="center",
840
+ show_lines=False,
841
+ pad_edge=True,
842
+ )
843
+ table.add_column("Subnet", justify="right")
844
+ table.add_column("Symbol", style=COLOR_PALETTE["GENERAL"]["SYMBOL"])
845
+ table.add_column("Stake Amount", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"])
846
+ table.add_column(
847
+ f"[bold white]RATE ({Balance.get_unit(0)}_in/{Balance.get_unit(1)}_in)",
848
+ style=COLOR_PALETTE["POOLS"]["RATE"],
849
+ justify="left",
850
+ )
851
+
852
+ for netuid_, stake_amount in netuid_stakes.items():
853
+ symbol = dynamic_info[netuid_].symbol
854
+ rate = f"{dynamic_info[netuid_].price.tao:.4f} τ/{symbol}"
855
+ table.add_row(str(netuid_), symbol, str(stake_amount), rate)
856
+ console.print("\n", table, "\n")
857
+
858
+ # Ask which netuids to unstake from for the selected hotkey.
859
+ unstake_all = False
860
+ if netuid is not None:
861
+ selected_netuids = [netuid]
862
+ else:
863
+ while True:
864
+ netuid_input = Prompt.ask(
865
+ "\nEnter the netuids of the [blue]subnets to unstake[/blue] from (comma-separated), or '[blue]all[/blue]' to unstake from all",
866
+ default="all",
867
+ )
868
+
869
+ if netuid_input.lower() == "all":
870
+ selected_netuids = list(netuid_stakes.keys())
871
+ unstake_all = True
872
+ break
873
+ else:
874
+ try:
875
+ netuid_list = [int(n.strip()) for n in netuid_input.split(",")]
876
+ invalid_netuids = [n for n in netuid_list if n not in netuid_stakes]
877
+ if invalid_netuids:
878
+ print_error(
879
+ f"The following netuids are invalid or not available: {', '.join(map(str, invalid_netuids))}. Please try again."
880
+ )
881
+ else:
882
+ selected_netuids = netuid_list
883
+ break
884
+ except ValueError:
885
+ print_error(
886
+ "Please enter valid netuids (numbers), separated by commas, or 'all'."
887
+ )
888
+
889
+ hotkeys_to_unstake_from = []
890
+ for netuid_ in selected_netuids:
891
+ hotkeys_to_unstake_from.append(
892
+ (selected_hotkey_name, selected_hotkey_ss58, netuid_)
893
+ )
894
+ return hotkeys_to_unstake_from, unstake_all
895
+
896
+
897
+ def _ask_unstake_amount(
898
+ current_stake_balance: Balance,
899
+ netuid: int,
900
+ staking_address_name: str,
901
+ staking_address_ss58: str,
902
+ interactive: bool,
903
+ ) -> Optional[Balance]:
904
+ """Prompt the user to decide the amount to unstake.
905
+
906
+ Args:
907
+ current_stake_balance: The current stake balance available to unstake
908
+ netuid: The subnet ID
909
+ staking_address_name: Display name of the staking address
910
+ staking_address_ss58: SS58 address of the staking address
911
+ interactive: Whether in interactive mode (affects default choice)
912
+
913
+ Returns:
914
+ Balance amount to unstake, or None if user chooses to quit
915
+ """
916
+ stake_color = COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"]
917
+ display_address = (
918
+ staking_address_name if staking_address_name else staking_address_ss58
919
+ )
920
+
921
+ # First prompt: Ask if user wants to unstake all
922
+ unstake_all_prompt = (
923
+ f"Unstake all: [{stake_color}]{current_stake_balance}[/{stake_color}]"
924
+ f" from [{stake_color}]{display_address}[/{stake_color}]"
925
+ f" on netuid: [{stake_color}]{netuid}[/{stake_color}]? [y/n/q]"
926
+ )
927
+
928
+ while True:
929
+ response = Prompt.ask(
930
+ unstake_all_prompt,
931
+ choices=["y", "n", "q"],
932
+ default="n",
933
+ show_choices=True,
934
+ ).lower()
935
+
936
+ if response == "q":
937
+ return None
938
+ if response == "y":
939
+ return current_stake_balance
940
+ if response != "n":
941
+ console.print("[red]Invalid input. Please enter 'y', 'n', or 'q'.[/red]")
942
+ continue
943
+
944
+ amount_prompt = (
945
+ f"Enter amount to unstake in [{stake_color}]{Balance.get_unit(netuid)}[/{stake_color}]"
946
+ f" from subnet: [{stake_color}]{netuid}[/{stake_color}]"
947
+ f" (Max: [{stake_color}]{current_stake_balance}[/{stake_color}])"
948
+ )
949
+
950
+ while True:
951
+ amount_input = Prompt.ask(amount_prompt)
952
+ if amount_input.lower() == "q":
953
+ return None
954
+
955
+ try:
956
+ amount_value = float(amount_input)
957
+
958
+ # Validate amount
959
+ if amount_value <= 0:
960
+ console.print("[red]Amount must be greater than zero.[/red]")
961
+ continue
962
+
963
+ amount_to_unstake = Balance.from_tao(amount_value)
964
+ amount_to_unstake.set_unit(netuid)
965
+
966
+ if amount_to_unstake > current_stake_balance:
967
+ console.print(
968
+ f"[red]Amount exceeds current stake balance of {current_stake_balance}.[/red]"
969
+ )
970
+ continue
971
+
972
+ return amount_to_unstake
973
+
974
+ except ValueError:
975
+ console.print(
976
+ "[red]Invalid input. Please enter a numeric value or 'q' to quit.[/red]"
977
+ )
978
+
979
+
980
+ def _get_hotkeys_to_unstake(
981
+ wallet: Wallet,
982
+ hotkey_ss58_address: Optional[str],
983
+ all_hotkeys: bool,
984
+ include_hotkeys: list[str],
985
+ exclude_hotkeys: list[str],
986
+ ) -> list[tuple[Optional[str], str]]:
987
+ """Get list of hotkeys to unstake from based on input parameters.
988
+
989
+ Args:
990
+ wallet: The wallet to unstake from
991
+ hotkey_ss58_address: Specific hotkey SS58 address to unstake from
992
+ all_hotkeys: Whether to unstake from all hotkeys
993
+ include_hotkeys: List of hotkey names/addresses to include
994
+ exclude_hotkeys: List of hotkey names to exclude
995
+
996
+ Returns:
997
+ List of tuples containing (hotkey_name, hotkey_ss58) pairs to unstake from
998
+ """
999
+ if hotkey_ss58_address:
1000
+ print_verbose(f"Unstaking from ss58 ({hotkey_ss58_address})")
1001
+ return [(None, hotkey_ss58_address)]
1002
+
1003
+ if all_hotkeys:
1004
+ print_verbose("Unstaking from all hotkeys")
1005
+ all_hotkeys_: list[Wallet] = get_hotkey_wallets_for_wallet(wallet=wallet)
1006
+ return [
1007
+ (wallet.hotkey_str, wallet.hotkey.ss58_address)
1008
+ for wallet in all_hotkeys_
1009
+ if wallet.hotkey_str not in exclude_hotkeys
1010
+ ]
1011
+
1012
+ if include_hotkeys:
1013
+ print_verbose("Unstaking from included hotkeys")
1014
+ result = []
1015
+ for hotkey_identifier in include_hotkeys:
1016
+ if is_valid_ss58_address(hotkey_identifier):
1017
+ result.append((None, hotkey_identifier))
1018
+ else:
1019
+ wallet_ = Wallet(
1020
+ name=wallet.name,
1021
+ path=wallet.path,
1022
+ hotkey=hotkey_identifier,
1023
+ )
1024
+ result.append((wallet_.hotkey_str, wallet_.hotkey.ss58_address))
1025
+ return result
1026
+
1027
+ # Only cli.config.wallet.hotkey is specified
1028
+ print_verbose(
1029
+ f"Unstaking from wallet: ({wallet.name}) from hotkey: ({wallet.hotkey_str})"
1030
+ )
1031
+ assert wallet.hotkey is not None
1032
+ return [(wallet.hotkey_str, wallet.hotkey.ss58_address)]
1033
+
1034
+
1035
+ def _create_unstake_table(
1036
+ wallet_name: str,
1037
+ wallet_coldkey_ss58: str,
1038
+ network: str,
1039
+ total_received_amount: Balance,
1040
+ safe_staking: bool,
1041
+ rate_tolerance: float,
1042
+ ) -> Table:
1043
+ """Create a table summarizing unstake operations.
1044
+
1045
+ Args:
1046
+ wallet_name: Name of the wallet
1047
+ wallet_coldkey_ss58: Coldkey SS58 address
1048
+ network: Network name
1049
+ total_received_amount: Total amount to be received after unstaking
1050
+
1051
+ Returns:
1052
+ Rich Table object configured for unstake summary
1053
+ """
1054
+ title = (
1055
+ f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Unstaking to: \n"
1056
+ f"Wallet: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet_name}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}], "
1057
+ f"Coldkey ss58: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet_coldkey_ss58}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\n"
1058
+ f"Network: {network}[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n"
1059
+ )
1060
+ table = Table(
1061
+ title=title,
1062
+ show_footer=True,
1063
+ show_edge=False,
1064
+ header_style="bold white",
1065
+ border_style="bright_black",
1066
+ style="bold",
1067
+ title_justify="center",
1068
+ show_lines=False,
1069
+ pad_edge=True,
1070
+ )
1071
+
1072
+ table.add_column("Netuid", justify="center", style="grey89")
1073
+ table.add_column(
1074
+ "Hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"]
1075
+ )
1076
+ table.add_column(
1077
+ f"Amount ({Balance.get_unit(1)})",
1078
+ justify="center",
1079
+ style=COLOR_PALETTE["POOLS"]["TAO"],
1080
+ )
1081
+ table.add_column(
1082
+ f"Rate ({Balance.get_unit(0)}/{Balance.get_unit(1)})",
1083
+ justify="center",
1084
+ style=COLOR_PALETTE["POOLS"]["RATE"],
1085
+ )
1086
+ table.add_column(
1087
+ f"Received ({Balance.get_unit(0)})",
1088
+ justify="center",
1089
+ style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"],
1090
+ footer=str(total_received_amount),
1091
+ )
1092
+ table.add_column(
1093
+ "Slippage", justify="center", style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"]
1094
+ )
1095
+ if safe_staking:
1096
+ table.add_column(
1097
+ f"Rate with tolerance: [blue]({rate_tolerance*100}%)[/blue]",
1098
+ justify="center",
1099
+ style=COLOR_PALETTE["POOLS"]["RATE"],
1100
+ )
1101
+ table.add_column(
1102
+ "Partial unstake enabled",
1103
+ justify="center",
1104
+ style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"],
1105
+ )
1106
+
1107
+ return table
1108
+
1109
+
1110
+ def _print_table_and_slippage(
1111
+ table: Table,
1112
+ max_float_slippage: float,
1113
+ safe_staking: bool,
1114
+ ) -> None:
1115
+ """Print the unstake summary table and additional information.
1116
+
1117
+ Args:
1118
+ table: The Rich table containing unstake details
1119
+ max_float_slippage: Maximum slippage percentage across all operations
1120
+ """
1121
+ console.print(table)
1122
+
1123
+ if max_float_slippage > 5:
1124
+ console.print(
1125
+ "\n"
1126
+ f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]-------------------------------------------------------------------------------------------------------------------\n"
1127
+ f"[bold]WARNING:[/bold] The slippage on one of your operations is high: [{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]{max_float_slippage} %[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}],"
1128
+ " this may result in a loss of funds.\n"
1129
+ f"-------------------------------------------------------------------------------------------------------------------\n"
1130
+ )
1131
+ base_description = """
1132
+ [bold white]Description[/bold white]:
1133
+ The table displays information about the stake remove operation you are about to perform.
1134
+ The columns are as follows:
1135
+ - [bold white]Netuid[/bold white]: The netuid of the subnet you are unstaking from.
1136
+ - [bold white]Hotkey[/bold white]: The ss58 address or identity of the hotkey you are unstaking from.
1137
+ - [bold white]Amount to Unstake[/bold white]: The stake amount you are removing from this key.
1138
+ - [bold white]Rate[/bold white]: The rate of exchange between TAO and the subnet's stake.
1139
+ - [bold white]Received[/bold white]: The amount of free balance TAO you will receive on this subnet after slippage.
1140
+ - [bold white]Slippage[/bold white]: The slippage percentage of the unstake operation. (0% if the subnet is not dynamic i.e. root)."""
1141
+
1142
+ safe_staking_description = """
1143
+ - [bold white]Rate Tolerance[/bold white]: Maximum acceptable alpha rate. If the rate reduces below this tolerance, the transaction will be limited or rejected.
1144
+ - [bold white]Partial unstaking[/bold white]: If True, allows unstaking up to the rate tolerance limit. If False, the entire transaction will fail if rate tolerance is exceeded.\n"""
1145
+
1146
+ console.print(base_description + (safe_staking_description if safe_staking else ""))