diff --git a/gittensor/validator/issue_competitions/contract_client.py b/gittensor/validator/issue_competitions/contract_client.py index a88d155b..703a8a5e 100644 --- a/gittensor/validator/issue_competitions/contract_client.py +++ b/gittensor/validator/issue_competitions/contract_client.py @@ -49,6 +49,22 @@ def load_contract_metadata() -> Tuple[Dict[str, bytes], Dict[str, List]]: CONTRACT_SELECTORS, CONTRACT_ARG_TYPES = load_contract_metadata() +def _scale_compact_length(n: int) -> bytes: + """SCALE-encode a non-negative integer as a compact length prefix. + + Used to prefix variable-length SCALE payloads (Vec, String). + """ + if n < 0: + raise ValueError(f'Length must be non-negative: {n}') + if n < 1 << 6: + return bytes([n << 2]) + if n < 1 << 14: + return ((n << 2) | 1).to_bytes(2, 'little') + if n < 1 << 30: + return ((n << 2) | 2).to_bytes(4, 'little') + raise ValueError(f'Length too large for compact encoding: {n}') + + class IssueStatus(Enum): """Status of an issue in its lifecycle""" @@ -564,6 +580,11 @@ def _encode_args(self, method_name: str, args: dict) -> bytes: encoded += struct.pack('> 64) + elif type_def == 'str': + if not isinstance(value, str): + raise ValueError(f'Expected str for {arg_name}, got {type(value).__name__}') + data = value.encode('utf-8') + encoded += _scale_compact_length(len(data)) + data elif type_def == 'AccountId': if isinstance(value, str): encoded += bytes.fromhex(self.subtensor.substrate.ss58_decode(value)) diff --git a/tests/validator/test_contract_client_transactions.py b/tests/validator/test_contract_client_transactions.py index 3fe7c41f..c570015e 100644 --- a/tests/validator/test_contract_client_transactions.py +++ b/tests/validator/test_contract_client_transactions.py @@ -3,6 +3,7 @@ """Tests for IssueCompetitionContractClient transaction methods.""" import hashlib +import struct from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -11,6 +12,7 @@ from gittensor.validator.issue_competitions.contract_client import ( DEFAULT_GAS_LIMIT, IssueCompetitionContractClient, + _scale_compact_length, ) # (method, call_kwargs, expected_contract_method, expected_args, uses_hotkey, explicit_gas) @@ -102,3 +104,127 @@ def test_revert_returns_false(client, wallet, method, kwargs_fn, _cm, _ea, _hk, def test_exception_returns_false(client, wallet, method, kwargs_fn, _cm, _ea, _hk, _gas): with patch.object(client, '_exec_contract_raw', side_effect=RuntimeError('node down')): assert getattr(client, method)(**kwargs_fn(wallet)) is False + + +class TestScaleCompactLength: + """Boundary coverage for the SCALE compact-length encoder.""" + + @pytest.mark.parametrize( + 'n, expected', + [ + (0, b'\x00'), + (1, b'\x04'), + (63, bytes([63 << 2])), + ], + ) + def test_mode_0_single_byte(self, n, expected): + assert _scale_compact_length(n) == expected + + @pytest.mark.parametrize('n', [64, 100, 16383]) + def test_mode_1_two_bytes(self, n): + encoded = _scale_compact_length(n) + assert len(encoded) == 2 + assert encoded == ((n << 2) | 1).to_bytes(2, 'little') + + @pytest.mark.parametrize('n', [16384, 100_000, (1 << 30) - 1]) + def test_mode_2_four_bytes(self, n): + encoded = _scale_compact_length(n) + assert len(encoded) == 4 + assert encoded == ((n << 2) | 2).to_bytes(4, 'little') + + def test_rejects_negative(self): + with pytest.raises(ValueError, match='non-negative'): + _scale_compact_length(-1) + + def test_rejects_oversize(self): + with pytest.raises(ValueError, match='too large'): + _scale_compact_length(1 << 30) + + +class TestEncodeArgsStr: + """SCALE encoding of `str` arguments via _encode_args (regression for #1374).""" + + def test_register_issue_short_url_encodes(self, client): + url = 'https://github.com/owner/repo/issues/1' + repo = 'owner/repo' + url_bytes = url.encode('utf-8') + repo_bytes = repo.encode('utf-8') + assert len(url_bytes) < 64 + + encoded = client._encode_args( + 'register_issue', + { + 'github_url': url, + 'repository_full_name': repo, + 'issue_number': 1, + 'target_bounty': 10_000_000_000, + }, + ) + + offset = 0 + assert encoded[offset] == len(url_bytes) << 2 + offset += 1 + assert encoded[offset : offset + len(url_bytes)] == url_bytes + offset += len(url_bytes) + + assert encoded[offset] == len(repo_bytes) << 2 + offset += 1 + assert encoded[offset : offset + len(repo_bytes)] == repo_bytes + offset += len(repo_bytes) + + assert struct.unpack_from('