diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 9ad6c89df..7d94ab34b 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -7344,11 +7344,6 @@ def liquidity_add( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, netuid: Optional[int] = Options.netuid, - liquidity_: Optional[float] = typer.Option( - None, - "--liquidity", - help="Amount of liquidity to add to the subnet.", - ), price_low: Optional[float] = typer.Option( None, "--price-low", @@ -7365,6 +7360,18 @@ def liquidity_add( "--liquidity_price_high", help="High price for the adding liquidity position.", ), + tao_amount: Optional[float] = typer.Option( + None, + "--tao-amount", + "--tao_amount", + help="Amount of TAO to provide (for mixed range positions).", + ), + alpha_amount: Optional[float] = typer.Option( + None, + "--alpha-amount", + "--alpha_amount", + help="Amount of Alpha to provide (for mixed range positions).", + ), prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -7372,6 +7379,8 @@ def liquidity_add( ): """Add liquidity to the swap (as a combination of TAO + Alpha).""" self.verbosity_handler(quiet, verbose, json_output) + + # Step 1: Ask for netuid if not netuid: netuid = Prompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", @@ -7379,53 +7388,23 @@ def liquidity_add( show_default=False, ) - wallet, hotkey = self.wallet_ask( + wallet = self.wallet_ask( wallet_name=wallet_name, wallet_path=wallet_path, wallet_hotkey=wallet_hotkey, ask_for=[WO.NAME, WO.HOTKEY, WO.PATH], validate=WV.WALLET, - return_wallet_and_hotkey=True, ) - # Determine the liquidity amount. - if liquidity_: - liquidity_ = Balance.from_tao(liquidity_) - else: - liquidity_ = prompt_liquidity("Enter the amount of liquidity") - # Determine price range - if price_low: - price_low = Balance.from_tao(price_low) - else: - price_low = prompt_liquidity("Enter liquidity position low price") - - if price_high: - price_high = Balance.from_tao(price_high) - else: - price_high = prompt_liquidity( - "Enter liquidity position high price (must be greater than low price)" - ) - - if price_low >= price_high: - err_console.print("The low price must be lower than the high price.") - return False - logger.debug( - f"args:\n" - f"hotkey: {hotkey}\n" - f"netuid: {netuid}\n" - f"liquidity: {liquidity_}\n" - f"price_low: {price_low}\n" - f"price_high: {price_high}\n" - ) return self._run_command( - liquidity.add_liquidity( + liquidity.add_liquidity_interactive( subtensor=self.initialize_chain(network), wallet=wallet, - hotkey_ss58=hotkey, netuid=netuid, - liquidity=liquidity_, price_low=price_low, price_high=price_high, + tao_amount=tao_amount, + alpha_amount=alpha_amount, prompt=prompt, json_output=json_output, ) diff --git a/bittensor_cli/src/bittensor/extrinsics/liquidity.py b/bittensor_cli/src/bittensor/extrinsics/liquidity.py new file mode 100644 index 000000000..c83072fd2 --- /dev/null +++ b/bittensor_cli/src/bittensor/extrinsics/liquidity.py @@ -0,0 +1,211 @@ +from typing import TYPE_CHECKING, Optional + +from async_substrate_interface import AsyncExtrinsicReceipt + +from bittensor_cli.src.bittensor.utils import unlock_key +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.commands.liquidity.utils import price_to_tick + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def add_liquidity_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + liquidity: Balance, + price_low: Balance, + price_high: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: + """ + Adds liquidity to the specified price range. + + Arguments: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + hotkey_ss58: the SS58 of the hotkey to use for this transaction. + netuid: The UID of the target subnet for which the call is being initiated. + liquidity: The amount of liquidity to be added. + price_low: The lower bound of the price tick range. + price_high: The upper bound of the price tick range. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. + wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + + Returns: + Tuple[bool, str]: + - True and a success message if the extrinsic is successfully submitted or processed. + - False and an error message if the submission fails or the wallet cannot be unlocked. + + Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call + `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. + """ + if not (unlock := unlock_key(wallet)).success: + return False, unlock.message, None + + tick_low = price_to_tick(price_low.tao) + tick_high = price_to_tick(price_high.tao) + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="add_liquidity", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "tick_low": tick_low, + "tick_high": tick_high, + "liquidity": liquidity.rao, + }, + ) + + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +async def modify_liquidity_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + position_id: int, + liquidity_delta: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: + """Modifies liquidity in liquidity position by adding or removing liquidity from it. + + Arguments: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + hotkey_ss58: the SS58 of the hotkey to use for this transaction. + netuid: The UID of the target subnet for which the call is being initiated. + position_id: The id of the position record in the pool. + liquidity_delta: The amount of liquidity to be added or removed (add if positive or remove if negative). + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. + wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + + Returns: + Tuple[bool, str]: + - True and a success message if the extrinsic is successfully submitted or processed. + - False and an error message if the submission fails or the wallet cannot be unlocked. + + Note: Modifying is allowed even when user liquidity is enabled in specified subnet. + Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. + """ + if not (unlock := unlock_key(wallet)).success: + return False, unlock.message, None + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="modify_position", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "position_id": position_id, + "liquidity_delta": liquidity_delta.rao, + }, + ) + + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +async def remove_liquidity_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + position_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: + """Remove liquidity and credit balances back to wallet's hotkey stake. + + Arguments: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + hotkey_ss58: the SS58 of the hotkey to use for this transaction. + netuid: The UID of the target subnet for which the call is being initiated. + position_id: The id of the position record in the pool. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. + wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + + Returns: + Tuple[bool, str]: + - True and a success message if the extrinsic is successfully submitted or processed. + - False and an error message if the submission fails or the wallet cannot be unlocked. + + Note: Adding is allowed even when user liquidity is enabled in specified subnet. + Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. + """ + if not (unlock := unlock_key(wallet)).success: + return False, unlock.message, None + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="remove_liquidity", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "position_id": position_id, + }, + ) + + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +async def toggle_user_liquidity_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + netuid: int, + enable: bool, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: + """Allow to toggle user liquidity for specified subnet. + + Arguments: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + netuid: The UID of the target subnet for which the call is being initiated. + enable: Boolean indicating whether to enable user liquidity. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. + wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + + Returns: + Tuple[bool, str]: + - True and a success message if the extrinsic is successfully submitted or processed. + - False and an error message if the submission fails or the wallet cannot be unlocked. + """ + if not (unlock := unlock_key(wallet)).success: + return False, unlock.message, None + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="toggle_user_liquidity", + call_params={"netuid": netuid, "enable": enable}, + ) + + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) diff --git a/bittensor_cli/src/commands/liquidity/liquidity.py b/bittensor_cli/src/commands/liquidity/liquidity.py index a262e8874..2f618e16c 100644 --- a/bittensor_cli/src/commands/liquidity/liquidity.py +++ b/bittensor_cli/src/commands/liquidity/liquidity.py @@ -1,9 +1,9 @@ import asyncio import json +import math from typing import TYPE_CHECKING, Optional -from async_substrate_interface import AsyncExtrinsicReceipt -from rich.prompt import Confirm +from rich.prompt import Confirm, FloatPrompt, Prompt from rich.table import Column, Table from bittensor_cli.src import COLORS @@ -13,248 +13,358 @@ err_console, json_console, print_extrinsic_id, + get_hotkey_pub_ss58, ) from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float +from bittensor_cli.src.bittensor.extrinsics.liquidity import ( + add_liquidity_extrinsic, + modify_liquidity_extrinsic, + remove_liquidity_extrinsic, +) from bittensor_cli.src.commands.liquidity.utils import ( LiquidityPosition, calculate_fees, get_fees, price_to_tick, tick_to_price, + calculate_max_liquidity_from_balances, + calculate_alpha_from_tao, + calculate_tao_from_alpha, ) +from bittensor_wallet import Wallet if TYPE_CHECKING: from bittensor_wallet import Wallet from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface -async def add_liquidity_extrinsic( +async def add_liquidity_interactive( subtensor: "SubtensorInterface", wallet: "Wallet", - hotkey_ss58: str, netuid: int, - liquidity: Balance, - price_low: Balance, - price_high: Balance, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, -) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: + price_low: Optional[float], + price_high: Optional[float], + tao_amount: Optional[float], + alpha_amount: Optional[float], + prompt: bool, + json_output: bool, +) -> tuple[bool, str]: + """Interactive flow for adding liquidity based on the improved logic. + + Steps: + 1. Check if subnet exists + 2. Ask user to enter low and high position prices + 3. Fetch current SN price + 4. Based on price position: + - If low >= current: only ask for Alpha amount + - If high <= current: only ask for TAO amount + - Otherwise: calculate max liquidity and ask for TAO or Alpha amount + 5. Execute the extrinsic """ - Adds liquidity to the specified price range. - - Arguments: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - hotkey_ss58: the SS58 of the hotkey to use for this transaction. - netuid: The UID of the target subnet for which the call is being initiated. - liquidity: The amount of liquidity to be added. - price_low: The lower bound of the price tick range. - price_high: The upper bound of the price tick range. - wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. - wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + # Step 2: Check if the subnet exists + if not await subtensor.subnet_exists(netuid=netuid): + return False, f"Subnet with netuid: {netuid} does not exist in {subtensor}." - Returns: - Tuple[bool, str]: - - True and a success message if the extrinsic is successfully submitted or processed. - - False and an error message if the submission fails or the wallet cannot be unlocked. + # Check if user liquidity is enabled for this subnet + with console.status( + ":satellite: Checking user liquidity status...", spinner="aesthetic" + ): + hyperparams = await subtensor.get_subnet_hyperparameters(netuid=netuid) - Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call - `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ - if not (unlock := unlock_key(wallet)).success: - return False, unlock.message, None - - tick_low = price_to_tick(price_low.tao) - tick_high = price_to_tick(price_high.tao) - - call = await subtensor.substrate.compose_call( - call_module="Swap", - call_function="add_liquidity", - call_params={ - "hotkey": hotkey_ss58, - "netuid": netuid, - "tick_low": tick_low, - "tick_high": tick_high, - "liquidity": liquidity.rao, - }, - ) + if not hyperparams: + return False, f"Failed to get hyperparameters for subnet {netuid}." - return await subtensor.sign_and_send_extrinsic( - call=call, - wallet=wallet, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) + if not hyperparams.user_liquidity_enabled: + err_console.print( + f"[red]User liquidity is disabled for subnet {netuid}.[/red]\n" + ) + return False, f"User liquidity is disabled for subnet {netuid}." + console.print(f"[green]✓ User liquidity is enabled for subnet {netuid}[/green]\n") -async def modify_liquidity_extrinsic( - subtensor: "SubtensorInterface", - wallet: "Wallet", - hotkey_ss58: str, - netuid: int, - position_id: int, - liquidity_delta: Balance, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, -) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: - """Modifies liquidity in liquidity position by adding or removing liquidity from it. - - Arguments: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - hotkey_ss58: the SS58 of the hotkey to use for this transaction. - netuid: The UID of the target subnet for which the call is being initiated. - position_id: The id of the position record in the pool. - liquidity_delta: The amount of liquidity to be added or removed (add if positive or remove if negative). - wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. - wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + # Step 3: Ask user to enter low and high position prices + if price_low is None: + while True: + price_low_input = FloatPrompt.ask( + f"[{COLORS.G.SUBHEAD_MAIN}]Enter the low price for the liquidity position[/{COLORS.G.SUBHEAD_MAIN}]" + ) + if price_low_input > 0: + price_low = price_low_input + break + console.print("[red]Price must be greater than 0[/red]") + + if price_high is None: + while True: + price_high_input = FloatPrompt.ask( + f"[{COLORS.G.SUBHEAD_MAIN}]Enter the high price for the liquidity position[/{COLORS.G.SUBHEAD_MAIN}]" + ) + if price_high_input > price_low: + price_high = price_high_input + break + console.print( + f"[red]High price must be greater than low price ({price_low})[/red]" + ) - Returns: - Tuple[bool, str]: - - True and a success message if the extrinsic is successfully submitted or processed. - - False and an error message if the submission fails or the wallet cannot be unlocked. + price_low_balance = Balance.from_tao(price_low) + price_high_balance = Balance.from_tao(price_high) - Note: Modifying is allowed even when user liquidity is enabled in specified subnet. - Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ - if not (unlock := unlock_key(wallet)).success: - return False, unlock.message, None - - call = await subtensor.substrate.compose_call( - call_module="Swap", - call_function="modify_position", - call_params={ - "hotkey": hotkey_ss58, - "netuid": netuid, - "position_id": position_id, - "liquidity_delta": liquidity_delta.rao, - }, - ) + # Step 4: Fetch current SN price + with console.status( + ":satellite: Fetching current subnet price...", spinner="aesthetic" + ): + current_price = await subtensor.get_subnet_price(netuid=netuid) - return await subtensor.sign_and_send_extrinsic( - call=call, - wallet=wallet, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) + console.print(f"Current subnet price: [cyan]{current_price.tao:.6f} τ[/cyan]") + # Determine hotkey to use - default to wallet's hotkey + hotkey_ss58 = get_hotkey_pub_ss58(wallet) -async def remove_liquidity_extrinsic( - subtensor: "SubtensorInterface", - wallet: "Wallet", - hotkey_ss58: str, - netuid: int, - position_id: int, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, -) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: - """Remove liquidity and credit balances back to wallet's hotkey stake. - - Arguments: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - hotkey_ss58: the SS58 of the hotkey to use for this transaction. - netuid: The UID of the target subnet for which the call is being initiated. - position_id: The id of the position record in the pool. - wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. - wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + # Step 5: Determine which case we're in based on price position + liquidity_to_add = None + tao_to_provide = Balance.from_tao(0) + alpha_to_provide = Balance.from_tao(0) - Returns: - Tuple[bool, str]: - - True and a success message if the extrinsic is successfully submitted or processed. - - False and an error message if the submission fails or the wallet cannot be unlocked. + # Case 1: Low price >= current price (only Alpha needed) + if price_low >= current_price.tao: + console.print( + f"\n[yellow]The low price ({price_low:.6f}) is higher than or equal to the current price ({current_price.tao:.6f}).[/yellow]" + ) + console.print( + "[yellow]Only Alpha tokens are needed for this position.[/yellow]\n" + ) - Note: Adding is allowed even when user liquidity is enabled in specified subnet. - Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ - if not (unlock := unlock_key(wallet)).success: - return False, unlock.message, None - - call = await subtensor.substrate.compose_call( - call_module="Swap", - call_function="remove_liquidity", - call_params={ - "hotkey": hotkey_ss58, - "netuid": netuid, - "position_id": position_id, - }, - ) + # Fetch Alpha balance + with console.status( + ":satellite: Fetching Alpha balance...", spinner="aesthetic" + ): + alpha_balance_available = await subtensor.get_stake_for_coldkey_and_hotkey( + hotkey_ss58=hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + ) - return await subtensor.sign_and_send_extrinsic( - call=call, - wallet=wallet, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) + console.print( + f"Available Alpha: {alpha_balance_available.tao:.6f} α (for subnet {netuid})\n" + ) + # Ask for Alpha amount + if alpha_amount is None: + alpha_amount = FloatPrompt.ask( + f"[{COLORS.G.SUBHEAD_MAIN}]Enter the amount of Alpha to provide[/{COLORS.G.SUBHEAD_MAIN}]" + ) -async def toggle_user_liquidity_extrinsic( - subtensor: "SubtensorInterface", - wallet: "Wallet", - netuid: int, - enable: bool, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, -) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: - """Allow to toggle user liquidity for specified subnet. - - Arguments: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - enable: Boolean indicating whether to enable user liquidity. - wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. - wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + alpha_to_provide = Balance.from_tao(alpha_amount) - Returns: - Tuple[bool, str]: - - True and a success message if the extrinsic is successfully submitted or processed. - - False and an error message if the submission fails or the wallet cannot be unlocked. - """ - if not (unlock := unlock_key(wallet)).success: - return False, unlock.message, None + # Check if user has enough Alpha + if alpha_to_provide > alpha_balance_available: + err_console.print( + f"[red]Insufficient Alpha balance.[/red]\n" + f"Required: {alpha_to_provide.tao:.6f} α (for subnet {netuid})\n" + f"Available: {alpha_balance_available.tao:.6f} α (for subnet {netuid})" + ) + return False, "Insufficient Alpha balance." + + # Calculate liquidity from Alpha + # L = alpha / (1/sqrt_price_low - 1/sqrt_price_high) + sqrt_price_low = math.sqrt(price_low) + sqrt_price_high = math.sqrt(price_high) + liquidity_to_add = Balance.from_rao( + int(alpha_to_provide.rao / (1 / sqrt_price_low - 1 / sqrt_price_high)) + ) - call = await subtensor.substrate.compose_call( - call_module="Swap", - call_function="toggle_user_liquidity", - call_params={"netuid": netuid, "enable": enable}, - ) + # Case 2: High price <= current price (only TAO needed) + elif price_high <= current_price.tao: + console.print( + f"\n[yellow]The high price ({price_high:.6f}) is lower than or equal to the current price ({current_price.tao:.6f}).[/yellow]" + ) + console.print( + "[yellow]Only TAO tokens are needed for this position.[/yellow]\n" + ) - return await subtensor.sign_and_send_extrinsic( - call=call, - wallet=wallet, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) + # Fetch TAO balance + with console.status(":satellite: Fetching TAO balance...", spinner="aesthetic"): + tao_balance_available = await subtensor.get_balance( + wallet.coldkeypub.ss58_address + ) + console.print(f"Available TAO: {tao_balance_available.tao:.6f} τ\n") -# Command -async def add_liquidity( - subtensor: "SubtensorInterface", - wallet: "Wallet", - hotkey_ss58: str, - netuid: Optional[int], - liquidity: Balance, - price_low: Balance, - price_high: Balance, - prompt: bool, - json_output: bool, -) -> tuple[bool, str]: - """Add liquidity position to provided subnet.""" - # Check wallet access - if not (ulw := unlock_key(wallet)).success: - return False, ulw.message + # Ask for TAO amount + if tao_amount is None: + tao_amount = FloatPrompt.ask( + f"[{COLORS.G.SUBHEAD_MAIN}]Enter the amount of TAO to provide[/{COLORS.G.SUBHEAD_MAIN}]" + ) - # Check that the subnet exists. - if not await subtensor.subnet_exists(netuid=netuid): - return False, f"Subnet with netuid: {netuid} does not exist in {subtensor}." + tao_to_provide = Balance.from_tao(tao_amount) + + # Check if user has enough TAO + if tao_to_provide > tao_balance_available: + err_console.print( + f"[red]Insufficient TAO balance.[/red]\n" + f"Required: {tao_to_provide.tao:.6f} τ\n" + f"Available: {tao_balance_available.tao:.6f} τ" + ) + return False, "Insufficient TAO balance." + + # Calculate liquidity from TAO + # L = tao / (sqrt_price_high - sqrt_price_low) + sqrt_price_low = math.sqrt(price_low) + sqrt_price_high = math.sqrt(price_high) + liquidity_to_add = Balance.from_rao( + int(tao_to_provide.rao / (sqrt_price_high - sqrt_price_low)) + ) + + # Case 3: Current price is within range (both TAO and Alpha needed) + else: + console.print( + f"\n[green]The current price ({current_price.tao:.6f}) is within the range ({price_low:.6f} - {price_high:.6f}).[/green]" + ) + console.print( + "[green]Both TAO and Alpha tokens are needed for this position.[/green]\n" + ) + + # Fetch TAO and Alpha balances + with console.status(":satellite: Fetching balances...", spinner="aesthetic"): + tao_balance_available, alpha_balance_available = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.get_stake_for_coldkey_and_hotkey( + hotkey_ss58=hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + ), + ) + + # Calculate maximum liquidity + max_liquidity, max_tao_needed, max_alpha_needed = ( + calculate_max_liquidity_from_balances( + tao_balance=tao_balance_available, + alpha_balance=alpha_balance_available, + current_price=current_price, + price_low=price_low_balance, + price_high=price_high_balance, + ) + ) + + console.print( + f"\n[cyan]Maximum liquidity that can be provided:[/cyan]\n" + f" TAO: {max_tao_needed.tao:.6f} τ\n" + f" Alpha: {max_alpha_needed.tao:.6f} α (for subnet {netuid})\n" + ) + + # Determine which amount to use based on what was provided + if tao_amount is not None and alpha_amount is not None: + # Both provided - use TAO amount and calculate Alpha + choice = "tao" + elif tao_amount is not None: + # Only TAO provided + choice = "tao" + elif alpha_amount is not None: + # Only Alpha provided + choice = "alpha" + else: + # Neither provided - ask user + choice = Prompt.ask( + f"[{COLORS.G.SUBHEAD_MAIN}]Enter 'tao' to specify TAO amount or 'alpha' to specify Alpha amount[/{COLORS.G.SUBHEAD_MAIN}]", + choices=["tao", "alpha"], + default="tao", + ) + + if choice == "tao": + if tao_amount is None: + tao_amount = FloatPrompt.ask( + f"[{COLORS.G.SUBHEAD_MAIN}]Enter the amount of TAO to provide (max: {max_tao_needed.tao:.6f})[/{COLORS.G.SUBHEAD_MAIN}]" + ) + tao_to_provide = Balance.from_tao(tao_amount) + + # Calculate corresponding Alpha + alpha_to_provide = calculate_alpha_from_tao( + tao_amount=tao_to_provide, + current_price=current_price, + price_low=price_low_balance, + price_high=price_high_balance, + ) + + console.print( + f"[cyan]This will require {alpha_to_provide.tao:.6f} Alpha tokens[/cyan]" + ) + + # Check if user has enough balance + if tao_to_provide > tao_balance_available: + err_console.print( + f"[red]Insufficient TAO balance.[/red]\n" + f"Required: {tao_to_provide.tao:.6f} τ\n" + f"Available: {tao_balance_available.tao:.6f} τ" + ) + return False, "Insufficient TAO balance." + + if alpha_to_provide > alpha_balance_available: + err_console.print( + f"[red]Insufficient Alpha balance.[/red]\n" + f"Required: {alpha_to_provide.tao:.6f} α (for subnet {netuid})\n" + f"Available: {alpha_balance_available.tao:.6f} α (for subnet {netuid})" + ) + return False, "Insufficient Alpha balance." + + # Calculate liquidity + sqrt_current_price = math.sqrt(current_price.tao) + sqrt_price_low = math.sqrt(price_low) + liquidity_to_add = Balance.from_rao( + int(tao_to_provide.rao / (sqrt_current_price - sqrt_price_low)) + ) + else: + if alpha_amount is None: + alpha_amount = FloatPrompt.ask( + f"[{COLORS.G.SUBHEAD_MAIN}]Enter the amount of Alpha to provide (max: {max_alpha_needed.tao:.6f})[/{COLORS.G.SUBHEAD_MAIN}]" + ) + alpha_to_provide = Balance.from_tao(alpha_amount) + + # Calculate corresponding TAO + tao_to_provide = calculate_tao_from_alpha( + alpha_amount=alpha_to_provide, + current_price=current_price, + price_low=price_low_balance, + price_high=price_high_balance, + ) + + console.print( + f"[cyan]This will require {tao_to_provide.tao:.6f} TAO tokens[/cyan]" + ) + + # Check if user has enough balance + if tao_to_provide > tao_balance_available: + err_console.print( + f"[red]Insufficient TAO balance.[/red]\n" + f"Required: {tao_to_provide.tao:.6f} τ\n" + f"Available: {tao_balance_available.tao:.6f} τ" + ) + return False, "Insufficient TAO balance." + + if alpha_to_provide > alpha_balance_available: + err_console.print( + f"[red]Insufficient Alpha balance.[/red]\n" + f"Required: {alpha_to_provide.tao:.6f} α (for subnet {netuid})\n" + f"Available: {alpha_balance_available.tao:.6f} α (for subnet {netuid})" + ) + return False, "Insufficient Alpha balance." + + # Calculate liquidity + sqrt_current_price = math.sqrt(current_price.tao) + sqrt_price_high = math.sqrt(price_high) + liquidity_to_add = Balance.from_rao( + int( + alpha_to_provide.rao + / (1 / sqrt_current_price - 1 / sqrt_price_high) + ) + ) + # Step 6: Confirm and execute the extrinsic if prompt: console.print( "You are about to add a LiquidityPosition with:\n" - f"\tliquidity: {liquidity}\n" - f"\tprice low: {price_low}\n" - f"\tprice high: {price_high}\n" + f"\tTAO amount: {tao_to_provide.tao:.6f} τ\n" + f"\tAlpha amount: {alpha_to_provide.tao:.6f} α (for subnet {netuid})\n" + f"\tprice low: {price_low_balance}\n" + f"\tprice high: {price_high_balance}\n" f"\tto SN: {netuid}\n" f"\tusing wallet with name: {wallet.name}" ) @@ -262,17 +372,25 @@ async def add_liquidity( if not Confirm.ask("Would you like to continue?"): return False, "User cancelled operation." + # Unlock wallet before executing extrinsic + if not (ulw := unlock_key(wallet)).success: + return False, ulw.message + success, message, ext_receipt = await add_liquidity_extrinsic( subtensor=subtensor, wallet=wallet, hotkey_ss58=hotkey_ss58, netuid=netuid, - liquidity=liquidity, - price_low=price_low, - price_high=price_high, + liquidity=liquidity_to_add, + price_low=price_low_balance, + price_high=price_high_balance, ) - await print_extrinsic_id(ext_receipt) - ext_id = await ext_receipt.get_extrinsic_identifier() + + ext_id = None + if ext_receipt: + await print_extrinsic_id(ext_receipt) + ext_id = await ext_receipt.get_extrinsic_identifier() + if json_output: json_console.print( json.dumps( @@ -286,6 +404,7 @@ async def add_liquidity( ) else: err_console.print(f"[red]Error: {message}[/red]") + return success, message diff --git a/bittensor_cli/src/commands/liquidity/utils.py b/bittensor_cli/src/commands/liquidity/utils.py index f364a64e4..66680f3c9 100644 --- a/bittensor_cli/src/commands/liquidity/utils.py +++ b/bittensor_cli/src/commands/liquidity/utils.py @@ -200,3 +200,153 @@ def prompt_position_id() -> int: console.print("[red]Please enter a valid number[/red].") # will never return this, but fixes the type checker return 0 + + +def calculate_max_liquidity_from_balances( + tao_balance: Balance, + alpha_balance: Balance, + current_price: Balance, + price_low: Balance, + price_high: Balance, +) -> tuple[Balance, Balance, Balance]: + """Calculate the maximum liquidity that can be provided given TAO and Alpha balances. + + Arguments: + tao_balance: Available TAO balance + alpha_balance: Available Alpha balance + current_price: Current subnet price (Alpha/TAO) + price_low: Lower bound of the price range + price_high: Upper bound of the price range + + Returns: + tuple[Balance, Balance, Balance]: + - Maximum liquidity that can be provided + - TAO amount needed for this liquidity + - Alpha amount needed for this liquidity + """ + sqrt_price_low = math.sqrt(price_low.tao) + sqrt_price_high = math.sqrt(price_high.tao) + sqrt_current_price = math.sqrt(current_price.tao) + + # Case 1: Current price is below the range (only Alpha needed) + if sqrt_current_price < sqrt_price_low: + # L = alpha / (1/sqrt_price_low - 1/sqrt_price_high) + max_liquidity_rao = alpha_balance.rao / ( + 1 / sqrt_price_low - 1 / sqrt_price_high + ) + return ( + Balance.from_rao(int(max_liquidity_rao)), + Balance.from_rao(0), # No TAO needed + alpha_balance, + ) + + # Case 2: Current price is above the range (only TAO needed) + elif sqrt_current_price > sqrt_price_high: + # L = tao / (sqrt_price_high - sqrt_price_low) + max_liquidity_rao = tao_balance.rao / (sqrt_price_high - sqrt_price_low) + return ( + Balance.from_rao(int(max_liquidity_rao)), + tao_balance, + Balance.from_rao(0), # No Alpha needed + ) + + # Case 3: Current price is within the range (both TAO and Alpha needed) + else: + # Calculate liquidity from TAO: L = tao / (sqrt_current_price - sqrt_price_low) + liquidity_from_tao = tao_balance.rao / (sqrt_current_price - sqrt_price_low) + + # Calculate liquidity from Alpha: L = alpha / (1/sqrt_current_price - 1/sqrt_price_high) + liquidity_from_alpha = alpha_balance.rao / ( + 1 / sqrt_current_price - 1 / sqrt_price_high + ) + + # Maximum liquidity is limited by the smaller of the two + max_liquidity_rao = min(liquidity_from_tao, liquidity_from_alpha) + + # Calculate the actual amounts needed + tao_needed_rao = max_liquidity_rao * (sqrt_current_price - sqrt_price_low) + alpha_needed_rao = max_liquidity_rao * ( + 1 / sqrt_current_price - 1 / sqrt_price_high + ) + + return ( + Balance.from_rao(int(max_liquidity_rao)), + Balance.from_rao(int(tao_needed_rao)), + Balance.from_rao(int(alpha_needed_rao)), + ) + + +def calculate_alpha_from_tao( + tao_amount: Balance, + current_price: Balance, + price_low: Balance, + price_high: Balance, +) -> Balance: + """Calculate the Alpha amount needed for a given TAO amount. + + Arguments: + tao_amount: TAO amount to provide + current_price: Current subnet price (Alpha/TAO) + price_low: Lower bound of the price range + price_high: Upper bound of the price range + + Returns: + Balance: Alpha amount needed + """ + sqrt_price_low = math.sqrt(price_low.tao) + sqrt_price_high = math.sqrt(price_high.tao) + sqrt_current_price = math.sqrt(current_price.tao) + + # If current price is below range, no TAO should be provided + if sqrt_current_price < sqrt_price_low: + return Balance.from_rao(0) + + # If current price is above range, no Alpha is needed + if sqrt_current_price > sqrt_price_high: + return Balance.from_rao(0) + + # Calculate liquidity from TAO + liquidity_rao = tao_amount.rao / (sqrt_current_price - sqrt_price_low) + + # Calculate Alpha needed for this liquidity + alpha_needed_rao = liquidity_rao * (1 / sqrt_current_price - 1 / sqrt_price_high) + + return Balance.from_rao(int(alpha_needed_rao)) + + +def calculate_tao_from_alpha( + alpha_amount: Balance, + current_price: Balance, + price_low: Balance, + price_high: Balance, +) -> Balance: + """Calculate the TAO amount needed for a given Alpha amount. + + Arguments: + alpha_amount: Alpha amount to provide + current_price: Current subnet price (Alpha/TAO) + price_low: Lower bound of the price range + price_high: Upper bound of the price range + + Returns: + Balance: TAO amount needed + """ + sqrt_price_low = math.sqrt(price_low.tao) + sqrt_price_high = math.sqrt(price_high.tao) + sqrt_current_price = math.sqrt(current_price.tao) + + # If current price is above range, no Alpha should be provided + if sqrt_current_price > sqrt_price_high: + return Balance.from_rao(0) + + # If current price is below range, no TAO is needed + if sqrt_current_price < sqrt_price_low: + return Balance.from_rao(0) + + # Calculate liquidity from Alpha + liquidity_rao = alpha_amount.rao / (1 / sqrt_current_price - 1 / sqrt_price_high) + + # Calculate TAO needed for this liquidity + tao_needed_rao = liquidity_rao * (sqrt_current_price - sqrt_price_low) + + return Balance.from_rao(int(tao_needed_rao)) diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py index 7a210f0a1..9fd159591 100644 --- a/tests/e2e_tests/test_liquidity.py +++ b/tests/e2e_tests/test_liquidity.py @@ -204,7 +204,7 @@ def test_liquidity(local_chain, wallet_setup): wallet_alice.hotkey_str, "--netuid", netuid, - "--liquidity", + "--tao-amount", "1.0", "--price-low", "1.7", diff --git a/tests/unit_tests/test_liquidity_utils.py b/tests/unit_tests/test_liquidity_utils.py new file mode 100644 index 000000000..3337b8afc --- /dev/null +++ b/tests/unit_tests/test_liquidity_utils.py @@ -0,0 +1,170 @@ +"""Unit tests for liquidity utility functions.""" + +import math +import pytest +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.commands.liquidity.utils import ( + calculate_max_liquidity_from_balances, + calculate_alpha_from_tao, + calculate_tao_from_alpha, +) + + +class TestLiquidityCalculations: + """Test the new liquidity calculation helper functions.""" + + def test_calculate_max_liquidity_only_alpha_needed(self): + """Test when current price is below the range (only Alpha needed).""" + tao_balance = Balance.from_tao(100.0) + alpha_balance = Balance.from_tao(50.0) + current_price = Balance.from_tao(1.0) # Below range + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + max_liquidity, max_tao, max_alpha = calculate_max_liquidity_from_balances( + tao_balance, alpha_balance, current_price, price_low, price_high + ) + + # When price is below range, only Alpha is needed + assert max_tao.rao == 0, "No TAO should be needed when price is below range" + assert max_alpha.rao == alpha_balance.rao, "All available Alpha should be used" + assert max_liquidity.rao > 0, "Liquidity should be calculated" + + def test_calculate_max_liquidity_only_tao_needed(self): + """Test when current price is above the range (only TAO needed).""" + tao_balance = Balance.from_tao(100.0) + alpha_balance = Balance.from_tao(50.0) + current_price = Balance.from_tao(5.0) # Above range + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + max_liquidity, max_tao, max_alpha = calculate_max_liquidity_from_balances( + tao_balance, alpha_balance, current_price, price_low, price_high + ) + + # When price is above range, only TAO is needed + assert max_tao.rao == tao_balance.rao, "All available TAO should be used" + assert max_alpha.rao == 0, "No Alpha should be needed when price is above range" + assert max_liquidity.rao > 0, "Liquidity should be calculated" + + def test_calculate_max_liquidity_both_needed(self): + """Test when current price is within the range (both TAO and Alpha needed).""" + tao_balance = Balance.from_tao(100.0) + alpha_balance = Balance.from_tao(50.0) + current_price = Balance.from_tao(2.5) # Within range + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + max_liquidity, max_tao, max_alpha = calculate_max_liquidity_from_balances( + tao_balance, alpha_balance, current_price, price_low, price_high + ) + + # When price is within range, both are needed + assert max_tao.rao > 0, "TAO should be needed when price is within range" + assert max_alpha.rao > 0, "Alpha should be needed when price is within range" + assert max_liquidity.rao > 0, "Liquidity should be calculated" + # Should not exceed available balances + assert max_tao.rao <= tao_balance.rao, "TAO needed should not exceed balance" + assert max_alpha.rao <= alpha_balance.rao, ( + "Alpha needed should not exceed balance" + ) + + def test_calculate_alpha_from_tao_within_range(self): + """Test calculating Alpha amount from TAO when price is within range.""" + tao_amount = Balance.from_tao(10.0) + current_price = Balance.from_tao(2.5) + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + alpha_needed = calculate_alpha_from_tao( + tao_amount, current_price, price_low, price_high + ) + + assert alpha_needed.rao > 0, "Alpha should be needed for TAO within range" + + def test_calculate_alpha_from_tao_below_range(self): + """Test that no Alpha is calculated when price is below range.""" + tao_amount = Balance.from_tao(10.0) + current_price = Balance.from_tao(1.0) # Below range + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + alpha_needed = calculate_alpha_from_tao( + tao_amount, current_price, price_low, price_high + ) + + assert alpha_needed.rao == 0, "No Alpha needed when price is below range" + + def test_calculate_alpha_from_tao_above_range(self): + """Test that no Alpha is needed when price is above range.""" + tao_amount = Balance.from_tao(10.0) + current_price = Balance.from_tao(5.0) # Above range + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + alpha_needed = calculate_alpha_from_tao( + tao_amount, current_price, price_low, price_high + ) + + assert alpha_needed.rao == 0, "No Alpha needed when price is above range" + + def test_calculate_tao_from_alpha_within_range(self): + """Test calculating TAO amount from Alpha when price is within range.""" + alpha_amount = Balance.from_tao(10.0) + current_price = Balance.from_tao(2.5) + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + tao_needed = calculate_tao_from_alpha( + alpha_amount, current_price, price_low, price_high + ) + + assert tao_needed.rao > 0, "TAO should be needed for Alpha within range" + + def test_calculate_tao_from_alpha_below_range(self): + """Test that no TAO is needed when price is below range.""" + alpha_amount = Balance.from_tao(10.0) + current_price = Balance.from_tao(1.0) # Below range + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + tao_needed = calculate_tao_from_alpha( + alpha_amount, current_price, price_low, price_high + ) + + assert tao_needed.rao == 0, "No TAO needed when price is below range" + + def test_calculate_tao_from_alpha_above_range(self): + """Test that no TAO is calculated when price is above range.""" + alpha_amount = Balance.from_tao(10.0) + current_price = Balance.from_tao(5.0) # Above range + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + tao_needed = calculate_tao_from_alpha( + alpha_amount, current_price, price_low, price_high + ) + + assert tao_needed.rao == 0, "No TAO calculated when price is above range" + + def test_reciprocal_calculation(self): + """Test that TAO->Alpha->TAO conversion is consistent.""" + tao_amount = Balance.from_tao(10.0) + current_price = Balance.from_tao(2.5) + price_low = Balance.from_tao(2.0) + price_high = Balance.from_tao(3.0) + + # Calculate Alpha from TAO + alpha_needed = calculate_alpha_from_tao( + tao_amount, current_price, price_low, price_high + ) + + # Calculate TAO back from Alpha + tao_back = calculate_tao_from_alpha( + alpha_needed, current_price, price_low, price_high + ) + + # Should be approximately equal (within rounding error) + assert abs(tao_back.rao - tao_amount.rao) < 1000, ( + "Reciprocal calculation should yield similar result" + )