Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions gittensor/cli/issue_commands/vote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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 <HOTKEY>[/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))
77 changes: 42 additions & 35 deletions gittensor/validator/issue_competitions/contract_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AccountId> from clean return bytes.
Expand All @@ -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<AccountId>.
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,
Expand Down
112 changes: 112 additions & 0 deletions tests/cli/test_vote_list_json.py
Original file line number Diff line number Diff line change
@@ -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<AccountId> 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,
}
Loading