diff --git a/bittensor/cli.py b/bittensor/cli.py index 1c5549bd02..b3913cdced 100644 --- a/bittensor/cli.py +++ b/bittensor/cli.py @@ -103,6 +103,7 @@ "swap_hotkey": SwapHotkeyCommand, "set_identity": SetIdentityCommand, "get_identity": GetIdentityCommand, + "history": GetWalletHistoryCommand, }, }, "stake": { diff --git a/bittensor/commands/__init__.py b/bittensor/commands/__init__.py index deb89d9e4b..7e52f0a1ed 100644 --- a/bittensor/commands/__init__.py +++ b/bittensor/commands/__init__.py @@ -87,6 +87,7 @@ UpdateWalletCommand, WalletCreateCommand, WalletBalanceCommand, + GetWalletHistoryCommand, ) from .transfer import TransferCommand from .inspect import InspectCommand diff --git a/bittensor/commands/wallets.py b/bittensor/commands/wallets.py index 0e300fd0a8..7acb6cf832 100644 --- a/bittensor/commands/wallets.py +++ b/bittensor/commands/wallets.py @@ -23,6 +23,8 @@ from rich.table import Table from typing import Optional, List from . import defaults +import requests +from ..utils import RAOPERTAO class RegenColdkeyCommand: @@ -827,3 +829,151 @@ def check_config(config: "bittensor.config"): _, config.subtensor.chain_endpoint, ) = bittensor.subtensor.determine_chain_endpoint_and_network(str(network)) + + +API_URL = "https://api.subquery.network/sq/TaoStats/bittensor-indexer" +MAX_TXN = 1000 +GRAPHQL_QUERY = """ +query ($first: Int!, $after: Cursor, $filter: TransferFilter, $order: [TransfersOrderBy!]!) { + transfers(first: $first, after: $after, filter: $filter, orderBy: $order) { + nodes { + id + from + to + amount + extrinsicId + blockNumber + } + pageInfo { + endCursor + hasNextPage + hasPreviousPage + } + totalCount + } +} +""" + + +class GetWalletHistoryCommand: + """ + Executes the 'history' command to fetch the latest transfers of the provided wallet on the Bittensor network. + + This command provides a detailed view of the transfers carried out on the wallet. + + Usage: + The command lists the latest transfers of the provided wallet, showing the From, To, Amount, Extrinsic Id and Block Number. + + Optional arguments: + None. The command uses the wallet and subtensor configurations to fetch latest transfer data associated with a wallet. + + Example usage: + >>> btcli wallet history + + Note: + This command is essential for users to monitor their financial status on the Bittensor network. + It helps in fetching info on all the transfers so that user can easily tally and cross check the transactions. + """ + + @staticmethod + def run(cli): + r"""Check the transfer history of the provided wallet.""" + wallet = bittensor.wallet(config=cli.config) + wallet_address = wallet.get_coldkeypub().ss58_address + # Fetch all transfers + transfers = get_wallet_transfers(wallet_address) + + # Create output table + table = create_transfer_history_table(transfers) + + bittensor.__console__.print(table) + + @staticmethod + def add_args(parser: argparse.ArgumentParser): + history_parser = parser.add_parser( + "history", + help="""Fetch transfer history associated with the provided wallet""", + ) + bittensor.wallet.add_args(history_parser) + bittensor.subtensor.add_args(history_parser) + + @staticmethod + def check_config(config: "bittensor.config"): + if not config.is_set("wallet.name") and not config.no_prompt: + wallet_name = Prompt.ask("Enter wallet name", default=defaults.wallet.name) + config.wallet.name = str(wallet_name) + + +def get_wallet_transfers(wallet_address) -> List[dict]: + """Get all transfers associated with the provided wallet address.""" + + variables = { + "first": MAX_TXN, + "filter": { + "or": [ + {"from": {"equalTo": wallet_address}}, + {"to": {"equalTo": wallet_address}}, + ] + }, + "order": "BLOCK_NUMBER_DESC", + } + + response = requests.post( + API_URL, json={"query": GRAPHQL_QUERY, "variables": variables} + ) + data = response.json() + + # Extract nodes and pageInfo from the response + transfer_data = data.get("data", {}).get("transfers", {}) + transfers = transfer_data.get("nodes", []) + + return transfers + + +def create_transfer_history_table(transfers): + """Get output transfer table""" + + table = Table(show_footer=False) + # Define the column names + column_names = ["Id", "From", "To", "Amount", "Extrinsic Id", "Block Number"] + + # Create a table + table = Table(show_footer=False) + table.title = "[white]Wallet Transfers" + + # Define the column styles + header_style = "overline white" + footer_style = "overline white" + column_style = "rgb(50,163,219)" + no_wrap = True + + # Add columns to the table + for column_name in column_names: + table.add_column( + f"[white]{column_name}", + header_style=header_style, + footer_style=footer_style, + style=column_style, + no_wrap=no_wrap, + ) + + # Add rows to the table + for item in transfers: + try: + tao_amount = int(item["amount"]) / RAOPERTAO + except: + tao_amount = item["amount"] + table.add_row( + item["id"], + item["from"], + item["to"], + str(tao_amount), + str(item["extrinsicId"]), + item["blockNumber"], + ) + table.add_row() + table.show_footer = True + table.box = None + table.pad_edge = False + table.width = None + return table diff --git a/tests/integration_tests/test_cli.py b/tests/integration_tests/test_cli.py index 4dfb4caba9..13e779dead 100644 --- a/tests/integration_tests/test_cli.py +++ b/tests/integration_tests/test_cli.py @@ -2090,6 +2090,12 @@ def test_inspect(self, _): cli.config = config cli.run() + # Run History Command to get list of transfers + config.command = "wallet" + cli.config.subcommand = "history" + cli.config = config + cli.run() + @patch("bittensor.subtensor", new_callable=return_mock_sub) class TestCLIWithNetworkUsingArgs(unittest.TestCase): diff --git a/tests/integration_tests/test_cli_no_network.py b/tests/integration_tests/test_cli_no_network.py index e534675f54..18eb26cd8e 100644 --- a/tests/integration_tests/test_cli_no_network.py +++ b/tests/integration_tests/test_cli_no_network.py @@ -1026,6 +1026,64 @@ def test_undelegate_prompt_wallet_name(self, _): # NO prompt happened mock_ask_prompt.assert_not_called() + def test_history_prompt_wallet_name(self, _): + base_args = [ + "wallet", + "history", + ] + # Patch command to exit early + with patch( + "bittensor.commands.wallets.GetWalletHistoryCommand.run", return_value=None + ): + # Test prompt happens when + # - wallet name IS NOT passed + with patch("rich.prompt.Prompt.ask") as mock_ask_prompt: + mock_ask_prompt.side_effect = ["mock"] + + cli = bittensor.cli( + args=base_args + + [ + # '--wallet.name', 'mock', + ] + ) + cli.run() + + # Prompt happened + mock_ask_prompt.assert_called() + self.assertEqual( + mock_ask_prompt.call_count, + 1, + msg="Prompt should have been called ONCE", + ) + args0, kwargs0 = mock_ask_prompt.call_args_list[0] + combined_args_kwargs0 = [arg for arg in args0] + [ + val for val in kwargs0.values() + ] + # check that prompt was called for wallet name + self.assertTrue( + any( + filter( + lambda x: "wallet name" in x.lower(), combined_args_kwargs0 + ) + ), + msg=f"Prompt should have been called for wallet name: {combined_args_kwargs0}", + ) + + # Test NO prompt happens when + # - wallet name IS passed + with patch("rich.prompt.Prompt.ask") as mock_ask_prompt: + cli = bittensor.cli( + args=base_args + + [ + "--wallet.name", + "coolwalletname", + ] + ) + cli.run() + + # NO prompt happened + mock_ask_prompt.assert_not_called() + def test_delegate_prompt_hotkey(self, _): # Tests when # - wallet name IS passed, AND