diff --git a/gittensor/cli/issue_commands/vote.py b/gittensor/cli/issue_commands/vote.py index 6483fc23..3c289a74 100644 --- a/gittensor/cli/issue_commands/vote.py +++ b/gittensor/cli/issue_commands/vote.py @@ -229,6 +229,7 @@ def vote_list_validators(network: str, rpc_url: str, contract: str, as_json: boo import bittensor as bt from gittensor.validator.issue_competitions.contract_client import ( + ContractReadError, IssueCompetitionContractClient, ) @@ -269,5 +270,7 @@ def vote_list_validators(network: str, rpc_url: str, contract: str, as_json: boo err_console.print('[yellow]No validators whitelisted.[/yellow]') err_console.print('[dim]Add validators with: gitt admin add-vali [/dim]') + except ContractReadError as e: + handle_exception(as_json=as_json, message=str(e), error_type='read_failed') except Exception as e: handle_exception(as_json=as_json, message=str(e)) diff --git a/gittensor/validator/issue_competitions/contract_client.py b/gittensor/validator/issue_competitions/contract_client.py index 3039c9b4..72a755b7 100644 --- a/gittensor/validator/issue_competitions/contract_client.py +++ b/gittensor/validator/issue_competitions/contract_client.py @@ -73,6 +73,10 @@ class ContractIssue: is_fully_funded: bool +class ContractReadError(RuntimeError): + """Raised when a contract read cannot produce a trustworthy value.""" + + class IssueCompetitionContractClient: """ Client for interacting with the Issue Bounty smart contract @@ -808,17 +812,21 @@ def get_validators(self) -> List[str]: """Query the list of whitelisted validator hotkeys. Returns: - List of SS58 addresses, or empty list on error. + List of SS58 addresses. + + Raises: + ContractReadError: If the contract read or response decoding fails. """ try: response = self._raw_contract_read('get_validators') if response is None: - return [] + raise ContractReadError('Failed to read validator whitelist from contract') return self._decode_validator_list(response) + except ContractReadError: + raise except Exception as e: - bt.logging.error(f'Error fetching validators: {e}') - return [] + raise ContractReadError(f'Failed to read validator whitelist from contract: {e}') from e def _decode_validator_list(self, response_bytes: bytes) -> List[str]: """Decode a SCALE-encoded Vec from clean return bytes. @@ -827,39 +835,38 @@ def _decode_validator_list(self, response_bytes: bytes) -> List[str]: N * 32-byte AccountIds. """ if not response_bytes: - return [] - - try: - offset = 0 - - # Read SCALE compact length - first_byte = response_bytes[offset] - mode = first_byte & 0x03 - if mode == 0: - count = first_byte >> 2 - offset += 1 - elif mode == 1: - if offset + 2 > len(response_bytes): - return [] - count = (response_bytes[offset] | (response_bytes[offset + 1] << 8)) >> 2 - offset += 2 - else: - return [] - - validators = [] - for _ in range(count): - if offset + 32 > len(response_bytes): - break - account_bytes = response_bytes[offset : offset + 32] - ss58 = self.subtensor.substrate.ss58_encode(account_bytes.hex()) - validators.append(ss58) - offset += 32 + raise ContractReadError('Validator whitelist response was empty') + + offset = 0 + + # Read SCALE compact length. `0x00` is a valid empty Vec. + first_byte = response_bytes[offset] + mode = first_byte & 0x03 + if mode == 0: + count = first_byte >> 2 + offset += 1 + elif mode == 1: + if offset + 2 > len(response_bytes): + raise ContractReadError('Validator whitelist response length was truncated') + count = (response_bytes[offset] | (response_bytes[offset + 1] << 8)) >> 2 + offset += 2 + else: + raise ContractReadError('Validator whitelist response used an unsupported length encoding') + + expected_len = offset + (count * 32) + if len(response_bytes) != expected_len: + raise ContractReadError( + f'Validator whitelist response length mismatch: expected {expected_len} bytes, got {len(response_bytes)}' + ) - return validators + validators = [] + for _ in range(count): + account_bytes = response_bytes[offset : offset + 32] + ss58 = self.subtensor.substrate.ss58_encode(account_bytes.hex()) + validators.append(ss58) + offset += 32 - except Exception as e: - bt.logging.error(f'Error decoding validator list: {e}') - return [] + return validators def set_treasury_hotkey( self, diff --git a/tests/cli/test_vote_list_json.py b/tests/cli/test_vote_list_json.py new file mode 100644 index 00000000..a22c2a2c --- /dev/null +++ b/tests/cli/test_vote_list_json.py @@ -0,0 +1,112 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +"""Regression tests for `vote list --json` validator whitelist reads.""" + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + + +def _subtensor_with_contract(): + substrate = MagicMock() + substrate.query.return_value = {'contract': True} + return SimpleNamespace(substrate=substrate) + + +def test_vote_list_json_raw_read_none_returns_read_failed(cli_root, runner): + """A failed raw validator read must not look like a successful empty whitelist.""" + with ( + patch( + 'gittensor.cli.issue_commands.vote._resolve_contract_and_network', + return_value=('5Fakeaddr', 'ws://x', 'test'), + ), + patch('bittensor.Subtensor', return_value=_subtensor_with_contract()), + patch( + 'gittensor.validator.issue_competitions.contract_client.IssueCompetitionContractClient._raw_contract_read', + return_value=None, + ), + ): + result = runner.invoke(cli_root, ['vote', 'list', '--json'], catch_exceptions=False) + + assert result.exit_code != 0 + + payload = json.loads(result.stdout) + assert payload['success'] is False + assert payload['error']['type'] == 'read_failed' + assert 'validator whitelist' in payload['error']['message'] + assert 'validators' not in payload + + +def test_vote_list_json_raw_read_exception_returns_read_failed(cli_root, runner): + """RPC exceptions from the validator whitelist read must surface as read failures.""" + with ( + patch( + 'gittensor.cli.issue_commands.vote._resolve_contract_and_network', + return_value=('5Fakeaddr', 'ws://x', 'test'), + ), + patch('bittensor.Subtensor', return_value=_subtensor_with_contract()), + patch( + 'gittensor.validator.issue_competitions.contract_client.IssueCompetitionContractClient._raw_contract_read', + side_effect=ConnectionRefusedError('[Errno 111] Connection refused'), + ), + ): + result = runner.invoke(cli_root, ['vote', 'list', '--json'], catch_exceptions=False) + + assert result.exit_code != 0 + + payload = json.loads(result.stdout) + assert payload['success'] is False + assert payload['error']['type'] == 'read_failed' + assert 'Connection refused' in payload['error']['message'] + assert 'validators' not in payload + + +def test_vote_list_json_malformed_validator_payload_returns_read_failed(cli_root, runner): + """Malformed validator Vec payloads must not be reported as a valid empty whitelist.""" + with ( + patch( + 'gittensor.cli.issue_commands.vote._resolve_contract_and_network', + return_value=('5Fakeaddr', 'ws://x', 'test'), + ), + patch('bittensor.Subtensor', return_value=_subtensor_with_contract()), + patch( + 'gittensor.validator.issue_competitions.contract_client.IssueCompetitionContractClient._raw_contract_read', + return_value=b'\x04', + ), + ): + result = runner.invoke(cli_root, ['vote', 'list', '--json'], catch_exceptions=False) + + assert result.exit_code != 0 + + payload = json.loads(result.stdout) + assert payload['success'] is False + assert payload['error']['type'] == 'read_failed' + assert 'length mismatch' in payload['error']['message'] + assert 'validators' not in payload + + +def test_vote_list_json_empty_validator_vec_still_succeeds(cli_root, runner): + """A reachable contract with an encoded empty Vec remains a successful empty whitelist.""" + with ( + patch( + 'gittensor.cli.issue_commands.vote._resolve_contract_and_network', + return_value=('5Fakeaddr', 'ws://x', 'test'), + ), + patch('bittensor.Subtensor', return_value=_subtensor_with_contract()), + patch( + 'gittensor.validator.issue_competitions.contract_client.IssueCompetitionContractClient._raw_contract_read', + return_value=b'\x00', + ), + ): + result = runner.invoke(cli_root, ['vote', 'list', '--json'], catch_exceptions=False) + + assert result.exit_code == 0 + + payload = json.loads(result.stdout) + assert payload == { + 'success': True, + 'validators': [], + 'count': 0, + 'consensus_threshold': 0, + }