diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 585b1a6d3..042d14f81 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -8226,12 +8226,6 @@ def liquidity_add( """Add liquidity to the swap (as a combination of TAO + Alpha).""" self.verbosity_handler(quiet, verbose, json_output, prompt) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) - if not netuid: - netuid = Prompt.ask( - f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", - default=None, - show_default=False, - ) wallet, hotkey = self.wallet_ask( wallet_name=wallet_name, @@ -8241,35 +8235,23 @@ def liquidity_add( 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)" - ) + # Defer prompting + chain-dependent logic to the async command implementation. + liquidity_balance = ( + Balance.from_tao(liquidity_) if liquidity_ is not None else None + ) + price_low_balance = Balance.from_tao(price_low) if price_low is not None else None + price_high_balance = ( + Balance.from_tao(price_high) if price_high is not None else None + ) - 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" + f"liquidity: {liquidity_balance}\n" + f"price_low: {price_low_balance}\n" + f"price_high: {price_high_balance}\n" f"proxy: {proxy}\n" ) return self._run_command( @@ -8279,9 +8261,9 @@ def liquidity_add( hotkey_ss58=hotkey, netuid=netuid, proxy=proxy, - liquidity=liquidity_, - price_low=price_low, - price_high=price_high, + liquidity=liquidity_balance, + price_low=price_low_balance, + price_high=price_high_balance, prompt=prompt, json_output=json_output, ) diff --git a/bittensor_cli/src/commands/liquidity/liquidity.py b/bittensor_cli/src/commands/liquidity/liquidity.py index 48383e605..485a9466a 100644 --- a/bittensor_cli/src/commands/liquidity/liquidity.py +++ b/bittensor_cli/src/commands/liquidity/liquidity.py @@ -3,11 +3,12 @@ from typing import TYPE_CHECKING, Optional from async_substrate_interface import AsyncExtrinsicReceipt -from rich.prompt import Confirm +from rich.prompt import Confirm, FloatPrompt, IntPrompt, Prompt from rich.table import Column, Table from bittensor_cli.src import COLORS from bittensor_cli.src.bittensor.utils import ( + is_valid_ss58_address, unlock_key, console, err_console, @@ -19,6 +20,11 @@ LiquidityPosition, calculate_fees, get_fees, + liquidity_from_alpha_below_range, + liquidity_from_alpha_in_range, + liquidity_from_tao_above_range, + liquidity_from_tao_in_range, + max_liquidity_in_range, price_to_tick, tick_to_price, ) @@ -247,24 +253,248 @@ async def add_liquidity( hotkey_ss58: str, netuid: Optional[int], proxy: Optional[str], - liquidity: Balance, - price_low: Balance, - price_high: Balance, + liquidity: Optional[Balance], + price_low: Optional[Balance], + price_high: Optional[Balance], prompt: bool, json_output: bool, ) -> tuple[bool, str]: """Add liquidity position to provided subnet.""" + + def _maybe_print_json(success: bool, message: str, ext_id: Optional[str] = None) -> None: + if not json_output: + return + json_console.print_json( + data={ + "success": success, + "message": message, + "extrinsic_identifier": ext_id, + } + ) + + def _return(success: bool, message: str, ext_id: Optional[str] = None) -> tuple[bool, str]: + _maybe_print_json(success=success, message=message, ext_id=ext_id) + return success, message + + def _prompt_hotkey(default_hotkey: str) -> str: + while True: + chosen = Prompt.ask( + "Enter the hotkey to use for this liquidity position (press enter to use default)", + default=default_hotkey, + show_default=True, + ).strip() + if is_valid_ss58_address(chosen): + return chosen + console.print("[red]Invalid SS58 hotkey address.[/red]") + + def _prompt_price(prompt_text: str) -> Balance: + while True: + price = FloatPrompt.ask(prompt_text) + if price <= 0: + console.print("[red]Price must be greater than 0[/red].") + continue + return Balance.from_tao(price) + + def _prompt_amount_tao(prompt_text: str, *, maximum: Optional[Balance] = None) -> Balance: + while True: + amt = FloatPrompt.ask(prompt_text) + if amt <= 0: + console.print("[red]Amount must be greater than 0[/red].") + continue + b = Balance.from_tao(amt) + if maximum is not None and b > maximum: + console.print(f"[red]Amount exceeds maximum available: {maximum}[/red]") + continue + return b + + def _prompt_amount_alpha( + prompt_text: str, *, netuid_: int, maximum: Optional[Balance] = None + ) -> Balance: + while True: + amt = FloatPrompt.ask(prompt_text) + if amt <= 0: + console.print("[red]Amount must be greater than 0[/red].") + continue + b = Balance.from_tao(amt).set_unit(netuid_) + if maximum is not None and b > maximum: + console.print(f"[red]Amount exceeds maximum available: {maximum}[/red]") + continue + return b + # Check wallet access if not (ulw := unlock_key(wallet)).success: - return False, ulw.message - - # 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}." + return _return(False, ulw.message) if prompt: + if netuid is None: + netuid = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use" + ) + + # Check that the subnet exists. + if not await subtensor.subnet_exists(netuid=netuid): + return _return( + False, f"Subnet with netuid: {netuid} does not exist in {subtensor}." + ) + + if price_low is None: + price_low = _prompt_price("Enter liquidity position low price") + + if price_high is None: + price_high = _prompt_price( + "Enter liquidity position high price (must be greater than low price)" + ) + + while price_low >= price_high: + console.print("[red]The low price must be lower than the high price.[/red]") + price_low = _prompt_price("Enter liquidity position low price") + price_high = _prompt_price( + "Enter liquidity position high price (must be greater than low price)" + ) + + current_price = await subtensor.get_subnet_price(netuid=netuid) + console.print(f"Current subnet price: {current_price}") + + # If user passed --liquidity explicitly, keep backwards-compat behavior. + chosen_hotkey_ss58 = hotkey_ss58 + if liquidity is None: + if price_low >= current_price: + # Price is below range -> Alpha-only deposit + chosen_hotkey_ss58 = _prompt_hotkey(hotkey_ss58) + alpha_amount = _prompt_amount_alpha( + "Enter Alpha amount to provide", + netuid_=netuid, + ) + alpha_available = await subtensor.get_stake_for_coldkey_and_hotkey( + hotkey_ss58=chosen_hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + ) + if alpha_amount > alpha_available: + return _return( + False, + f"Insufficient Alpha stake on hotkey {chosen_hotkey_ss58}: have {alpha_available}, need {alpha_amount}.", + ) + liquidity = liquidity_from_alpha_below_range( + amount_alpha=alpha_amount, + price_low=price_low, + price_high=price_high, + ) + if liquidity.rao <= 0: + return _return(False, "Alpha amount is too small to create non-zero liquidity.") + elif price_high <= current_price: + # Price is above range -> TAO-only deposit (hotkey is still required but doesn't affect amounts) + chosen_hotkey_ss58 = _prompt_hotkey(hotkey_ss58) + tao_amount = _prompt_amount_tao("Enter TAO amount to provide") + tao_available = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + if tao_amount > tao_available: + return _return( + False, + f"Insufficient TAO balance: have {tao_available}, need {tao_amount}.", + ) + liquidity = liquidity_from_tao_above_range( + amount_tao=tao_amount, + price_low=price_low, + price_high=price_high, + ) + if liquidity.rao <= 0: + return _return(False, "TAO amount is too small to create non-zero liquidity.") + else: + # Price is inside range -> mixed deposit + chosen_hotkey_ss58 = _prompt_hotkey(hotkey_ss58) + tao_available, alpha_available = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.get_stake_for_coldkey_and_hotkey( + hotkey_ss58=chosen_hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + ), + ) + + l_max = max_liquidity_in_range( + tao_available=tao_available, + alpha_available=alpha_available, + current_price=current_price, + price_low=price_low, + price_high=price_high, + ) + if l_max.rao <= 0: + return _return( + False, + "Insufficient TAO and/or Alpha to provide liquidity at the current subnet price.", + ) + max_alpha, max_tao = LiquidityPosition( + id=0, + price_low=price_low, + price_high=price_high, + liquidity=l_max, + fees_tao=Balance.from_rao(0), + fees_alpha=Balance.from_rao(0).set_unit(netuid), + netuid=netuid, + ).to_token_amounts(current_price) + + console.print( + "This is the max amount of TAO and Alpha that can be currently provided:\n" + f"\tMax TAO: {max_tao}\n" + f"\tMax Alpha: {max_alpha}" + ) + + token_choice = Prompt.ask( + "Would you like to specify your deposit in TAO or Alpha?", + choices=["tao", "alpha"], + default="tao", + show_default=True, + ) + + if token_choice == "tao": + tao_amount = _prompt_amount_tao( + "Enter TAO amount to provide", + maximum=max_tao, + ) + liquidity = liquidity_from_tao_in_range( + amount_tao=tao_amount, + current_price=current_price, + price_low=price_low, + ) + if liquidity.rao <= 0: + return _return( + False, + "Selected TAO amount is too small to create non-zero liquidity.", + ) + else: + alpha_amount = _prompt_amount_alpha( + "Enter Alpha amount to provide", + netuid_=netuid, + maximum=max_alpha, + ) + liquidity = liquidity_from_alpha_in_range( + amount_alpha=alpha_amount, + current_price=current_price, + price_high=price_high, + ) + + if liquidity.rao <= 0: + return _return(False, "Selected amount is too small to create non-zero liquidity.") + + needed_alpha, needed_tao = LiquidityPosition( + id=0, + price_low=price_low, + price_high=price_high, + liquidity=liquidity, + fees_tao=Balance.from_rao(0), + fees_alpha=Balance.from_rao(0).set_unit(netuid), + netuid=netuid, + ).to_token_amounts(current_price) + + console.print( + "Your selected liquidity corresponds to approximately:\n" + f"\tTAO: {needed_tao}\n" + f"\tAlpha: {needed_alpha}" + ) + console.print( "You are about to add a LiquidityPosition with:\n" + f"\thotkey: {chosen_hotkey_ss58}\n" f"\tliquidity: {liquidity}\n" f"\tprice low: {price_low}\n" f"\tprice high: {price_high}\n" @@ -273,7 +503,24 @@ async def add_liquidity( ) if not Confirm.ask("Would you like to continue?"): - return False, "User cancelled operation." + return _return(False, "User cancelled operation.") + + hotkey_ss58 = chosen_hotkey_ss58 + + else: + # Non-interactive mode: require all args. + if netuid is None: + return _return(False, "Missing required argument: netuid") + if liquidity is None: + return _return(False, "Missing required argument: liquidity (use --liquidity)") + if price_low is None or price_high is None: + return _return(False, "Missing required arguments: price_low/price_high") + if price_low >= price_high: + return _return(False, "The low price must be lower than the high price.") + if not await subtensor.subnet_exists(netuid=netuid): + return _return( + False, f"Subnet with netuid: {netuid} does not exist in {subtensor}." + ) success, message, ext_receipt = await add_liquidity_extrinsic( subtensor=subtensor, @@ -285,19 +532,15 @@ async def add_liquidity( price_low=price_low, price_high=price_high, ) + if success: await print_extrinsic_id(ext_receipt) ext_id = await ext_receipt.get_extrinsic_identifier() else: ext_id = None + if json_output: - json_console.print_json( - data={ - "success": success, - "message": message, - "extrinsic_identifier": ext_id, - } - ) + _maybe_print_json(success=success, message=message, ext_id=ext_id) else: if success: console.print( @@ -305,6 +548,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..627223307 100644 --- a/bittensor_cli/src/commands/liquidity/utils.py +++ b/bittensor_cli/src/commands/liquidity/utils.py @@ -87,6 +87,91 @@ def tick_to_price(tick: int) -> float: return PRICE_STEP**tick +def liquidity_from_alpha_below_range( + *, + amount_alpha: Balance, + price_low: Balance, + price_high: Balance, +) -> Balance: + """Compute liquidity from an Alpha-only deposit when current price <= price_low.""" + sqrt_price_low = math.sqrt(float(price_low)) + sqrt_price_high = math.sqrt(float(price_high)) + denom = (1 / sqrt_price_low) - (1 / sqrt_price_high) + if denom <= 0: + raise ValueError("Invalid price range: expected price_low < price_high") + return Balance.from_rao(int(amount_alpha.rao / denom)) + + +def liquidity_from_tao_above_range( + *, + amount_tao: Balance, + price_low: Balance, + price_high: Balance, +) -> Balance: + """Compute liquidity from a TAO-only deposit when current price >= price_high.""" + sqrt_price_low = math.sqrt(float(price_low)) + sqrt_price_high = math.sqrt(float(price_high)) + denom = sqrt_price_high - sqrt_price_low + if denom <= 0: + raise ValueError("Invalid price range: expected price_low < price_high") + return Balance.from_rao(int(amount_tao.rao / denom)) + + +def liquidity_from_tao_in_range( + *, + amount_tao: Balance, + current_price: Balance, + price_low: Balance, +) -> Balance: + """Compute liquidity from a TAO deposit when price_low < current_price < price_high.""" + sqrt_price_low = math.sqrt(float(price_low)) + sqrt_current_price = math.sqrt(float(current_price)) + denom = sqrt_current_price - sqrt_price_low + if denom <= 0: + return Balance.from_rao(0) + return Balance.from_rao(int(amount_tao.rao / denom)) + + +def liquidity_from_alpha_in_range( + *, + amount_alpha: Balance, + current_price: Balance, + price_high: Balance, +) -> Balance: + """Compute liquidity from an Alpha deposit when price_low < current_price < price_high.""" + sqrt_current_price = math.sqrt(float(current_price)) + sqrt_price_high = math.sqrt(float(price_high)) + denom = (1 / sqrt_current_price) - (1 / sqrt_price_high) + if denom <= 0: + return Balance.from_rao(0) + return Balance.from_rao(int(amount_alpha.rao / denom)) + + +def max_liquidity_in_range( + *, + tao_available: Balance, + alpha_available: Balance, + current_price: Balance, + price_low: Balance, + price_high: Balance, +) -> Balance: + """Compute the maximum liquidity possible given token availability at current price. + + This assumes price_low < current_price < price_high. + """ + l_from_tao = liquidity_from_tao_in_range( + amount_tao=tao_available, + current_price=current_price, + price_low=price_low, + ) + l_from_alpha = liquidity_from_alpha_in_range( + amount_alpha=alpha_available, + current_price=current_price, + price_high=price_high, + ) + return Balance.from_rao(min(l_from_tao.rao, l_from_alpha.rao)) + + def get_fees( current_tick: int, tick: dict, diff --git a/tests/unit_tests/test_liquidity_add.py b/tests/unit_tests/test_liquidity_add.py new file mode 100644 index 000000000..d1e338fba --- /dev/null +++ b/tests/unit_tests/test_liquidity_add.py @@ -0,0 +1,153 @@ +import pytest +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.commands.liquidity import liquidity as liquidity_cmd +from bittensor_cli.src.commands.liquidity.utils import max_liquidity_in_range + + +def _wallet_stub(): + wallet = MagicMock() + wallet.name = "test_wallet" + wallet.coldkeypub.ss58_address = "5F3sa2TJcPq7Qm8kXy...coldkey" + return wallet + + +@pytest.mark.asyncio +async def test_add_liquidity_json_output_on_unlock_failure(monkeypatch): + subtensor = MagicMock() + + # Force unlock failure + monkeypatch.setattr( + liquidity_cmd, + "unlock_key", + lambda _wallet: SimpleNamespace(success=False, message="unlock failed"), + ) + + printed = {} + + def fake_print_json(*, data): + printed["data"] = data + + monkeypatch.setattr(liquidity_cmd.json_console, "print_json", fake_print_json) + + success, message = await liquidity_cmd.add_liquidity( + subtensor=subtensor, + wallet=_wallet_stub(), + hotkey_ss58="5F3sa2TJcPq7Qm8kXy...hotkey", + netuid=1, + proxy=None, + liquidity=Balance.from_tao(1.0), + price_low=Balance.from_tao(1.0), + price_high=Balance.from_tao(2.0), + prompt=False, + json_output=True, + ) + + assert success is False + assert message == "unlock failed" + assert printed["data"] == { + "success": False, + "message": "unlock failed", + "extrinsic_identifier": None, + } + + +@pytest.mark.asyncio +async def test_add_liquidity_json_output_on_subnet_missing(monkeypatch): + subtensor = MagicMock() + subtensor.subnet_exists = AsyncMock(return_value=False) + + monkeypatch.setattr( + liquidity_cmd, + "unlock_key", + lambda _wallet: SimpleNamespace(success=True, message=""), + ) + + printed = {} + + def fake_print_json(*, data): + printed["data"] = data + + monkeypatch.setattr(liquidity_cmd.json_console, "print_json", fake_print_json) + + success, message = await liquidity_cmd.add_liquidity( + subtensor=subtensor, + wallet=_wallet_stub(), + hotkey_ss58="5F3sa2TJcPq7Qm8kXy...hotkey", + netuid=120, + proxy=None, + liquidity=Balance.from_tao(100.0), + price_low=Balance.from_tao(1.0), + price_high=Balance.from_tao(2.0), + prompt=False, + json_output=True, + ) + + assert success is False + assert "Subnet with netuid: 120" in message + assert printed["data"]["success"] is False + assert printed["data"]["message"] == message + assert printed["data"]["extrinsic_identifier"] is None + + +@pytest.mark.asyncio +async def test_add_liquidity_json_output_on_extrinsic_failure(monkeypatch): + subtensor = MagicMock() + subtensor.subnet_exists = AsyncMock(return_value=True) + + monkeypatch.setattr( + liquidity_cmd, + "unlock_key", + lambda _wallet: SimpleNamespace(success=True, message=""), + ) + + add_ext = AsyncMock(return_value=(False, "Invalid Transaction", None)) + monkeypatch.setattr(liquidity_cmd, "add_liquidity_extrinsic", add_ext) + + printed = {} + + def fake_print_json(*, data): + printed["data"] = data + + monkeypatch.setattr(liquidity_cmd.json_console, "print_json", fake_print_json) + + success, message = await liquidity_cmd.add_liquidity( + subtensor=subtensor, + wallet=_wallet_stub(), + hotkey_ss58="5F3sa2TJcPq7Qm8kXy...hotkey", + netuid=120, + proxy=None, + liquidity=Balance.from_tao(100.0), + price_low=Balance.from_tao(1.0), + price_high=Balance.from_tao(2.0), + prompt=False, + json_output=True, + ) + + assert success is False + assert message == "Invalid Transaction" + assert printed["data"] == { + "success": False, + "message": "Invalid Transaction", + "extrinsic_identifier": None, + } + + add_ext.assert_awaited_once() + + +def test_max_liquidity_in_range_uses_limiting_side(): + # price_low=1, price_high=4, current_price=2.25 + # sqrt_low=1, sqrt_high=2, sqrt_cur=1.5 + # L_from_tao = tao / (1.5-1) = 2*tao + # L_from_alpha = alpha / (1/1.5-1/2) = alpha / (2/3-1/2)= alpha / (1/6)= 6*alpha + # => tao-limited for equal tao/alpha + l_max = max_liquidity_in_range( + tao_available=Balance.from_tao(10.0), + alpha_available=Balance.from_tao(10.0), + current_price=Balance.from_tao(2.25), + price_low=Balance.from_tao(1.0), + price_high=Balance.from_tao(4.0), + ) + assert l_max.tao == pytest.approx(20.0)