diff --git a/bittensor/core/extrinsics/async_transfer.py b/bittensor/core/extrinsics/async_transfer.py index e4190023d..814e23ae8 100644 --- a/bittensor/core/extrinsics/async_transfer.py +++ b/bittensor/core/extrinsics/async_transfer.py @@ -1,9 +1,6 @@ import asyncio from typing import TYPE_CHECKING -from bittensor_wallet import Wallet -from substrateinterface.exceptions import SubstrateRequestException - from bittensor.core.settings import NETWORK_EXPLORER_MAP from bittensor.utils import ( format_error_message, @@ -16,11 +13,66 @@ if TYPE_CHECKING: from bittensor.core.async_subtensor import AsyncSubtensor + from bittensor_wallet import Wallet + + +async def _do_transfer( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + destination: str, + amount: "Balance", + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> tuple[bool, str, str]: + """ + Makes transfer from wallet to destination public key address. + + Args: + subtensor (bittensor.core.async_subtensor.AsyncSubtensor): initialized AsyncSubtensor object used for transfer + wallet (bittensor_wallet.Wallet): Bittensor wallet object to make transfer from. + destination (str): Destination public key address (ss58_address or ed25519) of recipient. + amount (bittensor.utils.balance.Balance): Amount to stake as Bittensor balance. + wait_for_inclusion (bool): If set, waits for the extrinsic to enter a block before returning `True`, or returns `False` if the extrinsic fails to enter the block within the timeout. + wait_for_finalization (bool): If set, waits for the extrinsic to be finalized on the chain before returning `True`, or returns `False` if the extrinsic fails to be finalized within the timeout. + + Returns: + success, block hash, formatted error message + """ + call = await subtensor.substrate.compose_call( + call_module="Balances", + call_function="transfer_allow_death", + call_params={"dest": destination, "value": amount.rao}, + ) + extrinsic = await subtensor.substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey + ) + response = await subtensor.substrate.submit_extrinsic( + extrinsic, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + # We only wait here if we expect finalization. + if not wait_for_finalization and not wait_for_inclusion: + return True, "", "Success, extrinsic submitted without waiting." + + # Otherwise continue with finalization. + await response.process_events() + if await response.is_success: + block_hash_ = response.block_hash + return True, block_hash_, "Success with response." + else: + return ( + False, + "", + format_error_message( + await response.error_message, substrate=subtensor.substrate + ), + ) async def transfer_extrinsic( subtensor: "AsyncSubtensor", - wallet: Wallet, + wallet: "Wallet", destination: str, amount: "Balance", transfer_all: bool = False, @@ -43,71 +95,6 @@ async def transfer_extrinsic( Returns: success (bool): Flag is `True` if extrinsic was finalized or included in the block. If we did not wait for finalization / inclusion, the response is `True`, regardless of its inclusion. """ - - async def get_transfer_fee() -> Balance: - """ - Calculates the transaction fee for transferring tokens from a wallet to a specified destination address. - This function simulates the transfer to estimate the associated cost, taking into account the current - network conditions and transaction complexity. - """ - call = await subtensor.substrate.compose_call( - call_module="Balances", - call_function="transfer_allow_death", - call_params={"dest": destination, "value": amount.rao}, - ) - - try: - payment_info = await subtensor.substrate.get_payment_info( - call=call, keypair=wallet.coldkeypub - ) - except SubstrateRequestException as e: - payment_info = {"partialFee": int(2e7)} # assume 0.02 Tao - logging.error(f":cross_mark: Failed to get payment info:") - logging.error(f"\t\t{format_error_message(e, subtensor.substrate)}") - logging.error( - f"\t\tDefaulting to default transfer fee: {payment_info['partialFee']}" - ) - - return Balance.from_rao(payment_info["partialFee"]) - - async def do_transfer() -> tuple[bool, str, str]: - """ - Makes transfer from wallet to destination public key address. - - Returns: - success, block hash, formatted error message - """ - call = await subtensor.substrate.compose_call( - call_module="Balances", - call_function="transfer_allow_death", - call_params={"dest": destination, "value": amount.rao}, - ) - extrinsic = await subtensor.substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey - ) - response = await subtensor.substrate.submit_extrinsic( - extrinsic, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) - # We only wait here if we expect finalization. - if not wait_for_finalization and not wait_for_inclusion: - return True, "", "" - - # Otherwise continue with finalization. - await response.process_events() - if await response.is_success: - block_hash_ = response.block_hash - return True, block_hash_, "" - else: - return ( - False, - "", - format_error_message( - await response.error_message, substrate=subtensor.substrate - ), - ) - # Validate destination address. if not is_valid_bittensor_address_or_public_key(destination): logging.error( @@ -132,7 +119,9 @@ async def do_transfer() -> tuple[bool, str, str]: subtensor.get_existential_deposit(block_hash=block_hash), ) account_balance = account_balance_[wallet.coldkeypub.ss58_address] - fee = await get_transfer_fee() + fee = await subtensor.get_transfer_fee( + wallet=wallet, dest=destination, value=amount.rao + ) if not keep_alive: # Check if the transfer should keep_alive the account @@ -153,7 +142,14 @@ async def do_transfer() -> tuple[bool, str, str]: return False logging.info(":satellite: Transferring...") @@ -171,17 +167,13 @@ async def do_transfer() -> tuple[bool, str, str]: logging.info( f"[green]Taostats Explorer Link: {explorer_urls.get('taostats')}" ) - else: - logging.error(f":cross_mark: Failed: {err_msg}") - if success: logging.info(":satellite: Checking Balance...") - new_balance = await subtensor.get_balance( - wallet.coldkeypub.ss58_address, reuse_block=False - ) + new_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) logging.info( f"Balance: [blue]{account_balance} :arrow_right: [green]{new_balance[wallet.coldkeypub.ss58_address]}" ) return True - - return False + else: + logging.error(f":cross_mark: Failed: {err_msg}") + return False diff --git a/tests/unit_tests/extrinsics/test_async_transfer.py b/tests/unit_tests/extrinsics/test_async_transfer.py new file mode 100644 index 000000000..91e6bfc49 --- /dev/null +++ b/tests/unit_tests/extrinsics/test_async_transfer.py @@ -0,0 +1,503 @@ +import pytest +from bittensor.core import async_subtensor +from bittensor_wallet import Wallet +from bittensor.core.extrinsics import async_transfer +from bittensor.utils.balance import Balance + + +@pytest.fixture(autouse=True) +def subtensor(mocker): + fake_async_substrate = mocker.AsyncMock( + autospec=async_subtensor.AsyncSubstrateInterface + ) + mocker.patch.object( + async_subtensor, "AsyncSubstrateInterface", return_value=fake_async_substrate + ) + return async_subtensor.AsyncSubtensor() + + +@pytest.mark.asyncio +async def test_do_transfer_success(subtensor, mocker): + """Tests _do_transfer when the transfer is successful.""" + # Preps + fake_wallet = mocker.Mock(autospec=Wallet) + fake_destination = "destination_address" + fake_amount = mocker.Mock(autospec=Balance, rao=1000) + + fake_call = mocker.AsyncMock() + fake_extrinsic = mocker.AsyncMock() + fake_response = mocker.Mock() + + fake_response.is_success = mocker.AsyncMock(return_value=True)() + fake_response.process_events = mocker.AsyncMock() + fake_response.block_hash = "fake_block_hash" + + mocker.patch.object(subtensor.substrate, "compose_call", return_value=fake_call) + mocker.patch.object( + subtensor.substrate, "create_signed_extrinsic", return_value=fake_extrinsic + ) + mocker.patch.object( + subtensor.substrate, "submit_extrinsic", return_value=fake_response + ) + + # Call + success, block_hash, error_message = await async_transfer._do_transfer( + subtensor=subtensor, + wallet=fake_wallet, + destination=fake_destination, + amount=fake_amount, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + # Asserts + subtensor.substrate.compose_call.assert_called_once_with( + call_module="Balances", + call_function="transfer_allow_death", + call_params={"dest": fake_destination, "value": fake_amount.rao}, + ) + subtensor.substrate.create_signed_extrinsic.assert_called_once_with( + call=subtensor.substrate.compose_call.return_value, keypair=fake_wallet.coldkey + ) + subtensor.substrate.submit_extrinsic.assert_called_once_with( + subtensor.substrate.create_signed_extrinsic.return_value, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert block_hash == "fake_block_hash" + assert error_message == "Success with response." + + +@pytest.mark.asyncio +async def test_do_transfer_failure(subtensor, mocker): + """Tests _do_transfer when the transfer fails.""" + # Preps + fake_wallet = mocker.Mock(autospec=Wallet) + fake_destination = "destination_address" + fake_amount = mocker.Mock(autospec=Balance, rao=1000) + + fake_call = mocker.AsyncMock() + fake_extrinsic = mocker.AsyncMock() + fake_response = mocker.Mock() + + fake_response.is_success = mocker.AsyncMock(return_value=False)() + fake_response.process_events = mocker.AsyncMock() + fake_response.error_message = mocker.AsyncMock(return_value="Fake error message")() + + mocker.patch.object(subtensor.substrate, "compose_call", return_value=fake_call) + mocker.patch.object( + subtensor.substrate, "create_signed_extrinsic", return_value=fake_extrinsic + ) + mocker.patch.object( + subtensor.substrate, "submit_extrinsic", return_value=fake_response + ) + + mocked_format_error_message = mocker.patch.object( + async_transfer, + "format_error_message", + return_value="Formatted error message", + ) + + # Call + success, block_hash, error_message = await async_transfer._do_transfer( + subtensor=subtensor, + wallet=fake_wallet, + destination=fake_destination, + amount=fake_amount, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + # Asserts + subtensor.substrate.compose_call.assert_called_once_with( + call_module="Balances", + call_function="transfer_allow_death", + call_params={"dest": fake_destination, "value": fake_amount.rao}, + ) + subtensor.substrate.create_signed_extrinsic.assert_called_once_with( + call=subtensor.substrate.compose_call.return_value, keypair=fake_wallet.coldkey + ) + subtensor.substrate.submit_extrinsic.assert_called_once_with( + subtensor.substrate.create_signed_extrinsic.return_value, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is False + assert block_hash == "" + mocked_format_error_message.assert_called_once_with( + "Fake error message", substrate=subtensor.substrate + ) + assert error_message == "Formatted error message" + + +@pytest.mark.asyncio +async def test_do_transfer_no_waiting(subtensor, mocker): + """Tests _do_transfer when no waiting for inclusion or finalization.""" + # Preps + fake_wallet = mocker.Mock(autospec=Wallet) + fake_destination = "destination_address" + fake_amount = mocker.Mock(autospec=Balance, rao=1000) + + fake_call = mocker.AsyncMock() + fake_extrinsic = mocker.AsyncMock() + + mocker.patch.object(subtensor.substrate, "compose_call", return_value=fake_call) + mocker.patch.object( + subtensor.substrate, "create_signed_extrinsic", return_value=fake_extrinsic + ) + mocker.patch.object( + subtensor.substrate, + "submit_extrinsic", + return_value=mocker.Mock(), + ) + + # Call + success, block_hash, error_message = await async_transfer._do_transfer( + subtensor=subtensor, + wallet=fake_wallet, + destination=fake_destination, + amount=fake_amount, + wait_for_inclusion=False, + wait_for_finalization=False, + ) + + # Asserts + subtensor.substrate.compose_call.assert_called_once_with( + call_module="Balances", + call_function="transfer_allow_death", + call_params={"dest": fake_destination, "value": fake_amount.rao}, + ) + subtensor.substrate.create_signed_extrinsic.assert_called_once_with( + call=subtensor.substrate.compose_call.return_value, keypair=fake_wallet.coldkey + ) + subtensor.substrate.submit_extrinsic.assert_called_once_with( + subtensor.substrate.create_signed_extrinsic.return_value, + wait_for_inclusion=False, + wait_for_finalization=False, + ) + assert success is True + assert block_hash == "" + assert error_message == "Success, extrinsic submitted without waiting." + + +@pytest.mark.asyncio +async def test_transfer_extrinsic_success(subtensor, mocker): + """Tests successful transfer.""" + # Preps + fake_wallet = mocker.Mock(autospec=Wallet) + fake_wallet.coldkeypub.ss58_address = "fake_ss58_address" + fake_destination = "valid_ss58_address" + fake_amount = Balance(15) + + mocked_is_valid_address = mocker.patch.object( + async_transfer, + "is_valid_bittensor_address_or_public_key", + return_value=True, + ) + mocked_unlock_key = mocker.patch.object( + async_transfer, + "unlock_key", + return_value=mocker.Mock(success=True, message="Unlocked"), + ) + mocked_get_chain_head = mocker.patch.object( + subtensor.substrate, "get_chain_head", return_value="some_block_hash" + ) + mocked_get_balance = mocker.patch.object( + subtensor, + "get_balance", + return_value={fake_wallet.coldkeypub.ss58_address: 10000}, + ) + mocked_get_existential_deposit = mocker.patch.object( + subtensor, "get_existential_deposit", return_value=1 + ) + subtensor.get_transfer_fee = mocker.patch.object( + subtensor, "get_transfer_fee", return_value=2 + ) + mocked_do_transfer = mocker.patch.object( + async_transfer, "_do_transfer", return_value=(True, "fake_block_hash", "") + ) + + # Call + result = await async_transfer.transfer_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + destination=fake_destination, + amount=fake_amount, + transfer_all=False, + wait_for_inclusion=True, + wait_for_finalization=True, + keep_alive=True, + ) + + # Asserts + mocked_is_valid_address.assert_called_once_with(fake_destination) + mocked_unlock_key.assert_called_once_with(fake_wallet) + mocked_get_chain_head.assert_called_once() + mocked_get_balance.assert_called_with( + fake_wallet.coldkeypub.ss58_address, + ) + mocked_get_existential_deposit.assert_called_once_with( + block_hash=mocked_get_chain_head.return_value + ) + mocked_do_transfer.assert_called_once() + assert result is True + + +@pytest.mark.asyncio +async def test_transfer_extrinsic_call_successful_with_failed_response( + subtensor, mocker +): + """Tests successful transfer call is successful with failed response.""" + # Preps + fake_wallet = mocker.Mock(autospec=Wallet) + fake_wallet.coldkeypub.ss58_address = "fake_ss58_address" + fake_destination = "valid_ss58_address" + fake_amount = Balance(15) + + mocked_is_valid_address = mocker.patch.object( + async_transfer, + "is_valid_bittensor_address_or_public_key", + return_value=True, + ) + mocked_unlock_key = mocker.patch.object( + async_transfer, + "unlock_key", + return_value=mocker.Mock(success=True, message="Unlocked"), + ) + mocked_get_chain_head = mocker.patch.object( + subtensor.substrate, "get_chain_head", return_value="some_block_hash" + ) + mocked_get_balance = mocker.patch.object( + subtensor, + "get_balance", + return_value={fake_wallet.coldkeypub.ss58_address: 10000}, + ) + mocked_get_existential_deposit = mocker.patch.object( + subtensor, "get_existential_deposit", return_value=1 + ) + subtensor.get_transfer_fee = mocker.patch.object( + subtensor, "get_transfer_fee", return_value=2 + ) + mocked_do_transfer = mocker.patch.object( + async_transfer, "_do_transfer", return_value=(False, "", "") + ) + + # Call + result = await async_transfer.transfer_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + destination=fake_destination, + amount=fake_amount, + transfer_all=False, + wait_for_inclusion=True, + wait_for_finalization=True, + keep_alive=True, + ) + + # Asserts + mocked_is_valid_address.assert_called_once_with(fake_destination) + mocked_unlock_key.assert_called_once_with(fake_wallet) + mocked_get_chain_head.assert_called_once() + mocked_get_balance.assert_called_with( + fake_wallet.coldkeypub.ss58_address, + block_hash=mocked_get_chain_head.return_value, + ) + mocked_get_existential_deposit.assert_called_once_with( + block_hash=mocked_get_chain_head.return_value + ) + mocked_do_transfer.assert_called_once() + assert result is False + + +@pytest.mark.asyncio +async def test_transfer_extrinsic_insufficient_balance(subtensor, mocker): + """Tests transfer when balance is insufficient.""" + # Preps + fake_wallet = mocker.Mock(autospec=Wallet) + fake_wallet.coldkeypub.ss58_address = "fake_ss58_address" + fake_destination = "valid_ss58_address" + fake_amount = Balance(5000) + + mocked_is_valid_address = mocker.patch.object( + async_transfer, + "is_valid_bittensor_address_or_public_key", + return_value=True, + ) + mocked_unlock_key = mocker.patch.object( + async_transfer, + "unlock_key", + return_value=mocker.Mock(success=True, message="Unlocked"), + ) + mocked_get_chain_head = mocker.patch.object( + subtensor.substrate, "get_chain_head", return_value="some_block_hash" + ) + mocked_get_balance = mocker.patch.object( + subtensor, + "get_balance", + return_value={ + fake_wallet.coldkeypub.ss58_address: 1000 + }, # Insufficient balance + ) + mocked_get_existential_deposit = mocker.patch.object( + subtensor, "get_existential_deposit", return_value=1 + ) + subtensor.get_transfer_fee = mocker.patch.object( + subtensor, "get_transfer_fee", return_value=2 + ) + + # Call + result = await async_transfer.transfer_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + destination=fake_destination, + amount=fake_amount, + transfer_all=False, + wait_for_inclusion=True, + wait_for_finalization=True, + keep_alive=True, + ) + + # Asserts + mocked_is_valid_address.assert_called_once_with(fake_destination) + mocked_unlock_key.assert_called_once_with(fake_wallet) + mocked_get_chain_head.assert_called_once() + mocked_get_balance.assert_called_once() + mocked_get_existential_deposit.assert_called_once_with( + block_hash=mocked_get_chain_head.return_value + ) + assert result is False + + +@pytest.mark.asyncio +async def test_transfer_extrinsic_invalid_destination(subtensor, mocker): + """Tests transfer with invalid destination address.""" + # Preps + fake_wallet = mocker.Mock(autospec=Wallet) + fake_wallet.coldkeypub.ss58_address = "fake_ss58_address" + fake_destination = "invalid_address" + fake_amount = Balance(15) + + mocked_is_valid_address = mocker.patch.object( + async_transfer, + "is_valid_bittensor_address_or_public_key", + return_value=False, + ) + + # Call + result = await async_transfer.transfer_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + destination=fake_destination, + amount=fake_amount, + transfer_all=False, + wait_for_inclusion=True, + wait_for_finalization=True, + keep_alive=True, + ) + + # Asserts + mocked_is_valid_address.assert_called_once_with(fake_destination) + assert result is False + + +@pytest.mark.asyncio +async def test_transfer_extrinsic_unlock_key_false(subtensor, mocker): + """Tests transfer failed unlock_key.""" + # Preps + fake_wallet = mocker.Mock(autospec=Wallet) + fake_wallet.coldkeypub.ss58_address = "fake_ss58_address" + fake_destination = "invalid_address" + fake_amount = Balance(15) + + mocked_is_valid_address = mocker.patch.object( + async_transfer, + "is_valid_bittensor_address_or_public_key", + return_value=True, + ) + + mocked_unlock_key = mocker.patch.object( + async_transfer, + "unlock_key", + return_value=mocker.Mock(success=False, message=""), + ) + + # Call + result = await async_transfer.transfer_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + destination=fake_destination, + amount=fake_amount, + transfer_all=False, + wait_for_inclusion=True, + wait_for_finalization=True, + keep_alive=True, + ) + + # Asserts + mocked_is_valid_address.assert_called_once_with(fake_destination) + mocked_unlock_key.assert_called_once_with(fake_wallet) + assert result is False + + +@pytest.mark.asyncio +async def test_transfer_extrinsic_keep_alive_false_and_transfer_all_true( + subtensor, mocker +): + """Tests transfer with keep_alive flag set to False and transfer_all flag set to True.""" + # Preps + fake_wallet = mocker.Mock(autospec=Wallet) + fake_wallet.coldkeypub.ss58_address = "fake_ss58_address" + fake_destination = "valid_ss58_address" + fake_amount = Balance(15) + + mocked_is_valid_address = mocker.patch.object( + async_transfer, + "is_valid_bittensor_address_or_public_key", + return_value=True, + ) + mocked_unlock_key = mocker.patch.object( + async_transfer, + "unlock_key", + return_value=mocker.Mock(success=True, message="Unlocked"), + ) + mocked_get_chain_head = mocker.patch.object( + subtensor.substrate, "get_chain_head", return_value="some_block_hash" + ) + mocked_get_balance = mocker.patch.object( + subtensor, + "get_balance", + return_value={fake_wallet.coldkeypub.ss58_address: 1}, + ) + mocked_get_existential_deposit = mocker.patch.object( + subtensor, "get_existential_deposit", return_value=1 + ) + subtensor.get_transfer_fee = mocker.patch.object( + subtensor, "get_transfer_fee", return_value=2 + ) + mocked_do_transfer = mocker.patch.object( + async_transfer, "_do_transfer", return_value=(True, "fake_block_hash", "") + ) + + # Call + result = await async_transfer.transfer_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + destination=fake_destination, + amount=fake_amount, + transfer_all=True, + wait_for_inclusion=True, + wait_for_finalization=True, + keep_alive=False, + ) + + # Asserts + mocked_is_valid_address.assert_called_once_with(fake_destination) + mocked_unlock_key.assert_called_once_with(fake_wallet) + mocked_get_chain_head.assert_called_once() + + mocked_get_existential_deposit.assert_called_once_with( + block_hash=mocked_get_chain_head.return_value + ) + mocked_do_transfer.assert_not_called() + assert result is False