diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index f7bbe37ec1..d198e72c4d 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -40,16 +40,16 @@ ) from bittensor.core.config import Config from bittensor.core.errors import ChainError, SubstrateRequestException +from bittensor.core.extrinsics.asyncex.children import ( + root_set_pending_childkey_cooldown_extrinsic, + set_children_extrinsic, +) from bittensor.core.extrinsics.asyncex.commit_reveal import commit_reveal_v3_extrinsic from bittensor.core.extrinsics.asyncex.move_stake import ( transfer_stake_extrinsic, swap_stake_extrinsic, move_stake_extrinsic, ) -from bittensor.core.extrinsics.asyncex.children import ( - root_set_pending_childkey_cooldown_extrinsic, - set_children_extrinsic, -) from bittensor.core.extrinsics.asyncex.registration import ( burned_register_extrinsic, register_extrinsic, @@ -98,12 +98,25 @@ u16_normalized_float, u64_normalized_float, ) +from bittensor.core.extrinsics.asyncex.liquidity import ( + add_liquidity_extrinsic, + modify_liquidity_extrinsic, + remove_liquidity_extrinsic, + toggle_user_liquidity_extrinsic, +) from bittensor.utils.balance import ( Balance, fixed_to_float, check_and_convert_to_balance, ) from bittensor.utils.btlogging import logging +from bittensor.utils.liquidity import ( + calculate_fees, + get_fees, + tick_to_price, + price_to_tick, + LiquidityPosition, +) from bittensor.utils.weight_utils import generate_weight_hash, convert_uids_and_weights if TYPE_CHECKING: @@ -1846,6 +1859,174 @@ async def get_all_neuron_certificates( output[decode_account_id(key)] = Certificate(item.value) return output + async def get_liquidity_list( + self, + wallet: "Wallet", + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Optional[list[LiquidityPosition]]: + """ + Retrieves all liquidity positions for the given wallet on a specified subnet (netuid). + Calculates associated fee rewards based on current global and tick-level fee data. + + Args: + wallet: Wallet instance to fetch positions for. + netuid: Subnet unique id. + block: The blockchain block number for the query. + block_hash: The hash of the block to retrieve the parameter from. Do not specify if using block or + reuse_block. + reuse_block: Whether to use the last-used block. Do not set if using block_hash or block. + + Returns: + List of liquidity positions, or None if subnet does not exist. + """ + if not await self.subnet_exists(netuid=netuid): + logging.debug(f"Subnet {netuid} does not exist.") + return None + + if not await self.is_subnet_active(netuid=netuid): + logging.debug(f"Subnet {netuid} is not active.") + return None + + block_hash = await self.determine_block_hash( + block=block, block_hash=block_hash, reuse_block=reuse_block + ) + + query = self.substrate.query + ( + fee_global_tao, + fee_global_alpha, + sqrt_price, + positions_response, + ) = await asyncio.gather( + query( + module="Swap", + storage_function="FeeGlobalTao", + params=[netuid], + block_hash=block_hash, + ), + query( + module="Swap", + storage_function="FeeGlobalAlpha", + params=[netuid], + block_hash=block_hash, + ), + query( + module="Swap", + storage_function="AlphaSqrtPrice", + params=[netuid], + block_hash=block_hash, + ), + self.query_map( + module="Swap", + name="Positions", + block=block, + params=[netuid, wallet.coldkeypub.ss58_address], + ), + ) + # convert to floats + fee_global_tao = fixed_to_float(fee_global_tao) + fee_global_alpha = fixed_to_float(fee_global_alpha) + sqrt_price = fixed_to_float(sqrt_price) + + # Fetch global fees and current price + current_tick = price_to_tick(sqrt_price**2) + + # Fetch positions + positions = [] + async for _, p in positions_response: + position = p.value + + tick_low_idx = position.get("tick_low")[0] + tick_high_idx = position.get("tick_high")[0] + + tick_low, tick_high = await asyncio.gather( + query( + module="Swap", + storage_function="Ticks", + params=[netuid, tick_low_idx], + block_hash=block_hash, + ), + query( + module="Swap", + storage_function="Ticks", + params=[netuid, tick_high_idx], + block_hash=block_hash, + ), + ) + + # Calculate fees above/below range for both tokens + tao_below = get_fees( + current_tick=current_tick, + tick=tick_low, + tick_index=tick_low_idx, + quote=True, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=False, + ) + tao_above = get_fees( + current_tick=current_tick, + tick=tick_high, + tick_index=tick_high_idx, + quote=True, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=True, + ) + alpha_below = get_fees( + current_tick=current_tick, + tick=tick_low, + tick_index=tick_low_idx, + quote=False, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=False, + ) + alpha_above = get_fees( + current_tick=current_tick, + tick=tick_high, + tick_index=tick_high_idx, + quote=False, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=True, + ) + + # Calculate fees earned by position + fees_tao, fees_alpha = calculate_fees( + position=position, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + tao_fees_below_low=tao_below, + tao_fees_above_high=tao_above, + alpha_fees_below_low=alpha_below, + alpha_fees_above_high=alpha_above, + netuid=netuid, + ) + + positions.append( + LiquidityPosition( + **{ + "id": position.get("id")[0], + "price_low": Balance.from_tao( + tick_to_price(position.get("tick_low")[0]) + ), + "price_high": Balance.from_tao( + tick_to_price(position.get("tick_high")[0]) + ), + "liquidity": Balance.from_rao(position.get("liquidity")), + "fees_tao": fees_tao, + "fees_alpha": fees_alpha, + "netuid": position.get("netuid"), + } + ) + ) + + return positions + async def get_neuron_for_pubkey_and_subnet( self, hotkey_ss58: str, @@ -3597,6 +3778,52 @@ async def add_stake( period=period, ) + async def add_liquidity( + self, + wallet: "Wallet", + netuid: int, + liquidity: Balance, + price_low: Balance, + price_high: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, + ) -> tuple[bool, str]: + """ + Adds liquidity to the specified price range. + + Arguments: + 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. + liquidity: The amount of liquidity to be added. + price_low: The lower bound of the price tick range. In TAO. + price_high: The upper bound of the price tick range. In TAO. + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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` + method to enable/disable user liquidity. + """ + return await add_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + liquidity=liquidity, + price_low=price_low, + price_high=price_high, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + ) + async def add_stake_multiple( self, wallet: "Wallet", @@ -3759,6 +3986,74 @@ async def commit_weights( return success, message + async def modify_liquidity( + self, + wallet: "Wallet", + netuid: int, + position_id: int, + liquidity_delta: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, + ) -> tuple[bool, str]: + """Modifies liquidity in liquidity position by adding or removing liquidity from it. + + Arguments: + 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. + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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. + + Example: + import bittensor as bt + + subtensor = bt.AsyncSubtensor(network="local") + await subtensor.initialize() + + my_wallet = bt.Wallet() + + # if `liquidity_delta` is negative + my_liquidity_delta = Balance.from_tao(100) * -1 + await subtensor.modify_liquidity( + wallet=my_wallet, + netuid=123, + position_id=2, + liquidity_delta=my_liquidity_delta + ) + + # if `liquidity_delta` is positive + my_liquidity_delta = Balance.from_tao(120) + await subtensor.modify_liquidity( + wallet=my_wallet, + netuid=123, + position_id=2, + liquidity_delta=my_liquidity_delta + ) + + Note: Modifying is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity` + to enable/disable user liquidity. + """ + return await modify_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + position_id=position_id, + liquidity_delta=liquidity_delta, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + ) + async def move_stake( self, wallet: "Wallet", @@ -3900,6 +4195,47 @@ async def register_subnet( period=period, ) + async def remove_liquidity( + self, + wallet: "Wallet", + netuid: int, + position_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, + ) -> tuple[bool, str]: + """Remove liquidity and credit balances back to wallet's hotkey stake. + + Arguments: + 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. + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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. + - To get the `position_id` use `get_liquidity_list` method. + """ + return await remove_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + position_id=position_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + ) + async def reveal_weights( self, wallet: "Wallet", @@ -4524,6 +4860,44 @@ async def swap_stake( period=period, ) + async def toggle_user_liquidity( + self, + wallet: "Wallet", + netuid: int, + enable: bool, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, + ) -> tuple[bool, str]: + """Allow to toggle user liquidity for specified subnet. + + Arguments: + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: The call can be executed successfully by the subnet owner only. + """ + return await toggle_user_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + enable=enable, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + ) + async def transfer( self, wallet: "Wallet", diff --git a/bittensor/core/extrinsics/asyncex/liquidity.py b/bittensor/core/extrinsics/asyncex/liquidity.py new file mode 100644 index 0000000000..cbe43575ba --- /dev/null +++ b/bittensor/core/extrinsics/asyncex/liquidity.py @@ -0,0 +1,231 @@ +from typing import Optional, TYPE_CHECKING + +from bittensor.utils import unlock_key +from bittensor.utils.balance import Balance +from bittensor.utils.btlogging import logging +from bittensor.utils.liquidity import price_to_tick + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.async_subtensor import AsyncSubtensor + + +async def add_liquidity_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + liquidity: Balance, + price_low: Balance, + price_high: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, +) -> tuple[bool, str]: + """ + 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). + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: + logging.error(unlock.message) + return False, unlock.message + + 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": wallet.hotkey.ss58_address, + "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, + use_nonce=True, + period=period, + ) + + +async def modify_liquidity_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + position_id: int, + liquidity_delta: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, +) -> tuple[bool, str]: + """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). + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: + logging.error(unlock.message) + return False, unlock.message + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="modify_position", + call_params={ + "hotkey": wallet.hotkey.ss58_address, + "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, + use_nonce=True, + period=period, + ) + + +async def remove_liquidity_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + position_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, +) -> tuple[bool, str]: + """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). + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: + logging.error(unlock.message) + return False, unlock.message + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="remove_liquidity", + call_params={ + "hotkey": wallet.hotkey.ss58_address, + "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, + use_nonce=True, + period=period, + ) + + +async def toggle_user_liquidity_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + enable: bool, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, +) -> tuple[bool, str]: + """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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: + logging.error(unlock.message) + return False, unlock.message + + 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, + period=period, + ) diff --git a/bittensor/core/extrinsics/liquidity.py b/bittensor/core/extrinsics/liquidity.py new file mode 100644 index 0000000000..9e22da4139 --- /dev/null +++ b/bittensor/core/extrinsics/liquidity.py @@ -0,0 +1,231 @@ +from typing import Optional, TYPE_CHECKING + +from bittensor.utils import unlock_key +from bittensor.utils.balance import Balance +from bittensor.utils.btlogging import logging +from bittensor.utils.liquidity import price_to_tick + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.subtensor import Subtensor + + +def add_liquidity_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + liquidity: Balance, + price_low: Balance, + price_high: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, +) -> tuple[bool, str]: + """ + 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). + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: + logging.error(unlock.message) + return False, unlock.message + + tick_low = price_to_tick(price_low.tao) + tick_high = price_to_tick(price_high.tao) + + call = subtensor.substrate.compose_call( + call_module="Swap", + call_function="add_liquidity", + call_params={ + "hotkey": wallet.hotkey.ss58_address, + "netuid": netuid, + "tick_low": tick_low, + "tick_high": tick_high, + "liquidity": liquidity.rao, + }, + ) + + return subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + use_nonce=True, + period=period, + ) + + +def modify_liquidity_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + position_id: int, + liquidity_delta: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, +) -> tuple[bool, str]: + """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). + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: + logging.error(unlock.message) + return False, unlock.message + + call = subtensor.substrate.compose_call( + call_module="Swap", + call_function="modify_position", + call_params={ + "hotkey": wallet.hotkey.ss58_address, + "netuid": netuid, + "position_id": position_id, + "liquidity_delta": liquidity_delta.rao, + }, + ) + + return subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + use_nonce=True, + period=period, + ) + + +def remove_liquidity_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + position_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, +) -> tuple[bool, str]: + """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). + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: + logging.error(unlock.message) + return False, unlock.message + + call = subtensor.substrate.compose_call( + call_module="Swap", + call_function="remove_liquidity", + call_params={ + "hotkey": wallet.hotkey.ss58_address, + "netuid": netuid, + "position_id": position_id, + }, + ) + + return subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + use_nonce=True, + period=period, + ) + + +def toggle_user_liquidity_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + enable: bool, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, +) -> tuple[bool, str]: + """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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: + logging.error(unlock.message) + return False, unlock.message + + call = subtensor.substrate.compose_call( + call_module="Swap", + call_function="toggle_user_liquidity", + call_params={"netuid": netuid, "enable": enable}, + ) + + return subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + ) diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index d3e4481020..a98b703beb 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -47,6 +47,12 @@ commit_weights_extrinsic, reveal_weights_extrinsic, ) +from bittensor.core.extrinsics.liquidity import ( + add_liquidity_extrinsic, + modify_liquidity_extrinsic, + remove_liquidity_extrinsic, + toggle_user_liquidity_extrinsic, +) from bittensor.core.extrinsics.move_stake import ( transfer_stake_extrinsic, swap_stake_extrinsic, @@ -107,6 +113,13 @@ check_and_convert_to_balance, ) from bittensor.utils.btlogging import logging +from bittensor.utils.liquidity import ( + calculate_fees, + get_fees, + tick_to_price, + price_to_tick, + LiquidityPosition, +) from bittensor.utils.weight_utils import generate_weight_hash, convert_uids_and_weights if TYPE_CHECKING: @@ -1436,6 +1449,157 @@ def get_all_neuron_certificates( output[decode_account_id(key)] = Certificate(item.value) return output + def get_liquidity_list( + self, + wallet: "Wallet", + netuid: int, + block: Optional[int] = None, + ) -> Optional[list[LiquidityPosition]]: + """ + Retrieves all liquidity positions for the given wallet on a specified subnet (netuid). + Calculates associated fee rewards based on current global and tick-level fee data. + + Args: + wallet: Wallet instance to fetch positions for. + netuid: Subnet unique id. + block: The blockchain block number for the query. + + Returns: + List of liquidity positions, or None if subnet does not exist. + """ + if not self.subnet_exists(netuid=netuid): + logging.debug(f"Subnet {netuid} does not exist.") + return None + + if not self.is_subnet_active(netuid=netuid): + logging.debug(f"Subnet {netuid} is not active.") + return None + + query = self.substrate.query + block_hash = self.determine_block_hash(block) + + # Fetch global fees and current price + fee_global_tao_query = query( + module="Swap", + storage_function="FeeGlobalTao", + params=[netuid], + block_hash=block_hash, + ) + fee_global_alpha_query = query( + module="Swap", + storage_function="FeeGlobalAlpha", + params=[netuid], + block_hash=block_hash, + ) + sqrt_price_query = query( + module="Swap", + storage_function="AlphaSqrtPrice", + params=[netuid], + block_hash=block_hash, + ) + fee_global_tao = fixed_to_float(fee_global_tao_query) + fee_global_alpha = fixed_to_float(fee_global_alpha_query) + sqrt_price = fixed_to_float(sqrt_price_query) + current_tick = price_to_tick(sqrt_price**2) + + # Fetch positions + positions_response = self.query_map( + module="Swap", + name="Positions", + block=block, + params=[netuid, wallet.coldkeypub.ss58_address], + ) + + positions = [] + for _, p in positions_response: + position = p.value + + tick_low_idx = position["tick_low"][0] + tick_high_idx = position["tick_high"][0] + + tick_low = query( + module="Swap", + storage_function="Ticks", + params=[netuid, tick_low_idx], + block_hash=block_hash, + ) + tick_high = query( + module="Swap", + storage_function="Ticks", + params=[netuid, tick_high_idx], + block_hash=block_hash, + ) + + # Calculate fees above/below range for both tokens + tao_below = get_fees( + current_tick=current_tick, + tick=tick_low, + tick_index=tick_low_idx, + quote=True, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=False, + ) + tao_above = get_fees( + current_tick=current_tick, + tick=tick_high, + tick_index=tick_high_idx, + quote=True, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=True, + ) + alpha_below = get_fees( + current_tick=current_tick, + tick=tick_low, + tick_index=tick_low_idx, + quote=False, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=False, + ) + alpha_above = get_fees( + current_tick=current_tick, + tick=tick_high, + tick_index=tick_high_idx, + quote=False, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=True, + ) + + # Calculate fees earned by position + fees_tao, fees_alpha = calculate_fees( + position=position, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + tao_fees_below_low=tao_below, + tao_fees_above_high=tao_above, + alpha_fees_below_low=alpha_below, + alpha_fees_above_high=alpha_above, + netuid=netuid, + ) + + positions.append( + LiquidityPosition( + **{ + "id": position.get("id")[0], + "price_low": Balance.from_tao( + tick_to_price(position.get("tick_low")[0]) + ), + "price_high": Balance.from_tao( + tick_to_price(position.get("tick_high")[0]) + ), + "liquidity": Balance.from_rao(position.get("liquidity")), + "fees_tao": fees_tao, + "fees_alpha": fees_alpha, + "netuid": position.get("netuid"), + } + ) + ) + + return positions + def get_neuron_for_pubkey_and_subnet( self, hotkey_ss58: str, netuid: int, block: Optional[int] = None ) -> Optional["NeuronInfo"]: @@ -2368,11 +2532,14 @@ def query_identity( See the `Bittensor CLI documentation `_ for supported identity parameters. """ - identity_info = self.substrate.query( - module="SubtensorModule", - storage_function="IdentitiesV2", - params=[coldkey_ss58], - block_hash=self.determine_block_hash(block), + identity_info = cast( + dict, + self.substrate.query( + module="SubtensorModule", + storage_function="IdentitiesV2", + params=[coldkey_ss58], + block_hash=self.determine_block_hash(block), + ), ) if not identity_info: @@ -2828,6 +2995,52 @@ def add_stake( period=period, ) + def add_liquidity( + self, + wallet: "Wallet", + netuid: int, + liquidity: Balance, + price_low: Balance, + price_high: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, + ) -> tuple[bool, str]: + """ + Adds liquidity to the specified price range. + + Arguments: + 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. + liquidity: The amount of liquidity to be added. + price_low: The lower bound of the price tick range. In TAO. + price_high: The upper bound of the price tick range. In TAO. + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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` + method to enable/disable user liquidity. + """ + return add_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + liquidity=liquidity, + price_low=price_low, + price_high=price_high, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + ) + def add_stake_multiple( self, wallet: "Wallet", @@ -2995,6 +3208,74 @@ def commit_weights( return success, message + def modify_liquidity( + self, + wallet: "Wallet", + netuid: int, + position_id: int, + liquidity_delta: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, + ) -> tuple[bool, str]: + """Modifies liquidity in liquidity position by adding or removing liquidity from it. + + Arguments: + 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. + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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. + + Example: + import bittensor as bt + + subtensor = bt.subtensor(network="local") + my_wallet = bt.Wallet() + + # if `liquidity_delta` is negative + my_liquidity_delta = Balance.from_tao(100) * -1 + + subtensor.modify_liquidity( + wallet=my_wallet, + netuid=123, + position_id=2, + liquidity_delta=my_liquidity_delta + ) + + # if `liquidity_delta` is positive + my_liquidity_delta = Balance.from_tao(120) + + subtensor.modify_liquidity( + wallet=my_wallet, + netuid=123, + position_id=2, + liquidity_delta=my_liquidity_delta + ) + + Note: Modifying is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity` + to enable/disable user liquidity. + """ + return modify_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + position_id=position_id, + liquidity_delta=liquidity_delta, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + ) + def move_stake( self, wallet: "Wallet", @@ -3135,6 +3416,47 @@ def register_subnet( period=period, ) + def remove_liquidity( + self, + wallet: "Wallet", + netuid: int, + position_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, + ) -> tuple[bool, str]: + """Remove liquidity and credit balances back to wallet's hotkey stake. + + Arguments: + 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. + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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. + - To get the `position_id` use `get_liquidity_list` method. + """ + return remove_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + position_id=position_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + ) + def reveal_weights( self, wallet: "Wallet", @@ -3539,7 +3861,7 @@ def _blocks_weight_limit() -> bool: f"Hotkey {wallet.hotkey.ss58_address} not registered in subnet {netuid}", ) - if self.commit_reveal_enabled(netuid=netuid) is True: + if self.commit_reveal_enabled(netuid=netuid): # go with `commit reveal v3` extrinsic while retries < max_retries and success is False and _blocks_weight_limit(): @@ -3730,6 +4052,44 @@ def swap_stake( period=period, ) + def toggle_user_liquidity( + self, + wallet: "Wallet", + netuid: int, + enable: bool, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + period: Optional[int] = None, + ) -> tuple[bool, str]: + """Allow to toggle user liquidity for specified subnet. + + Arguments: + 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. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + + 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: The call can be executed successfully by the subnet owner only. + """ + return toggle_user_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + enable=enable, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + ) + def transfer( self, wallet: "Wallet", diff --git a/bittensor/core/subtensor_api/extrinsics.py b/bittensor/core/subtensor_api/extrinsics.py index e67d99674a..bc24f9f349 100644 --- a/bittensor/core/subtensor_api/extrinsics.py +++ b/bittensor/core/subtensor_api/extrinsics.py @@ -7,13 +7,16 @@ class Extrinsics: """Class for managing extrinsic operations.""" def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): + self.add_liquidity = subtensor.add_liquidity self.add_stake = subtensor.add_stake self.add_stake_multiple = subtensor.add_stake_multiple self.burned_register = subtensor.burned_register self.commit_weights = subtensor.commit_weights + self.modify_liquidity = subtensor.modify_liquidity self.move_stake = subtensor.move_stake self.register = subtensor.register self.register_subnet = subtensor.register_subnet + self.remove_liquidity = subtensor.remove_liquidity self.reveal_weights = subtensor.reveal_weights self.root_register = subtensor.root_register self.root_set_weights = subtensor.root_set_weights @@ -26,6 +29,7 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.serve_axon = subtensor.serve_axon self.start_call = subtensor.start_call self.swap_stake = subtensor.swap_stake + self.toggle_user_liquidity = subtensor.toggle_user_liquidity self.transfer = subtensor.transfer self.transfer_stake = subtensor.transfer_stake self.unstake = subtensor.unstake diff --git a/bittensor/core/subtensor_api/subnets.py b/bittensor/core/subtensor_api/subnets.py index ddeedaf1fa..c5eb782164 100644 --- a/bittensor/core/subtensor_api/subnets.py +++ b/bittensor/core/subtensor_api/subnets.py @@ -19,6 +19,7 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_children_pending = subtensor.get_children_pending self.get_current_weight_commit_info = subtensor.get_current_weight_commit_info self.get_hyperparameter = subtensor.get_hyperparameter + self.get_liquidity_list = subtensor.get_liquidity_list self.get_neuron_for_pubkey_and_subnet = ( subtensor.get_neuron_for_pubkey_and_subnet ) diff --git a/bittensor/core/subtensor_api/utils.py b/bittensor/core/subtensor_api/utils.py index ecb30be73a..c992d467c5 100644 --- a/bittensor/core/subtensor_api/utils.py +++ b/bittensor/core/subtensor_api/utils.py @@ -6,6 +6,7 @@ def add_legacy_methods(subtensor: "SubtensorApi"): """If SubtensorApi get `subtensor_fields=True` arguments, then all classic Subtensor fields added to root level.""" + subtensor.add_liquidity = subtensor._subtensor.add_liquidity subtensor.add_stake = subtensor._subtensor.add_stake subtensor.add_stake_multiple = subtensor._subtensor.add_stake_multiple subtensor.all_subnets = subtensor._subtensor.all_subnets @@ -56,6 +57,7 @@ def add_legacy_methods(subtensor: "SubtensorApi"): subtensor.get_hotkey_owner = subtensor._subtensor.get_hotkey_owner subtensor.get_hotkey_stake = subtensor._subtensor.get_hotkey_stake subtensor.get_hyperparameter = subtensor._subtensor.get_hyperparameter + subtensor.get_liquidity_list = subtensor._subtensor.get_liquidity_list subtensor.get_metagraph_info = subtensor._subtensor.get_metagraph_info subtensor.get_minimum_required_stake = ( subtensor._subtensor.get_minimum_required_stake @@ -119,6 +121,7 @@ def add_legacy_methods(subtensor: "SubtensorApi"): subtensor.max_weight_limit = subtensor._subtensor.max_weight_limit subtensor.metagraph = subtensor._subtensor.metagraph subtensor.min_allowed_weights = subtensor._subtensor.min_allowed_weights + subtensor.modify_liquidity = subtensor._subtensor.modify_liquidity subtensor.move_stake = subtensor._subtensor.move_stake subtensor.network = subtensor._subtensor.network subtensor.neurons = subtensor._subtensor.neurons @@ -132,6 +135,7 @@ def add_legacy_methods(subtensor: "SubtensorApi"): subtensor.query_runtime_api = subtensor._subtensor.query_runtime_api subtensor.query_subtensor = subtensor._subtensor.query_subtensor subtensor.recycle = subtensor._subtensor.recycle + subtensor.remove_liquidity = subtensor._subtensor.remove_liquidity subtensor.register = subtensor._subtensor.register subtensor.register_subnet = subtensor._subtensor.register_subnet subtensor.reveal_weights = subtensor._subtensor.reveal_weights @@ -157,6 +161,7 @@ def add_legacy_methods(subtensor: "SubtensorApi"): subtensor.substrate = subtensor._subtensor.substrate subtensor.swap_stake = subtensor._subtensor.swap_stake subtensor.tempo = subtensor._subtensor.tempo + subtensor.toggle_user_liquidity = subtensor._subtensor.toggle_user_liquidity subtensor.transfer = subtensor._subtensor.transfer subtensor.transfer_stake = subtensor._subtensor.transfer_stake subtensor.tx_rate_limit = subtensor._subtensor.tx_rate_limit diff --git a/bittensor/utils/balance.py b/bittensor/utils/balance.py index d6c78d7d95..b65fdaec82 100644 --- a/bittensor/utils/balance.py +++ b/bittensor/utils/balance.py @@ -68,7 +68,9 @@ def __init__(self, balance: Union[int, float]): # Assume tao value for the float self.rao = int(balance * pow(10, 9)) else: - raise TypeError("balance must be an int (rao) or a float (tao)") + raise TypeError( + f"Balance must be an int (rao) or a float (tao), not `{type(balance)}`." + ) @property def tao(self): diff --git a/bittensor/utils/liquidity.py b/bittensor/utils/liquidity.py new file mode 100644 index 0000000000..55e206225c --- /dev/null +++ b/bittensor/utils/liquidity.py @@ -0,0 +1,158 @@ +""" +This module provides utilities for managing liquidity positions and price conversions in the Bittensor network. The +module handles conversions between TAO and Alpha tokens while maintaining precise calculations for liquidity +provisioning and fee distribution. +""" + +import math +from typing import Any +from dataclasses import dataclass + +from bittensor.utils.balance import Balance, fixed_to_float + +# These three constants are unchangeable at the level of Uniswap math +MIN_TICK = -887272 +MAX_TICK = 887272 +PRICE_STEP = 1.0001 + + +@dataclass +class LiquidityPosition: + id: int + price_low: Balance # RAO + price_high: Balance # RAO + liquidity: Balance # TAO + ALPHA (sqrt by TAO balance * Alpha Balance -> math under the hood) + fees_tao: Balance # RAO + fees_alpha: Balance # RAO + netuid: int + + def to_token_amounts( + self, current_subnet_price: Balance + ) -> tuple[Balance, Balance]: + """Convert a position to token amounts. + + Arguments: + current_subnet_price: current subnet price in Alpha. + + Returns: + tuple[int, int]: + Amount of Alpha in liquidity + Amount of TAO in liquidity + + Liquidity is a combination of TAO and Alpha depending on the price of the subnet at the moment. + """ + sqrt_price_low = math.sqrt(self.price_low) + sqrt_price_high = math.sqrt(self.price_high) + sqrt_current_subnet_price = math.sqrt(current_subnet_price) + + if sqrt_current_subnet_price < sqrt_price_low: + amount_alpha = self.liquidity * (1 / sqrt_price_low - 1 / sqrt_price_high) + amount_tao = 0 + elif sqrt_current_subnet_price > sqrt_price_high: + amount_alpha = 0 + amount_tao = self.liquidity * (sqrt_price_high - sqrt_price_low) + else: + amount_alpha = self.liquidity * ( + 1 / sqrt_current_subnet_price - 1 / sqrt_price_high + ) + amount_tao = self.liquidity * (sqrt_current_subnet_price - sqrt_price_low) + return Balance.from_rao(int(amount_alpha), self.netuid), Balance.from_rao( + int(amount_tao) + ) + + +def price_to_tick(price: float) -> int: + """Converts a float price to the nearest Uniswap V3 tick index.""" + if price <= 0: + raise ValueError(f"Price must be positive, got `{price}`.") + + tick = int(math.log(price) / math.log(PRICE_STEP)) + + if not (MIN_TICK <= tick <= MAX_TICK): + raise ValueError( + f"Resulting tick {tick} is out of allowed range ({MIN_TICK} to {MAX_TICK})" + ) + return tick + + +def tick_to_price(tick: int) -> float: + """Convert an integer Uniswap V3 tick index to float price.""" + if not (MIN_TICK <= tick <= MAX_TICK): + raise ValueError("Tick is out of allowed range") + return PRICE_STEP**tick + + +def get_fees( + current_tick: int, + tick: dict, + tick_index: int, + quote: bool, + global_fees_tao: float, + global_fees_alpha: float, + above: bool, +) -> float: + """Returns the liquidity fee.""" + tick_fee_key = "fees_out_tao" if quote else "fees_out_alpha" + tick_fee_value = fixed_to_float(tick.get(tick_fee_key)) + global_fee_value = global_fees_tao if quote else global_fees_alpha + + if above: + return ( + global_fee_value - tick_fee_value + if tick_index <= current_tick + else tick_fee_value + ) + return ( + tick_fee_value + if tick_index <= current_tick + else global_fee_value - tick_fee_value + ) + + +def get_fees_in_range( + quote: bool, + global_fees_tao: float, + global_fees_alpha: float, + fees_below_low: float, + fees_above_high: float, +) -> float: + """Returns the liquidity fee value in a range.""" + global_fees = global_fees_tao if quote else global_fees_alpha + return global_fees - fees_below_low - fees_above_high + + +# Calculate fees for a position +def calculate_fees( + position: dict[str, Any], + global_fees_tao: float, + global_fees_alpha: float, + tao_fees_below_low: float, + tao_fees_above_high: float, + alpha_fees_below_low: float, + alpha_fees_above_high: float, + netuid: int, +) -> tuple[Balance, Balance]: + fee_tao_agg = get_fees_in_range( + quote=True, + global_fees_tao=global_fees_tao, + global_fees_alpha=global_fees_alpha, + fees_below_low=tao_fees_below_low, + fees_above_high=tao_fees_above_high, + ) + + fee_alpha_agg = get_fees_in_range( + quote=False, + global_fees_tao=global_fees_tao, + global_fees_alpha=global_fees_alpha, + fees_below_low=alpha_fees_below_low, + fees_above_high=alpha_fees_above_high, + ) + + fee_tao = fee_tao_agg - fixed_to_float(position["fees_tao"]) + fee_alpha = fee_alpha_agg - fixed_to_float(position["fees_alpha"]) + liquidity_frac = position["liquidity"] + + fee_tao = liquidity_frac * fee_tao + fee_alpha = liquidity_frac * fee_alpha + + return Balance.from_rao(int(fee_tao)), Balance.from_rao(int(fee_alpha), netuid) diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py new file mode 100644 index 0000000000..fd9ad09d42 --- /dev/null +++ b/tests/e2e_tests/test_liquidity.py @@ -0,0 +1,270 @@ +import pytest + +from bittensor import Balance +from tests.e2e_tests.utils.e2e_test_utils import wait_to_start_call +from bittensor.utils.liquidity import LiquidityPosition + + +@pytest.mark.asyncio +async def test_liquidity(local_chain, subtensor, alice_wallet, bob_wallet): + """ + Tests the liquidity mechanism + + Steps: + 1. Check `get_liquidity_list` return None if SN doesn't exist. + 2. Register a subnet through Alice. + 3. Make sure `get_liquidity_list` return None without activ SN. + 4. Wait until start call availability and do this call. + 5. Add liquidity to the subnet and check `get_liquidity_list` return liquidity positions. + 6. Modify liquidity and check `get_liquidity_list` return new liquidity positions with modified liquidity value. + 7. Add second liquidity position and check `get_liquidity_list` return new liquidity positions with 0 index. + 8. Add stake from Bob to Alice and check `get_liquidity_list` return new liquidity positions with fees_tao. + 9. Remove all stake from Alice and check `get_liquidity_list` return new liquidity positions with 0 fees_tao. + 10. Remove all liquidity positions and check `get_liquidity_list` return empty list. + """ + + alice_subnet_netuid = subtensor.get_total_subnets() # 2 + + # Make sure `get_liquidity_list` return None if SN doesn't exist + assert ( + subtensor.get_liquidity_list(wallet=alice_wallet, netuid=alice_subnet_netuid) + is None + ), "❌ `get_liquidity_list` is not None for unexisting subnet." + + # Register root as Alice + assert subtensor.register_subnet(alice_wallet), "❌ Unable to register the subnet" + + # Verify subnet 2 created successfully + assert subtensor.subnets.subnet_exists(alice_subnet_netuid), ( + f"❌ Subnet {alice_subnet_netuid} wasn't created successfully" + ) + + # Make sure `get_liquidity_list` return None without activ SN + assert ( + subtensor.subnets.get_liquidity_list( + wallet=alice_wallet, netuid=alice_subnet_netuid + ) + is None + ), "❌ `get_liquidity_list` is not None when no activ subnet." + + # Wait until start call availability and do this call + assert wait_to_start_call(subtensor, alice_wallet, alice_subnet_netuid) + + # Make sure `get_liquidity_list` return None without activ SN + assert ( + subtensor.subnets.get_liquidity_list( + wallet=alice_wallet, netuid=alice_subnet_netuid + ) + == [] + ), "❌ `get_liquidity_list` is not empty list before fist liquidity add." + + # enable user liquidity in SN + success, message = subtensor.extrinsics.toggle_user_liquidity( + wallet=alice_wallet, + netuid=alice_subnet_netuid, + enable=True, + ) + assert success, message + assert message == "", "❌ Cannot enable user liquidity." + + # In non fast-blocks node Alice doesn't have stake + if not subtensor.chain.is_fast_blocks(): + assert subtensor.extrinsics.add_stake( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_subnet_netuid, + amount=Balance.from_tao(1000), + wait_for_inclusion=True, + wait_for_finalization=True, + ), "❌ Cannot cannot add stake to Alice from Alice." + + # Add liquidity + success, message = subtensor.extrinsics.add_liquidity( + wallet=alice_wallet, + netuid=alice_subnet_netuid, + liquidity=Balance.from_tao(1), + price_low=Balance.from_tao(1.7), + price_high=Balance.from_tao(1.8), + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success, message + assert message == "", "❌ Cannot add liquidity." + + # Get liquidity + liquidity_positions = subtensor.subnets.get_liquidity_list( + wallet=alice_wallet, netuid=alice_subnet_netuid + ) + + assert len(liquidity_positions) == 1, ( + "❌ liquidity_positions has more than one element." + ) + + # Check if liquidity is correct + liquidity_position = liquidity_positions[0] + assert liquidity_position == LiquidityPosition( + id=2, + price_low=liquidity_position.price_low, + price_high=liquidity_position.price_high, + liquidity=Balance.from_tao(1), + fees_tao=Balance.from_tao(0), + fees_alpha=Balance.from_tao(0, netuid=alice_subnet_netuid), + netuid=alice_subnet_netuid, + ), "❌ `get_liquidity_list` still empty list after liquidity add." + + # Modify liquidity position with positive value + success, message = subtensor.extrinsics.modify_liquidity( + wallet=alice_wallet, + netuid=alice_subnet_netuid, + position_id=liquidity_position.id, + liquidity_delta=Balance.from_tao(20), + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success, message + assert message == "", "❌ cannot modify liquidity position." + + liquidity_positions = subtensor.subnets.get_liquidity_list( + wallet=alice_wallet, netuid=alice_subnet_netuid + ) + + assert len(liquidity_positions) == 1, ( + "❌ liquidity_positions has more than one element." + ) + liquidity_position = liquidity_positions[0] + + assert liquidity_position == LiquidityPosition( + id=2, + price_low=liquidity_position.price_low, + price_high=liquidity_position.price_high, + liquidity=Balance.from_tao(21), + fees_tao=Balance.from_tao(0), + fees_alpha=Balance.from_tao(0, netuid=alice_subnet_netuid), + netuid=alice_subnet_netuid, + ) + + # Modify liquidity position with negative value + success, message = subtensor.extrinsics.modify_liquidity( + wallet=alice_wallet, + netuid=alice_subnet_netuid, + position_id=liquidity_position.id, + liquidity_delta=-Balance.from_tao(11), + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success, message + assert message == "", "❌ cannot modify liquidity position." + + liquidity_positions = subtensor.subnets.get_liquidity_list( + wallet=alice_wallet, netuid=alice_subnet_netuid + ) + + assert len(liquidity_positions) == 1, ( + "❌ liquidity_positions has more than one element." + ) + liquidity_position = liquidity_positions[0] + + assert liquidity_position == LiquidityPosition( + id=2, + price_low=liquidity_position.price_low, + price_high=liquidity_position.price_high, + liquidity=Balance.from_tao(10), + fees_tao=Balance.from_tao(0), + fees_alpha=Balance.from_tao(0, netuid=alice_subnet_netuid), + netuid=alice_subnet_netuid, + ) + + # Add stake from Bob to Alice + assert subtensor.extrinsics.add_stake( + wallet=bob_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_subnet_netuid, + amount=Balance.from_tao(1000), + wait_for_inclusion=True, + wait_for_finalization=True, + ), "❌ Cannot add stake from Bob to Alice." + + # Add second liquidity position + success, message = subtensor.extrinsics.add_liquidity( + wallet=alice_wallet, + netuid=alice_subnet_netuid, + liquidity=Balance.from_tao(150), + price_low=Balance.from_tao(0.8), + price_high=Balance.from_tao(1.2), + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success, message + assert message == "", "❌ Cannot add liquidity." + + liquidity_positions = subtensor.subnets.get_liquidity_list( + wallet=alice_wallet, netuid=alice_subnet_netuid + ) + + assert len(liquidity_positions) == 2, ( + f"❌ liquidity_positions should have 2 elements, but has only {len(liquidity_positions)} element." + ) + + # All new liquidity inserts on the 0 index + liquidity_position_second = liquidity_positions[0] + assert liquidity_position_second == LiquidityPosition( + id=3, + price_low=liquidity_position_second.price_low, + price_high=liquidity_position_second.price_high, + liquidity=Balance.from_tao(150), + fees_tao=Balance.from_tao(0), + fees_alpha=Balance.from_tao(0, netuid=alice_subnet_netuid), + netuid=alice_subnet_netuid, + ) + + liquidity_position_first = liquidity_positions[1] + assert liquidity_position_first == LiquidityPosition( + id=2, + price_low=liquidity_position_first.price_low, + price_high=liquidity_position_first.price_high, + liquidity=Balance.from_tao(10), + fees_tao=liquidity_position_first.fees_tao, + fees_alpha=Balance.from_tao(0, netuid=alice_subnet_netuid), + netuid=alice_subnet_netuid, + ) + # After adding stake alice liquidity position has a fees_tao bc of high price + assert liquidity_position_first.fees_tao > Balance.from_tao(0) + + # Bob remove all stake from alice + assert subtensor.extrinsics.unstake_all( + wallet=bob_wallet, + hotkey=alice_wallet.hotkey.ss58_address, + netuid=alice_subnet_netuid, + rate_tolerance=0.9, # keep high rate tolerance to avoid flaky behavior + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + # Check that fees_alpha comes too after all unstake + liquidity_position_first = subtensor.subnets.get_liquidity_list( + wallet=alice_wallet, netuid=alice_subnet_netuid + )[1] + assert liquidity_position_first.fees_tao > Balance.from_tao(0) + assert liquidity_position_first.fees_alpha > Balance.from_tao( + 0, alice_subnet_netuid + ) + + # Remove all liquidity positions + for p in liquidity_positions: + success, message = subtensor.extrinsics.remove_liquidity( + wallet=alice_wallet, + netuid=alice_subnet_netuid, + position_id=p.id, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success, message + assert message == "", "❌ Cannot remove liquidity." + + # Make sure all liquidity positions removed + assert ( + subtensor.subnets.get_liquidity_list( + wallet=alice_wallet, netuid=alice_subnet_netuid + ) + == [] + ), "❌ Not all liquidity positions removed." diff --git a/tests/unit_tests/extrinsics/asyncex/test_liquidity.py b/tests/unit_tests/extrinsics/asyncex/test_liquidity.py new file mode 100644 index 0000000000..ae780e6c4b --- /dev/null +++ b/tests/unit_tests/extrinsics/asyncex/test_liquidity.py @@ -0,0 +1,174 @@ +import pytest +from bittensor.core.extrinsics.asyncex import liquidity + + +@pytest.mark.asyncio +async def test_add_liquidity_extrinsic(subtensor, fake_wallet, mocker): + """Test that the add `add_liquidity_extrinsic` executes correct calls.""" + # Preps + fake_netuid = 1 + fake_liquidity = mocker.Mock() + fake_price_low = mocker.Mock() + fake_price_high = mocker.Mock() + + mocked_compose_call = mocker.patch.object(subtensor.substrate, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + mocked_price_to_tick = mocker.patch.object(liquidity, "price_to_tick") + + # Call + result = await liquidity.add_liquidity_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=fake_netuid, + liquidity=fake_liquidity, + price_low=fake_price_low, + price_high=fake_price_high, + ) + + # Asserts + mocked_compose_call.assert_awaited_once_with( + call_module="Swap", + call_function="add_liquidity", + call_params={ + "hotkey": fake_wallet.hotkey.ss58_address, + "netuid": fake_netuid, + "tick_low": mocked_price_to_tick.return_value, + "tick_high": mocked_price_to_tick.return_value, + "liquidity": fake_liquidity.rao, + }, + ) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + use_nonce=True, + period=None, + ) + assert result == mocked_sign_and_send_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_modify_liquidity_extrinsic(subtensor, fake_wallet, mocker): + """Test that the add `modify_liquidity_extrinsic` executes correct calls.""" + # Preps + fake_netuid = 1 + fake_position_id = 2 + fake_liquidity_delta = mocker.Mock() + + mocked_compose_call = mocker.patch.object(subtensor.substrate, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + result = await liquidity.modify_liquidity_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=fake_netuid, + position_id=fake_position_id, + liquidity_delta=fake_liquidity_delta, + ) + + # Asserts + mocked_compose_call.assert_awaited_once_with( + call_module="Swap", + call_function="modify_position", + call_params={ + "hotkey": fake_wallet.hotkey.ss58_address, + "netuid": fake_netuid, + "position_id": fake_position_id, + "liquidity_delta": fake_liquidity_delta.rao, + }, + ) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + use_nonce=True, + period=None, + ) + assert result == mocked_sign_and_send_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_remove_liquidity_extrinsic(subtensor, fake_wallet, mocker): + """Test that the add `remove_liquidity_extrinsic` executes correct calls.""" + # Preps + fake_netuid = 1 + fake_position_id = 2 + + mocked_compose_call = mocker.patch.object(subtensor.substrate, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + result = await liquidity.remove_liquidity_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=fake_netuid, + position_id=fake_position_id, + ) + + # Asserts + mocked_compose_call.assert_awaited_once_with( + call_module="Swap", + call_function="remove_liquidity", + call_params={ + "hotkey": fake_wallet.hotkey.ss58_address, + "netuid": fake_netuid, + "position_id": fake_position_id, + }, + ) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + use_nonce=True, + period=None, + ) + assert result == mocked_sign_and_send_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_toggle_user_liquidity_extrinsic(subtensor, fake_wallet, mocker): + """Test that the add `toggle_user_liquidity_extrinsic` executes correct calls.""" + # Preps + fake_netuid = 1 + fake_enable = mocker.Mock() + + mocked_compose_call = mocker.patch.object(subtensor.substrate, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + result = await liquidity.toggle_user_liquidity_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=fake_netuid, + enable=fake_enable, + ) + + # Asserts + mocked_compose_call.assert_awaited_once_with( + call_module="Swap", + call_function="toggle_user_liquidity", + call_params={ + "netuid": fake_netuid, + "enable": fake_enable, + }, + ) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_sign_and_send_extrinsic.return_value diff --git a/tests/unit_tests/extrinsics/test_liquidity.py b/tests/unit_tests/extrinsics/test_liquidity.py new file mode 100644 index 0000000000..7d3942909e --- /dev/null +++ b/tests/unit_tests/extrinsics/test_liquidity.py @@ -0,0 +1,169 @@ +from bittensor.core.extrinsics import liquidity + + +def test_add_liquidity_extrinsic(subtensor, fake_wallet, mocker): + """Test that the add `add_liquidity_extrinsic` executes correct calls.""" + # Preps + fake_netuid = 1 + fake_liquidity = mocker.Mock() + fake_price_low = mocker.Mock() + fake_price_high = mocker.Mock() + + mocked_compose_call = mocker.patch.object(subtensor.substrate, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + mocked_price_to_tick = mocker.patch.object(liquidity, "price_to_tick") + + # Call + result = liquidity.add_liquidity_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=fake_netuid, + liquidity=fake_liquidity, + price_low=fake_price_low, + price_high=fake_price_high, + ) + + # Asserts + mocked_compose_call.assert_called_once_with( + call_module="Swap", + call_function="add_liquidity", + call_params={ + "hotkey": fake_wallet.hotkey.ss58_address, + "netuid": fake_netuid, + "tick_low": mocked_price_to_tick.return_value, + "tick_high": mocked_price_to_tick.return_value, + "liquidity": fake_liquidity.rao, + }, + ) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + use_nonce=True, + period=None, + ) + assert result == mocked_sign_and_send_extrinsic.return_value + + +def test_modify_liquidity_extrinsic(subtensor, fake_wallet, mocker): + """Test that the add `modify_liquidity_extrinsic` executes correct calls.""" + # Preps + fake_netuid = 1 + fake_position_id = 2 + fake_liquidity_delta = mocker.Mock() + + mocked_compose_call = mocker.patch.object(subtensor.substrate, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + result = liquidity.modify_liquidity_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=fake_netuid, + position_id=fake_position_id, + liquidity_delta=fake_liquidity_delta, + ) + + # Asserts + mocked_compose_call.assert_called_once_with( + call_module="Swap", + call_function="modify_position", + call_params={ + "hotkey": fake_wallet.hotkey.ss58_address, + "netuid": fake_netuid, + "position_id": fake_position_id, + "liquidity_delta": fake_liquidity_delta.rao, + }, + ) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + use_nonce=True, + period=None, + ) + assert result == mocked_sign_and_send_extrinsic.return_value + + +def test_remove_liquidity_extrinsic(subtensor, fake_wallet, mocker): + """Test that the add `remove_liquidity_extrinsic` executes correct calls.""" + # Preps + fake_netuid = 1 + fake_position_id = 2 + + mocked_compose_call = mocker.patch.object(subtensor.substrate, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + result = liquidity.remove_liquidity_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=fake_netuid, + position_id=fake_position_id, + ) + + # Asserts + mocked_compose_call.assert_called_once_with( + call_module="Swap", + call_function="remove_liquidity", + call_params={ + "hotkey": fake_wallet.hotkey.ss58_address, + "netuid": fake_netuid, + "position_id": fake_position_id, + }, + ) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + use_nonce=True, + period=None, + ) + assert result == mocked_sign_and_send_extrinsic.return_value + + +def test_toggle_user_liquidity_extrinsic(subtensor, fake_wallet, mocker): + """Test that the add `toggle_user_liquidity_extrinsic` executes correct calls.""" + # Preps + fake_netuid = 1 + fake_enable = mocker.Mock() + + mocked_compose_call = mocker.patch.object(subtensor.substrate, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + result = liquidity.toggle_user_liquidity_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=fake_netuid, + enable=fake_enable, + ) + + # Asserts + mocked_compose_call.assert_called_once_with( + call_module="Swap", + call_function="toggle_user_liquidity", + call_params={ + "netuid": fake_netuid, + "enable": fake_enable, + }, + ) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_sign_and_send_extrinsic.return_value diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index f7c6206d28..7e60be2ae0 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -3519,3 +3519,294 @@ async def test_unstake_all(subtensor, fake_wallet, mocker): period=None, ) assert result == fake_unstake_all_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_get_liquidity_list_subnet_does_not_exits(subtensor, mocker): + """Test get_liquidity_list returns None when subnet doesn't exist.""" + # Preps + mocker.patch.object(subtensor, "subnet_exists", return_value=False) + + # Call + result = await subtensor.get_liquidity_list(wallet=mocker.Mock(), netuid=1) + + # Asserts + subtensor.subnet_exists.assert_awaited_once_with(netuid=1) + assert result is None + + +@pytest.mark.asyncio +async def test_get_liquidity_list_subnet_is_not_active(subtensor, mocker): + """Test get_liquidity_list returns None when subnet is not active.""" + # Preps + mocker.patch.object(subtensor, "subnet_exists", return_value=True) + mocker.patch.object(subtensor, "is_subnet_active", return_value=False) + + # Call + result = await subtensor.get_liquidity_list(wallet=mocker.Mock(), netuid=1) + + # Asserts + subtensor.subnet_exists.assert_awaited_once_with(netuid=1) + subtensor.is_subnet_active.assert_awaited_once_with(netuid=1) + assert result is None + + +@pytest.mark.asyncio +async def test_get_liquidity_list_happy_path(subtensor, fake_wallet, mocker): + """Tests `get_liquidity_list` returns the correct value.""" + # Preps + netuid = 2 + + mocker.patch.object(subtensor, "subnet_exists", return_value=True) + mocker.patch.object(subtensor, "is_subnet_active", return_value=True) + mocker.patch.object(subtensor, "determine_block_hash") + + mocker.patch.object( + async_subtensor, "price_to_tick", return_value=Balance.from_tao(1.0, netuid) + ) + mocker.patch.object( + async_subtensor, + "calculate_fees", + return_value=(Balance.from_tao(0.0), Balance.from_tao(0.0, netuid)), + ) + + mocked_substrate_query = mocker.AsyncMock( + side_effect=[ + # for gather + {"bits": 0}, + {"bits": 0}, + {"bits": 18446744073709551616}, + # for loop + { + "liquidity_net": 1000000000000, + "liquidity_gross": 1000000000000, + "fees_out_tao": {"bits": 0}, + "fees_out_alpha": {"bits": 0}, + }, + { + "liquidity_net": -1000000000000, + "liquidity_gross": 1000000000000, + "fees_out_tao": {"bits": 0}, + "fees_out_alpha": {"bits": 0}, + }, + { + "liquidity_net": 1000000000000, + "liquidity_gross": 1000000000000, + "fees_out_tao": {"bits": 0}, + "fees_out_alpha": {"bits": 0}, + }, + { + "liquidity_net": -1000000000000, + "liquidity_gross": 1000000000000, + "fees_out_tao": {"bits": 0}, + "fees_out_alpha": {"bits": 0}, + }, + { + "liquidity_net": 1000000000000, + "liquidity_gross": 1000000000000, + "fees_out_tao": {"bits": 0}, + "fees_out_alpha": {"bits": 0}, + }, + { + "liquidity_net": -1000000000000, + "liquidity_gross": 1000000000000, + "fees_out_tao": {"bits": 0}, + "fees_out_alpha": {"bits": 0}, + }, + ] + ) + mocker.patch.object(subtensor.substrate, "query", mocked_substrate_query) + + fake_positions = [ + [ + (2,), + mocker.Mock( + value={ + "id": (2,), + "netuid": 2, + "tick_low": (206189,), + "tick_high": (208196,), + "liquidity": 1000000000000, + "fees_tao": {"bits": 0}, + "fees_alpha": {"bits": 0}, + } + ), + ], + [ + (2,), + mocker.Mock( + value={ + "id": (2,), + "netuid": 2, + "tick_low": (216189,), + "tick_high": (198196,), + "liquidity": 2000000000000, + "fees_tao": {"bits": 0}, + "fees_alpha": {"bits": 0}, + } + ), + ], + [ + (2,), + mocker.Mock( + value={ + "id": (2,), + "netuid": 2, + "tick_low": (226189,), + "tick_high": (188196,), + "liquidity": 3000000000000, + "fees_tao": {"bits": 0}, + "fees_alpha": {"bits": 0}, + } + ), + ], + ] + + fake_result = mocker.AsyncMock(autospec=list) + fake_result.__aiter__.return_value = iter(fake_positions) + + mocked_query_map = mocker.AsyncMock(return_value=fake_result) + mocker.patch.object(subtensor, "query_map", new=mocked_query_map) + + # Call + + result = await subtensor.get_liquidity_list(wallet=fake_wallet, netuid=netuid) + + # Asserts + subtensor.determine_block_hash.assert_awaited_once_with( + block=None, block_hash=None, reuse_block=False + ) + assert async_subtensor.price_to_tick.call_count == 1 + assert async_subtensor.calculate_fees.call_count == len(fake_positions) + + mocked_query_map.assert_awaited_once_with( + module="Swap", + name="Positions", + block=None, + params=[netuid, fake_wallet.coldkeypub.ss58_address], + ) + assert len(result) == len(fake_positions) + assert all([isinstance(p, async_subtensor.LiquidityPosition) for p in result]) + + +@pytest.mark.asyncio +async def test_add_liquidity(subtensor, fake_wallet, mocker): + """Test add_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object(async_subtensor, "add_liquidity_extrinsic") + + # Call + result = await subtensor.add_liquidity( + wallet=fake_wallet, + netuid=netuid, + liquidity=Balance.from_tao(150), + price_low=Balance.from_tao(180).rao, + price_high=Balance.from_tao(130).rao, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + liquidity=Balance.from_tao(150), + price_low=Balance.from_tao(180).rao, + price_high=Balance.from_tao(130).rao, + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_modify_liquidity(subtensor, fake_wallet, mocker): + """Test modify_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object( + async_subtensor, "modify_liquidity_extrinsic" + ) + position_id = 2 + + # Call + result = await subtensor.modify_liquidity( + wallet=fake_wallet, + netuid=netuid, + position_id=position_id, + liquidity_delta=Balance.from_tao(150), + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + position_id=position_id, + liquidity_delta=Balance.from_tao(150), + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_remove_liquidity(subtensor, fake_wallet, mocker): + """Test remove_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object( + async_subtensor, "remove_liquidity_extrinsic" + ) + position_id = 2 + + # Call + result = await subtensor.remove_liquidity( + wallet=fake_wallet, + netuid=netuid, + position_id=position_id, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + position_id=position_id, + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_toggle_user_liquidity(subtensor, fake_wallet, mocker): + """Test toggle_user_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object( + async_subtensor, "toggle_user_liquidity_extrinsic" + ) + enable = mocker.Mock() + + # Call + result = await subtensor.toggle_user_liquidity( + wallet=fake_wallet, + netuid=netuid, + enable=enable, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + enable=enable, + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_extrinsic.return_value diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 2de5dd45bc..d580a9f109 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -3903,3 +3903,237 @@ def test_unstake_all(subtensor, fake_wallet, mocker): period=None, ) assert result == fake_unstake_all_extrinsic.return_value + + +def test_get_liquidity_list_subnet_does_not_exits(subtensor, mocker): + """Test get_liquidity_list returns None when subnet doesn't exist.""" + # Preps + mocker.patch.object(subtensor, "subnet_exists", return_value=False) + + # Call + result = subtensor.get_liquidity_list(wallet=mocker.Mock(), netuid=1) + + # Asserts + subtensor.subnet_exists.assert_called_once_with(netuid=1) + assert result is None + + +def test_get_liquidity_list_subnet_is_not_active(subtensor, mocker): + """Test get_liquidity_list returns None when subnet is not active.""" + # Preps + mocker.patch.object(subtensor, "subnet_exists", return_value=True) + mocker.patch.object(subtensor, "is_subnet_active", return_value=False) + + # Call + result = subtensor.get_liquidity_list(wallet=mocker.Mock(), netuid=1) + + # Asserts + subtensor.subnet_exists.assert_called_once_with(netuid=1) + subtensor.is_subnet_active.assert_called_once_with(netuid=1) + assert result is None + + +def test_get_liquidity_list_happy_path(subtensor, fake_wallet, mocker): + """Tests `get_liquidity_list` returns the correct value.""" + # Preps + netuid = 2 + + mocker.patch.object(subtensor, "subnet_exists", return_value=True) + mocker.patch.object(subtensor, "is_subnet_active", return_value=True) + mocker.patch.object(subtensor, "determine_block_hash") + + mocker.patch.object( + subtensor_module, "price_to_tick", return_value=Balance.from_tao(1.0, netuid) + ) + mocker.patch.object( + subtensor_module, + "calculate_fees", + return_value=(Balance.from_tao(0.0), Balance.from_tao(0.0, netuid)), + ) + + mocked_substrate_query = mocker.MagicMock() + mocker.patch.object(subtensor.substrate, "query", mocked_substrate_query) + + fake_positions = [ + [ + (2,), + mocker.Mock( + value={ + "id": (2,), + "netuid": 2, + "tick_low": (206189,), + "tick_high": (208196,), + "liquidity": 1000000000000, + "fees_tao": {"bits": 0}, + "fees_alpha": {"bits": 0}, + } + ), + ], + [ + (2,), + mocker.Mock( + value={ + "id": (2,), + "netuid": 2, + "tick_low": (216189,), + "tick_high": (198196,), + "liquidity": 2000000000000, + "fees_tao": {"bits": 0}, + "fees_alpha": {"bits": 0}, + } + ), + ], + [ + (2,), + mocker.Mock( + value={ + "id": (2,), + "netuid": 2, + "tick_low": (226189,), + "tick_high": (188196,), + "liquidity": 3000000000000, + "fees_tao": {"bits": 0}, + "fees_alpha": {"bits": 0}, + } + ), + ], + ] + mocked_query_map = mocker.MagicMock(return_value=fake_positions) + mocker.patch.object(subtensor, "query_map", new=mocked_query_map) + + # Call + + result = subtensor.get_liquidity_list(wallet=fake_wallet, netuid=netuid) + + # Asserts + subtensor.determine_block_hash.assert_called_once_with(None) + assert subtensor_module.price_to_tick.call_count == 1 + assert subtensor_module.calculate_fees.call_count == len(fake_positions) + + mocked_query_map.assert_called_once_with( + module="Swap", + name="Positions", + block=None, + params=[netuid, fake_wallet.coldkeypub.ss58_address], + ) + assert len(result) == len(fake_positions) + assert all([isinstance(p, subtensor_module.LiquidityPosition) for p in result]) + + +def test_add_liquidity(subtensor, fake_wallet, mocker): + """Test add_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object(subtensor_module, "add_liquidity_extrinsic") + + # Call + result = subtensor.add_liquidity( + wallet=fake_wallet, + netuid=netuid, + liquidity=Balance.from_tao(150), + price_low=Balance.from_tao(180).rao, + price_high=Balance.from_tao(130).rao, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + liquidity=Balance.from_tao(150), + price_low=Balance.from_tao(180).rao, + price_high=Balance.from_tao(130).rao, + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_extrinsic.return_value + + +def test_modify_liquidity(subtensor, fake_wallet, mocker): + """Test modify_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object( + subtensor_module, "modify_liquidity_extrinsic" + ) + position_id = 2 + + # Call + result = subtensor.modify_liquidity( + wallet=fake_wallet, + netuid=netuid, + position_id=position_id, + liquidity_delta=Balance.from_tao(150), + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + position_id=position_id, + liquidity_delta=Balance.from_tao(150), + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_extrinsic.return_value + + +def test_remove_liquidity(subtensor, fake_wallet, mocker): + """Test remove_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object( + subtensor_module, "remove_liquidity_extrinsic" + ) + position_id = 2 + + # Call + result = subtensor.remove_liquidity( + wallet=fake_wallet, + netuid=netuid, + position_id=position_id, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + position_id=position_id, + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_extrinsic.return_value + + +def test_toggle_user_liquidity(subtensor, fake_wallet, mocker): + """Test toggle_user_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object( + subtensor_module, "toggle_user_liquidity_extrinsic" + ) + enable = mocker.Mock() + + # Call + result = subtensor.toggle_user_liquidity( + wallet=fake_wallet, + netuid=netuid, + enable=enable, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + enable=enable, + wait_for_inclusion=True, + wait_for_finalization=False, + period=None, + ) + assert result == mocked_extrinsic.return_value diff --git a/tests/unit_tests/utils/test_liquidity_utils.py b/tests/unit_tests/utils/test_liquidity_utils.py new file mode 100644 index 0000000000..5a761cc6a7 --- /dev/null +++ b/tests/unit_tests/utils/test_liquidity_utils.py @@ -0,0 +1,124 @@ +import math + +import pytest + +from bittensor.utils.balance import Balance +from bittensor.utils.liquidity import ( + LiquidityPosition, + price_to_tick, + tick_to_price, + get_fees, + get_fees_in_range, + calculate_fees, +) + + +def test_liquidity_position_to_token_amounts(): + """Test conversion of liquidity position to token amounts.""" + # Preps + pos = LiquidityPosition( + id=1, + price_low=Balance.from_tao(10000), + price_high=Balance.from_tao(40000), + liquidity=Balance.from_tao(25000), + fees_tao=Balance.from_tao(0), + fees_alpha=Balance.from_tao(0), + netuid=1, + ) + current_price = Balance.from_tao(20000) + # Call + alpha, tao = pos.to_token_amounts(current_price) + # Asserts + assert isinstance(alpha, Balance) + assert isinstance(tao, Balance) + assert alpha.rao >= 0 and tao.rao >= 0 + + +def test_price_to_tick_and_back(): + """Test price to tick conversion and back.""" + # Preps + price = 1.25 + # Call + tick = price_to_tick(price) + restored_price = tick_to_price(tick) + # Asserts + assert math.isclose(restored_price, price, rel_tol=1e-3) + + +def test_price_to_tick_invalid(): + """Test price to tick conversion with invalid input.""" + with pytest.raises(ValueError): + price_to_tick(0) + + +def test_tick_to_price_invalid(): + """Test tick to price conversion with invalid input.""" + with pytest.raises(ValueError): + tick_to_price(1_000_000) + + +def test_get_fees_above_true(): + """Test fee calculation for above position.""" + # Preps + tick = { + "liquidity_net": 1000000000000, + "liquidity_gross": 1000000000000, + "fees_out_tao": {"bits": 0}, + "fees_out_alpha": {"bits": 0}, + } + # Call + result = get_fees( + current_tick=100, + tick=tick, + tick_index=90, + quote=True, + global_fees_tao=8000, + global_fees_alpha=6000, + above=True, + ) + # Asserts + assert result == 8000 + + +def test_get_fees_in_range(): + """Test fee calculation within a range.""" + # Call + value = get_fees_in_range( + quote=True, + global_fees_tao=10000, + global_fees_alpha=5000, + fees_below_low=2000, + fees_above_high=1000, + ) + # Asserts + assert value == 7000 + + +def test_calculate_fees(): + """Test calculation of fees for a position.""" + # Preps + position = { + "id": (2,), + "netuid": 2, + "tick_low": (206189,), + "tick_high": (208196,), + "liquidity": 1000000000000, + "fees_tao": {"bits": 0}, + "fees_alpha": {"bits": 0}, + } + # Call + result = calculate_fees( + position=position, + global_fees_tao=5000, + global_fees_alpha=8000, + tao_fees_below_low=1000, + tao_fees_above_high=1000, + alpha_fees_below_low=2000, + alpha_fees_above_high=1000, + netuid=1, + ) + # Asserts + assert isinstance(result[0], Balance) + assert isinstance(result[1], Balance) + assert result[0].rao > 0 + assert result[1].rao > 0