diff --git a/.env.example b/.env.example index a78aa968..20c7aa1a 100644 --- a/.env.example +++ b/.env.example @@ -11,13 +11,17 @@ WALLET_PATH=~/.bittensor/wallets WALLET_NAME=default HOTKEY_NAME=default -# For validators: Weights & Biases API key for logging +# ******* VALIDATOR VARIABLES ******* +# Weights & Biases API key for logging # Signup https://wandb.ai/site for a key WANDB_API_KEY= -# Optional custom name for wandb logging +# for issue bounties api calls +GITTENSOR_VALIDATOR_PAT= +# Optional custom name for wandb logging WANDB_VALIDATOR_NAME=vali -# For miners: GitHub Personal Access Token +# ******* MINER VARIABLES ******* +# GitHub Personal Access Token # https://github.com/settings/personal-access-tokens GITTENSOR_MINER_PAT= diff --git a/.gitignore b/.gitignore index b6308893..53a4d77f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ wandb CLAUDE.md .claude/ .vscode/ + +# Rust/Cargo build artifacts +target/ +**/*.rs.bk +*.lock diff --git a/gittensor/__init__.py b/gittensor/__init__.py index ddecd4db..ed045292 100644 --- a/gittensor/__init__.py +++ b/gittensor/__init__.py @@ -16,6 +16,6 @@ # DEALINGS IN THE SOFTWARE. # NOTE: bump this value when updating the codebase -__version__ = '3.2.0' +__version__ = '4.0.0' version_split = __version__.split('.') __spec_version__ = (1000 * int(version_split[0])) + (10 * int(version_split[1])) + (1 * int(version_split[2])) diff --git a/gittensor/cli/__init__.py b/gittensor/cli/__init__.py new file mode 100644 index 00000000..e75bf23d --- /dev/null +++ b/gittensor/cli/__init__.py @@ -0,0 +1,8 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +"""Gittensor CLI package""" + +from .main import cli, main + +__all__ = ['main', 'cli'] diff --git a/gittensor/cli/issue_commands/__init__.py b/gittensor/cli/issue_commands/__init__.py new file mode 100644 index 00000000..19f2a1a8 --- /dev/null +++ b/gittensor/cli/issue_commands/__init__.py @@ -0,0 +1,102 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +""" +CLI commands for managing issue bounties + +Command structure: + gitt issues (alias: i) - Issue management commands + list [--id ] List issues or view a specific issue + register Register a new issue bounty + bounty-pool View total bounty pool + pending-harvest View pending emissions + gitt harvest - Harvest emissions (top-level) + gitt vote - Validator consensus commands + gitt admin (alias: a) - Owner-only commands + info View contract configuration + cancel-issue Cancel an issue + payout-issue Manual payout fallback + set-owner Transfer ownership + set-treasury Change treasury hotkey +""" + +import click + +from .admin import admin + +# Re-export helpers +from .helpers import ( + CONFIG_FILE, + GITTENSOR_DIR, + NETWORK_MAP, + console, + get_contract_address, + load_config, + read_issues_from_contract, + resolve_network, +) +from .mutations import ( + issue_harvest, + issue_register, +) +from .view import admin_info, issues_bounty_pool, issues_list, issues_pending_harvest +from .vote import vote + + +@click.group(name='issues') +def issues_group(): + """Issue management commands. + + \b + Commands: + list List issues or view a specific issue + register Register a new issue bounty + bounty-pool View total bounty pool + pending-harvest View pending emissions + """ + pass + + +issues_group.add_command(issues_list, name='list') +issues_group.add_command(issue_register, name='register') +issues_group.add_command(issues_bounty_pool, name='bounty-pool') +issues_group.add_command(issues_pending_harvest, name='pending-harvest') + +# Add info to admin group +admin.add_command(admin_info, name='info') + + +def register_commands(cli): + """Register all issue-related commands with the root CLI group.""" + # Issues group with alias + cli.add_command(issues_group, name='issues') + cli.add_alias('issues', 'i') + + # Harvest as top-level command + cli.add_command(issue_harvest, name='harvest') + + # Validator vote group + cli.add_command(vote, name='vote') + + # Admin group with alias + cli.add_command(admin) + cli.add_alias('admin', 'a') + + +__all__ = [ + 'register_commands', + 'issues_group', + 'vote', + 'admin', + 'issue_register', + 'issue_harvest', + # Helpers + 'console', + 'load_config', + 'get_contract_address', + 'resolve_network', + 'read_issues_from_contract', + 'GITTENSOR_DIR', + 'CONFIG_FILE', + 'NETWORK_MAP', +] diff --git a/gittensor/cli/issue_commands/admin.py b/gittensor/cli/issue_commands/admin.py new file mode 100644 index 00000000..9cf5e4a2 --- /dev/null +++ b/gittensor/cli/issue_commands/admin.py @@ -0,0 +1,540 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +""" +Admin subgroup commands for issue CLI + +Commands: + gitt admin cancel-issue (alias: a cancel-issue) + gitt admin payout-issue (alias: a payout-issue) + gitt admin set-owner (alias: a set-owner) + gitt admin set-treasury (alias: a set-treasury) + gitt admin add-vali (alias: a add-vali) + gitt admin remove-vali (alias: a remove-vali) +""" + +import click + +from .helpers import ( + console, + get_contract_address, + resolve_network, +) + + +@click.group(name='admin') +def admin(): + """Owner-only administrative commands. + + These commands require the contract owner wallet. + + \b + Commands: + info View contract configuration + cancel-issue Cancel an issue + payout-issue Manual payout fallback + set-owner Transfer ownership + set-treasury Change treasury hotkey + add-vali Add a validator to the whitelist + remove-vali Remove a validator from the whitelist + """ + pass + + +@admin.command('cancel-issue') +@click.argument('issue_id', type=int) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses config if empty)', +) +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='default', + help='Wallet name (must be owner)', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +def admin_cancel(issue_id: int, network: str, rpc_url: str, contract: str, wallet_name: str, wallet_hotkey: str): + """Cancel an issue (owner only). + + Immediately cancels an issue without requiring validator consensus. + Bounty funds are returned to the alpha pool. + + \b + Arguments: + ISSUE_ID: Issue to cancel + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print(f'[yellow]Cancelling issue {issue_id}...[/yellow]\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + wallet = bt.Wallet(name=wallet_name, hotkey=wallet_hotkey) + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + # Show issue info before cancellation + issue = client.get_issue(issue_id) + if issue: + console.print(f' Issue: {issue.repository_full_name}#{issue.issue_number}') + console.print(f' Status: {issue.status.name}') + console.print(f' Bounty: {issue.bounty_amount / 1e9:.4f} ALPHA\n') + + result = client.cancel_issue(issue_id, wallet) + if result: + console.print(f'[green]Issue {issue_id} cancelled successfully![/green]') + else: + console.print('[red]Cancellation failed.[/red]') + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') + + +@admin.command('payout-issue') +@click.argument('issue_id', type=int) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses config if empty)', +) +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='default', + help='Wallet name (must be owner)', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +def admin_payout(issue_id: int, network: str, rpc_url: str, contract: str, wallet_name: str, wallet_hotkey: str): + """Manual payout fallback (owner only). + + Pays out a completed issue bounty to the solver. The solver address + is determined by validator consensus and stored in the contract. + + \b + Arguments: + ISSUE_ID: Completed issue ID + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print(f'[yellow]Manual payout for issue {issue_id}...[/yellow]\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + wallet = bt.Wallet(name=wallet_name, hotkey=wallet_hotkey) + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + # Show issue info before payout + issue = client.get_issue(issue_id) + if issue: + console.print(f' Issue: {issue.repository_full_name}#{issue.issue_number}') + console.print(f' Status: {issue.status.name}') + console.print(f' Bounty: {issue.bounty_amount / 1e9:.4f} ALPHA\n') + + result = client.payout_bounty(issue_id, wallet) + if result: + console.print(f'[green]Payout successful! Amount: {result / 1e9:.4f} ALPHA[/green]') + else: + console.print('[red]Payout failed.[/red]') + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') + + +@admin.command('set-owner') +@click.argument('new_owner', type=str) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address', +) +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='default', + help='Wallet name (must be current owner)', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +def admin_set_owner(new_owner: str, network: str, rpc_url: str, contract: str, wallet_name: str, wallet_hotkey: str): + """Transfer contract ownership (owner only). + + \b + Arguments: + NEW_OWNER: SS58 address of the new owner + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print(f'[yellow]Transferring ownership to {new_owner}...[/yellow]\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + wallet = bt.Wallet(name=wallet_name, hotkey=wallet_hotkey) + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + result = client.set_owner(new_owner, wallet) + if result: + console.print(f'[green]Ownership transferred to {new_owner}![/green]') + else: + console.print('[red]Ownership transfer failed.[/red]') + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') + + +@admin.command('set-treasury') +@click.argument('new_treasury', type=str) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address', +) +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='default', + help='Wallet name (must be owner)', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +def admin_set_treasury( + new_treasury: str, network: str, rpc_url: str, contract: str, wallet_name: str, wallet_hotkey: str +): + """Change treasury hotkey (owner only). + + The treasury hotkey receives staking emissions that fund bounty payouts. + Changing the treasury resets all Active/Registered issue bounty amounts + to 0 (they will be re-funded on next harvest from the new treasury). + + \b + Arguments: + NEW_TREASURY: SS58 address of the new treasury hotkey + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print(f'[yellow]Setting treasury hotkey to {new_treasury}...[/yellow]\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + wallet = bt.Wallet(name=wallet_name, hotkey=wallet_hotkey) + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + result = client.set_treasury_hotkey(new_treasury, wallet) + if result: + console.print(f'[green]Treasury hotkey updated to {new_treasury}![/green]') + console.print( + '[dim]Note: Issue bounty amounts have been reset. Run harvest to re-fund from new treasury.[/dim]' + ) + else: + console.print('[red]Treasury hotkey update failed.[/red]') + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') + + +@admin.command('add-vali') +@click.argument('hotkey', type=str) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address', +) +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='default', + help='Wallet name (must be owner)', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +def admin_add_validator(hotkey: str, network: str, rpc_url: str, contract: str, wallet_name: str, wallet_hotkey: str): + """Add a validator to the voting whitelist (owner only). + + Whitelisted validators can vote on solutions and issue cancellations. + The consensus threshold adjusts automatically: simple majority after + 3 validators are added. + + \b + Arguments: + HOTKEY: SS58 address of the validator hotkey to whitelist + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print(f'[yellow]Adding validator {hotkey}...[/yellow]\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + wallet = bt.Wallet(name=wallet_name, hotkey=wallet_hotkey) + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + result = client.add_validator(hotkey, wallet) + if result: + console.print(f'[green]Validator {hotkey} added to whitelist![/green]') + else: + console.print('[red]Failed to add validator.[/red]') + console.print('[yellow]Possible reasons:[/yellow]') + console.print(' - Caller is not the contract owner') + console.print(' - Validator is already whitelisted') + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') + + +@admin.command('remove-vali') +@click.argument('hotkey', type=str) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address', +) +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='default', + help='Wallet name (must be owner)', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +def admin_remove_validator( + hotkey: str, network: str, rpc_url: str, contract: str, wallet_name: str, wallet_hotkey: str +): + """Remove a validator from the voting whitelist (owner only). + + The consensus threshold adjusts automatically after removal. + + \b + Arguments: + HOTKEY: SS58 address of the validator hotkey to remove + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print(f'[yellow]Removing validator {hotkey}...[/yellow]\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + wallet = bt.Wallet(name=wallet_name, hotkey=wallet_hotkey) + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + result = client.remove_validator(hotkey, wallet) + if result: + console.print(f'[green]Validator {hotkey} removed from whitelist![/green]') + else: + console.print('[red]Failed to remove validator.[/red]') + console.print('[yellow]Possible reasons:[/yellow]') + console.print(' - Caller is not the contract owner') + console.print(' - Validator is not in the whitelist') + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') diff --git a/gittensor/cli/issue_commands/helpers.py b/gittensor/cli/issue_commands/helpers.py new file mode 100644 index 00000000..de131e43 --- /dev/null +++ b/gittensor/cli/issue_commands/helpers.py @@ -0,0 +1,454 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +""" +Shared helper functions for issue commands +""" + +import hashlib +import json +import os +import struct +from pathlib import Path +from typing import Any, Dict, List, Optional + +from rich.console import Console + +from gittensor.constants import CONTRACT_ADDRESS + +# Default paths +GITTENSOR_DIR = Path.home() / '.gittensor' +CONFIG_FILE = GITTENSOR_DIR / 'config.json' + +console = Console() + + +def load_config() -> Dict[str, Any]: + """ + Load configuration from ~/.gittensor/config.json. + + Priority: + 1. CLI arguments (highest - handled by callers) + 2. ~/.gittensor/config.json + 3. Defaults + + Config file format: + { + "contract_address": "5Cxxx...", + "ws_endpoint": "wss://entrypoint-finney.opentensor.ai:443", + "network": "finney", + "wallet": "default", + "hotkey": "default" + } + + Manage via: gitt config + + Returns: + Dict with all config keys + """ + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return {} + + +def get_contract_address(cli_value: str = '') -> str: + """ + Get contract address. CLI arg > env var > constants.py default. + + Args: + cli_value: Value passed via --contract CLI option + + Returns: + Contract address string + """ + if cli_value: + return cli_value + return os.environ.get('CONTRACT_ADDRESS') or CONTRACT_ADDRESS + + +NETWORK_MAP = { + 'finney': 'wss://entrypoint-finney.opentensor.ai:443', + 'test': 'wss://test.finney.opentensor.ai:443', + 'local': 'ws://127.0.0.1:9944', +} + +# Reverse lookup: URL -> network name +_URL_TO_NETWORK = {url: name for name, url in NETWORK_MAP.items()} + + +def resolve_network(network: Optional[str] = None, rpc_url: Optional[str] = None) -> tuple: + """ + Resolve --network and --rpc-url into (endpoint, network_name). + + Priority: + 1. --rpc-url (explicit URL always wins) + 2. --network (mapped to known endpoint) + 3. Config file ws_endpoint / network + 4. Default: finney (mainnet) + + Args: + network: Network name from --network option (test/finney/local) + rpc_url: Explicit RPC URL from --rpc-url option + + Returns: + Tuple of (ws_endpoint, network_name) + """ + # --rpc-url takes highest priority + if rpc_url: + name = _URL_TO_NETWORK.get(rpc_url, 'custom') + return rpc_url, name + + # --network maps to a known endpoint + if network: + key = network.lower() + if key in NETWORK_MAP: + return NETWORK_MAP[key], key + # Treat unknown network value as a custom URL + return network, 'custom' + + # Fall back to config file + config = load_config() + if config.get('ws_endpoint'): + endpoint = config['ws_endpoint'] + name = _URL_TO_NETWORK.get(endpoint, config.get('network', 'custom')) + return endpoint, name + + # Default: finney (mainnet) + return NETWORK_MAP['finney'], 'finney' + + +def get_ws_endpoint(cli_value: str = '') -> str: + """ + Get WebSocket endpoint from CLI arg, env, or config file. + + Deprecated: prefer resolve_network() for new code. + + Args: + cli_value: Value passed via --rpc-url CLI option + + Returns: + WebSocket endpoint string + """ + if cli_value and cli_value != 'wss://entrypoint-finney.opentensor.ai:443': + return cli_value + + config = load_config() + if config.get('ws_endpoint'): + return config['ws_endpoint'] + + return cli_value # Return CLI default + + +# ============================================================================ +# Contract storage reading helpers (shared by view and admin commands) +# ============================================================================ + + +def _get_contract_child_storage_key(substrate, contract_addr: str, verbose: bool = False) -> Optional[str]: + """ + Get the child storage key for a contract's trie. + + Args: + substrate: SubstrateInterface instance + contract_addr: Contract address + verbose: If True, print debug output + + Returns: + Hex-encoded child storage key or None if contract doesn't exist + """ + try: + contract_info = substrate.query('Contracts', 'ContractInfoOf', [contract_addr]) + if not contract_info or not contract_info.value: + if verbose: + console.print(f'[dim]Debug: Contract not found at {contract_addr}[/dim]') + return None + + trie_id_hex = contract_info.value['trie_id'].replace('0x', '') + prefix = b':child_storage:default:' + trie_id_bytes = bytes.fromhex(trie_id_hex) + return '0x' + (prefix + trie_id_bytes).hex() + except Exception as e: + if verbose: + console.print(f'[dim]Debug: Contract info query failed: {e}[/dim]') + return None + + +def _read_contract_packed_storage(substrate, contract_addr: str, verbose: bool = False) -> Optional[Dict[str, Any]]: + """ + Read the packed root storage from a contract using childstate RPC + + This bypasses the broken state_call/ContractsApi_call method and reads + storage directly. Works around substrate-interface Ink! 5 compatibility issues. + + Args: + substrate: SubstrateInterface instance + contract_addr: Contract address + verbose: If True, print debug output + + Returns: + Dict with owner, netuid, next_issue_id, etc. or None on error + """ + child_key = _get_contract_child_storage_key(substrate, contract_addr, verbose) + if not child_key: + if verbose: + console.print('[dim]Debug: Failed to get contract child storage key[/dim]') + return None + + # Get all storage keys for this contract + keys_result = substrate.rpc_request('childstate_getKeysPaged', [child_key, '0x', 100, None, None]) + keys = keys_result.get('result', []) + + if verbose: + console.print(f'[dim]Debug: Found {len(keys)} storage keys in contract[/dim]') + + # Find the packed storage key (ends with 00000000) + packed_key = None + for k in keys: + if k.endswith('00000000'): + packed_key = k + break + + if not packed_key: + if verbose: + console.print('[dim]Debug: No packed storage key (ending in 00000000) found[/dim]') + return None + + # Read the packed storage value + val_result = substrate.rpc_request('childstate_getStorage', [child_key, packed_key, None]) + if not val_result.get('result'): + if verbose: + console.print('[dim]Debug: Failed to read packed storage value[/dim]') + return None + + data = bytes.fromhex(val_result['result'].replace('0x', '')) + if verbose: + console.print(f'[dim]Debug: Packed storage data length = {len(data)} bytes[/dim]') + + # Decode packed struct (matches IssueBountyManager in lib.rs): + # owner: AccountId (32 bytes) + # treasury_hotkey: AccountId (32 bytes) + # netuid: u16 (2 bytes) + # next_issue_id: u64 (8 bytes) + # alpha_pool: u128 (16 bytes) + # Total: 74 bytes minimum + + if len(data) < 74: + if verbose: + console.print(f'[dim]Debug: Packed storage too small ({len(data)} < 74 bytes)[/dim]') + return None + + offset = 0 + owner = data[offset : offset + 32] + offset += 32 + treasury = data[offset : offset + 32] + offset += 32 + netuid = struct.unpack_from(' str: + """ + Compute Ink! 5 lazy mapping storage key using blake2_128concat. + + Args: + root_key_hex: Hex string of the mapping root key (e.g., '52789899') + encoded_key: SCALE-encoded key bytes + + Returns: + Hex-encoded storage key + """ + root_key = bytes.fromhex(root_key_hex.replace('0x', '')) + # Blake2_128Concat: blake2_128(root_key || encoded_key) || root_key || encoded_key + data = root_key + encoded_key + h = hashlib.blake2b(data, digest_size=16).digest() + return '0x' + (h + data).hex() + + +def _read_issues_from_child_storage(substrate, contract_addr: str, verbose: bool = False) -> List[Dict[str, Any]]: + """ + Read all issues from contract child storage. + + Uses Ink! 5 lazy mapping key computation to directly read issue storage. + + Args: + substrate: SubstrateInterface instance + contract_addr: Contract address + verbose: If True, print debug output + + Returns: + List of issue dictionaries + """ + child_key = _get_contract_child_storage_key(substrate, contract_addr, verbose) + if not child_key: + if verbose: + console.print('[dim]Debug: Cannot read issues - no child storage key[/dim]') + return [] + + # First, read packed storage to get next_issue_id + packed_storage = _read_contract_packed_storage(substrate, contract_addr, verbose) + if not packed_storage: + if verbose: + console.print('[dim]Debug: Cannot read issues - packed storage read failed[/dim]') + return [] + + next_issue_id = packed_storage.get('next_issue_id', 1) + if verbose: + console.print(f'[dim]Debug: next_issue_id from contract = {next_issue_id}[/dim]') + + # Sanity check: next_issue_id should be reasonable (< 1 million for any real deployment) + MAX_REASONABLE_ISSUE_ID = 1_000_000 + if next_issue_id > MAX_REASONABLE_ISSUE_ID: + console.print(f'[yellow]Warning: next_issue_id ({next_issue_id}) is unreasonably large.[/yellow]') + console.print('[yellow]This may indicate a storage format mismatch. Check contract version.[/yellow]') + return [] + + # If next_issue_id is 1, no issues have been registered yet + if next_issue_id <= 1: + if verbose: + console.print('[dim]Debug: No issues registered (next_issue_id <= 1)[/dim]') + return [] + + issues = [] + status_names = ['Registered', 'Active', 'Completed', 'Cancelled'] + + # Iterate through all issue IDs (1 to next_issue_id - 1) + # Issues mapping root key is '52789899' + if verbose: + console.print(f'[dim]Debug: Reading issues 1 to {next_issue_id - 1} using mapping key 52789899[/dim]') + + for issue_id in range(1, next_issue_id): + # SCALE encode u64 as little-endian 8 bytes + encoded_id = struct.pack('> 2 + offset += 1 + elif len_byte & 0x03 == 1: + # Two-byte length + str_len = (data[offset] | (data[offset + 1] << 8)) >> 2 + offset += 2 + else: + str_len = 0 + offset += 1 + + repo_name = data[offset : offset + str_len].decode('utf-8', errors='replace') + offset += str_len + + issue_number = struct.unpack_from(' List[Dict[str, Any]]: + """ + Read issues directly from the smart contract (no API dependency). + + Uses childstate_getStorage RPC to read contract storage directly, + bypassing the broken ContractsApi_call method in substrate-interface. + + Args: + ws_endpoint: WebSocket endpoint for Subtensor + contract_addr: Contract address + verbose: If True, print debug output + + Returns: + List of issue dictionaries + """ + try: + from substrateinterface import SubstrateInterface + + if verbose: + console.print(f'[dim]Debug: Connecting to {ws_endpoint}...[/dim]') + + # Connect to subtensor + substrate = SubstrateInterface(url=ws_endpoint) + + if verbose: + console.print('[dim]Debug: Connected successfully[/dim]') + + # Read issues directly from child storage + return _read_issues_from_child_storage(substrate, contract_addr, verbose) + + except ImportError as e: + console.print(f'[yellow]Cannot read from contract: {e}[/yellow]') + console.print('[dim]Install with: pip install substrate-interface[/dim]') + return [] + except Exception as e: + if verbose: + console.print(f'[dim]Debug: Connection/read error: {e}[/dim]') + console.print(f'[yellow]Error reading from contract: {e}[/yellow]') + return [] diff --git a/gittensor/cli/issue_commands/mutations.py b/gittensor/cli/issue_commands/mutations.py new file mode 100644 index 00000000..e1a26a34 --- /dev/null +++ b/gittensor/cli/issue_commands/mutations.py @@ -0,0 +1,394 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +""" +Top-level mutation commands for issue CLI + +Commands: + gitt register + gitt harvest +""" + +from pathlib import Path + +import click +from rich.panel import Panel + +from .helpers import ( + console, + get_contract_address, + load_config, + resolve_network, +) + + +@click.command('register') +@click.option( + '--repo', + required=True, + help='Repository in owner/repo format (e.g., opentensor/btcli)', +) +@click.option( + '--issue', + 'issue_number', + required=True, + type=int, + help='GitHub issue number', +) +@click.option( + '--bounty', + required=True, + type=float, + help='Bounty amount in ALPHA tokens', +) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses default if empty)', +) +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='default', + help='Wallet name (must be contract owner)', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +def issue_register( + repo: str, + issue_number: int, + bounty: float, + network: str, + rpc_url: str, + contract: str, + wallet_name: str, + wallet_hotkey: str, +): + """ + Register a new issue with a bounty (OWNER ONLY). + + This command registers a GitHub issue on the smart contract + with a target bounty amount. Only the contract owner can + register new issues. + + \b + Arguments: + --repo: Repository in owner/repo format + --issue: GitHub issue number + --bounty: Target bounty amount in ALPHA + + \b + Examples: + gitt issues register --repo opentensor/btcli --issue 144 --bounty 100 + gitt i reg --repo tensorflow/tensorflow --issue 12345 --bounty 50 + """ + console.print('\n[bold cyan]Register Issue for Bounty[/bold cyan]\n') + + # Validate repo format + if '/' not in repo: + console.print('[red]Error: Repository must be in owner/repo format[/red]') + return + + # Construct GitHub URL + github_url = f'https://github.com/{repo}/issues/{issue_number}' + + # Display registration details + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + config = load_config() + + console.print( + Panel( + f'[cyan]Repository:[/cyan] {repo}\n' + f'[cyan]Issue Number:[/cyan] #{issue_number}\n' + f'[cyan]GitHub URL:[/cyan] {github_url}\n' + f'[cyan]Target Bounty:[/cyan] {bounty:.2f} ALPHA\n' + f'[cyan]Network:[/cyan] {network_name}\n' + f'[cyan]RPC Endpoint:[/cyan] {ws_endpoint}\n' + f'[cyan]Contract:[/cyan] {contract_addr if contract_addr else "(not configured)"}', + title='Issue Registration', + border_style='blue', + ) + ) + + if not contract_addr: + console.print('\n[red]Error: Contract address not configured.[/red]') + console.print('[dim]Run ./up.sh --issues to deploy the contract first.[/dim]') + return + + if not click.confirm('\nProceed with registration?', default=True): + console.print('[yellow]Registration cancelled.[/yellow]') + return + + # Perform actual contract call (on-chain transaction) + console.print('\n[yellow]Submitting on-chain transaction to contract...[/yellow]') + + try: + import bittensor as bt + from substrateinterface import Keypair, SubstrateInterface + from substrateinterface.contracts import ContractInstance + + # Connect to subtensor + console.print(f'[dim]Connecting to {ws_endpoint}...[/dim]') + substrate = SubstrateInterface(url=ws_endpoint) + + # CLI flags override config; fall back to config if not explicitly supplied + effective_wallet = wallet_name if wallet_name != 'default' else config.get('wallet', wallet_name) + effective_hotkey = wallet_hotkey if wallet_hotkey != 'default' else config.get('hotkey', wallet_hotkey) + + # For local development, check config first, then fall back to //Alice + if network_name.lower() == 'local' and effective_wallet == 'default' and effective_hotkey == 'default': + console.print('[dim]Using //Alice for local development (no config set)...[/dim]') + keypair = Keypair.create_from_uri('//Alice') + else: + # Load wallet from config or CLI args + console.print(f'[dim]Loading wallet {effective_wallet}/{effective_hotkey}...[/dim]') + wallet = bt.Wallet(name=effective_wallet, hotkey=effective_hotkey) + # Use COLDKEY for owner-only operations (register_issue requires owner) + # Contract owner is set to deployer's coldkey during contract instantiation + keypair = wallet.coldkey + + # Load contract + # Go up 4 levels: mutations.py -> issue_commands -> cli -> gittensor -> REPO_ROOT + contract_metadata = ( + Path(__file__).parent.parent.parent.parent + / 'smart-contracts' + / 'issues-v0' + / 'target' + / 'ink' + / 'issue_bounty_manager.contract' + ) + if not contract_metadata.exists(): + console.print(f'[red]Error: Contract metadata not found at {contract_metadata}[/red]') + return + + contract = ContractInstance.create_from_address( + contract_address=contract_addr, + metadata_file=str(contract_metadata), + substrate=substrate, + ) + + # Convert bounty to contract units (9 decimals for ALPHA) + bounty_amount = int(bounty * 1_000_000_000) + + console.print('[yellow]Calling register_issue on contract...[/yellow]') + + result = contract.exec( + keypair, + 'register_issue', + args={ + 'github_url': github_url, + 'repository_full_name': repo, + 'issue_number': issue_number, + 'target_bounty': bounty_amount, + }, + gas_limit={'ref_time': 10_000_000_000, 'proof_size': 1_000_000}, + ) + + # Check if transaction was successful + if hasattr(result, 'is_success') and not result.is_success: + console.print('\n[red]Transaction failed: Contract rejected the request[/red]') + + # Check for ContractReverted and provide helpful context + error_info = getattr(result, 'error_message', None) + is_revert = error_info and isinstance(error_info, dict) and error_info.get('name') == 'ContractReverted' + + if is_revert: + console.print('[yellow]Possible reasons:[/yellow]') + console.print(' • Issue already registered (same repo + issue number)') + console.print(' • Bounty too low (minimum 10 ALPHA)') + console.print(' • Invalid repository format (must be owner/repo)') + console.print(' • Caller is not the contract owner') + console.print('[dim]Use "gitt view issues" to check existing issues[/dim]') + elif error_info: + console.print(f'[red]Error: {error_info}[/red]') + + console.print(f'[cyan]Transaction Hash:[/cyan] {result.extrinsic_hash}') + return + + console.print('\n[green]Issue registered successfully![/green]') + console.print(f'[cyan]Transaction Hash:[/cyan] {result.extrinsic_hash}') + console.print('[dim]Issue will be visible once bounty is funded via harvest_emissions()[/dim]') + + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + console.print('[dim]Install with: pip install substrate-interface bittensor[/dim]') + except Exception as e: + error_msg = str(e) + # Map contract errors to user-friendly messages + if 'ContractReverted' in error_msg: + console.print('\n[red]Transaction failed: Contract rejected the request[/red]') + # Provide context-specific hints based on the operation + console.print('[yellow]Possible reasons:[/yellow]') + console.print(' • Issue already registered (same repo + issue number)') + console.print(' • Bounty too low (minimum 10 ALPHA)') + console.print(' • Invalid repository format (must be owner/repo)') + console.print(' • Caller is not the contract owner') + console.print('[dim]Use "gitt view issues" to check existing issues[/dim]') + else: + console.print(f'[red]Error registering issue: {e}[/red]') + + +@click.command('harvest') +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='validator', + help='Wallet name', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses config if empty)', +) +@click.option('--verbose', '-v', is_flag=True, help='Show detailed output') +def issue_harvest(wallet_name: str, wallet_hotkey: str, network: str, rpc_url: str, contract: str, verbose: bool): + """ + Manually trigger emission harvest from contract treasury. + + This command is permissionless - any wallet can trigger it. + The contract handles emission collection and distribution internally. + + \b + Examples: + gitt harvest + gitt harvest --verbose + gitt harvest --wallet-name mywallet --wallet-hotkey mykey + """ + console.print('\n[bold cyan]Manual Emission Harvest[/bold cyan]\n') + + # Get configuration + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + console.print('[dim]Set CONTRACT_ADDRESS env var or run ./up.sh --issues[/dim]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print(f'[dim]Wallet: {wallet_name}/{wallet_hotkey}[/dim]\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + # Load wallet + console.print('[yellow]Loading wallet...[/yellow]') + wallet = bt.Wallet(name=wallet_name, hotkey=wallet_hotkey) + hotkey_addr = wallet.hotkey.ss58_address + console.print(f'[green]Hotkey address:[/green] {hotkey_addr}') + + # Connect to subtensor + console.print('\n[yellow]Connecting to subtensor...[/yellow]') + subtensor = bt.Subtensor(network=ws_endpoint) + + # Show wallet balance (informational only) + if verbose: + try: + balance = subtensor.get_balance(hotkey_addr) + console.print(f'[dim]Wallet balance: {balance}[/dim]') + except Exception as e: + console.print(f'[dim]Could not fetch balance: {e}[/dim]') + + # Create contract client + console.print('\n[yellow]Initializing contract client...[/yellow]') + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + if verbose: + # Show contract state + console.print('[dim]Reading contract state...[/dim]') + try: + alpha_pool = client.get_alpha_pool() + pending = client.get_treasury_stake() + last_harvest = client.get_last_harvest_block() + current_block = subtensor.get_current_block() + + console.print(f'[dim]Alpha pool: {alpha_pool / 1e9:.4f} ALPHA[/dim]') + console.print(f'[dim]Treasury stake: {pending / 1e9:.4f} ALPHA[/dim]') + console.print(f'[dim]Last harvest block: {last_harvest}[/dim]') + console.print(f'[dim]Current block: {current_block}[/dim]') + if last_harvest > 0: + console.print(f'[dim]Blocks since harvest: {current_block - last_harvest}[/dim]') + except Exception as e: + console.print(f'[yellow]Warning: Could not read contract state: {e}[/yellow]') + + # Attempt harvest + console.print('\n[yellow]Calling harvest_emissions()...[/yellow]') + result = client.harvest_emissions(wallet) + + if result: + if result.get('status') == 'success': + console.print('\n[green]Harvest succeeded![/green]') + console.print(f'[cyan]Transaction hash:[/cyan] {result.get("tx_hash", "N/A")}') + console.print('[dim]Treasury stake processed. Excess emissions recycled if any.[/dim]') + elif result.get('status') == 'partial': + console.print('\n[yellow]Harvest completed but recycling failed![/yellow]') + console.print(f'[cyan]Transaction hash:[/cyan] {result.get("tx_hash", "N/A")}') + console.print(f'[red]Error: {result.get("error", "Unknown")}[/red]') + console.print('[dim]Check proxy permissions: contract needs NonCritical proxy.[/dim]') + elif result.get('status') == 'failed': + console.print('\n[red]Harvest failed![/red]') + console.print(f'[red]Error: {result.get("error", "Unknown error")}[/red]') + else: + console.print(f'\n[yellow]Harvest result: {result}[/yellow]') + else: + console.print('\n[red]Harvest returned None - check logs for details.[/red]') + console.print('[dim]Run with --verbose for more information.[/dim]') + + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + console.print('[dim]Install with: pip install bittensor substrate-interface[/dim]') + except Exception as e: + import traceback + + console.print(f'\n[red]Error during harvest: {type(e).__name__}: {e}[/red]') + if verbose: + console.print(f'[dim]Full traceback:\n{traceback.format_exc()}[/dim]') + else: + console.print('[dim]Run with --verbose for full traceback.[/dim]') diff --git a/gittensor/cli/issue_commands/view.py b/gittensor/cli/issue_commands/view.py new file mode 100644 index 00000000..f1ff8053 --- /dev/null +++ b/gittensor/cli/issue_commands/view.py @@ -0,0 +1,325 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +""" +Read-only issue commands + +Commands: + gitt issues list [--id ] + gitt issues bounty-pool + gitt issues pending-harvest + gitt admin info +""" + +import click +from rich.panel import Panel +from rich.table import Table + +from .helpers import ( + _read_contract_packed_storage, + _read_issues_from_child_storage, + console, + get_contract_address, + read_issues_from_contract, + resolve_network, +) + + +@click.command('list') +@click.option( + '--id', + 'issue_id', + default=None, + type=int, + help='View a specific issue by ID', +) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses default if empty)', +) +@click.option('--verbose', '-v', is_flag=True, help='Show debug output for contract reads') +def issues_list(issue_id: int, network: str, rpc_url: str, contract: str, verbose: bool): + """ + List issues or view a specific issue. + + Shows all issues with their status and bounty amounts. + Use --id to view details for a specific issue. + + \b + Examples: + gitt issues list + gitt i list --network test + gitt i list --id 1 + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + console.print('[dim]Set via: gitt config set contract_address [/dim]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr[:20]}...[/dim]\n') + + issues = read_issues_from_contract(ws_endpoint, contract_addr, verbose) + + # Single issue detail view + if issue_id is not None: + issue = next((i for i in issues if i['id'] == issue_id), None) + + if issue: + console.print( + Panel( + f'[cyan]ID:[/cyan] {issue["id"]}\n' + f'[cyan]Repository:[/cyan] {issue["repository_full_name"]}\n' + f'[cyan]Issue Number:[/cyan] #{issue["issue_number"]}\n' + f'[cyan]Bounty Amount:[/cyan] {issue["bounty_amount"] / 1e9:.4f} ALPHA\n' + f'[cyan]Target Bounty:[/cyan] {issue["target_bounty"] / 1e9:.4f} ALPHA\n' + f'[cyan]Fill %:[/cyan] {(issue["bounty_amount"] / issue["target_bounty"] * 100) if issue["target_bounty"] > 0 else 0:.1f}%\n' + f'[cyan]Status:[/cyan] {issue["status"]}', + title=f'Issue #{issue_id}', + border_style='green', + ) + ) + else: + console.print(f'[yellow]Issue {issue_id} not found.[/yellow]') + return + + # Table view of all issues + console.print('[bold cyan]Available Issues[/bold cyan]\n') + + table = Table(show_header=True, header_style='bold magenta') + table.add_column('ID', style='cyan', justify='right') + table.add_column('Repository', style='green') + table.add_column('Issue #', style='yellow', justify='right') + table.add_column('Bounty Pool', style='magenta', justify='right') + table.add_column('Status', style='blue') + + if issues: + for issue in issues: + issue_id = issue.get('id', '?') + repo = issue.get('repository_full_name', '?') + num = issue.get('issue_number', '?') + bounty_raw = issue.get('bounty_amount', 0) + target_raw = issue.get('target_bounty', 0) + status = issue.get('status', 'unknown') + + try: + bounty = float(bounty_raw) / 1_000_000_000 if bounty_raw else 0.0 + target = float(target_raw) / 1_000_000_000 if target_raw else 0.0 + except (ValueError, TypeError): + bounty = 0.0 + target = 0.0 + + # Format bounty pool display with fill percentage + if target > 0: + fill_pct = (bounty / target) * 100 + if fill_pct >= 100: + bounty_display = f'{bounty:.1f} (100%)' + elif bounty > 0: + bounty_display = f'{bounty:.1f}/{target:.1f} ({fill_pct:.0f}%)' + else: + bounty_display = f'0/{target:.1f} (0%)' + else: + bounty_display = f'{bounty:.2f}' if bounty > 0 else '0.00' + + # Format status + if isinstance(status, dict): + status = list(status.keys())[0] if status else 'Unknown' + elif isinstance(status, str): + status = status.capitalize() + else: + status = str(status) + + table.add_row( + str(issue_id), + repo, + f'#{num}', + bounty_display, + status, + ) + console.print(table) + console.print(f'\n[dim]Showing {len(issues)} issue(s)[/dim]') + console.print('[dim]Bounty Pool shows: filled/target (percentage)[/dim]') + else: + console.print('[yellow]No issues found. Register an issue with:[/yellow]') + console.print('[dim] gitt issues register --repo owner/repo --issue 1 --bounty 100[/dim]') + + +@click.command('bounty-pool') +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses config if empty)', +) +@click.option('--verbose', '-v', is_flag=True, help='Show debug output') +def issues_bounty_pool(network: str, rpc_url: str, contract: str, verbose: bool): + """View total bounty pool (sum of all issue bounty amounts).""" + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + + try: + from substrateinterface import SubstrateInterface + + substrate = SubstrateInterface(url=ws_endpoint) + issues = _read_issues_from_child_storage(substrate, contract_addr, verbose) + + total_bounty_pool = sum(issue.get('bounty_amount', 0) for issue in issues) + console.print( + f'[green]Issue Bounty Pool:[/green] {total_bounty_pool / 1e9:.4f} ALPHA ({total_bounty_pool} raw)' + ) + console.print(f'[dim]Sum of bounty amounts from {len(issues)} issue(s)[/dim]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') + + +@click.command('pending-harvest') +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses config if empty)', +) +@click.option('--verbose', '-v', is_flag=True, help='Show debug output') +def issues_pending_harvest(network: str, rpc_url: str, contract: str, verbose: bool): + """View pending harvest (treasury stake minus allocated bounties).""" + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + + try: + import bittensor as bt + from substrateinterface import SubstrateInterface + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + # Get treasury stake + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + treasury_stake = client.get_treasury_stake() + + # Get total bounty pool (sum of all issue bounty amounts) + substrate = SubstrateInterface(url=ws_endpoint) + issues = _read_issues_from_child_storage(substrate, contract_addr, verbose) + total_bounty_pool = sum(issue.get('bounty_amount', 0) for issue in issues) + + # Pending harvest = treasury stake - allocated bounties + pending_harvest = max(0, treasury_stake - total_bounty_pool) + + console.print(f'[green]Treasury Stake:[/green] {treasury_stake / 1e9:.4f} ALPHA') + console.print(f'[green]Allocated to Bounties:[/green] {total_bounty_pool / 1e9:.4f} ALPHA') + console.print(f'[green]Pending Harvest:[/green] {pending_harvest / 1e9:.4f} ALPHA') + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') + + +@click.command('info') +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses config if empty)', +) +@click.option('--verbose', '-v', is_flag=True, help='Show debug output') +def admin_info(network: str, rpc_url: str, contract: str, verbose: bool): + """View contract configuration.""" + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print('[dim]Reading config...[/dim]\n') + + try: + from substrateinterface import SubstrateInterface + + substrate = SubstrateInterface(url=ws_endpoint) + packed = _read_contract_packed_storage(substrate, contract_addr, verbose) + + if packed: + console.print( + Panel( + f'[cyan]Owner:[/cyan] {packed.get("owner", "N/A")}\n' + f'[cyan]Treasury Hotkey:[/cyan] {packed.get("treasury_hotkey", "N/A")}\n' + f'[cyan]Netuid:[/cyan] {packed.get("netuid", "N/A")}\n' + f'[cyan]Next Issue ID:[/cyan] {packed.get("next_issue_id", "N/A")}', + title='Contract Configuration (v0)', + border_style='green', + ) + ) + else: + console.print('[yellow]Could not read contract configuration.[/yellow]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') diff --git a/gittensor/cli/issue_commands/vote.py b/gittensor/cli/issue_commands/vote.py new file mode 100644 index 00000000..a33d8d81 --- /dev/null +++ b/gittensor/cli/issue_commands/vote.py @@ -0,0 +1,339 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +""" +Validator vote commands for issue CLI + +Commands: + gitt vote solution + gitt vote cancel + gitt vote list +""" + +import re + +import click +from rich.table import Table + +from .helpers import ( + console, + get_contract_address, + resolve_network, +) + + +def parse_pr_number(pr_input: str) -> int: + """ + Parse PR number from either a number or a URL. + + Args: + pr_input: Either a PR number as string, or a full GitHub PR URL + + Returns: + PR number as integer + + Examples: + parse_pr_number("123") -> 123 + parse_pr_number("https://github.com/owner/repo/pull/123") -> 123 + """ + # First try as plain number + if pr_input.isdigit(): + return int(pr_input) + + # Try to extract from URL + match = re.search(r'/pull/(\d+)', pr_input) + if match: + return int(match.group(1)) + + # Invalid input + raise ValueError(f'Cannot parse PR number from: {pr_input}') + + +@click.group(name='vote') +def vote(): + """Validator consensus operations. + + These commands are used by validators to manage issue bounty payouts. + + \b + Commands: + solution Vote for a solver on an active issue + cancel Vote to cancel an issue + list List whitelisted validators + """ + pass + + +@vote.command('solution') +@click.argument('issue_id', type=int) +@click.argument('solver_hotkey', type=str) +@click.argument('solver_coldkey', type=str) +@click.argument('pr_number_or_url', type=str) +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='default', + help='Wallet name', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses config if empty)', +) +def val_vote_solution( + issue_id: int, + solver_hotkey: str, + solver_coldkey: str, + pr_number_or_url: str, + wallet_name: str, + wallet_hotkey: str, + network: str, + rpc_url: str, + contract: str, +): + """Vote for a solution on an active issue (triggers auto-payout on consensus). + + \b + Arguments: + ISSUE_ID: Issue to vote on + SOLVER_HOTKEY: Solver's hotkey + SOLVER_COLDKEY: Solver's coldkey (payout destination) + PR_NUMBER_OR_URL: PR number or full URL (e.g., 123 or https://github.com/.../pull/123) + + \b + Examples: + gitt vote solution 1 5Hxxx... 5Hyyy... 123 + gitt vote solution 1 5Hxxx... 5Hyyy... https://github.com/.../pull/123 + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + try: + pr_number = parse_pr_number(pr_number_or_url) + except ValueError as e: + console.print(f'[red]Error: {e}[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print(f'[yellow]Voting on solution for issue {issue_id}...[/yellow]\n') + console.print(f' Solver Hotkey: {solver_hotkey}') + console.print(f' Solver Coldkey: {solver_coldkey}') + console.print(f' PR Number: {pr_number}\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + wallet = bt.Wallet(name=wallet_name, hotkey=wallet_hotkey) + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + result = client.vote_solution(issue_id, solver_hotkey, solver_coldkey, pr_number, wallet) + if result: + console.print('[green]Solution vote submitted![/green]') + else: + console.print('[red]Vote failed.[/red]') + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') + + +@vote.command('cancel') +@click.argument('issue_id', type=int) +@click.argument('reason', type=str) +@click.option( + '--wallet-name', + '--wallet.name', + '--wallet', + default='default', + help='Wallet name', +) +@click.option( + '--wallet-hotkey', + '--wallet.hotkey', + '--hotkey', + default='default', + help='Hotkey name', +) +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses config if empty)', +) +def val_vote_cancel_issue( + issue_id: int, + reason: str, + wallet_name: str, + wallet_hotkey: str, + network: str, + rpc_url: str, + contract: str, +): + """Vote to cancel an issue (works on Registered or Active). + + \b + Arguments: + ISSUE_ID: Issue to cancel + REASON: Reason for cancellation + + \b + Examples: + gitt vote cancel 1 "External solution found" + gitt vote cancel 42 "Issue invalid" + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]') + console.print(f'[yellow]Voting to cancel issue {issue_id}...[/yellow]\n') + console.print(f' Reason: {reason}\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + wallet = bt.Wallet(name=wallet_name, hotkey=wallet_hotkey) + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + result = client.vote_cancel_issue(issue_id, reason, wallet) + if result: + console.print('[green]Vote cancel submitted![/green]') + else: + console.print('[red]Vote cancel failed.[/red]') + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') + + +@vote.command('list') +@click.option( + '--network', + '-n', + default=None, + type=click.Choice(['finney', 'test', 'local'], case_sensitive=False), + help='Network (finney/test/local)', +) +@click.option( + '--rpc-url', + default=None, + help='Subtensor RPC endpoint (overrides --network)', +) +@click.option( + '--contract', + default='', + help='Contract address (uses config if empty)', +) +def vote_list_validators(network: str, rpc_url: str, contract: str): + """List whitelisted validators and consensus threshold. + + Shows all validator hotkeys that are authorized to vote on + solutions and issue cancellations. + + \b + Examples: + gitt vote list + gitt vote list --network test + """ + contract_addr = get_contract_address(contract) + ws_endpoint, network_name = resolve_network(network, rpc_url) + + if not contract_addr: + console.print('[red]Error: Contract address not configured.[/red]') + return + + console.print(f'[dim]Network: {network_name} ({ws_endpoint})[/dim]') + console.print(f'[dim]Contract: {contract_addr}[/dim]\n') + + try: + import bittensor as bt + + from gittensor.validator.issue_competitions.contract_client import ( + IssueCompetitionContractClient, + ) + + subtensor = bt.Subtensor(network=ws_endpoint) + client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=subtensor, + ) + + validators = client.get_validators() + n = len(validators) + required = (n // 2) + 1 + + if validators: + table = Table(show_header=True, header_style='bold magenta') + table.add_column('#', style='dim', justify='right') + table.add_column('Validator Hotkey', style='cyan') + + for i, v in enumerate(validators, 1): + table.add_row(str(i), v) + + console.print(table) + console.print(f'\n[green]Validators:[/green] {n}') + console.print(f'[green]Consensus threshold:[/green] {required} of {n} votes required') + else: + console.print('[yellow]No validators whitelisted.[/yellow]') + console.print('[dim]Add validators with: gitt admin add-vali [/dim]') + + except ImportError as e: + console.print(f'[red]Error: Missing dependency - {e}[/red]') + except Exception as e: + console.print(f'[red]Error: {e}[/red]') diff --git a/gittensor/cli/main.py b/gittensor/cli/main.py new file mode 100644 index 00000000..931b9c50 --- /dev/null +++ b/gittensor/cli/main.py @@ -0,0 +1,184 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +""" +Gittensor CLI - Main entry point + +Usage: + gitt config - Show/set CLI configuration + gitt issues ... - Issue management (alias: i) + gitt harvest - Harvest emissions + gitt vote ... - Validator vote commands + gitt admin ... - Owner commands (alias: a) +""" + +import json +from pathlib import Path + +import click +from rich.console import Console +from rich.table import Table + +from gittensor.cli.issue_commands import register_commands + +console = Console() + +# Config paths +GITTENSOR_DIR = Path.home() / '.gittensor' +CONFIG_FILE = GITTENSOR_DIR / 'config.json' + + +class AliasGroup(click.Group): + """Click Group that supports command aliases without duplicate help entries.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._aliases = {} # alias -> canonical name + + def add_alias(self, name, alias): + """Register an alias for an existing command.""" + self._aliases[alias] = name + + def get_command(self, ctx, cmd_name): + # Resolve alias to canonical name + canonical = self._aliases.get(cmd_name, cmd_name) + return super().get_command(ctx, canonical) + + def format_commands(self, ctx, formatter): + """Write the help text, appending aliases to command descriptions.""" + # Build reverse map: canonical -> list of aliases + alias_map = {} + for alias, canonical in self._aliases.items(): + alias_map.setdefault(canonical, []).append(alias) + + commands = [] + for subcommand in self.list_commands(ctx): + cmd = self.commands.get(subcommand) + if cmd is None or cmd.hidden: + continue + help_text = cmd.get_short_help_str(limit=150) + aliases = alias_map.get(subcommand) + if aliases: + alias_str = ', '.join(sorted(aliases)) + subcommand = f'{subcommand}, {alias_str}' + commands.append((subcommand, help_text)) + + if commands: + with formatter.section('Commands'): + formatter.write_dl(commands) + + +@click.group(cls=AliasGroup) +@click.version_option(version='3.2.0', prog_name='gittensor') +def cli(): + """Gittensor CLI - Manage issue bounties and validator operations""" + pass + + +@click.group(name='config', invoke_without_command=True) +@click.pass_context +def config_group(ctx): + """CLI configuration management. + + Show current configuration (default) or set config values. + + \b + Subcommands: + set Set a config value + """ + # If no subcommand, show config + if ctx.invoked_subcommand is None: + show_config() + + +def show_config(): + """Show current CLI configuration""" + console.print('\n[bold]Gittensor CLI Configuration[/bold]\n') + + if not CONFIG_FILE.exists(): + console.print('[yellow]No config file found at ~/.gittensor/config.json[/yellow]') + console.print('[dim]Run ./up.sh --issues to create config[/dim]') + return + + try: + config = json.loads(CONFIG_FILE.read_text()) + + table = Table(show_header=True) + table.add_column('Setting', style='cyan') + table.add_column('Value', style='green') + + for key, value in config.items(): + # Truncate long values + str_val = str(value) + if len(str_val) > 25: + str_val = str_val[:12] + '...' + str_val[-10:] + table.add_row(key, str_val) + + console.print(table) + console.print(f'\n[dim]Config file: {CONFIG_FILE}[/dim]\n') + + except json.JSONDecodeError: + console.print('[red]Error: Invalid JSON in config file[/red]') + except Exception as e: + console.print(f'[red]Error reading config: {e}[/red]') + + +@config_group.command('set') +@click.argument('key', type=str) +@click.argument('value', type=str) +def config_set(key: str, value: str): + """Set a configuration value. + + \b + Common keys: + wallet Wallet name + hotkey Hotkey name + contract_address Contract address + ws_endpoint WebSocket endpoint + network Network (local, test, finney) + + \b + Examples: + gitt config set wallet alice + gitt config set contract_address 5Cxxx... + gitt config set network local + """ + # Ensure config directory exists + GITTENSOR_DIR.mkdir(parents=True, exist_ok=True) + + # Load existing config or start fresh + config = {} + if CONFIG_FILE.exists(): + try: + config = json.loads(CONFIG_FILE.read_text()) + except json.JSONDecodeError: + console.print('[yellow]Warning: Existing config was invalid, starting fresh[/yellow]') + + # Set the value + old_value = config.get(key) + config[key] = value + + # Write config + CONFIG_FILE.write_text(json.dumps(config, indent=2)) + + if old_value is not None: + console.print(f'[green]Updated {key}:[/green] {old_value} → {value}') + else: + console.print(f'[green]Set {key}:[/green] {value}') + + +# Register config group +cli.add_command(config_group) + + +# Register issue commands with new flat structure +register_commands(cli) + + +def main(): + """Main entry point for the CLI""" + cli() + + +if __name__ == '__main__': + main() diff --git a/gittensor/constants.py b/gittensor/constants.py index e2ee9d9f..30cd7a17 100644 --- a/gittensor/constants.py +++ b/gittensor/constants.py @@ -52,13 +52,14 @@ # ============================================================================= # Repository & PR Scoring # ============================================================================= +PR_LOOKBACK_DAYS = 90 # how many days a merged pr will count for scoring DEFAULT_MERGED_PR_BASE_SCORE = 30 MIN_TOKEN_SCORE_FOR_BASE_SCORE = 5 # PRs below this get 0 base score (can still earn contribution bonus) MAX_CONTRIBUTION_BONUS = 30 DEFAULT_MAX_CONTRIBUTION_SCORE_FOR_FULL_BONUS = 2000 # Boosts -UNIQUE_PR_BOOST = 0.4 +UNIQUE_PR_BOOST = 0.74 MAX_CODE_DENSITY_MULTIPLIER = 3.0 # Issue boosts @@ -125,3 +126,10 @@ # Example: 1500 token score across unlocked tiers / 500 = +3 bonus OPEN_PR_THRESHOLD_TOKEN_SCORE = 500.0 # Token score per +1 bonus (sum of all unlocked tiers) MAX_OPEN_PR_THRESHOLD = 30 # Maximum open PR threshold (base + bonus capped at this value) + +# ============================================================================= +# Issues Competition +# ============================================================================= +CONTRACT_ADDRESS = '5FWNdk8YNtNcHKrAx2krqenFrFAZG7vmsd2XN2isJSew3MrD' +ISSUES_TREASURY_UID = 111 # UID of the smart contract neuron, if set to RECYCLE_UID then it's disabled +ISSUES_TREASURY_EMISSION_SHARE = 0.15 # % of emissions routed to funding issues treasury diff --git a/gittensor/utils/config.py b/gittensor/utils/config.py index eaf87a85..f81e7fa0 100644 --- a/gittensor/utils/config.py +++ b/gittensor/utils/config.py @@ -243,9 +243,9 @@ def config(cls): Returns the configuration object specific to this miner or validator after adding relevant arguments. """ parser = argparse.ArgumentParser() - bt.wallet.add_args(parser) - bt.subtensor.add_args(parser) + bt.Wallet.add_args(parser) + bt.Subtensor.add_args(parser) bt.logging.add_args(parser) - bt.axon.add_args(parser) + bt.Axon.add_args(parser) cls.add_args(parser) - return bt.config(parser) + return bt.Config(parser) diff --git a/gittensor/utils/github_api_tools.py b/gittensor/utils/github_api_tools.py index 41a8eec1..2dbe68ad 100644 --- a/gittensor/utils/github_api_tools.py +++ b/gittensor/utils/github_api_tools.py @@ -1,11 +1,14 @@ # Entrius 2025 import base64 import fnmatch +import re import time from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, Dict, List, Optional +from gittensor.utils.utils import parse_repo_name + if TYPE_CHECKING: from gittensor.classes import FileChange as FileChangeType @@ -21,10 +24,9 @@ BASE_GITHUB_API_URL, MAINTAINER_ASSOCIATIONS, MAX_FILE_SIZE_BYTES, + PR_LOOKBACK_DAYS, TIER_BASED_INCENTIVE_MECHANISM_START_DATE, ) -from gittensor.utils.utils import parse_repo_name -from gittensor.validator.utils.config import PR_LOOKBACK_DAYS from gittensor.validator.utils.load_weights import RepositoryConfig # core github graphql query @@ -654,6 +656,168 @@ def load_miners_prs( bt.logging.error(f'Error fetching PRs via GraphQL: {e}') +def extract_pr_number_from_url(pr_url: str) -> Optional[int]: + """Extract PR number from a GitHub PR URL. + + Args: + pr_url: Full GitHub PR URL (e.g., https://github.com/owner/repo/pull/123) + + Returns: + PR number as integer, or None if invalid URL + """ + if not pr_url: + return None + match = re.search(r'/pull/(\d+)', pr_url) + return int(match.group(1)) if match else None + + +def find_solver_from_cross_references(repo: str, issue_number: int, token: str) -> tuple: + """Fallback solver detection via GraphQL cross-referenced merged PRs. + + When a closed event has no commit_id, this queries GitHub's GraphQL API + directly for cross-referenced merged PRs that close the issue. + + Filters to only PRs targeting the same repo (baseRepository match) to + prevent false matches from unrelated repos mentioning the issue. + When multiple candidates exist, the most recently merged PR is selected. + + Returns: + (solver_github_id, pr_number) — either may be None if no match found. + """ + owner, name = repo.split('/') + + query = """ + query($owner: String!, $name: String!, $issueNumber: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $issueNumber) { + timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], first: 50) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + merged + mergedAt + author { ... on User { databaseId } } + baseRepository { nameWithOwner } + closingIssuesReferences(first: 20) { + nodes { number } + } + } + } + } + } + } + } + } + } + """ + + result = execute_graphql_query( + query=query, + variables={'owner': owner, 'name': name, 'issueNumber': issue_number}, + token=token, + max_attempts=3, + ) + if not result: + bt.logging.warning(f'GraphQL cross-reference query failed for {repo}#{issue_number}') + return None, None + + timeline_nodes = ( + result.get('data', {}).get('repository', {}).get('issue', {}).get('timelineItems', {}).get('nodes', []) + ) + + candidates = [] + for node in timeline_nodes: + pr = node.get('source', {}) + if not pr or not pr.get('merged'): + continue + + # Reject PRs targeting a different repo (prevents cross-repo gaming) + base_repo = pr.get('baseRepository', {}).get('nameWithOwner', '') + if base_repo.lower() != repo.lower(): + bt.logging.debug(f'Skipping PR#{pr.get("number")} from {base_repo} (does not target {repo})') + continue + + pr_number = pr.get('number') + user_id = pr.get('author', {}).get('databaseId') + merged_at = pr.get('mergedAt', '') + closing_numbers = [n.get('number') for n in pr.get('closingIssuesReferences', {}).get('nodes', [])] + if pr_number and issue_number in closing_numbers: + candidates.append((pr_number, user_id, merged_at)) + + bt.logging.debug(f'Found {len(candidates)} verified closing PRs via GraphQL for {repo}#{issue_number}') + + if not candidates: + return None, None + + if len(candidates) > 1: + bt.logging.warning(f'Multiple closing PRs found for {repo}#{issue_number}:') + for pr_num, uid, merged in candidates: + bt.logging.debug(f' PR#{pr_num}, solver_id={uid}, merged_at={merged}') + # Sort by mergedAt descending, pick the most recent + candidates.sort(key=lambda c: c[2], reverse=True) + + pr_number, user_id, merged_at = candidates[0] + bt.logging.debug(f'Solver via GraphQL cross-reference: PR#{pr_number}, solver_id={user_id}, merged_at={merged_at}') + return user_id, pr_number + + +def find_solver_from_timeline(repo: str, issue_number: int, token: str) -> tuple: + """Find the PR author who closed an issue. + + Uses GraphQL cross-reference analysis to find merged PRs that close the + issue, with baseRepository validation and closingIssuesReferences check. + + Returns: + (solver_github_id, pr_number) — either may be None if not found. + """ + bt.logging.debug(f'Finding solver for {repo}#{issue_number}') + return find_solver_from_cross_references(repo, issue_number, token) + + +def check_github_issue_closed(repo: str, issue_number: int, token: str) -> Optional[Dict[str, Any]]: + """Check if a GitHub issue is closed and get the solving PR info. + + Args: + repo: Repository full name (e.g., 'owner/repo') + issue_number: GitHub issue number + token: GitHub PAT for authentication + + Returns: + Dict with 'is_closed', 'solver_github_id', 'pr_number' or None on error + """ + headers = make_headers(token) + + try: + response = requests.get( + f'{BASE_GITHUB_API_URL}/repos/{repo}/issues/{issue_number}', + headers=headers, + timeout=15, + ) + + if response.status_code != 200: + bt.logging.warning(f'GitHub API error for {repo}#{issue_number}: {response.status_code}') + return None + + data = response.json() + + if data.get('state') != 'closed': + return {'is_closed': False} + + solver_github_id, pr_number = find_solver_from_timeline(repo, issue_number, token) + + return { + 'is_closed': True, + 'solver_github_id': solver_github_id, + 'pr_number': pr_number, + } + + except Exception as e: + bt.logging.error(f'Error checking GitHub issue {repo}#{issue_number}: {e}') + return None + + def fetch_file_contents_batch( repo_owner: str, repo_name: str, diff --git a/gittensor/validator/evaluation/reward.py b/gittensor/validator/evaluation/reward.py index 69cf2624..2d21431e 100644 --- a/gittensor/validator/evaluation/reward.py +++ b/gittensor/validator/evaluation/reward.py @@ -2,7 +2,7 @@ # Copyright © 2025 Entrius from __future__ import annotations -from typing import TYPE_CHECKING, Dict +from typing import TYPE_CHECKING, Dict, Tuple import bittensor as bt import numpy as np @@ -102,7 +102,7 @@ async def get_rewards( master_repositories: Dict[str, RepositoryConfig], programming_languages: Dict[str, LanguageConfig], token_config: TokenConfig, -) -> np.ndarray: +) -> Tuple[np.ndarray, Dict[int, MinerEvaluation]]: """ Args: uids (set[int]): All valid miner uids in the subnet @@ -151,4 +151,7 @@ async def get_rewards( # Store miner evaluations after calculating all scores await self.bulk_store_evaluation(miner_evaluations, skip_uids=cached_uids) - return np.array([final_rewards.get(uid, 0.0) for uid in sorted(uids)]) + return ( + np.array([final_rewards.get(uid, 0.0) for uid in sorted(uids)]), + miner_evaluations, + ) diff --git a/gittensor/validator/forward.py b/gittensor/validator/forward.py index 0a888648..16e86d82 100644 --- a/gittensor/validator/forward.py +++ b/gittensor/validator/forward.py @@ -2,10 +2,12 @@ # Copyright © 2025 Entrius import asyncio -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict import bittensor as bt +from gittensor.classes import MinerEvaluation +from gittensor.constants import ISSUES_TREASURY_EMISSION_SHARE, ISSUES_TREASURY_UID from gittensor.validator.utils.load_weights import ( load_master_repo_weights, load_programming_language_weights, @@ -16,9 +18,16 @@ if TYPE_CHECKING: from neurons.base.validator import BaseValidatorNeuron +# Issue bounties integration +from gittensor.utils.github_api_tools import check_github_issue_closed from gittensor.utils.uids import get_all_uids from gittensor.validator.evaluation.reward import get_rewards -from gittensor.validator.utils.config import VALIDATOR_STEPS_INTERVAL, VALIDATOR_WAIT +from gittensor.validator.issue_competitions.contract_client import IssueCompetitionContractClient, IssueStatus +from gittensor.validator.utils.config import GITTENSOR_VALIDATOR_PAT, VALIDATOR_STEPS_INTERVAL, VALIDATOR_WAIT +from gittensor.validator.utils.issue_competitions import ( + get_contract_address, + get_miner_coldkey, +) async def forward(self: 'BaseValidatorNeuron') -> None: @@ -26,8 +35,9 @@ async def forward(self: 'BaseValidatorNeuron') -> None: Performs the core validation cycle every VALIDATOR_STEPS_INTERVAL steps: 1. Get all available miner UIDs - 2. Query miners and calculate rewards + 2. Score OSS contributions and get miner evaluations 3. Update scores using exponential moving average + 4. Run issue bounties verification (needs tier data from scoring) Args: self: The validator instance containing all necessary state @@ -36,23 +46,218 @@ async def forward(self: 'BaseValidatorNeuron') -> None: if self.step % VALIDATOR_STEPS_INTERVAL == 0: miner_uids = get_all_uids(self) - master_repositories = load_master_repo_weights() - programming_languages = load_programming_language_weights() - token_config = load_token_config() + # Score OSS contributions - returns evaluations for issue verification + miner_evaluations = await oss_contributions(self, miner_uids) - # Count languages with tree-sitter support - tree_sitter_count = sum(1 for c in token_config.language_configs.values() if c.language is not None) + # Issue bounties verification + await issues_competition(self, miner_evaluations) - bt.logging.info('***** Starting scoring round *****') - bt.logging.info(f'Total Repositories loaded from master_repositories.json: {len(master_repositories)}') - bt.logging.info(f'Total Languages loaded from programming_languages.json: {len(programming_languages)}') - bt.logging.info(f'Total Token config loaded from token_weights.json: {tree_sitter_count} tree-sitter languages') - bt.logging.info(f'Number of neurons to evaluate: {len(miner_uids)}') + await asyncio.sleep(VALIDATOR_WAIT) - # Get rewards for the responses - queries miners individually - rewards = await get_rewards(self, miner_uids, master_repositories, programming_languages, token_config) - # Update the scores based on the rewards - self.update_scores(rewards, miner_uids) +async def oss_contributions(self: 'BaseValidatorNeuron', miner_uids: set[int]) -> Dict[int, MinerEvaluation]: + """Score OSS contributions and return miner evaluations for downstream use.""" + master_repositories = load_master_repo_weights() + programming_languages = load_programming_language_weights() + token_config = load_token_config() - await asyncio.sleep(VALIDATOR_WAIT) + tree_sitter_count = sum(1 for c in token_config.language_configs.values() if c.language is not None) + + bt.logging.info('***** Starting scoring round *****') + bt.logging.info(f'Total Repositories loaded: {len(master_repositories)}') + bt.logging.info(f'Total Languages loaded: {len(programming_languages)}') + bt.logging.info(f'Token config: {tree_sitter_count} tree-sitter languages') + bt.logging.info(f'Neurons to evaluate: {len(miner_uids)}') + + rewards, miner_evaluations = await get_rewards( + self, miner_uids, master_repositories, programming_languages, token_config + ) + + # ------------------------------------------------------------------------- + # Issue Bounties Treasury Allocation + # The smart contract neuron (ISSUES_TREASURY_UID) accumulates emissions + # which fund issue bounty payouts. We allocate a fixed percentage of + # total emissions to this treasury by scaling down all miner rewards + # and assigning the remainder to the treasury UID. + # ------------------------------------------------------------------------- + if ISSUES_TREASURY_UID > 0 and ISSUES_TREASURY_UID in miner_uids: + treasury_share = ISSUES_TREASURY_EMISSION_SHARE + miner_share = 1.0 - treasury_share + + # rewards array is indexed by position in sorted(miner_uids) + sorted_uids = sorted(miner_uids) + treasury_idx = sorted_uids.index(ISSUES_TREASURY_UID) + + # Scale down all rewards proportionally + rewards *= miner_share + + # Assign treasury's share + rewards[treasury_idx] = treasury_share + + bt.logging.info( + f'Treasury allocation: Smart Contract UID {ISSUES_TREASURY_UID} receives ' + f'{treasury_share * 100:.0f}% of emissions, miners share {miner_share * 100:.0f}%' + ) + + self.update_scores(rewards, miner_uids) + + return miner_evaluations + + +async def issues_competition( + self: 'BaseValidatorNeuron', + miner_evaluations: Dict[int, MinerEvaluation], +) -> None: + """ + Run the issue bounties forward pass. + + 1. Harvest emissions into the bounty pool + 2. Get active issues from the smart contract + 3. For each active issue, check GitHub: + - If solved by bronze+ miner -> vote_solution + - If closed but not by eligible miner -> vote_cancel_issue + + Args: + self: The validator instance + miner_evaluations: Fresh scoring data from oss_contributions(), keyed by UID + """ + try: + if not GITTENSOR_VALIDATOR_PAT: + bt.logging.info('GITTENSOR_VALIDATOR_PAT not set, skipping issue bounties voting entirely.') + return + + contract_addr = get_contract_address() + if not contract_addr: + bt.logging.warning('Issue bounties: no contract address configured') + return + + bt.logging.info('***** Starting Issue Bounties *****') + bt.logging.info(f'Contract address: {contract_addr}') + + # Create contract client + contract_client = IssueCompetitionContractClient( + contract_address=contract_addr, + subtensor=self.subtensor, + ) + + # Harvest emissions first - flush accumulated stake into bounty pool + harvest_result = contract_client.harvest_emissions(self.wallet) + if harvest_result and harvest_result.get('status') == 'success': + bt.logging.success(f'Harvested emissions! Extrinsic: {harvest_result.get("tx_hash", "")}') + + # Build mapping of github_id->hotkey for bronze+ miners only (eligible for payouts) + eligible_miners = { + eval.github_id: eval.hotkey + for eval in miner_evaluations.values() + if eval.github_id and eval.github_id != '0' and eval.current_tier is not None + } + bt.logging.info( + f'Issue bounties: {len(eligible_miners)} eligible miners (bronze+) out of {len(miner_evaluations)} total' + ) + for github_id, hotkey in eligible_miners.items(): + bt.logging.info(f' Eligible miner: github_id={github_id}, hotkey={hotkey[:12]}...') + + # Get active issues from contract + active_issues = contract_client.get_issues_by_status(IssueStatus.ACTIVE) + bt.logging.info(f'Found {len(active_issues)} active issues') + + votes_cast = 0 + cancels_cast = 0 + errors = [] + + for issue in active_issues: + bounty_display = issue.bounty_amount / 1e9 + issue_label = ( + f'{issue.repository_full_name}#{issue.issue_number} (id={issue.id}, bounty={bounty_display:.2f} ALPHA)' + ) + try: + bt.logging.info(f'--- Processing issue: {issue_label} ---') + + github_state = check_github_issue_closed( + issue.repository_full_name, issue.issue_number, GITTENSOR_VALIDATOR_PAT + ) + + if github_state is None: + bt.logging.warning(f'Could not check GitHub state for {issue_label}') + continue + + if not github_state.get('is_closed'): + bt.logging.info(f'Issue still open on GitHub: {issue_label}') + continue + + solver_github_id = github_state.get('solver_github_id') + pr_number = github_state.get('pr_number') + bt.logging.info( + f'Issue closed on GitHub: {issue_label} | solver_github_id={solver_github_id}, pr_number={pr_number}' + ) + + if not solver_github_id: + bt.logging.info(f'No identifiable solver, voting cancel: {issue_label}') + success = contract_client.vote_cancel_issue( + issue_id=issue.id, + reason='Issue closed without identifiable solver', + wallet=self.wallet, + ) + if success: + cancels_cast += 1 + bt.logging.info(f'Voted cancel (no solver): {issue_label}') + continue + + miner_hotkey = eligible_miners.get(str(solver_github_id)) + if not miner_hotkey: + bt.logging.info(f'Solver {solver_github_id} not in eligible miners, voting cancel: {issue_label}') + success = contract_client.vote_cancel_issue( + issue_id=issue.id, + reason=f'Issue closed externally (not by eligible miner, solver: {solver_github_id})', + wallet=self.wallet, + ) + if success: + cancels_cast += 1 + bt.logging.info(f'Voted cancel (solver {solver_github_id} not eligible): {issue_label}') + continue + + miner_coldkey = get_miner_coldkey(miner_hotkey, self.subtensor, self.config.netuid) + if not miner_coldkey: + bt.logging.warning( + f'Could not get coldkey for hotkey {miner_hotkey} (solver {solver_github_id}): {issue_label}' + ) + continue + + bt.logging.info( + f'Voting solution: {issue_label} | PR#{pr_number}, solver={solver_github_id}, hotkey={miner_hotkey[:12]}...' + ) + success = contract_client.vote_solution( + issue_id=issue.id, + solver_hotkey=miner_hotkey, + solver_coldkey=miner_coldkey, + pr_number=pr_number or 0, + wallet=self.wallet, + ) + if success: + votes_cast += 1 + bt.logging.success( + f'Voted solution for {issue_label}: hotkey={miner_hotkey[:12]}..., PR#{pr_number}' + ) + else: + bt.logging.warning(f'Vote solution call failed: {issue_label}') + errors.append(f'Vote failed for {issue_label}') + + except Exception as e: + bt.logging.error(f'Error processing {issue_label}: {e}') + errors.append(f'{issue_label}: {str(e)}') + + if errors: + bt.logging.warning(f'Issue bounties errors: {errors[:3]}') + + if votes_cast > 0 or cancels_cast > 0: + bt.logging.success( + f'=== Issue Bounties Complete: processed {len(active_issues)} issues, ' + f'{votes_cast} solution votes, {cancels_cast} cancel votes ===' + ) + else: + bt.logging.info( + '***** Issue Bounties Complete: processed {len(active_issues)} issues (no state changes) *****' + ) + + except Exception as e: + bt.logging.error(f'Issue bounties forward failed: {e}') diff --git a/gittensor/validator/issue_competitions/__init__.py b/gittensor/validator/issue_competitions/__init__.py new file mode 100644 index 00000000..8a4a34d7 --- /dev/null +++ b/gittensor/validator/issue_competitions/__init__.py @@ -0,0 +1,2 @@ +# The MIT License (MIT) +# Copyright 2025 Entrius diff --git a/gittensor/validator/issue_competitions/contract_client.py b/gittensor/validator/issue_competitions/contract_client.py new file mode 100644 index 00000000..8d2bc29c --- /dev/null +++ b/gittensor/validator/issue_competitions/contract_client.py @@ -0,0 +1,1063 @@ +# The MIT License (MIT) +# Copyright 2025 Entrius + +"""Client for interacting with the Issue Bounty smart contract""" + +import hashlib +import json +import struct +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import bittensor as bt +from substrateinterface import Keypair +from substrateinterface.exceptions import ExtrinsicNotFound + +# Bittensor uses async_substrate_interface which has its own exception type +try: + from async_substrate_interface.errors import ExtrinsicNotFound as AsyncExtrinsicNotFound +except ImportError: + AsyncExtrinsicNotFound = ExtrinsicNotFound + +# Default gas limits for contract calls +DEFAULT_GAS_LIMIT = { + 'ref_time': 10_000_000_000, + 'proof_size': 500_000, +} + +# Load contract metadata from JSON (selectors and arg types) +# Regenerate with: python gittensor/validator/issue_competitions/update_metadata.py +_METADATA_PATH = Path(__file__).parent / 'metadata.json' + + +def load_contract_metadata() -> Tuple[Dict[str, bytes], Dict[str, List]]: + """Load selectors and arg types from metadata.json.""" + with open(_METADATA_PATH) as f: + data = json.load(f) + + selectors = {name: bytes.fromhex(sel) for name, sel in data['selectors'].items()} + arg_types = {name: [tuple(arg) for arg in args] for name, args in data['arg_types'].items()} + + return selectors, arg_types + + +CONTRACT_SELECTORS, CONTRACT_ARG_TYPES = load_contract_metadata() + + +class IssueStatus(Enum): + """Status of an issue in its lifecycle""" + + REGISTERED = 0 + ACTIVE = 1 + COMPLETED = 2 + CANCELLED = 3 + + +@dataclass +class ContractIssue: + """Issue data from the smart contract.""" + + id: int + github_url_hash: bytes + repository_full_name: str + issue_number: int + bounty_amount: int + target_bounty: int + status: IssueStatus + registered_at_block: int + is_fully_funded: bool + + +class IssueCompetitionContractClient: + """ + Client for interacting with the Issue Bounty smart contract + + This client handles all read/write operations with the on-chain contract + for the issue bounties sub-mechanism. + """ + + def __init__( + self, + contract_address: str, + subtensor: bt.Subtensor, + ): + """Initialize the contract client. + + Args: + contract_address: SS58 address of the deployed contract. + subtensor: Connected Subtensor instance. + + Raises: + ValueError: If contract_address is empty or contract not found on-chain. + """ + if not contract_address: + raise ValueError('contract_address is required') + + self.contract_address = contract_address + self.subtensor = subtensor + + # Validate the contract exists on-chain + try: + contract_info = self.subtensor.substrate.query('Contracts', 'ContractInfoOf', [self.contract_address]) + if not contract_info or (hasattr(contract_info, 'value') and not contract_info.value): + raise ValueError( + f'No contract found at {self.contract_address}. ' + 'Verify the address and that the contract is deployed.' + ) + except ValueError: + raise + except Exception as e: + bt.logging.warning(f'Could not verify contract at {self.contract_address}: {e}') + + bt.logging.debug(f'Contract client initialized: {self.contract_address}') + + @staticmethod + def hash_url(url: str) -> bytes: + """Hash a URL for deduplication.""" + return hashlib.sha256(url.encode()).digest() + + # ========================================================================= + # Query Functions (Read-only) + # ========================================================================= + + def _get_child_storage_key(self) -> Optional[str]: + """Get the child storage key for the contract's trie.""" + try: + contract_info = self.subtensor.substrate.query('Contracts', 'ContractInfoOf', [self.contract_address]) + if not contract_info: + return None + + if hasattr(contract_info, 'value'): + info = contract_info.value + else: + info = contract_info + + if not info or 'trie_id' not in info: + return None + + trie_id = info['trie_id'] + + if isinstance(trie_id, str): + trie_id_hex = trie_id.replace('0x', '') + trie_id_bytes = bytes.fromhex(trie_id_hex) + elif isinstance(trie_id, (tuple, list)): + if len(trie_id) == 1 and isinstance(trie_id[0], (tuple, list)): + trie_id = trie_id[0] + trie_id_bytes = bytes(trie_id) + elif isinstance(trie_id, bytes): + trie_id_bytes = trie_id + else: + return None + + prefix = b':child_storage:default:' + return '0x' + (prefix + trie_id_bytes).hex() + except Exception as e: + bt.logging.debug(f'Error getting child storage key: {e}') + return None + + def compute_ink5_lazy_key(self, root_key_hex: str, encoded_key: bytes) -> str: + """Compute Ink! 5 lazy mapping storage key using blake2_128concat.""" + root_key = bytes.fromhex(root_key_hex.replace('0x', '')) + data = root_key + encoded_key + h = hashlib.blake2b(data, digest_size=16).digest() + return '0x' + (h + data).hex() + + def _read_packed_storage(self) -> Optional[dict]: + """Read the packed root storage from the contract""" + child_key = self._get_child_storage_key() + if not child_key: + return None + + try: + keys_result = self.subtensor.substrate.rpc_request( + 'childstate_getKeysPaged', [child_key, '0x', 10, None, None] + ) + keys = keys_result.get('result', []) + + packed_key = None + for k in keys: + if k.endswith('00000000'): + packed_key = k + break + + if not packed_key: + return None + + val_result = self.subtensor.substrate.rpc_request('childstate_getStorage', [child_key, packed_key, None]) + if not val_result.get('result'): + return None + + data = bytes.fromhex(val_result['result'].replace('0x', '')) + + # owner (32) + treasury (32) + netuid (2) + next_issue_id (8) + if len(data) < 74: + return None + + offset = 64 # Skip owner + treasury + netuid = struct.unpack_from(' Optional[ContractIssue]: + """Read a single issue from contract child storage.""" + child_key = self._get_child_storage_key() + if not child_key: + return None + + try: + encoded_id = struct.pack('> 2 + offset += 1 + elif len_byte & 0x03 == 1: + str_len = (data[offset] | (data[offset + 1] << 8)) >> 2 + offset += 2 + else: + str_len = 0 + offset += 1 + + repo_name = data[offset : offset + str_len].decode('utf-8', errors='replace') + offset += str_len + + issue_number = struct.unpack_from('= int(target_bounty), + ) + except Exception as e: + bt.logging.debug(f'Error reading issue {issue_id}: {e}') + return None + + def get_issues_by_status(self, status: IssueStatus) -> List[ContractIssue]: + """Get all issues with a given status.""" + try: + packed = self._read_packed_storage() + if not packed: + return [] + + next_issue_id = packed.get('next_issue_id', 1) + if next_issue_id <= 1: + return [] + + MAX_REASONABLE_ISSUE_ID = 1_000_000 + if next_issue_id > MAX_REASONABLE_ISSUE_ID: + bt.logging.warning(f'next_issue_id ({next_issue_id}) unreasonably large') + return [] + + issues = [] + for issue_id in range(1, next_issue_id): + issue = self.read_issue_from_child_storage(issue_id) + if issue and issue.status == status: + issues.append(issue) + + return issues + except Exception as e: + bt.logging.error(f'Error fetching issues by status: {e}') + return [] + + def get_available_issues(self) -> List[ContractIssue]: + """Query contract for issues with status=Active.""" + return self.get_issues_by_status(IssueStatus.ACTIVE) + + def get_issue(self, issue_id: int) -> Optional[ContractIssue]: + """Get a specific issue by ID.""" + try: + return self.read_issue_from_child_storage(issue_id) + except Exception as e: + bt.logging.error(f'Error fetching issue {issue_id}: {e}') + return None + + def get_alpha_pool(self) -> int: + """Get the current alpha pool balance.""" + try: + value = self._read_contract_u128('get_alpha_pool') + return value + except Exception as e: + bt.logging.error(f'Error fetching alpha pool: {e}') + return 0 + + def _read_contract_u128(self, method_name: str) -> int: + """Read a u128 value from a no-arg contract method.""" + response = self._raw_contract_read(method_name) + if response is None: + return 0 + + value = self._extract_u128_from_response(response) + return value if value is not None else 0 + + def _read_contract_u32(self, method_name: str) -> int: + """Read a u32 value from a no-arg contract method.""" + response = self._raw_contract_read(method_name) + if response is None: + return 0 + + value = self._extract_u32_from_response(response) + return value if value is not None else 0 + + def _raw_contract_read(self, method_name: str, args: dict = None) -> Optional[bytes]: + """Read from contract using raw RPC call. + + Returns the ink! return payload (after stripping the ContractExecResult + envelope, ExecReturnValue flags, data Vec wrapper, and ink! Result + discriminant). Returns None on any error or revert. + """ + try: + selector = CONTRACT_SELECTORS.get(method_name) + if not selector: + return None + + input_data = selector + + caller = Keypair.create_from_uri('//Alice') + + origin = bytes.fromhex(self.subtensor.substrate.ss58_decode(caller.ss58_address)) + dest = bytes.fromhex(self.subtensor.substrate.ss58_decode(self.contract_address)) + # Subtensor chain Balance is u64, not u128 + value = b'\x00' * 8 + gas_limit = b'\x00' + storage_limit = b'\x00' + + data_len = len(input_data) + if data_len < 64: + compact_len = bytes([data_len << 2]) + else: + compact_len = bytes([(data_len << 2) | 1, data_len >> 6]) + + call_params = origin + dest + value + gas_limit + storage_limit + compact_len + input_data + + result = self.subtensor.substrate.rpc_request('state_call', ['ContractsApi_call', '0x' + call_params.hex()]) + + if not result.get('result'): + return None + + raw = bytes.fromhex(result['result'].replace('0x', '')) + + if len(raw) < 32: + return None + + # Parse ContractExecResult (after 16-byte gas prefix): + # StorageDeposit: 1 byte enum + 8 bytes u64 = 9 + # debug_message: 1 byte (compact 0 = empty) + # Result: 1 byte (0x00 = Ok) + # flags: 4 bytes u32 (0 = success, 1 = REVERT) + # data: compact len + bytes + # ink! data[0]: Result discriminant (0x00 = Ok) + # ink! data[1:]: SCALE-encoded return value + r = raw[16:] + + # Check Result discriminant at offset 10 + if len(r) < 15 or r[10] != 0x00: + return None + + # Check REVERT flag at offset 11-14 + flags = struct.unpack_from(' compact length at offset 15 + data_compact = r[15] + data_mode = data_compact & 0x03 + if data_mode == 0: + data_len = data_compact >> 2 + data_start = 16 + elif data_mode == 1: + if len(r) < 17: + return None + data_len = (r[15] | (r[16] << 8)) >> 2 + data_start = 17 + else: + return None + + if len(r) < data_start + data_len or data_len < 1: + return None + + # First byte of data is ink! Result discriminant (0x00 = Ok) + if r[data_start] != 0x00: + return None + + # Return the actual SCALE-encoded return value + return r[data_start + 1 : data_start + data_len] + + except Exception as e: + bt.logging.debug(f'Raw contract read failed: {e}') + return None + + def _extract_u32_from_response(self, response_bytes: bytes) -> Optional[int]: + """Extract u32 value from SCALE-encoded return bytes.""" + if not response_bytes or len(response_bytes) < 4: + return None + try: + return struct.unpack_from(' Optional[int]: + """Extract u128 value from SCALE-encoded return bytes.""" + if not response_bytes or len(response_bytes) < 16: + return None + try: + low = struct.unpack_from(' bool: + """ + Vote for a solution on an active issue + + Casts a vote for the proposed solver. When consensus is reached, + the issue completes and bounty is automatically paid to the + solver's coldkey. + + Args: + issue_id: Issue to vote on + solver_hotkey: Hotkey of the proposed solver + solver_coldkey: Coldkey to receive the payout + pr_number: PR number that solved the issue (combined with repo for URL) + wallet: Validator wallet for signing + + Returns: + True if vote succeeded + """ + try: + bt.logging.info(f'Voting solution for issue {issue_id}: solver={solver_hotkey[:8]}... PR#{pr_number}') + + keypair = wallet.hotkey + tx_hash = self._exec_contract_raw( + method_name='vote_solution', + args={ + 'issue_id': issue_id, + 'solver_hotkey': solver_hotkey, + 'solver_coldkey': solver_coldkey, + 'pr_number': pr_number, + }, + keypair=keypair, + ) + + if tx_hash: + bt.logging.info(f'Vote solution succeeded: {tx_hash}') + return True + else: + bt.logging.error('Vote solution failed') + return False + + except Exception as e: + bt.logging.error(f'Error voting solution: {e}') + return False + + def vote_cancel_issue( + self, + issue_id: int, + reason: str, + wallet: bt.Wallet, + ) -> bool: + """ + Vote to cancel an issue. + + Args: + issue_id: Issue to cancel + reason: Reason for cancellation + wallet: Validator wallet for signing + + Returns: + True if vote succeeded + """ + try: + reason_hash = hashlib.sha256(reason.encode()).digest() + bt.logging.info(f'Voting cancel for issue {issue_id}: {reason}') + + keypair = wallet.hotkey + tx_hash = self._exec_contract_raw( + method_name='vote_cancel_issue', + args={ + 'issue_id': issue_id, + 'reason_hash': reason_hash, + }, + keypair=keypair, + ) + + if tx_hash: + bt.logging.info(f'Vote cancel issue succeeded: {tx_hash}') + return True + else: + bt.logging.error('Vote cancel issue failed') + return False + + except Exception as e: + bt.logging.error(f'Error voting cancel issue: {e}') + return False + + # ========================================================================= + # Raw Extrinsic Execution (Ink! 5 Workaround) + # ========================================================================= + + def _exec_contract_raw( + self, + method_name: str, + args: dict, + keypair, + gas_limit: dict = None, + value: int = 0, + ) -> Optional[str]: + """Execute a contract method using raw extrinsic submission.""" + gas_limit = gas_limit or DEFAULT_GAS_LIMIT + + try: + selector = CONTRACT_SELECTORS.get(method_name) + if not selector: + bt.logging.error(f'Method {method_name} not found in CONTRACT_SELECTORS') + return None + + encoded_args = self._encode_args(method_name, args) + call_data = selector + encoded_args + + call = self.subtensor.substrate.compose_call( + call_module='Contracts', + call_function='call', + call_params={ + 'dest': {'Id': self.contract_address}, + 'value': value, + 'gas_limit': gas_limit, + 'storage_deposit_limit': None, + 'data': '0x' + call_data.hex(), + }, + ) + + signer_address = keypair.ss58_address + account_info = self.subtensor.substrate.query('System', 'Account', [signer_address]) + if hasattr(account_info, 'value'): + account_data = account_info.value + else: + account_data = account_info + free_balance = account_data.get('data', {}).get('free', 0) + if free_balance < 100_000_000: + bt.logging.error(f'{method_name}: insufficient balance for fees') + return None + + extrinsic = self.subtensor.substrate.create_signed_extrinsic( + call=call, + keypair=keypair, + ) + + result = self.subtensor.substrate.submit_extrinsic( + extrinsic, + wait_for_inclusion=True, + wait_for_finalization=False, + ) + + _extrinsic_not_found_types = tuple(t for t in [ExtrinsicNotFound, AsyncExtrinsicNotFound] if t is not None) + try: + if result.is_success: + return result.extrinsic_hash + else: + bt.logging.error(f'{method_name} failed: {result.error_message}') + return None + except _extrinsic_not_found_types: + return result.extrinsic_hash + + except Exception as e: + bt.logging.error(f'{method_name} error: {e}') + return None + + def _encode_args(self, method_name: str, args: dict) -> bytes: + """SCALE-encode method arguments using hardcoded type definitions.""" + arg_types = CONTRACT_ARG_TYPES.get(method_name, []) + encoded = b'' + + for arg_name, type_def in arg_types: + if arg_name not in args: + raise ValueError(f'Missing argument: {arg_name}') + + value = args[arg_name] + + if type_def == 'u32': + encoded += struct.pack('> 64) + elif type_def == 'AccountId': + if isinstance(value, str): + encoded += bytes.fromhex(self.subtensor.substrate.ss58_decode(value)) + elif isinstance(value, (list, bytes)): + encoded += bytes(value) if isinstance(value, list) else value + else: + raise ValueError(f'Unknown AccountId format: {type(value)}') + elif type_def == 'array32': + if isinstance(value, bytes): + if len(value) != 32: + raise ValueError('Array must be 32 bytes') + encoded += value + elif isinstance(value, list): + if len(value) != 32: + raise ValueError('Array must be 32 bytes') + encoded += bytes(value) + else: + raise ValueError(f'Unknown array format: {type(value)}') + else: + raise ValueError(f'Unsupported type: {type_def} for arg {arg_name}') + + return encoded + + # ========================================================================= + # Emission Harvesting Functions + # ========================================================================= + + def get_treasury_stake(self) -> int: + """ + Query total stake on treasury hotkey owned by the contract. + + NOTE: Chain extensions don't work in dry-run mode (state_call), so we + query the Subtensor Alpha storage directly instead of using the + contract's get_treasury_stake() method. + + Returns: + Total stake amount (0 if no stake found) + """ + try: + # Read contract's packed storage to get treasury_hotkey, owner, and netuid + child_key = self._get_child_storage_key() + if not child_key: + bt.logging.debug('Cannot get treasury stake: no child storage key') + return 0 + + # Get packed storage key (ends with 00000000) + keys_result = self.subtensor.substrate.rpc_request( + 'childstate_getKeysPaged', [child_key, '0x', 10, None, None] + ) + keys = keys_result.get('result', []) + packed_key = next((k for k in keys if k.endswith('00000000')), None) + if not packed_key: + bt.logging.debug('Cannot get treasury stake: no packed storage key') + return 0 + + # Read packed storage + val_result = self.subtensor.substrate.rpc_request('childstate_getStorage', [child_key, packed_key, None]) + if not val_result.get('result'): + bt.logging.debug('Cannot get treasury stake: no packed storage value') + return 0 + + data = bytes.fromhex(val_result['result'].replace('0x', '')) + if len(data) < 74: # Need at least owner(32) + treasury(32) + netuid(2) + next_id(8) + bt.logging.debug('Cannot get treasury stake: packed storage too small') + return 0 + + # Extract owner (coldkey), treasury_hotkey, and netuid from packed storage + # Layout: owner(32) + treasury(32) + netuid(2) + next_issue_id(8) + alpha_pool(16) + owner = data[0:32] + treasury_hotkey = data[32:64] + netuid = struct.unpack_from(' U64F64 stake amount + alpha_result = self.subtensor.substrate.query( + 'SubtensorModule', 'Alpha', [treasury_ss58, owner_ss58, netuid] + ) + + if not alpha_result: + bt.logging.debug('No Alpha stake found') + return 0 + + # Alpha returns U64F64 fixed-point: bits field contains raw value + # Upper 64 bits are integer part (the stake amount in raw units) + if hasattr(alpha_result, 'value') and alpha_result.value: + bits = alpha_result.value.get('bits', 0) + elif isinstance(alpha_result, dict): + bits = alpha_result.get('bits', 0) + else: + bits = 0 + + # Extract integer part (upper 64 bits of U64F64) + stake_raw = bits >> 64 if bits else 0 + + bt.logging.debug(f'Treasury stake (direct query): {stake_raw} ({stake_raw / 1e9:.4f} α)') + return stake_raw + + except Exception as e: + bt.logging.error(f'Error fetching treasury stake: {e}') + return 0 + + def get_last_harvest_block(self) -> int: + """Query the block number of the last harvest.""" + try: + value = self._read_contract_u32('get_last_harvest_block') + return value + except Exception as e: + bt.logging.error(f'Error fetching last harvest block: {e}') + return 0 + + def harvest_emissions(self, wallet: bt.Wallet) -> Optional[dict]: + """Harvest emissions from the treasury hotkey and distribute to bounties.""" + try: + keypair = wallet.hotkey + tx_hash = self._exec_contract_raw( + method_name='harvest_emissions', + args={}, + keypair=keypair, + gas_limit=DEFAULT_GAS_LIMIT, + ) + + if tx_hash: + return {'status': 'success', 'tx_hash': tx_hash} + else: + return {'status': 'failed', 'error': 'Transaction failed'} + + except Exception as e: + bt.logging.error(f'Harvest error: {e}') + return {'status': 'error', 'error': str(e)} + + def payout_bounty( + self, + issue_id: int, + wallet: bt.Wallet, + ) -> Optional[int]: + """Pay out a completed bounty to the solver. + + The solver address is determined by validator consensus and stored + in the contract - no need to pass it here. + + Args: + issue_id: The ID of the completed issue + wallet: Owner wallet for signing (uses coldkey) + + Returns: + Payout amount in raw units, or None on failure + """ + try: + issue = self.get_issue(issue_id) + expected_payout = issue.bounty_amount if issue else None + + bt.logging.info(f'Paying out bounty for issue {issue_id}') + + keypair = wallet.coldkey + tx_hash = self._exec_contract_raw( + method_name='payout_bounty', + args={ + 'issue_id': issue_id, + }, + keypair=keypair, + gas_limit=DEFAULT_GAS_LIMIT, + ) + + if tx_hash: + return int(expected_payout) if expected_payout else 0 + else: + return None + + except Exception as e: + bt.logging.error(f'Error paying out bounty: {e}') + return None + + def cancel_issue( + self, + issue_id: int, + wallet: bt.Wallet, + ) -> bool: + """Cancel an issue (owner only). + + Args: + issue_id: The ID of the issue to cancel + wallet: Owner wallet for signing (uses coldkey) + + Returns: + True if cancellation succeeded + """ + try: + bt.logging.info(f'Cancelling issue {issue_id}') + + keypair = wallet.coldkey + tx_hash = self._exec_contract_raw( + method_name='cancel_issue', + args={ + 'issue_id': issue_id, + }, + keypair=keypair, + gas_limit=DEFAULT_GAS_LIMIT, + ) + + if tx_hash: + bt.logging.info(f'Issue {issue_id} cancelled: {tx_hash}') + return True + else: + bt.logging.error(f'Failed to cancel issue {issue_id}') + return False + + except Exception as e: + bt.logging.error(f'Error cancelling issue: {e}') + return False + + def set_owner( + self, + new_owner: str, + wallet: bt.Wallet, + ) -> bool: + """Transfer contract ownership (owner only). + + WARNING: This operation is irreversible. The current owner will + lose all admin privileges. + + Args: + new_owner: SS58 address of the new owner + wallet: Current owner wallet for signing (uses coldkey) + + Returns: + True if ownership transfer succeeded + """ + try: + bt.logging.info(f'Transferring ownership to {new_owner}') + + keypair = wallet.coldkey + tx_hash = self._exec_contract_raw( + method_name='set_owner', + args={ + 'new_owner': new_owner, + }, + keypair=keypair, + gas_limit=DEFAULT_GAS_LIMIT, + ) + + if tx_hash: + bt.logging.info(f'Ownership transferred: {tx_hash}') + return True + else: + bt.logging.error('Failed to transfer ownership') + return False + + except Exception as e: + bt.logging.error(f'Error transferring ownership: {e}') + return False + + def add_validator( + self, + hotkey: str, + wallet: bt.Wallet, + ) -> bool: + """Add a validator hotkey to the whitelist (owner only). + + Args: + hotkey: SS58 address of the validator hotkey to whitelist + wallet: Owner wallet for signing (uses coldkey) + + Returns: + True if addition succeeded + """ + try: + bt.logging.info(f'Adding validator {hotkey}') + + keypair = wallet.coldkey + tx_hash = self._exec_contract_raw( + method_name='add_validator', + args={ + 'hotkey': hotkey, + }, + keypair=keypair, + gas_limit=DEFAULT_GAS_LIMIT, + ) + + if tx_hash: + bt.logging.info(f'Validator added: {tx_hash}') + return True + else: + bt.logging.error('Failed to add validator') + return False + + except Exception as e: + bt.logging.error(f'Error adding validator: {e}') + return False + + def remove_validator( + self, + hotkey: str, + wallet: bt.Wallet, + ) -> bool: + """Remove a validator hotkey from the whitelist (owner only). + + Args: + hotkey: SS58 address of the validator hotkey to remove + wallet: Owner wallet for signing (uses coldkey) + + Returns: + True if removal succeeded + """ + try: + bt.logging.info(f'Removing validator {hotkey}') + + keypair = wallet.coldkey + tx_hash = self._exec_contract_raw( + method_name='remove_validator', + args={ + 'hotkey': hotkey, + }, + keypair=keypair, + gas_limit=DEFAULT_GAS_LIMIT, + ) + + if tx_hash: + bt.logging.info(f'Validator removed: {tx_hash}') + return True + else: + bt.logging.error('Failed to remove validator') + return False + + except Exception as e: + bt.logging.error(f'Error removing validator: {e}') + return False + + def get_validators(self) -> List[str]: + """Query the list of whitelisted validator hotkeys. + + Returns: + List of SS58 addresses, or empty list on error. + """ + try: + response = self._raw_contract_read('get_validators') + if response is None: + return [] + + return self._decode_validator_list(response) + except Exception as e: + bt.logging.error(f'Error fetching validators: {e}') + return [] + + def _decode_validator_list(self, response_bytes: bytes) -> List[str]: + """Decode a SCALE-encoded Vec from clean return bytes. + + The bytes are the raw SCALE encoding: compact-length followed by + 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 + + return validators + + except Exception as e: + bt.logging.error(f'Error decoding validator list: {e}') + return [] + + def set_treasury_hotkey( + self, + new_hotkey: str, + wallet: bt.Wallet, + ) -> bool: + """Change the treasury hotkey (owner only). + + Args: + new_hotkey: SS58 address of the new treasury hotkey + wallet: Owner wallet for signing (uses coldkey) + + Returns: + True if treasury hotkey change succeeded + """ + try: + bt.logging.info(f'Setting treasury hotkey to {new_hotkey}') + + keypair = wallet.coldkey + tx_hash = self._exec_contract_raw( + method_name='set_treasury_hotkey', + args={ + 'new_hotkey': new_hotkey, + }, + keypair=keypair, + gas_limit=DEFAULT_GAS_LIMIT, + ) + + if tx_hash: + bt.logging.info(f'Treasury hotkey updated: {tx_hash}') + return True + else: + bt.logging.error('Failed to set treasury hotkey') + return False + + except Exception as e: + bt.logging.error(f'Error setting treasury hotkey: {e}') + return False diff --git a/gittensor/validator/issue_competitions/metadata.json b/gittensor/validator/issue_competitions/metadata.json new file mode 100644 index 00000000..efdef26f --- /dev/null +++ b/gittensor/validator/issue_competitions/metadata.json @@ -0,0 +1,121 @@ +{ + "selectors": { + "register_issue": "5c056a24", + "cancel_issue": "5d6026a5", + "add_validator": "82f48fa6", + "remove_validator": "62135acd", + "get_validators": "a28acf8e", + "vote_solution": "656be730", + "vote_cancel_issue": "e4bcd2ad", + "set_owner": "367facd6", + "set_treasury_hotkey": "a8abaa18", + "get_treasury_stake": "7bb7429c", + "get_last_harvest_block": "99eee47d", + "harvest_emissions": "44237deb", + "payout_bounty": "d38906bc", + "get_alpha_pool": "9b84c72a", + "get_issue": "f56df897", + "get_issues_by_status": "e4870d63" + }, + "arg_types": { + "register_issue": [ + [ + "github_url", + "str" + ], + [ + "repository_full_name", + "str" + ], + [ + "issue_number", + "u32" + ], + [ + "target_bounty", + "u128" + ] + ], + "cancel_issue": [ + [ + "issue_id", + "u64" + ] + ], + "add_validator": [ + [ + "hotkey", + "AccountId" + ] + ], + "remove_validator": [ + [ + "hotkey", + "AccountId" + ] + ], + "get_validators": [], + "vote_solution": [ + [ + "issue_id", + "u64" + ], + [ + "solver_hotkey", + "AccountId" + ], + [ + "solver_coldkey", + "AccountId" + ], + [ + "pr_number", + "u32" + ] + ], + "vote_cancel_issue": [ + [ + "issue_id", + "u64" + ], + [ + "reason_hash", + "array32" + ] + ], + "set_owner": [ + [ + "new_owner", + "AccountId" + ] + ], + "set_treasury_hotkey": [ + [ + "new_hotkey", + "AccountId" + ] + ], + "get_treasury_stake": [], + "get_last_harvest_block": [], + "harvest_emissions": [], + "payout_bounty": [ + [ + "issue_id", + "u64" + ] + ], + "get_alpha_pool": [], + "get_issue": [ + [ + "issue_id", + "u64" + ] + ], + "get_issues_by_status": [ + [ + "status", + "unknown" + ] + ] + } +} diff --git a/gittensor/validator/issue_competitions/update_metadata.py b/gittensor/validator/issue_competitions/update_metadata.py new file mode 100755 index 00000000..a2441a3a --- /dev/null +++ b/gittensor/validator/issue_competitions/update_metadata.py @@ -0,0 +1,103 @@ +""" +Update metadata.json from compiled contract. + +Run after: cargo contract build +Usage: python update_metadata.py +""" + +import json +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +REPO_ROOT = SCRIPT_DIR.parent.parent.parent +CONTRACT_FILE = REPO_ROOT / 'smart-contracts' / 'issues-v0' / 'target' / 'ink' / 'issue_bounty_manager.contract' +METADATA_FILE = SCRIPT_DIR / 'metadata.json' + +# Methods we use in the validator/CLI +METHODS_WE_USE = [ + 'register_issue', + 'cancel_issue', + 'vote_solution', + 'vote_cancel_issue', + 'set_owner', + 'set_treasury_hotkey', + 'get_treasury_stake', + 'get_last_harvest_block', + 'harvest_emissions', + 'payout_bounty', + 'get_alpha_pool', + 'get_issue', + 'get_issues_by_status', + 'add_validator', + 'remove_validator', + 'get_validators', +] + + +def get_type_string(type_id: int, types: list) -> str: + """Convert type ID to string representation.""" + for t in types: + if t.get('id') == type_id: + type_def = t.get('type', {}).get('def', {}) + path = t.get('type', {}).get('path', []) + + if 'primitive' in type_def: + return type_def['primitive'] + if 'array' in type_def: + if type_def['array'].get('len') == 32: + return 'array32' + return 'array' + if 'composite' in type_def: + if path and 'AccountId' in path[-1]: + return 'AccountId' + return 'unknown' + + +def main(): + if not CONTRACT_FILE.exists(): + print(f'Error: {CONTRACT_FILE} not found') + print("Run 'cargo contract build' first") + return 1 + + with open(CONTRACT_FILE) as f: + contract = json.load(f) + + types = contract.get('types', []) + messages = contract.get('spec', {}).get('messages', []) + + # Extract selectors + selectors = {} + for msg in messages: + name = msg['label'] + if name in METHODS_WE_USE: + selectors[name] = msg['selector'].replace('0x', '') + + # Extract arg types + arg_types = {} + for msg in messages: + name = msg['label'] + if name in METHODS_WE_USE: + args = [] + for arg in msg.get('args', []): + arg_name = arg['label'] + arg_type = get_type_string(arg['type']['type'], types) + args.append([arg_name, arg_type]) + arg_types[name] = args + + # Write metadata.json + metadata = { + 'selectors': selectors, + 'arg_types': arg_types, + } + + with open(METADATA_FILE, 'w') as f: + json.dump(metadata, f, indent=2) + f.write('\n') + + print(f'Updated {METADATA_FILE}') + print(f' {len(selectors)} selectors') + print(f' {len(arg_types)} arg type mappings') + + +if __name__ == '__main__': + exit(main() or 0) diff --git a/gittensor/validator/utils/config.py b/gittensor/validator/utils/config.py index f4377a18..39d11f75 100644 --- a/gittensor/validator/utils/config.py +++ b/gittensor/validator/utils/config.py @@ -4,9 +4,9 @@ VALIDATOR_WAIT = 60 # 60 seconds VALIDATOR_STEPS_INTERVAL = 120 # 2 hours, every time a scoring round happens -PR_LOOKBACK_DAYS = 90 # how many days a merged pr will count for scoring # required env vars +GITTENSOR_VALIDATOR_PAT = os.getenv('GITTENSOR_VALIDATOR_PAT') WANDB_API_KEY = os.getenv('WANDB_API_KEY') WANDB_PROJECT = os.getenv('WANDB_PROJECT', 'gittensor-validators') WANDB_VALIDATOR_NAME = os.getenv('WANDB_VALIDATOR_NAME', 'vali') @@ -17,5 +17,4 @@ # log values bt.logging.info(f'VALIDATOR_WAIT: {VALIDATOR_WAIT}') bt.logging.info(f'VALIDATOR_STEPS_INTERVAL: {VALIDATOR_STEPS_INTERVAL}') -bt.logging.info(f'PR_LOOKBACK_DAYS: {PR_LOOKBACK_DAYS}') bt.logging.info(f'WANDB_PROJECT: {WANDB_PROJECT}') diff --git a/gittensor/validator/utils/issue_competitions.py b/gittensor/validator/utils/issue_competitions.py new file mode 100644 index 00000000..09c58c5e --- /dev/null +++ b/gittensor/validator/utils/issue_competitions.py @@ -0,0 +1,42 @@ +# The MIT License (MIT) +# Copyright 2025 Entrius + +"""Utility functions for Issue Bounties sub-mechanism.""" + +import os +from typing import Optional + +import bittensor as bt + +from gittensor.constants import CONTRACT_ADDRESS + + +def get_contract_address() -> Optional[str]: + """ + Get contract address. Override via CONTRACT_ADDRESS env var for dev/testing. + + Returns: + Contract address string (env var override or constants.py default) + """ + return os.environ.get('CONTRACT_ADDRESS') or CONTRACT_ADDRESS + + +def get_miner_coldkey(hotkey: str, subtensor: bt.Subtensor, netuid: int) -> Optional[str]: + """ + Get the coldkey for a miner's hotkey. + + Args: + hotkey: Miner's hotkey address + subtensor: Bittensor subtensor instance + netuid: Network UID + + Returns: + Coldkey address or None + """ + try: + result = subtensor.get_hotkey_owner(hotkey) + if result: + return str(result) + except Exception as e: + bt.logging.debug(f'Error getting coldkey for {hotkey}: {e}') + return None diff --git a/neurons/base/miner.py b/neurons/base/miner.py index 5a40558b..31cf3075 100644 --- a/neurons/base/miner.py +++ b/neurons/base/miner.py @@ -53,7 +53,7 @@ def __init__(self, config=None): 'You are allowing non-registered entities to send requests to your miner. This is a security risk.' ) # The axon handles request processing, allowing validators to send this miner requests. - self.axon = bt.axon( + self.axon = bt.Axon( wallet=self.wallet, config=self.config() if callable(self.config) else self.config, ) diff --git a/neurons/base/neuron.py b/neurons/base/neuron.py index dd94a5cb..3ba39240 100644 --- a/neurons/base/neuron.py +++ b/neurons/base/neuron.py @@ -85,8 +85,8 @@ def __init__(self, config=None): self.subtensor = MockSubtensor(self.config.netuid, wallet=self.wallet) self.metagraph = MockMetagraph(self.config.netuid, subtensor=self.subtensor) else: - self.wallet = bt.wallet(config=self.config) - self.subtensor = bt.subtensor(config=self.config) + self.wallet = bt.Wallet(config=self.config) + self.subtensor = bt.Subtensor(config=self.config) self.metagraph = self.subtensor.metagraph(self.config.netuid) bt.logging.info(f'Wallet: {self.wallet}') @@ -108,7 +108,7 @@ def _reconnect_subtensor(self): if self.config.mock: return # Don't reconnect in mock mode bt.logging.info('Reconnecting subtensor...') - self.subtensor = bt.subtensor(config=self.config) + self.subtensor = bt.Subtensor(config=self.config) @abstractmethod async def forward(self, synapse: bt.Synapse) -> bt.Synapse: ... diff --git a/neurons/base/validator.py b/neurons/base/validator.py index 76df23e8..d7faba0e 100644 --- a/neurons/base/validator.py +++ b/neurons/base/validator.py @@ -57,7 +57,7 @@ def __init__(self, config=None): if self.config.mock: self.dendrite = MockDendrite(wallet=self.wallet) else: - self.dendrite = bt.dendrite(wallet=self.wallet) + self.dendrite = bt.Dendrite(wallet=self.wallet) bt.logging.info(f'Dendrite: {self.dendrite}') # Set up initial scoring weights for validation @@ -87,7 +87,7 @@ def serve_axon(self): bt.logging.info('serving ip to chain...') try: - self.axon = bt.axon(wallet=self.wallet, config=self.config) + self.axon = bt.Axon(wallet=self.wallet, config=self.config) try: self.subtensor.serve_axon( diff --git a/neurons/miner.py b/neurons/miner.py index f08effa2..c9d36b32 100644 --- a/neurons/miner.py +++ b/neurons/miner.py @@ -88,9 +88,14 @@ async def priority(self, synapse: GitPatSynapse) -> float: if __name__ == '__main__': with Miner() as miner: + # load token on startup just to check if it's valid if not then exit + if not token_mgmt.load_token(): + exit(1) + bt.logging.info( 'Repeating an action makes a habit. Your habits create your character. And your character is your destiny.' ) + while True: bt.logging.info('Gittensor miner running...') - time.sleep(30) + time.sleep(45) diff --git a/requirements.txt b/requirements.txt index ab09383a..a94c26db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -bittensor==9.9.0 -bittensor-cli==9.10.1 +bittensor==10.0.1 +bittensor-cli==9.17.0 bittensor-commit-reveal==0.4.0 bittensor-wallet==4.0.0 levenshtein==0.27.3 @@ -18,3 +18,4 @@ debugpy==1.8.11 pytz==2025.2 psycopg2-binary==2.9.10 python-dotenv==1.2.1 +substrate-interface \ No newline at end of file diff --git a/scripts/vali-entrypoint.sh b/scripts/vali-entrypoint.sh index 783503cb..fd378b49 100755 --- a/scripts/vali-entrypoint.sh +++ b/scripts/vali-entrypoint.sh @@ -6,6 +6,7 @@ if [ -z "$HOTKEY_NAME" ]; then echo "HOTKEY_NAME is not set" && exit 1; fi if [ -z "$SUBTENSOR_NETWORK" ]; then echo "SUBTENSOR_NETWORK is not set" && exit 1; fi if [ -z "$PORT" ]; then echo "PORT is not set" && exit 1; fi if [ -z "$LOG_LEVEL" ]; then echo "LOG_LEVEL is not set" && exit 1; fi +# if [ -z "$GITTENSOR_VALIDATOR_PAT" ]; then echo "GITTENSOR_VALIDATOR_PAT is not set" && exit 1; fi exec python neurons/validator.py \ --netuid ${NETUID} \ diff --git a/setup.py b/setup.py index 3e46d044..95f38a66 100644 --- a/setup.py +++ b/setup.py @@ -70,6 +70,11 @@ def read_requirements(path): license='MIT', python_requires='>=3.8', install_requires=requirements, + entry_points={ + 'console_scripts': [ + 'gitt=gittensor.cli.main:main', + ], + }, classifiers=[ 'Development Status :: 3 - Alpha', 'Intended Audience :: Developers', diff --git a/smart-contracts/issues-v0/Cargo.toml b/smart-contracts/issues-v0/Cargo.toml new file mode 100644 index 00000000..e77ff750 --- /dev/null +++ b/smart-contracts/issues-v0/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "issue_bounty_manager" +version = "0.1.0" +authors = ["Gittensor Team"] +edition = "2021" + +[dependencies] +ink = { version = "5", default-features = false } +scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive"] } +scale-info = { version = "2", default-features = false, features = ["derive"], optional = true } + +[lib] +path = "lib.rs" + +[features] +default = ["std"] +std = [ + "ink/std", + "scale/std", + "scale-info/std", +] +ink-as-dependency = [] diff --git a/smart-contracts/issues-v0/errors.rs b/smart-contracts/issues-v0/errors.rs new file mode 100644 index 00000000..bae1d817 --- /dev/null +++ b/smart-contracts/issues-v0/errors.rs @@ -0,0 +1,49 @@ +use scale::{Decode, Encode}; + +/// Errors that can occur in the IssueBountyManager contract +#[derive(Debug, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub enum Error { + /// Caller is not the contract owner + NotOwner, + /// Issue with the given ID does not exist + IssueNotFound, + /// Issue with the same URL already exists + IssueAlreadyExists, + /// Bounty amount is below minimum (10 ALPHA) + BountyTooLow, + /// Issue cannot be cancelled in its current state + CannotCancel, + /// Repository name is invalid (must be "owner/repo" format) + InvalidRepositoryName, + /// Issue number must be greater than zero + InvalidIssueNumber, + /// Issue is not in Active status + IssueNotActive, + /// Solver is not a valid miner (bronze+ tier required) + InvalidSolver, + /// Caller has already voted on this proposal + AlreadyVoted, + /// Caller is not a whitelisted validator + NotWhitelistedValidator, + /// Bounty has not been completed yet + BountyNotCompleted, + /// Bounty has no funds allocated + BountyNotFunded, + /// Stake transfer operation failed + TransferFailed, + /// Chain extension call failed + ChainExtensionFailed, + /// Recycling emissions failed during harvest + RecyclingFailed, + /// Issue has already been finalized (Completed or Cancelled) + IssueAlreadyFinalized, + /// No solver was set on the completed issue (should not happen) + NoSolverSet, + /// Bounty has already been paid out + BountyAlreadyPaid, + /// Validator already included as a voter + ValidatorAlreadyWhitelisted, + // Validator doesn't exist in whitelist + ValidatorNotWhitelisted, +} diff --git a/smart-contracts/issues-v0/events.rs b/smart-contracts/issues-v0/events.rs new file mode 100644 index 00000000..ba03da5b --- /dev/null +++ b/smart-contracts/issues-v0/events.rs @@ -0,0 +1,102 @@ +use ink::prelude::string::String; +use ink::primitives::AccountId; + +/// Event emitted when a new issue is registered +#[ink::event] +pub struct IssueRegistered { + #[ink(topic)] + pub issue_id: u64, + pub github_url_hash: [u8; 32], + pub repository_full_name: String, + pub issue_number: u32, + pub target_bounty: u128, +} + +/// Event emitted when an issue is cancelled +#[ink::event] +pub struct IssueCancelled { + #[ink(topic)] + pub issue_id: u64, + pub returned_bounty: u128, +} + +/// Event emitted when emissions are harvested +#[ink::event] +pub struct EmissionsHarvested { + #[ink(topic)] + pub amount: u128, + pub bounties_filled: u32, + pub recycled: u128, +} + +/// Event emitted when a bounty is filled from emissions +#[ink::event] +pub struct BountyFilled { + #[ink(topic)] + pub issue_id: u64, + pub amount: u128, +} + +/// Event emitted when excess emissions are recycled (destroyed via recycle_alpha) +/// True recycling: tokens are destroyed and SubnetAlphaOut is reduced +#[ink::event] +pub struct EmissionsRecycled { + pub amount: u128, + /// The hotkey from which tokens were recycled (source, not destination) + #[ink(topic)] + pub destination: AccountId, +} + +/// Event emitted when a bounty is paid out to a solver +#[ink::event] +pub struct BountyPaidOut { + #[ink(topic)] + pub issue_id: u64, + #[ink(topic)] + pub miner: AccountId, + pub amount: u128, +} + +/// Event emitted when harvest fails due to recycling error +#[ink::event] +pub struct HarvestFailed { + /// Error code from transfer_stake chain extension + #[ink(topic)] + pub reason: u8, + /// Amount that failed to recycle + pub amount: u128, +} + +/// Event emitted when recycling fails (amount kept in alpha_pool for retry) +#[ink::event] +pub struct RecycleFailed { + #[ink(topic)] + pub amount: u128, +} + +/// Event emitted when treasury hotkey is changed +#[ink::event] +pub struct TreasuryHotkeyChanged { + #[ink(topic)] + pub old_hotkey: AccountId, + #[ink(topic)] + pub new_hotkey: AccountId, + /// Total bounty amount that was reset across all issues + pub bounties_reset: u128, + /// Number of issues affected + pub issues_affected: u32, +} + +/// Event emitted when a new validator is added to the whitelist for voting +#[ink::event] +pub struct ValidatorAdded { + #[ink(topic)] + pub hotkey: AccountId, +} + +/// Event emitted when a validator is removed from the whitelist for voting +#[ink::event] +pub struct ValidatorRemoved { + #[ink(topic)] + pub hotkey: AccountId, +} diff --git a/smart-contracts/issues-v0/lib.rs b/smart-contracts/issues-v0/lib.rs new file mode 100644 index 00000000..fb62497b --- /dev/null +++ b/smart-contracts/issues-v0/lib.rs @@ -0,0 +1,1001 @@ +#![cfg_attr(not(feature = "std"), no_std, no_main)] + +mod errors; +mod events; +mod runtime_calls; +mod types; + +pub use errors::Error; +pub use runtime_calls::RawCall; +pub use types::*; + +// ============================================================================ +// Chain Extension for Subtensor Staking Operations +// ============================================================================ + +/// Subtensor chain extension for staking operations. +/// These functions allow the contract to interact with the Subtensor runtime +/// for querying and transferring stake. +/// +/// Note: All functions use `handle_status = false` which means they return +/// raw values without automatic error handling from status codes. The caller +/// is responsible for interpreting the return values. +/// +/// IMPORTANT: Function 0 returns Option, which ink! decodes automatically. +/// The StakeInfo struct in types.rs must match subtensor's StakeInfo exactly. +#[ink::chain_extension(extension = 5001)] +pub trait SubtensorExtension { + type ErrorCode = (); + + /// Query stake info for hotkey/coldkey/netuid. + /// Returns Option - None if no stake exists, Some(info) with stake details. + /// ink! handles SCALE decoding automatically. + #[ink(function = 0, handle_status = false)] + fn get_stake_info(hotkey: [u8; 32], coldkey: [u8; 32], netuid: u16) + -> Option; + + /// Transfer stake ownership to a different coldkey. + /// Amount is in AlphaCurrency (u64), NOT u128! + /// Returns 0 on success, non-zero error code on failure. + #[ink(function = 6, handle_status = false)] + fn transfer_stake( + destination_coldkey: [u8; 32], + hotkey: [u8; 32], + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + ) -> u32; +} + +/// Custom environment with Subtensor chain extension. +#[derive(Debug, Clone, PartialEq, Eq)] +#[ink::scale_derive(TypeInfo)] +pub enum CustomEnvironment {} + +impl ink::env::Environment for CustomEnvironment { + const MAX_EVENT_TOPICS: usize = 4; + type AccountId = ink::primitives::AccountId; + type Balance = u128; + type Hash = ink::primitives::Hash; + type Timestamp = u64; + type BlockNumber = u32; + type ChainExtension = SubtensorExtension; +} + +#[ink::contract(env = crate::CustomEnvironment)] +mod issue_bounty_manager { + use crate::events::*; + use crate::runtime_calls::RawCall; + use crate::types::*; + use crate::Error; + use ink::prelude::string::String; + use ink::prelude::vec::Vec; + use ink::storage::Mapping; + + // ======================================================================== + // Constants + // ======================================================================== + + /// Minimum bounty amount: 10 ALPHA (9 decimals) + pub const MIN_BOUNTY: u128 = 10_000_000_000; + + // ======================================================================== + // Contract Storage + // ======================================================================== + + #[ink(storage)] + pub struct IssueBountyManager { + /// Contract owner with administrative privileges + owner: AccountId, + /// Treasury hotkey for staking operations and bounty payouts + treasury_hotkey: AccountId, + /// Subnet ID for this contract + netuid: u16, + /// Counter for generating unique issue IDs + next_issue_id: u64, + /// Unallocated emissions storage (alpha pool) + alpha_pool: Balance, + + /// Mapping from issue ID to Issue struct + issues: Mapping, + /// Mapping from URL hash to issue ID for deduplication + url_hash_to_id: Mapping<[u8; 32], u64>, + /// FIFO queue of issue IDs awaiting bounty fill + bounty_queue: Vec, + + validators: Vec, + + // Solution votes (vote on issues directly) + solution_votes: Mapping, + solution_vote_voters: Mapping<(u64, AccountId), bool>, + + // Issue cancel votes (validators can cancel issues at any stage) + cancel_issue_votes: Mapping, + cancel_issue_voters: Mapping<(u64, AccountId), bool>, + + // Emission management + /// Block number of last harvest + last_harvest_block: u32, + } + + impl IssueBountyManager { + // ======================================================================== + // Constructor + // ======================================================================== + + /// Creates a new IssueBountyManager contract + #[ink(constructor)] + pub fn new(owner: AccountId, treasury_hotkey: AccountId, netuid: u16) -> Self { + Self { + owner, + treasury_hotkey, + netuid, + next_issue_id: 1, + alpha_pool: 0, + issues: Mapping::default(), + url_hash_to_id: Mapping::default(), + bounty_queue: Vec::new(), + validators: Vec::new(), + solution_votes: Mapping::default(), + solution_vote_voters: Mapping::default(), + cancel_issue_votes: Mapping::default(), + cancel_issue_voters: Mapping::default(), + last_harvest_block: 0, + } + } + + // ======================================================================== + // Issue Registry Functions + // ======================================================================== + + /// Registers a new GitHub issue for bounty + #[ink(message)] + pub fn register_issue( + &mut self, + github_url: String, + repository_full_name: String, + issue_number: u32, + target_bounty: u128, + ) -> Result { + if self.env().caller() != self.owner { + return Err(Error::NotOwner); + } + + if target_bounty < MIN_BOUNTY { + return Err(Error::BountyTooLow); + } + if issue_number == 0 { + return Err(Error::InvalidIssueNumber); + } + if !self.is_valid_repo_name(&repository_full_name) { + return Err(Error::InvalidRepositoryName); + } + + let url_hash = self.hash_string(&github_url); + + if self.url_hash_to_id.get(url_hash).is_some() { + return Err(Error::IssueAlreadyExists); + } + + let current_block = self.env().block_number(); + let issue_id = self.next_issue_id; + self.next_issue_id = self.next_issue_id.saturating_add(1); + + let new_issue = Issue { + id: issue_id, + github_url_hash: url_hash, + repository_full_name: repository_full_name.clone(), + issue_number, + bounty_amount: 0, + target_bounty, + status: IssueStatus::Registered, + registered_at_block: current_block, + solver_coldkey: None, + solver_hotkey: None, + winning_pr_number: None, + }; + + self.issues.insert(issue_id, &new_issue); + self.url_hash_to_id.insert(url_hash, &issue_id); + self.bounty_queue.push(issue_id); + + self.env().emit_event(IssueRegistered { + issue_id, + github_url_hash: url_hash, + repository_full_name, + issue_number, + target_bounty, + }); + + Ok(issue_id) + } + + /// Cancels an issue (owner only) + #[ink(message)] + pub fn cancel_issue(&mut self, issue_id: u64) -> Result<(), Error> { + if self.env().caller() != self.owner { + return Err(Error::NotOwner); + } + + let mut issue = self.issues.get(issue_id).ok_or(Error::IssueNotFound)?; + + if !self.is_modifiable(issue.status) { + return Err(Error::CannotCancel); + } + + let returned_bounty = issue.bounty_amount; + self.alpha_pool = self.alpha_pool.saturating_add(returned_bounty); + + issue.status = IssueStatus::Cancelled; + issue.bounty_amount = 0; + self.issues.insert(issue_id, &issue); + + self.remove_from_bounty_queue(issue_id); + + self.env().emit_event(IssueCancelled { + issue_id, + returned_bounty, + }); + + Ok(()) + } + + // ======================================================================== + // Validator Consensus Functions + // ======================================================================== + + fn required_validator_votes(&self) -> u32 { + let n = u32::try_from(self.validators.len()).unwrap_or(u32::MAX); + n.saturating_div(2).saturating_add(1) + } + + #[ink(message)] + pub fn add_validator(&mut self, hotkey: AccountId) -> Result<(), Error> { + if self.env().caller() != self.owner { + return Err(Error::NotOwner); + } + if self.validators.contains(&hotkey) { + return Err(Error::ValidatorAlreadyWhitelisted); + } + self.validators.push(hotkey); + self.env().emit_event(ValidatorAdded { hotkey }); + + Ok(()) + } + + #[ink(message)] + pub fn remove_validator(&mut self, hotkey: AccountId) -> Result<(), Error> { + if self.env().caller() != self.owner { + return Err(Error::NotOwner); + } + let pos = self + .validators + .iter() + .position(|v| v == &hotkey) + .ok_or(Error::ValidatorNotWhitelisted)?; + self.validators.remove(pos); + self.env().emit_event(ValidatorRemoved { hotkey }); + Ok(()) + } + + #[ink(message)] + pub fn get_validators(&self) -> Vec { + self.validators.clone() + } + + /// Votes for a solution on an active issue. + /// + /// When consensus is reached, the issue is completed and bounty paid out. + #[ink(message)] + pub fn vote_solution( + &mut self, + issue_id: u64, + solver_hotkey: AccountId, + solver_coldkey: AccountId, + pr_number: u32, + ) -> Result<(), Error> { + let issue = self.issues.get(issue_id).ok_or(Error::IssueNotFound)?; + + if issue.status != IssueStatus::Active { + return Err(Error::IssueNotActive); + } + + // Check not already voted + self.check_not_voted_solution(issue_id, self.env().caller())?; + let caller = self.validate_whitelisted_caller()?; + + // Get or create vote + let mut vote = self.get_or_create_solution_vote( + issue_id, + solver_hotkey, + pr_number, + solver_coldkey, + ); + self.solution_vote_voters.insert((issue_id, caller), &true); + vote.votes_count = vote.votes_count.saturating_add(1); + self.solution_votes.insert(issue_id, &vote); + + // Check consensus and execute (includes auto-payout) + if self.check_consensus(vote.votes_count) { + self.complete_issue(issue_id, solver_hotkey, pr_number, solver_coldkey); + self.clear_solution_vote(issue_id); + } + + Ok(()) + } + + /// Votes to cancel an issue (e.g., external solution found, issue invalid). + /// + /// Works on issues in Registered or Active state. + #[ink(message)] + pub fn vote_cancel_issue( + &mut self, + issue_id: u64, + reason_hash: [u8; 32], + ) -> Result<(), Error> { + let issue = self.issues.get(issue_id).ok_or(Error::IssueNotFound)?; + + // Can cancel Registered or Active + if matches!( + issue.status, + IssueStatus::Completed | IssueStatus::Cancelled + ) { + return Err(Error::IssueAlreadyFinalized); + } + + // Standard vote validation + self.check_not_voted_cancel_issue(issue_id, self.env().caller())?; + let caller = self.validate_whitelisted_caller()?; + + // Get or create vote, increment count + let mut vote = self.get_or_create_cancel_issue_vote(issue_id, reason_hash); + self.cancel_issue_voters.insert((issue_id, caller), &true); + vote.votes_count = vote.votes_count.saturating_add(1); + self.cancel_issue_votes.insert(issue_id, &vote); + + // Check consensus and execute + if self.check_consensus(vote.votes_count) { + self.execute_cancel_issue(issue_id, reason_hash); + self.clear_cancel_issue_vote(issue_id); + } + + Ok(()) + } + + // ======================================================================== + // Admin Functions + // ======================================================================== + + /// Sets a new owner + #[ink(message)] + pub fn set_owner(&mut self, new_owner: AccountId) -> Result<(), Error> { + if self.env().caller() != self.owner { + return Err(Error::NotOwner); + } + self.owner = new_owner; + Ok(()) + } + + /// Sets a new treasury hotkey. + /// + /// Resets bounty amounts to 0 for all Active/Registered issues since + /// the new treasury has no stake to back them. Issues remain in their + /// current status and will be re-funded on next harvest. + #[ink(message)] + pub fn set_treasury_hotkey(&mut self, new_hotkey: AccountId) -> Result<(), Error> { + if self.env().caller() != self.owner { + return Err(Error::NotOwner); + } + + let old_hotkey = self.treasury_hotkey; + + // Reset bounty amounts for all Active/Registered issues + let mut bounties_reset: u128 = 0; + let mut issues_affected: u32 = 0; + + for issue_id in 1..self.next_issue_id { + if let Some(mut issue) = self.issues.get(issue_id) { + if self.is_modifiable(issue.status) && issue.bounty_amount > 0 { + bounties_reset = bounties_reset.saturating_add(issue.bounty_amount); + issues_affected = issues_affected.saturating_add(1); + issue.bounty_amount = 0; + self.issues.insert(issue_id, &issue); + } + } + } + + // Reset alpha pool + self.alpha_pool = 0; + + // Update treasury hotkey + self.treasury_hotkey = new_hotkey; + + self.env().emit_event(TreasuryHotkeyChanged { + old_hotkey, + new_hotkey, + bounties_reset, + issues_affected, + }); + + Ok(()) + } + + // ======================================================================== + // Emission Harvesting Functions + // ======================================================================== + + /// Query total stake on treasury hotkey owned by owner. + /// Uses chain extension to query Subtensor runtime. + #[ink(message)] + pub fn get_treasury_stake(&self) -> Balance { + let hotkey_bytes: [u8; 32] = *self.treasury_hotkey.as_ref(); + let coldkey_bytes: [u8; 32] = *self.owner.as_ref(); + + let stake_info = + self.env() + .extension() + .get_stake_info(hotkey_bytes, coldkey_bytes, self.netuid); + + match stake_info { + Some(info) => info.stake.0 as u128, + None => 0, + } + } + + /// Returns the block number of the last harvest. + #[ink(message)] + pub fn get_last_harvest_block(&self) -> u32 { + self.last_harvest_block + } + + /// Harvest emissions and distribute to bounties. + /// + /// PERMISSIONLESS - Anyone can call this function. + /// + /// Flow (Ground Truth Accounting): + /// 1. Query current stake on treasury hotkey (via chain extension) + /// 2. Calculate committed funds (sum of bounty_amount for Registered/Active issues) + /// 3. Available = current_stake - committed (ground truth, self-correcting) + /// 4. Fill pending bounties from available funds + /// 5. Recycle any remainder to owner's coldkey + /// 6. Update alpha_pool as read-only cache for UI + #[ink(message)] + pub fn harvest_emissions(&mut self) -> Result { + // Query current total stake via chain extension + let current_stake = self.get_treasury_stake(); + + // Ground truth calculation: available = current_stake - committed + let committed = self.get_total_committed(); + let available = current_stake.saturating_sub(committed); + + if available == 0 { + // Update alpha_pool cache (should be 0 since nothing available) + self.alpha_pool = 0; + return Ok(HarvestResult { + harvested: 0, + bounties_filled: 0, + recycled: 0, + }); + } + + // Set alpha_pool to available funds for bounty filling + self.alpha_pool = available; + + // Fill bounties from available funds (returns list of fully-funded bounties) + let filled_bounties = self.fill_bounties(); + let bounties_filled: u32 = u32::try_from(filled_bounties.len()).unwrap_or(u32::MAX); + + // Emit BountyFilled event for each fully-funded bounty + for (issue_id, amount) in filled_bounties { + self.env().emit_event(BountyFilled { issue_id, amount }); + } + + // Recycle any remaining alpha pool + let to_recycle = self.alpha_pool; + let mut recycled: Balance = 0; + + if to_recycle > 0 { + let amount_u64: u64 = to_recycle.try_into().unwrap_or(u64::MAX); + + let proxy_call = RawCall::proxied_recycle_alpha( + &self.owner, + &self.treasury_hotkey, + amount_u64, + self.netuid, + ); + + let result = self.env().call_runtime(&proxy_call); + + if result.is_ok() { + recycled = to_recycle; + self.alpha_pool = 0; + + self.env().emit_event(EmissionsRecycled { + amount: recycled, + destination: self.treasury_hotkey, + }); + } else { + self.env().emit_event(HarvestFailed { + reason: 255, + amount: to_recycle, + }); + } + } + + self.last_harvest_block = self.env().block_number(); + + self.env().emit_event(EmissionsHarvested { + amount: available, + bounties_filled, + recycled, + }); + + Ok(HarvestResult { + harvested: available, + bounties_filled, + recycled, + }) + } + + /// Manual payout retry for cases where auto-payout failed. + /// Uses solver determined by validator consensus, not caller-specified. + #[ink(message)] + pub fn payout_bounty(&mut self, issue_id: u64) -> Result { + if self.env().caller() != self.owner { + return Err(Error::NotOwner); + } + + let issue = self.issues.get(issue_id).ok_or(Error::IssueNotFound)?; + + if issue.status != IssueStatus::Completed { + return Err(Error::BountyNotCompleted); + } + + if issue.bounty_amount == 0 { + return Err(Error::BountyAlreadyPaid); + } + + let solver_coldkey = issue.solver_coldkey.ok_or(Error::NoSolverSet)?; + let payout = issue.bounty_amount; + + // Attempt payout + let result = self.execute_payout_internal(issue_id, solver_coldkey, payout)?; + + // Zero bounty_amount on success + if let Some(mut issue) = self.issues.get(issue_id) { + issue.bounty_amount = 0; + self.issues.insert(issue_id, &issue); + } + + Ok(result) + } + + // ======================================================================== + // Query Functions + // ======================================================================== + + /// Returns the contract owner + #[ink(message)] + pub fn owner(&self) -> AccountId { + self.owner + } + + /// Returns the treasury hotkey + #[ink(message)] + pub fn treasury_hotkey(&self) -> AccountId { + self.treasury_hotkey + } + + /// Returns the subnet ID + #[ink(message)] + pub fn netuid(&self) -> u16 { + self.netuid + } + + /// Returns the next issue ID + #[ink(message)] + pub fn next_issue_id(&self) -> u64 { + self.next_issue_id + } + + /// Returns the alpha pool balance + #[ink(message)] + pub fn get_alpha_pool(&self) -> Balance { + self.alpha_pool + } + + /// Returns an issue by ID + #[ink(message)] + pub fn get_issue(&self, issue_id: u64) -> Option { + self.issues.get(issue_id) + } + + /// Returns the issue ID for a URL hash + #[ink(message)] + pub fn get_issue_by_url_hash(&self, url_hash: [u8; 32]) -> u64 { + self.url_hash_to_id.get(url_hash).unwrap_or(0) + } + + /// Returns the bounty queue + #[ink(message)] + pub fn get_bounty_queue(&self) -> Vec { + self.bounty_queue.clone() + } + + /// Returns all issues with a given status + #[ink(message)] + pub fn get_issues_by_status(&self, status: IssueStatus) -> Vec { + let mut result = Vec::new(); + let mut issue_id = 1u64; + while issue_id < self.next_issue_id { + if let Some(issue) = self.issues.get(issue_id) { + if issue.status == status { + result.push(issue); + } + } + issue_id = issue_id.saturating_add(1); + } + result + } + + /// Returns all contract configuration in a single call. + #[ink(message)] + pub fn get_config(&self) -> ContractConfig { + ContractConfig { + required_validator_votes: self.required_validator_votes(), + netuid: self.netuid, + } + } + + // ======================================================================== + // Internal Functions + // ======================================================================== + + /// Validates caller is a whitelisted validator, returns caller AccountId. + fn validate_whitelisted_caller(&self) -> Result { + let caller = self.env().caller(); + if !self.validators.contains(&caller) { + return Err(Error::NotWhitelistedValidator); + } + Ok(caller) + } + + /// Checks if caller has already voted for a solution. + fn check_not_voted_solution(&self, issue_id: u64, caller: AccountId) -> Result<(), Error> { + if self + .solution_vote_voters + .get((issue_id, caller)) + .unwrap_or(false) + { + return Err(Error::AlreadyVoted); + } + Ok(()) + } + + /// Checks if caller has already voted to cancel an issue. + fn check_not_voted_cancel_issue( + &self, + issue_id: u64, + caller: AccountId, + ) -> Result<(), Error> { + if self + .cancel_issue_voters + .get((issue_id, caller)) + .unwrap_or(false) + { + return Err(Error::AlreadyVoted); + } + Ok(()) + } + + /// Gets existing solution vote or creates a new one. + fn get_or_create_solution_vote( + &mut self, + issue_id: u64, + solver_hotkey: AccountId, + pr_number: u32, + solver_coldkey: AccountId, + ) -> SolutionVote { + if let Some(vote) = self.solution_votes.get(issue_id) { + vote + } else { + SolutionVote { + issue_id, + solver_hotkey, + solver_coldkey, + pr_number, + votes_count: 0, + } + } + } + + /// Gets existing issue cancel vote or creates a new one. + fn get_or_create_cancel_issue_vote( + &mut self, + issue_id: u64, + reason_hash: [u8; 32], + ) -> CancelVote { + if let Some(vote) = self.cancel_issue_votes.get(issue_id) { + vote + } else { + CancelVote { + issue_id, + reason_hash, + votes_count: 0, + } + } + } + + /// Clears issue cancel vote data + fn clear_cancel_issue_vote(&mut self, issue_id: u64) { + self.cancel_issue_votes.remove(issue_id); + } + + /// Validates repository name format (owner/repo) + fn is_valid_repo_name(&self, name: &str) -> bool { + let bytes = name.as_bytes(); + if bytes.is_empty() { + return false; + } + let mut slash_pos: Option = None; + + for (i, &b) in bytes.iter().enumerate() { + if b == b'/' { + if slash_pos.is_some() || i == 0 { + return false; + } + slash_pos = Some(i); + } + } + + match slash_pos { + Some(pos) => { + let len = bytes.len(); + pos < len.saturating_sub(1) + } + None => false, + } + } + + /// Checks if an issue status allows modification + fn is_modifiable(&self, status: IssueStatus) -> bool { + matches!(status, IssueStatus::Registered | IssueStatus::Active) + } + + /// Hashes a string to [u8; 32] using keccak256 + fn hash_string(&self, s: &str) -> [u8; 32] { + use ink::env::hash::{HashOutput, Keccak256}; + let mut output = ::Type::default(); + ink::env::hash_bytes::(s.as_bytes(), &mut output); + output + } + + /// Fills bounties from the alpha pool using FIFO order. + /// Issues are filled in registration order (first registered = first filled). + /// Returns a list of (issue_id, bounty_amount) for each fully-funded bounty. + fn fill_bounties(&mut self) -> Vec<(u64, Balance)> { + let mut i = 0usize; + let mut filled: Vec<(u64, Balance)> = Vec::new(); + + while i < self.bounty_queue.len() && self.alpha_pool > 0 { + let issue_id = self.bounty_queue[i]; + + if let Some(mut issue) = self.issues.get(issue_id) { + if !self.is_modifiable(issue.status) { + self.remove_at(i); + continue; + } + + let remaining = issue.target_bounty.saturating_sub(issue.bounty_amount); + if remaining == 0 { + self.remove_at(i); + continue; + } + + let fill_amount = if remaining < self.alpha_pool { + remaining + } else { + self.alpha_pool + }; + + issue.bounty_amount = issue.bounty_amount.saturating_add(fill_amount); + self.alpha_pool = self.alpha_pool.saturating_sub(fill_amount); + + let is_fully_funded = issue.bounty_amount >= issue.target_bounty; + + if is_fully_funded { + issue.status = IssueStatus::Active; + self.issues.insert(issue_id, &issue); + filled.push((issue_id, issue.bounty_amount)); + self.remove_at(i); + } else { + self.issues.insert(issue_id, &issue); + i = i.saturating_add(1); + } + } else { + self.remove_at(i); + } + } + + filled + } + + /// Helper to remove from bounty queue at index, preserving FIFO order. + /// Uses Vec::remove which shifts remaining elements left. + fn remove_at(&mut self, idx: usize) { + if idx < self.bounty_queue.len() { + self.bounty_queue.remove(idx); + } + } + + /// Removes an issue from the bounty queue, preserving FIFO order. + fn remove_from_bounty_queue(&mut self, issue_id: u64) { + if let Some(pos) = self.bounty_queue.iter().position(|&id| id == issue_id) { + self.remove_at(pos); + } + } + + /// Calculate total funds committed to issues that still need those funds (ground truth). + /// Sums bounty_amount for Registered/Active issues, plus Completed issues + /// with bounty_amount > 0 (failed payouts awaiting retry via payout_bounty). + fn get_total_committed(&self) -> u128 { + let mut committed = 0u128; + for issue_id in 1..self.next_issue_id { + if let Some(issue) = self.issues.get(issue_id) { + match issue.status { + IssueStatus::Registered | IssueStatus::Active => { + committed = committed.saturating_add(issue.bounty_amount); + } + // Completed issues with bounty_amount > 0 had failed payouts — + // these funds must stay reserved for retry via payout_bounty() + IssueStatus::Completed if issue.bounty_amount > 0 => { + committed = committed.saturating_add(issue.bounty_amount); + } + _ => {} + } + } + } + committed + } + + /// Checks if vote count meets minimum consensus threshold. + fn check_consensus(&self, votes_count: u32) -> bool { + let n = u32::try_from(self.validators.len()).unwrap_or(0); + if n == 0 { + return false; + } + votes_count >= self.required_validator_votes() + } + + /// Completes an issue with a solution and triggers auto-payout + fn complete_issue( + &mut self, + issue_id: u64, + solver_hotkey: AccountId, + pr_number: u32, + solver_coldkey: AccountId, + ) { + if let Some(mut issue) = self.issues.get(issue_id) { + let payout = issue.bounty_amount; + + // Mark issue as completed and store solver info + issue.status = IssueStatus::Completed; + issue.solver_coldkey = Some(solver_coldkey); + issue.solver_hotkey = Some(solver_hotkey); + issue.winning_pr_number = Some(pr_number); + self.issues.insert(issue_id, &issue); + + // Explicitly remove from bounty queue (don't rely on lazy cleanup) + self.remove_from_bounty_queue(issue_id); + + // Attempt payout - only zero bounty_amount on success + // If payout fails, bounty_amount remains non-zero for retry via payout_bounty + if payout > 0 + && self + .execute_payout_internal(issue_id, solver_coldkey, payout) + .is_ok() + { + // Zero bounty_amount only after successful payout + if let Some(mut issue) = self.issues.get(issue_id) { + issue.bounty_amount = 0; + self.issues.insert(issue_id, &issue); + } + } + } + } + + /// Executes issue cancellation + fn execute_cancel_issue(&mut self, issue_id: u64, _reason_hash: [u8; 32]) { + let mut issue = match self.issues.get(issue_id) { + Some(i) => i, + None => return, + }; + + let returned_bounty = issue.bounty_amount; + + self.remove_from_bounty_queue(issue_id); + let _ = self.recycle(returned_bounty); + + issue.status = IssueStatus::Cancelled; + issue.bounty_amount = 0; + self.issues.insert(issue_id, &issue); + + self.env().emit_event(IssueCancelled { + issue_id, + returned_bounty, + }); + } + + /// Internal payout helper - transfers stake from treasury_hotkey to solver + fn execute_payout_internal( + &mut self, + issue_id: u64, + solver_coldkey: AccountId, + payout_amount: Balance, + ) -> Result { + let amount_u64: u64 = payout_amount.try_into().unwrap_or(u64::MAX); + + let proxy_call = RawCall::proxied_transfer_stake( + &self.owner, + &solver_coldkey, + &self.treasury_hotkey, + self.netuid, + self.netuid, + amount_u64, + ); + + let result = self.env().call_runtime(&proxy_call); + + if result.is_ok() { + self.env().emit_event(BountyPaidOut { + issue_id, + miner: solver_coldkey, + amount: payout_amount, + }); + Ok(payout_amount) + } else { + Err(Error::TransferFailed) + } + } + + /// Recycles (destroys) alpha tokens via runtime call. + fn recycle(&mut self, amount: Balance) -> bool { + if amount == 0 { + return true; + } + + let amount_u64: u64 = amount.try_into().unwrap_or(u64::MAX); + + let proxy_call = RawCall::proxied_recycle_alpha( + &self.owner, + &self.treasury_hotkey, + amount_u64, + self.netuid, + ); + + let result = self.env().call_runtime(&proxy_call); + + if result.is_ok() { + self.env().emit_event(EmissionsRecycled { + amount, + destination: self.treasury_hotkey, + }); + true + } else { + self.alpha_pool = self.alpha_pool.saturating_add(amount); + self.env().emit_event(RecycleFailed { amount }); + false + } + } + + /// Clears solution vote data + fn clear_solution_vote(&mut self, issue_id: u64) { + self.solution_votes.remove(issue_id); + } + } + + #[cfg(test)] + mod tests { + include!("tests.rs"); + } +} diff --git a/smart-contracts/issues-v0/runtime_calls.rs b/smart-contracts/issues-v0/runtime_calls.rs new file mode 100644 index 00000000..99ba2a9f --- /dev/null +++ b/smart-contracts/issues-v0/runtime_calls.rs @@ -0,0 +1,185 @@ +use ink::prelude::vec::Vec; +use ink::primitives::AccountId; +use scale::{Encode, Output}; + +// ============================================================================= +// Pallet Indices (from construct_runtime!) +// ============================================================================= + +/// SubtensorModule pallet index in the runtime +pub const SUBTENSOR_MODULE_PALLET_INDEX: u8 = 7; + +/// Proxy pallet index in the runtime +pub const PROXY_PALLET_INDEX: u8 = 16; + +/// transfer_stake call variant index within SubtensorModule +/// NOTE: This MUST match the order in the pallet's Call enum. +/// Verify with: subtensor/pallets/subtensor/src/macros/dispatches.rs +pub const TRANSFER_STAKE_CALL_INDEX: u8 = 86; + +/// recycle_alpha call variant index within SubtensorModule +/// Verified: subtensor/pallets/subtensor/src/macros/dispatches.rs:1998 +/// Recycles alpha tokens, destroying them and reducing SubnetAlphaOut +pub const RECYCLE_ALPHA_CALL_INDEX: u8 = 101; + +/// ProxyType::Transfer variant index (for transfer_stake) +/// From Subtensor runtime (verified via substrate encoding): +/// Any=0, Owner=1, NonCritical=2, Governance=7, Staking=8, Transfer=10 +pub const PROXY_TYPE_TRANSFER: u8 = 10; + +/// ProxyType::NonCritical variant index (for recycle_alpha) +/// recycle_alpha is NOT in Staking or Transfer filters, but IS allowed by NonCritical +/// NonCritical allows all calls EXCEPT: dissolve_network, root_register, burned_register, Sudo +pub const PROXY_TYPE_NON_CRITICAL: u8 = 2; + +// ============================================================================= +// Raw Call Wrapper for call_runtime +// ============================================================================= + +/// Wrapper for pre-encoded runtime call bytes. +/// When encoded, outputs the raw bytes without any wrapping (no length prefix). +/// Used with `env().call_runtime()` to dispatch pre-encoded calls. +#[derive(Debug, Clone)] +pub struct RawCall(pub Vec); + +impl Encode for RawCall { + fn encode(&self) -> Vec { + self.0.clone() + } + + fn encode_to(&self, dest: &mut T) { + dest.write(&self.0); + } + + fn size_hint(&self) -> usize { + self.0.len() + } +} + +impl RawCall { + /// Encode a proxied transfer_stake call. + /// + /// Creates a Proxy::proxy call wrapping a SubtensorModule::transfer_stake call. + /// The proxy pallet will validate that the caller (contract) is a Transfer proxy + /// for the `real` account before executing the inner call with `real` as origin. + /// + /// # Arguments + /// * `real` - The account to execute as (owner/treasury coldkey) + /// * `destination_coldkey` - Where to transfer stake ownership to + /// * `hotkey` - The hotkey the stake is on + /// * `origin_netuid` - Source subnet ID + /// * `destination_netuid` - Target subnet ID + /// * `amount` - Amount of alpha to transfer (u64) + pub fn proxied_transfer_stake( + real: &AccountId, + destination_coldkey: &AccountId, + hotkey: &AccountId, + origin_netuid: u16, + destination_netuid: u16, + amount: u64, + ) -> Self { + let mut call_bytes = Vec::with_capacity(128); + + // Proxy pallet index + call_bytes.push(PROXY_PALLET_INDEX); + + // proxy() is the first call variant (index 0) + call_bytes.push(0); + + // real: MultiAddress + // MultiAddress::Id variant = 0, then 32 bytes of AccountId + call_bytes.push(0); + call_bytes.extend_from_slice(real.as_ref()); + + // force_proxy_type: Option + // Some = 1, then ProxyType::Transfer (transfer_stake requires Transfer proxy) + call_bytes.push(1); + call_bytes.push(PROXY_TYPE_TRANSFER); + + // call: Box - the inner transfer_stake call + // SubtensorModule pallet index + call_bytes.push(SUBTENSOR_MODULE_PALLET_INDEX); + + // transfer_stake call variant index + call_bytes.push(TRANSFER_STAKE_CALL_INDEX); + + // transfer_stake arguments: + // destination_coldkey: AccountId (32 bytes) + call_bytes.extend_from_slice(destination_coldkey.as_ref()); + + // hotkey: AccountId (32 bytes) + call_bytes.extend_from_slice(hotkey.as_ref()); + + // origin_netuid: u16 (2 bytes, little-endian) + call_bytes.extend_from_slice(&origin_netuid.to_le_bytes()); + + // destination_netuid: u16 (2 bytes, little-endian) + call_bytes.extend_from_slice(&destination_netuid.to_le_bytes()); + + // alpha_amount: u64 (8 bytes, little-endian) + call_bytes.extend_from_slice(&amount.to_le_bytes()); + + Self(call_bytes) + } + + /// Encode a proxied recycle_alpha call. + /// + /// Creates a Proxy::proxy call wrapping a SubtensorModule::recycle_alpha call. + /// The proxy pallet will validate that the caller (contract) is a NonCritical proxy + /// for the `real` account before executing the inner call with `real` as origin. + /// + /// recycle_alpha DESTROYS alpha tokens and reduces SubnetAlphaOut. + /// This is TRUE recycling - tokens cease to exist. + /// + /// NOTE: recycle_alpha is NOT in Staking or Transfer proxy filters. + /// It requires NonCritical (or Any) proxy type. + /// + /// # Arguments + /// * `real` - The account to execute as (owner/treasury coldkey) + /// * `hotkey` - The hotkey to recycle alpha from + /// * `amount` - Amount of alpha to recycle (u64) + /// * `netuid` - Subnet ID + pub fn proxied_recycle_alpha( + real: &AccountId, + hotkey: &AccountId, + amount: u64, + netuid: u16, + ) -> Self { + let mut call_bytes = Vec::with_capacity(128); + + // Proxy pallet index + call_bytes.push(PROXY_PALLET_INDEX); + + // proxy() is the first call variant (index 0) + call_bytes.push(0); + + // real: MultiAddress + // MultiAddress::Id variant = 0, then 32 bytes of AccountId + call_bytes.push(0); + call_bytes.extend_from_slice(real.as_ref()); + + // force_proxy_type: Option + // Some = 1, then ProxyType::NonCritical (recycle_alpha requires NonCritical) + call_bytes.push(1); + call_bytes.push(PROXY_TYPE_NON_CRITICAL); + + // call: Box - the inner recycle_alpha call + // SubtensorModule pallet index + call_bytes.push(SUBTENSOR_MODULE_PALLET_INDEX); + + // recycle_alpha call variant index + call_bytes.push(RECYCLE_ALPHA_CALL_INDEX); + + // recycle_alpha arguments: + // hotkey: AccountId (32 bytes) + call_bytes.extend_from_slice(hotkey.as_ref()); + + // amount: u64 (8 bytes, little-endian) + call_bytes.extend_from_slice(&amount.to_le_bytes()); + + // netuid: u16 (2 bytes, little-endian) + call_bytes.extend_from_slice(&netuid.to_le_bytes()); + + Self(call_bytes) + } +} diff --git a/smart-contracts/issues-v0/tests.rs b/smart-contracts/issues-v0/tests.rs new file mode 100644 index 00000000..00d6feff --- /dev/null +++ b/smart-contracts/issues-v0/tests.rs @@ -0,0 +1,1863 @@ +use super::*; +use ink::env::test; +use scale::Encode; + +/// Default netuid used across tests +const TEST_NETUID: u16 = 1; + +/// Default stake amount returned by the mock chain extension (100 ALPHA) +const MOCK_STAKE: u64 = 100_000_000_000; + +/// Creates distinct AccountIds for testing. +/// Each account is a 32-byte array with the given byte repeated. +fn account(byte: u8) -> AccountId { + AccountId::from([byte; 32]) +} + +/// Standard set of test accounts. Use these by convention: +/// - account(1) = owner +/// - account(2) = treasury_hotkey +/// - account(3) = validator_hotkey +/// - account(4) = random non-owner caller +/// - account(5) = solver_coldkey +/// - account(6) = solver_hotkey +/// +/// Creates a contract with sensible defaults. +/// +/// Caller is set to account(1) (the owner) before construction. +fn create_default_contract() -> IssueBountyManager { + // Set the caller for the constructor + test::set_caller::(account(1)); + + IssueBountyManager::new( + account(1), // owner + account(2), // treasury_hotkey + TEST_NETUID, + ) +} + +/// Sets the caller for the next contract call. +fn set_caller(caller: AccountId) { + test::set_caller::(caller); +} + +// ============================================================================ +// Mock Chain Extension +// ============================================================================ + +/// Mock for Subtensor chain extension (extension 5001). +/// Intercepts get_stake_info (func 0) and transfer_stake (func 6). +struct MockSubtensorExtension { + stake_amount: u64, +} + +impl ink::env::test::ChainExtension for MockSubtensorExtension { + fn ext_id(&self) -> u16 { + 5001 + } + + /// Handles chain extension calls: + /// func 0 (get_stake_info) -> returns Some(StakeInfo) with self.stake_amount + /// func 6 (transfer_stake) -> returns 0 (success) + fn call(&mut self, func_id: u16, _input: &[u8], output: &mut Vec) -> u32 { + match func_id { + 0 => { + // Build a StakeInfo with the configured stake amount. + // All other fields are zeroed/defaults -- only stake matters for tests. + let stake_info = crate::StakeInfo { + hotkey: AccountId::from([0u8; 32]), + coldkey: AccountId::from([0u8; 32]), + netuid: scale::Compact(TEST_NETUID), + stake: scale::Compact(self.stake_amount), + locked: scale::Compact(0u64), + emission: scale::Compact(0u64), + tao_emission: scale::Compact(0u64), + drain: scale::Compact(0u64), + is_registered: true, + }; + // Encode as Option = Some(stake_info) + let result: Option = Some(stake_info); + result.encode_to(output); + 0 // success + } + 6 => { + // transfer_stake -> return 0u32 (success) + 0u32.encode_to(output); + 0 + } + _ => 1, // unknown function + } + } +} + +/// Registers the mock chain extension so tests can call functions +/// that depend on get_stake_info (voting, treasury queries). +fn register_mock_extension() { + register_mock_extension_with_stake(MOCK_STAKE); +} + +/// Registers mock chain extension with a custom stake amount. +fn register_mock_extension_with_stake(stake: u64) { + ink::env::test::register_chain_extension(MockSubtensorExtension { + stake_amount: stake, + }); +} + +#[ink::test] +fn constructor_sets_fields_correctly() { + let contract = create_default_contract(); + + assert_eq!(contract.owner(), account(1)); + assert_eq!(contract.treasury_hotkey(), account(2)); + assert_eq!(contract.netuid(), TEST_NETUID); + assert_eq!(contract.next_issue_id(), 1); + assert_eq!(contract.get_alpha_pool(), 0); + assert_eq!(contract.get_bounty_queue(), Vec::::new()); + assert_eq!(contract.get_last_harvest_block(), 0); +} + +#[ink::test] +fn get_issue_returns_none_for_nonexistent() { + let contract = create_default_contract(); + assert_eq!(contract.get_issue(1), None); + assert_eq!(contract.get_issue(999), None); +} + +#[ink::test] +fn get_issue_by_url_hash_returns_zero_for_unknown() { + let contract = create_default_contract(); + let unknown_hash = [0xAA; 32]; + assert_eq!(contract.get_issue_by_url_hash(unknown_hash), 0); +} + +#[ink::test] +fn get_issues_by_status_returns_empty_initially() { + let contract = create_default_contract(); + assert!(contract + .get_issues_by_status(crate::IssueStatus::Registered) + .is_empty()); + assert!(contract + .get_issues_by_status(crate::IssueStatus::Active) + .is_empty()); + assert!(contract + .get_issues_by_status(crate::IssueStatus::Completed) + .is_empty()); + assert!(contract + .get_issues_by_status(crate::IssueStatus::Cancelled) + .is_empty()); +} + +#[ink::test] +fn get_config_returns_correct_values() { + let contract = create_default_contract(); + let config = contract.get_config(); + assert_eq!(config.required_validator_votes, 1); + assert_eq!(config.netuid, TEST_NETUID); +} + +#[ink::test] +fn get_bounty_queue_returns_empty_initially() { + let contract = create_default_contract(); + assert!(contract.get_bounty_queue().is_empty()); +} + +// ============================================================================ +// Admin Setter Tests +// ============================================================================ + +#[ink::test] +fn set_owner_works_for_owner() { + let mut contract = create_default_contract(); + set_caller(account(1)); + assert!(contract.set_owner(account(4)).is_ok()); + assert_eq!(contract.owner(), account(4)); +} + +#[ink::test] +fn set_owner_fails_for_non_owner() { + let mut contract = create_default_contract(); + set_caller(account(4)); // not the owner + assert_eq!(contract.set_owner(account(4)), Err(crate::Error::NotOwner)); +} + +#[ink::test] +fn set_treasury_hotkey_works_for_owner() { + let mut contract = create_default_contract(); + set_caller(account(1)); + assert!(contract.set_treasury_hotkey(account(7)).is_ok()); + assert_eq!(contract.treasury_hotkey(), account(7)); +} + +#[ink::test] +fn set_treasury_hotkey_fails_for_non_owner() { + let mut contract = create_default_contract(); + set_caller(account(4)); + assert_eq!( + contract.set_treasury_hotkey(account(7)), + Err(crate::Error::NotOwner), + ); +} + +// ============================================================================ +// Internal Helper Tests +// ============================================================================ + +#[ink::test] +fn is_valid_repo_name_accepts_valid_names() { + let contract = create_default_contract(); + assert!(contract.is_valid_repo_name("owner/repo")); + assert!(contract.is_valid_repo_name("a/b")); + assert!(contract.is_valid_repo_name("my-org/my-repo")); + assert!(contract.is_valid_repo_name("foo/bar.baz")); +} + +#[ink::test] +fn is_valid_repo_name_rejects_invalid_names() { + let contract = create_default_contract(); + assert!(!contract.is_valid_repo_name("")); + assert!(!contract.is_valid_repo_name("noslash")); + assert!(!contract.is_valid_repo_name("/leading")); + assert!(!contract.is_valid_repo_name("trailing/")); + assert!(!contract.is_valid_repo_name("two/slashes/here")); +} + +#[ink::test] +fn is_modifiable_returns_true_for_registered_and_active() { + let contract = create_default_contract(); + assert!(contract.is_modifiable(crate::IssueStatus::Registered)); + assert!(contract.is_modifiable(crate::IssueStatus::Active)); +} + +#[ink::test] +fn is_modifiable_returns_false_for_finalized() { + let contract = create_default_contract(); + assert!(!contract.is_modifiable(crate::IssueStatus::Completed)); + assert!(!contract.is_modifiable(crate::IssueStatus::Cancelled)); +} + +#[ink::test] +fn check_consensus_with_required_votes() { + let mut contract = create_default_contract(); + // With 0 validators, consensus is impossible + assert!(!contract.check_consensus(0)); + assert!(!contract.check_consensus(1)); + + // Add 1 validator: required = (1/2)+1 = 1 + set_caller(account(1)); + contract.add_validator(account(3)).unwrap(); + assert!(!contract.check_consensus(0)); + assert!(contract.check_consensus(1)); + assert!(contract.check_consensus(5)); +} + +#[ink::test] +fn hash_string_is_deterministic() { + let contract = create_default_contract(); + let hash1 = contract.hash_string("https://github.com/org/repo/issues/1"); + let hash2 = contract.hash_string("https://github.com/org/repo/issues/1"); + assert_eq!(hash1, hash2); +} + +#[ink::test] +fn hash_string_differs_for_different_inputs() { + let contract = create_default_contract(); + let hash1 = contract.hash_string("https://github.com/org/repo/issues/1"); + let hash2 = contract.hash_string("https://github.com/org/repo/issues/2"); + assert_ne!(hash1, hash2); +} + +// ============================================================================ +// Register Issue Tests +// ============================================================================ + +/// Helper: registers a standard test issue as the owner. +/// Returns the issue_id on success. +fn register_test_issue(contract: &mut IssueBountyManager) -> u64 { + set_caller(account(1)); + contract + .register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ) + .expect("register_issue should succeed") +} + +#[ink::test] +fn register_issue_succeeds_with_valid_inputs() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + assert_eq!(id, 1); + assert_eq!(contract.next_issue_id(), 2); + + // Issue should be stored and retrievable + let issue = contract.get_issue(id).expect("issue should exist"); + assert_eq!(issue.id, 1); + assert_eq!(issue.repository_full_name, "org/repo"); + assert_eq!(issue.issue_number, 1); + assert_eq!(issue.target_bounty, MIN_BOUNTY); + assert_eq!(issue.bounty_amount, 0); + assert_eq!(issue.status, crate::IssueStatus::Registered); + assert_eq!(issue.solver_coldkey, None); +} + +#[ink::test] +fn register_issue_adds_to_bounty_queue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + assert_eq!(contract.get_bounty_queue(), vec![id]); +} + +#[ink::test] +fn register_issue_is_findable_by_url_hash() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Compute the same hash the contract would + let url_hash = contract.hash_string("https://github.com/org/repo/issues/1"); + assert_eq!(contract.get_issue_by_url_hash(url_hash), id); +} + +#[ink::test] +fn register_issue_appears_in_status_query() { + let mut contract = create_default_contract(); + register_test_issue(&mut contract); + + let registered = contract.get_issues_by_status(crate::IssueStatus::Registered); + assert_eq!(registered.len(), 1); + assert_eq!(registered[0].issue_number, 1); + + // Other statuses should still be empty + assert!(contract + .get_issues_by_status(crate::IssueStatus::Active) + .is_empty()); +} + +#[ink::test] +fn register_issue_increments_id_for_multiple_issues() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + let id1 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ) + .unwrap(); + + let id2 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/2"), + String::from("org/repo"), + 2, + MIN_BOUNTY * 2, + ) + .unwrap(); + + assert_eq!(id1, 1); + assert_eq!(id2, 2); + assert_eq!(contract.next_issue_id(), 3); + assert_eq!(contract.get_bounty_queue(), vec![1, 2]); +} + +#[ink::test] +fn register_issue_fails_for_non_owner() { + let mut contract = create_default_contract(); + set_caller(account(4)); // not the owner + let result = contract.register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ); + assert_eq!(result, Err(crate::Error::NotOwner)); +} + +#[ink::test] +fn register_issue_fails_bounty_too_low() { + let mut contract = create_default_contract(); + set_caller(account(1)); + let result = contract.register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY - 1, // one below minimum + ); + assert_eq!(result, Err(crate::Error::BountyTooLow)); +} + +#[ink::test] +fn register_issue_fails_bounty_zero() { + let mut contract = create_default_contract(); + set_caller(account(1)); + let result = contract.register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + 0, + ); + assert_eq!(result, Err(crate::Error::BountyTooLow)); +} + +#[ink::test] +fn register_issue_fails_issue_number_zero() { + let mut contract = create_default_contract(); + set_caller(account(1)); + let result = contract.register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 0, // invalid + MIN_BOUNTY, + ); + assert_eq!(result, Err(crate::Error::InvalidIssueNumber)); +} + +#[ink::test] +fn register_issue_fails_invalid_repo_name() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + // No slash + let result = contract.register_issue( + String::from("https://github.com/bad"), + String::from("noslash"), + 1, + MIN_BOUNTY, + ); + assert_eq!(result, Err(crate::Error::InvalidRepositoryName)); +} + +#[ink::test] +fn register_issue_fails_duplicate_url() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + // First registration succeeds + contract + .register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ) + .unwrap(); + + // Same URL again fails + let result = contract.register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ); + assert_eq!(result, Err(crate::Error::IssueAlreadyExists)); +} + +#[ink::test] +fn register_issue_at_exact_min_bounty_succeeds() { + let mut contract = create_default_contract(); + set_caller(account(1)); + let result = contract.register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, // exactly at the boundary + ); + assert!(result.is_ok()); +} + +// ============================================================================ +// Cancel Issue Tests +// ============================================================================ + +#[ink::test] +fn cancel_issue_succeeds_on_registered_issue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + set_caller(account(1)); + assert!(contract.cancel_issue(id).is_ok()); + + let issue = contract.get_issue(id).expect("issue should still exist"); + assert_eq!(issue.status, crate::IssueStatus::Cancelled); + assert_eq!(issue.bounty_amount, 0); +} + +#[ink::test] +fn cancel_issue_removes_from_bounty_queue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + assert_eq!(contract.get_bounty_queue(), vec![id]); + + set_caller(account(1)); + contract.cancel_issue(id).unwrap(); + assert!(contract.get_bounty_queue().is_empty()); +} + +#[ink::test] +fn cancel_issue_returns_bounty_to_alpha_pool() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Manually give the issue some bounty to test the return path. + // We access the storage directly since fill_bounties needs chain ext. + if let Some(mut issue) = contract.issues.get(id) { + issue.bounty_amount = 5_000_000_000; // 5 ALPHA + contract.issues.insert(id, &issue); + } + + assert_eq!(contract.get_alpha_pool(), 0); + set_caller(account(1)); + contract.cancel_issue(id).unwrap(); + + // Bounty should have been returned to the pool + assert_eq!(contract.get_alpha_pool(), 5_000_000_000); +} + +#[ink::test] +fn cancel_issue_fails_for_non_owner() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + set_caller(account(4)); + assert_eq!(contract.cancel_issue(id), Err(crate::Error::NotOwner)); +} + +#[ink::test] +fn cancel_issue_fails_for_nonexistent_issue() { + let mut contract = create_default_contract(); + set_caller(account(1)); + assert_eq!(contract.cancel_issue(999), Err(crate::Error::IssueNotFound)); +} + +#[ink::test] +fn cancel_issue_fails_on_already_cancelled() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + set_caller(account(1)); + contract.cancel_issue(id).unwrap(); + + // Second cancel should fail -- status is now Cancelled, not modifiable + let result = contract.cancel_issue(id); + assert_eq!(result, Err(crate::Error::CannotCancel)); +} + +#[ink::test] +fn cancel_issue_shows_in_status_query() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + set_caller(account(1)); + contract.cancel_issue(id).unwrap(); + + assert!(contract + .get_issues_by_status(crate::IssueStatus::Registered) + .is_empty()); + let cancelled = contract.get_issues_by_status(crate::IssueStatus::Cancelled); + assert_eq!(cancelled.len(), 1); + assert_eq!(cancelled[0].id, id); +} + +#[ink::test] +fn cancel_middle_issue_preserves_other_queue_entries() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + let id1 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ) + .unwrap(); + + let id2 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/2"), + String::from("org/repo"), + 2, + MIN_BOUNTY, + ) + .unwrap(); + + let id3 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/3"), + String::from("org/repo"), + 3, + MIN_BOUNTY, + ) + .unwrap(); + + // Cancel the middle one + contract.cancel_issue(id2).unwrap(); + + // Queue should have id1 and id3 (swap_remove puts last in middle's spot) + let queue = contract.get_bounty_queue(); + assert_eq!(queue.len(), 2); + assert!(queue.contains(&id1)); + assert!(queue.contains(&id3)); + assert!(!queue.contains(&id2)); +} + +// ============================================================================ +// Fill Bounties Tests +// ============================================================================ + +#[ink::test] +fn fill_bounties_allocates_from_alpha_pool() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Simulate available emissions by setting alpha_pool directly + contract.alpha_pool = MIN_BOUNTY; + contract.fill_bounties(); + + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.bounty_amount, MIN_BOUNTY); + assert_eq!(issue.status, crate::IssueStatus::Active); + assert_eq!(contract.get_alpha_pool(), 0); +} + +#[ink::test] +fn fill_bounties_partial_fill_stays_registered() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Only give half the needed bounty + let half = MIN_BOUNTY / 2; + contract.alpha_pool = half; + contract.fill_bounties(); + + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.bounty_amount, half); + assert_eq!(issue.status, crate::IssueStatus::Registered); // not Active yet + assert_eq!(contract.get_alpha_pool(), 0); +} + +#[ink::test] +fn fill_bounties_fills_fifo_order() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + // Register two issues, each needing MIN_BOUNTY + let id1 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ) + .unwrap(); + + let id2 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/2"), + String::from("org/repo"), + 2, + MIN_BOUNTY, + ) + .unwrap(); + + // Only enough to fill the first one + contract.alpha_pool = MIN_BOUNTY; + contract.fill_bounties(); + + let issue1 = contract.get_issue(id1).unwrap(); + let issue2 = contract.get_issue(id2).unwrap(); + + assert_eq!(issue1.status, crate::IssueStatus::Active); + assert_eq!(issue1.bounty_amount, MIN_BOUNTY); + assert_eq!(issue2.status, crate::IssueStatus::Registered); + assert_eq!(issue2.bounty_amount, 0); +} + +#[ink::test] +fn fill_bounties_fills_multiple_when_pool_sufficient() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + contract + .register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ) + .unwrap(); + + contract + .register_issue( + String::from("https://github.com/org/repo/issues/2"), + String::from("org/repo"), + 2, + MIN_BOUNTY, + ) + .unwrap(); + + // Enough for both plus some leftover + contract.alpha_pool = MIN_BOUNTY * 3; + contract.fill_bounties(); + + let issue1 = contract.get_issue(1).unwrap(); + let issue2 = contract.get_issue(2).unwrap(); + assert_eq!(issue1.status, crate::IssueStatus::Active); + assert_eq!(issue2.status, crate::IssueStatus::Active); + assert_eq!(contract.get_alpha_pool(), MIN_BOUNTY); // leftover +} + +#[ink::test] +fn fill_bounties_skips_cancelled_issues() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + let id1 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ) + .unwrap(); + + let id2 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/2"), + String::from("org/repo"), + 2, + MIN_BOUNTY, + ) + .unwrap(); + + // Cancel the first issue + contract.cancel_issue(id1).unwrap(); + + // Give enough for one issue + contract.alpha_pool = MIN_BOUNTY; + contract.fill_bounties(); + + // id2 should get funded, not the cancelled id1 + let issue2 = contract.get_issue(id2).unwrap(); + assert_eq!(issue2.status, crate::IssueStatus::Active); + assert_eq!(contract.get_alpha_pool(), 0); +} + +#[ink::test] +fn fill_bounties_noop_when_pool_empty() { + let mut contract = create_default_contract(); + register_test_issue(&mut contract); + + contract.alpha_pool = 0; + contract.fill_bounties(); + + let issue = contract.get_issue(1).unwrap(); + assert_eq!(issue.bounty_amount, 0); + assert_eq!(issue.status, crate::IssueStatus::Registered); +} + +// ============================================================================ +// Get Total Committed Tests +// ============================================================================ + +#[ink::test] +fn get_total_committed_zero_initially() { + let contract = create_default_contract(); + assert_eq!(contract.get_total_committed(), 0); +} + +#[ink::test] +fn get_total_committed_sums_registered_bounties() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Give the issue some bounty + if let Some(mut issue) = contract.issues.get(id) { + issue.bounty_amount = 5_000_000_000; + contract.issues.insert(id, &issue); + } + + assert_eq!(contract.get_total_committed(), 5_000_000_000); +} + +#[ink::test] +fn get_total_committed_ignores_cancelled() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + set_caller(account(1)); + contract.cancel_issue(id).unwrap(); + + assert_eq!(contract.get_total_committed(), 0); +} + +#[ink::test] +fn payout_bounty_fails_on_non_completed_issue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + set_caller(account(1)); + let result = contract.payout_bounty(id); + assert_eq!(result, Err(crate::Error::BountyNotCompleted)); +} + +#[ink::test] +fn payout_bounty_fails_for_nonexistent_issue() { + let mut contract = create_default_contract(); + set_caller(account(1)); + let result = contract.payout_bounty(74); + + assert_eq!(result, Err(crate::Error::IssueNotFound)); +} + +#[ink::test] +fn payout_bounty_fails_for_non_owner() { + let mut contract = create_default_contract(); + set_caller(account(74)); + let result = contract.payout_bounty(74); + + assert_eq!(result, Err(crate::Error::NotOwner)); +} + +#[ink::test] +fn payout_bounty_fails_when_already_paid() { + let mut contract = create_default_contract(); + set_caller(account(1)); + let id = register_test_issue(&mut contract); + + if let Some(mut issue) = contract.issues.get(id) { + issue.status = crate::IssueStatus::Completed; + contract.issues.insert(id, &issue); + } + + let result = contract.payout_bounty(id); + + assert_eq!(result, Err(crate::Error::BountyAlreadyPaid)); +} + +#[ink::test] +fn cancel_issue_fails_on_completed_issue() { + let mut contract = create_default_contract(); + set_caller(account(1)); + let id = register_test_issue(&mut contract); + + if let Some(mut issue) = contract.issues.get(id) { + issue.status = crate::IssueStatus::Completed; + contract.issues.insert(id, &issue); + } + + let result = contract.cancel_issue(id); + + assert_eq!(result, Err(crate::Error::CannotCancel)); +} + +// ============================================================================ +// Payout Bounty Tests (additional) +// ============================================================================ + +#[ink::test] +fn payout_bounty_fails_no_solver_set() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Set to Completed with funds but no solver_coldkey + let mut issue = contract.issues.get(id).unwrap(); + issue.status = crate::IssueStatus::Completed; + issue.bounty_amount = MIN_BOUNTY; + // solver_coldkey is already None from registration + contract.issues.insert(id, &issue); + + set_caller(account(1)); + let result = contract.payout_bounty(id); + assert_eq!(result, Err(crate::Error::NoSolverSet)); +} + +// ============================================================================ +// Vote Solution Tests (validation paths -- chain extension blocks full flow) +// ============================================================================ + +#[ink::test] +fn vote_solution_fails_issue_not_found() { + let mut contract = create_default_contract(); + set_caller(account(4)); + let result = contract.vote_solution( + 999, + account(6), // solver_hotkey + account(5), // solver_coldkey + 42, // pr_number + ); + assert_eq!(result, Err(crate::Error::IssueNotFound)); +} + +#[ink::test] +fn vote_solution_fails_issue_not_active() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Issue is Registered, not Active + set_caller(account(4)); + let result = contract.vote_solution(id, account(6), account(5), 42); + assert_eq!(result, Err(crate::Error::IssueNotActive)); +} + +#[ink::test] +fn vote_solution_fails_on_completed_issue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + let mut issue = contract.issues.get(id).unwrap(); + issue.status = crate::IssueStatus::Completed; + contract.issues.insert(id, &issue); + + set_caller(account(4)); + let result = contract.vote_solution(id, account(6), account(5), 42); + assert_eq!(result, Err(crate::Error::IssueNotActive)); +} + +#[ink::test] +fn vote_solution_fails_on_cancelled_issue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + set_caller(account(1)); + contract.cancel_issue(id).unwrap(); + + set_caller(account(4)); + let result = contract.vote_solution(id, account(6), account(5), 42); + assert_eq!(result, Err(crate::Error::IssueNotActive)); +} + +#[ink::test] +fn vote_solution_fails_already_voted() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Make issue Active + let mut issue = contract.issues.get(id).unwrap(); + issue.status = crate::IssueStatus::Active; + issue.bounty_amount = MIN_BOUNTY; + contract.issues.insert(id, &issue); + + // Manually mark account(4) as having voted + contract + .solution_vote_voters + .insert((id, account(4)), &true); + + set_caller(account(4)); + let result = contract.vote_solution(id, account(6), account(5), 42); + assert_eq!(result, Err(crate::Error::AlreadyVoted)); +} + +// ============================================================================ +// Vote Cancel Issue Tests (validation paths) +// ============================================================================ + +#[ink::test] +fn vote_cancel_issue_fails_issue_not_found() { + let mut contract = create_default_contract(); + set_caller(account(4)); + let result = contract.vote_cancel_issue(999, [0xCC; 32]); + assert_eq!(result, Err(crate::Error::IssueNotFound)); +} + +#[ink::test] +fn vote_cancel_issue_fails_on_completed_issue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + let mut issue = contract.issues.get(id).unwrap(); + issue.status = crate::IssueStatus::Completed; + contract.issues.insert(id, &issue); + + set_caller(account(4)); + let result = contract.vote_cancel_issue(id, [0xCC; 32]); + assert_eq!(result, Err(crate::Error::IssueAlreadyFinalized)); +} + +#[ink::test] +fn vote_cancel_issue_fails_on_cancelled_issue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + set_caller(account(1)); + contract.cancel_issue(id).unwrap(); + + set_caller(account(4)); + let result = contract.vote_cancel_issue(id, [0xCC; 32]); + assert_eq!(result, Err(crate::Error::IssueAlreadyFinalized)); +} + +#[ink::test] +fn vote_cancel_issue_fails_already_voted() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Manually mark account(4) as having voted to cancel + contract.cancel_issue_voters.insert((id, account(4)), &true); + + set_caller(account(4)); + let result = contract.vote_cancel_issue(id, [0xCC; 32]); + assert_eq!(result, Err(crate::Error::AlreadyVoted)); +} + +// ============================================================================ +// Queue Helper Tests (Order-Preserving Removal) +// ============================================================================ + +#[ink::test] +fn remove_at_removes_only_element() { + let mut contract = create_default_contract(); + contract.bounty_queue.push(1); + + contract.remove_at(0); + assert!(contract.bounty_queue.is_empty()); +} + +#[ink::test] +fn remove_at_removes_last_element() { + let mut contract = create_default_contract(); + contract.bounty_queue.push(1); + contract.bounty_queue.push(2); + contract.bounty_queue.push(3); + + contract.remove_at(2); // remove last + assert_eq!(contract.bounty_queue, vec![1, 2]); +} + +#[ink::test] +fn remove_at_preserves_order() { + let mut contract = create_default_contract(); + contract.bounty_queue.push(1); + contract.bounty_queue.push(2); + contract.bounty_queue.push(3); + + contract.remove_at(0); // remove first, order preserved + assert_eq!(contract.bounty_queue, vec![2, 3]); +} + +#[ink::test] +fn remove_at_noop_on_empty() { + let mut contract = create_default_contract(); + contract.remove_at(0); // should not panic + assert!(contract.bounty_queue.is_empty()); +} + +#[ink::test] +fn remove_from_bounty_queue_noop_for_missing_id() { + let mut contract = create_default_contract(); + contract.bounty_queue.push(1); + contract.bounty_queue.push(2); + + contract.remove_from_bounty_queue(999); // not in queue + assert_eq!(contract.bounty_queue, vec![1, 2]); +} + +// ============================================================================ +// Vote Record Helper Tests +// ============================================================================ + +#[ink::test] +fn get_or_create_solution_vote_creates_new() { + let mut contract = create_default_contract(); + let vote = contract.get_or_create_solution_vote(1, account(6), 42, account(5)); + + assert_eq!(vote.issue_id, 1); + assert_eq!(vote.solver_hotkey, account(6)); + assert_eq!(vote.solver_coldkey, account(5)); + assert_eq!(vote.pr_number, 42); + assert_eq!(vote.votes_count, 0); +} + +#[ink::test] +fn get_or_create_solution_vote_returns_existing() { + let mut contract = create_default_contract(); + + // Store an existing vote with some data + let existing = crate::SolutionVote { + issue_id: 1, + solver_hotkey: account(6), + solver_coldkey: account(5), + pr_number: 42, + votes_count: 3, + }; + contract.solution_votes.insert(1, &existing); + + let vote = contract.get_or_create_solution_vote( + 1, + account(7), // different solver -- should be ignored + 99, // different pr -- should be ignored + account(8), + ); + + // Should return the stored vote, not create a new one + assert_eq!(vote.solver_hotkey, account(6)); + assert_eq!(vote.votes_count, 3); +} + +#[ink::test] +fn get_or_create_cancel_issue_vote_creates_new() { + let mut contract = create_default_contract(); + let vote = contract.get_or_create_cancel_issue_vote(1, [0xCC; 32]); + + assert_eq!(vote.issue_id, 1); + assert_eq!(vote.reason_hash, [0xCC; 32]); + assert_eq!(vote.votes_count, 0); +} + +#[ink::test] +fn get_or_create_cancel_issue_vote_returns_existing() { + let mut contract = create_default_contract(); + + let existing = crate::CancelVote { + issue_id: 1, + reason_hash: [0xCC; 32], + votes_count: 2, + }; + contract.cancel_issue_votes.insert(1, &existing); + + let vote = contract.get_or_create_cancel_issue_vote( + 1, [0xFF; 32], // different hash -- should be ignored + ); + + assert_eq!(vote.reason_hash, [0xCC; 32]); + assert_eq!(vote.votes_count, 2); +} + +// ============================================================================ +// Clear Vote Tests +// ============================================================================ + +#[ink::test] +fn clear_solution_vote_removes_record() { + let mut contract = create_default_contract(); + let vote = crate::SolutionVote { + issue_id: 1, + solver_hotkey: account(6), + solver_coldkey: account(5), + pr_number: 42, + votes_count: 1, + }; + contract.solution_votes.insert(1, &vote); + + contract.clear_solution_vote(1); + assert!(contract.solution_votes.get(1).is_none()); +} + +#[ink::test] +fn clear_cancel_issue_vote_removes_record() { + let mut contract = create_default_contract(); + let vote = crate::CancelVote { + issue_id: 1, + reason_hash: [0xCC; 32], + votes_count: 1, + }; + contract.cancel_issue_votes.insert(1, &vote); + + contract.clear_cancel_issue_vote(1); + assert!(contract.cancel_issue_votes.get(1).is_none()); +} + +// ============================================================================ +// Admin Setter Edge Cases +// ============================================================================ + +#[ink::test] +fn set_owner_transfers_authority() { + let mut contract = create_default_contract(); + + // Transfer ownership to account(4) + set_caller(account(1)); + contract.set_owner(account(4)).unwrap(); + + // Old owner can no longer act + set_caller(account(1)); + assert_eq!(contract.set_owner(account(1)), Err(crate::Error::NotOwner)); + + // New owner can act + set_caller(account(4)); + assert!(contract.set_owner(account(4)).is_ok()); +} + +#[ink::test] +fn new_owner_can_register_issues() { + let mut contract = create_default_contract(); + + set_caller(account(1)); + contract.set_owner(account(4)).unwrap(); + + // New owner registers an issue + set_caller(account(4)); + let result = contract.register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ); + assert!(result.is_ok()); + + // Old owner cannot + set_caller(account(1)); + let result = contract.register_issue( + String::from("https://github.com/org/repo/issues/2"), + String::from("org/repo"), + 2, + MIN_BOUNTY, + ); + assert_eq!(result, Err(crate::Error::NotOwner)); +} + +// ============================================================================ +// Get Total Committed (additional) +// ============================================================================ + +#[ink::test] +fn get_total_committed_sums_multiple_issues() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + let id1 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ) + .unwrap(); + + let id2 = contract + .register_issue( + String::from("https://github.com/org/repo/issues/2"), + String::from("org/repo"), + 2, + MIN_BOUNTY * 2, + ) + .unwrap(); + + // Give each issue partial bounty + let mut issue1 = contract.issues.get(id1).unwrap(); + issue1.bounty_amount = 3_000_000_000; + contract.issues.insert(id1, &issue1); + + let mut issue2 = contract.issues.get(id2).unwrap(); + issue2.bounty_amount = 7_000_000_000; + contract.issues.insert(id2, &issue2); + + assert_eq!(contract.get_total_committed(), 10_000_000_000); +} + +#[ink::test] +fn get_total_committed_includes_active_issues() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Fund it and make it Active + let mut issue = contract.issues.get(id).unwrap(); + issue.bounty_amount = MIN_BOUNTY; + issue.status = crate::IssueStatus::Active; + contract.issues.insert(id, &issue); + + assert_eq!(contract.get_total_committed(), MIN_BOUNTY); +} + +#[ink::test] +fn get_total_committed_includes_completed_with_unpaid_bounty() { + // Completed issue with bounty_amount > 0 means payout failed — funds must stay reserved + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + let mut issue = contract.issues.get(id).unwrap(); + issue.bounty_amount = MIN_BOUNTY; + issue.status = crate::IssueStatus::Completed; + contract.issues.insert(id, &issue); + + assert_eq!(contract.get_total_committed(), MIN_BOUNTY); +} + +#[ink::test] +fn get_total_committed_ignores_completed_with_zero_bounty() { + // Completed issue with bounty_amount = 0 means payout succeeded — not committed + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + let mut issue = contract.issues.get(id).unwrap(); + issue.bounty_amount = 0; + issue.status = crate::IssueStatus::Completed; + contract.issues.insert(id, &issue); + + assert_eq!(contract.get_total_committed(), 0); +} + +// ============================================================================ +// Fill Bounties Edge Cases +// ============================================================================ + +#[ink::test] +fn fill_bounties_resumes_partial_fill() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // First fill: give half + let half = MIN_BOUNTY / 2; + contract.alpha_pool = half; + contract.fill_bounties(); + + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.bounty_amount, half); + assert_eq!(issue.status, crate::IssueStatus::Registered); + + // Second fill: give the other half + contract.alpha_pool = half; + contract.fill_bounties(); + + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.bounty_amount, MIN_BOUNTY); + assert_eq!(issue.status, crate::IssueStatus::Active); +} + +#[ink::test] +fn fill_bounties_with_different_target_amounts() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + // Small bounty + contract + .register_issue( + String::from("https://github.com/org/repo/issues/1"), + String::from("org/repo"), + 1, + MIN_BOUNTY, + ) + .unwrap(); + + // Large bounty (5x) + contract + .register_issue( + String::from("https://github.com/org/repo/issues/2"), + String::from("org/repo"), + 2, + MIN_BOUNTY * 5, + ) + .unwrap(); + + // Give enough for the small one plus partial for the large one + contract.alpha_pool = MIN_BOUNTY * 2; + contract.fill_bounties(); + + let issue1 = contract.get_issue(1).unwrap(); + let issue2 = contract.get_issue(2).unwrap(); + + assert_eq!(issue1.status, crate::IssueStatus::Active); + assert_eq!(issue1.bounty_amount, MIN_BOUNTY); + assert_eq!(issue2.status, crate::IssueStatus::Registered); + assert_eq!(issue2.bounty_amount, MIN_BOUNTY); // got the remainder + assert_eq!(contract.get_alpha_pool(), 0); +} + +#[ink::test] +fn fill_bounties_fully_funded_removed_from_queue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + contract.alpha_pool = MIN_BOUNTY; + contract.fill_bounties(); + + // Fully funded issue should be removed from the queue + assert!(!contract.get_bounty_queue().contains(&id)); +} + +// ============================================================================ +// Cancel Issue on Active Issue +// ============================================================================ + +#[ink::test] +fn cancel_issue_succeeds_on_active_issue() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Make it active + let mut issue = contract.issues.get(id).unwrap(); + issue.status = crate::IssueStatus::Active; + issue.bounty_amount = MIN_BOUNTY; + contract.issues.insert(id, &issue); + + set_caller(account(1)); + assert!(contract.cancel_issue(id).is_ok()); + + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.status, crate::IssueStatus::Cancelled); + assert_eq!(issue.bounty_amount, 0); + assert_eq!(contract.get_alpha_pool(), MIN_BOUNTY); +} + +// ============================================================================ +// Chain Extension Mock Tests -- Treasury / Validator Stake Queries +// ============================================================================ + +#[ink::test] +fn get_treasury_stake_returns_mocked_value() { + register_mock_extension(); + let contract = create_default_contract(); + let stake = contract.get_treasury_stake(); + assert_eq!(stake, MOCK_STAKE as u128); +} + +#[ink::test] +fn get_treasury_stake_returns_zero_when_no_stake() { + register_mock_extension_with_stake(0); + let contract = create_default_contract(); + // Stake is 0 but Some(StakeInfo) is returned -- should get 0 + let stake = contract.get_treasury_stake(); + assert_eq!(stake, 0); +} + +// ============================================================================ +// Vote Solution Happy Path (with mocked chain extension) +// ============================================================================ + +/// Helper: creates a contract with an Active issue and mock extension. +/// bounty_amount is set to 0 so that complete_issue/execute_cancel_issue +/// won't call call_runtime (which the off-chain env doesn't support). +/// This lets us test the full consensus/completion/cancellation flow. +/// Payout transfers require E2E tests against a real Subtensor node. +fn setup_active_issue_with_mock() -> (IssueBountyManager, u64) { + register_mock_extension(); + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Whitelist account(4) as a validator for voting tests + set_caller(account(1)); + contract.add_validator(account(4)).unwrap(); + + let mut issue = contract.issues.get(id).unwrap(); + issue.status = crate::IssueStatus::Active; + issue.bounty_amount = 0; // zero avoids call_runtime in payout/recycle paths + contract.issues.insert(id, &issue); + + (contract, id) +} + +#[ink::test] +fn vote_solution_succeeds_and_completes_issue() { + let (mut contract, id) = setup_active_issue_with_mock(); + + // account(4) votes as a validator with mocked stake + set_caller(account(4)); + let result = contract.vote_solution( + id, + account(6), // solver_hotkey + account(5), // solver_coldkey + 42, // pr_number + ); + assert!(result.is_ok()); + + // With 1 whitelisted validator, required votes = (1/2)+1 = 1, so one vote completes + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.status, crate::IssueStatus::Completed); + assert_eq!(issue.solver_coldkey, Some(account(5))); +} + +#[ink::test] +fn vote_solution_removes_issue_from_bounty_queue() { + let (mut contract, id) = setup_active_issue_with_mock(); + // register_test_issue already added id to the queue + + assert!(contract.get_bounty_queue().contains(&id)); + + set_caller(account(4)); + contract + .vote_solution(id, account(6), account(5), 42) + .unwrap(); + + assert!(!contract.get_bounty_queue().contains(&id)); +} + +#[ink::test] +fn vote_solution_clears_vote_record_after_consensus() { + let (mut contract, id) = setup_active_issue_with_mock(); + + set_caller(account(4)); + contract + .vote_solution(id, account(6), account(5), 42) + .unwrap(); + + // Vote record should be cleaned up after consensus + assert!(contract.solution_votes.get(id).is_none()); +} + +#[ink::test] +fn vote_solution_records_voter() { + let (mut contract, id) = setup_active_issue_with_mock(); + + set_caller(account(4)); + contract + .vote_solution(id, account(6), account(5), 42) + .unwrap(); + + // Voter should be recorded (prevents double voting) + assert!(contract + .solution_vote_voters + .get((id, account(4))) + .unwrap_or(false)); +} + +#[ink::test] +fn vote_solution_fails_for_non_whitelisted_caller() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + let mut issue = contract.issues.get(id).unwrap(); + issue.status = crate::IssueStatus::Active; + issue.bounty_amount = MIN_BOUNTY; + contract.issues.insert(id, &issue); + + // account(4) is not whitelisted + set_caller(account(4)); + let result = contract.vote_solution(id, account(6), account(5), 42); + assert_eq!(result, Err(crate::Error::NotWhitelistedValidator)); +} + +// ============================================================================ +// Vote Cancel Issue Happy Path (with mocked chain extension) +// ============================================================================ + +#[ink::test] +fn vote_cancel_issue_succeeds_on_registered_issue() { + register_mock_extension(); + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Whitelist account(4) as validator + set_caller(account(1)); + contract.add_validator(account(4)).unwrap(); + + set_caller(account(4)); + let result = contract.vote_cancel_issue(id, [0xCC; 32]); + assert!(result.is_ok()); + + // With 1 whitelisted validator, one vote cancels + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.status, crate::IssueStatus::Cancelled); + assert_eq!(issue.bounty_amount, 0); +} + +#[ink::test] +fn vote_cancel_issue_succeeds_on_active_issue() { + let (mut contract, id) = setup_active_issue_with_mock(); + // bounty_amount is 0 from setup, so recycle(0) returns true + // without calling call_runtime + + set_caller(account(4)); + let result = contract.vote_cancel_issue(id, [0xCC; 32]); + assert!(result.is_ok()); + + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.status, crate::IssueStatus::Cancelled); +} + +#[ink::test] +fn vote_cancel_issue_removes_from_bounty_queue() { + register_mock_extension(); + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Whitelist account(4) as validator + set_caller(account(1)); + contract.add_validator(account(4)).unwrap(); + + assert!(contract.get_bounty_queue().contains(&id)); + + set_caller(account(4)); + contract.vote_cancel_issue(id, [0xCC; 32]).unwrap(); + + assert!(!contract.get_bounty_queue().contains(&id)); +} + +#[ink::test] +fn vote_cancel_issue_clears_vote_record_after_consensus() { + register_mock_extension(); + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Whitelist account(4) as validator + set_caller(account(1)); + contract.add_validator(account(4)).unwrap(); + + set_caller(account(4)); + contract.vote_cancel_issue(id, [0xCC; 32]).unwrap(); + + assert!(contract.cancel_issue_votes.get(id).is_none()); +} + +#[ink::test] +fn vote_cancel_issue_records_voter() { + register_mock_extension(); + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Whitelist account(4) as validator + set_caller(account(1)); + contract.add_validator(account(4)).unwrap(); + + set_caller(account(4)); + contract.vote_cancel_issue(id, [0xCC; 32]).unwrap(); + + assert!(contract + .cancel_issue_voters + .get((id, account(4))) + .unwrap_or(false)); +} + +#[ink::test] +fn vote_cancel_issue_fails_for_non_whitelisted_caller() { + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // account(4) is not whitelisted + set_caller(account(4)); + let result = contract.vote_cancel_issue(id, [0xCC; 32]); + assert_eq!(result, Err(crate::Error::NotWhitelistedValidator)); +} + +// ============================================================================ +// Validator Whitelist Tests +// ============================================================================ + +#[ink::test] +fn add_validator_succeeds() { + let mut contract = create_default_contract(); + set_caller(account(1)); + assert!(contract.add_validator(account(3)).is_ok()); + assert_eq!(contract.get_validators(), vec![account(3)]); +} + +#[ink::test] +fn add_validator_fails_for_non_owner() { + let mut contract = create_default_contract(); + set_caller(account(4)); + assert_eq!(contract.add_validator(account(3)), Err(crate::Error::NotOwner)); +} + +#[ink::test] +fn add_validator_fails_duplicate() { + let mut contract = create_default_contract(); + set_caller(account(1)); + contract.add_validator(account(3)).unwrap(); + assert_eq!( + contract.add_validator(account(3)), + Err(crate::Error::ValidatorAlreadyWhitelisted), + ); +} + +#[ink::test] +fn remove_validator_succeeds() { + let mut contract = create_default_contract(); + set_caller(account(1)); + contract.add_validator(account(3)).unwrap(); + assert!(contract.remove_validator(account(3)).is_ok()); + assert!(contract.get_validators().is_empty()); +} + +#[ink::test] +fn remove_validator_fails_for_non_owner() { + let mut contract = create_default_contract(); + set_caller(account(1)); + contract.add_validator(account(3)).unwrap(); + + set_caller(account(4)); + assert_eq!(contract.remove_validator(account(3)), Err(crate::Error::NotOwner)); +} + +#[ink::test] +fn remove_validator_fails_not_whitelisted() { + let mut contract = create_default_contract(); + set_caller(account(1)); + assert_eq!( + contract.remove_validator(account(3)), + Err(crate::Error::ValidatorNotWhitelisted), + ); +} + +#[ink::test] +fn required_votes_scales_with_validator_count() { + let mut contract = create_default_contract(); + set_caller(account(1)); + + // 0 validators: (0/2)+1 = 1 (but consensus blocked by n==0 guard) + assert_eq!(contract.required_validator_votes(), 1); + + // 1 validator: (1/2)+1 = 1 + contract.add_validator(account(3)).unwrap(); + assert_eq!(contract.required_validator_votes(), 1); + + // 2 validators: (2/2)+1 = 2 (unanimity) + contract.add_validator(account(4)).unwrap(); + assert_eq!(contract.required_validator_votes(), 2); + + // 3 validators: (3/2)+1 = 2 (simple majority) + contract.add_validator(account(5)).unwrap(); + assert_eq!(contract.required_validator_votes(), 2); + + // 4 validators: (4/2)+1 = 3 + contract.add_validator(account(6)).unwrap(); + assert_eq!(contract.required_validator_votes(), 3); + + // 5 validators: (5/2)+1 = 3 + contract.add_validator(account(7)).unwrap(); + assert_eq!(contract.required_validator_votes(), 3); +} + +// ============================================================================ +// 3-Validator Majority Tests (2 of 3 required) +// ============================================================================ + +/// Helper: creates contract with 3 whitelisted validators and an Active issue. +/// Uses accounts 3, 4, 5 as validators. bounty_amount = 0 to avoid call_runtime. +fn setup_3_validator_active_issue() -> (IssueBountyManager, u64) { + register_mock_extension(); + let mut contract = create_default_contract(); + let id = register_test_issue(&mut contract); + + // Whitelist 3 validators: required votes = (3/2)+1 = 2 + set_caller(account(1)); + contract.add_validator(account(3)).unwrap(); + contract.add_validator(account(4)).unwrap(); + contract.add_validator(account(5)).unwrap(); + + let mut issue = contract.issues.get(id).unwrap(); + issue.status = crate::IssueStatus::Active; + issue.bounty_amount = 0; + contract.issues.insert(id, &issue); + + (contract, id) +} + +#[ink::test] +fn three_validators_one_vote_does_not_complete() { + let (mut contract, id) = setup_3_validator_active_issue(); + + // First vote: not enough for consensus + set_caller(account(3)); + contract.vote_solution(id, account(6), account(5), 42).unwrap(); + + // Issue should still be Active (1 vote < 2 required) + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.status, crate::IssueStatus::Active); + + // Vote record should still exist (not cleared) + assert!(contract.solution_votes.get(id).is_some()); + let vote = contract.solution_votes.get(id).unwrap(); + assert_eq!(vote.votes_count, 1); +} + +#[ink::test] +fn three_validators_two_votes_completes() { + let (mut contract, id) = setup_3_validator_active_issue(); + + // First vote + set_caller(account(3)); + contract.vote_solution(id, account(6), account(5), 42).unwrap(); + + // Second vote reaches majority (2 of 3) + set_caller(account(4)); + contract.vote_solution(id, account(6), account(5), 42).unwrap(); + + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.status, crate::IssueStatus::Completed); + assert_eq!(issue.solver_coldkey, Some(account(5))); + assert_eq!(issue.solver_hotkey, Some(account(6))); + assert_eq!(issue.winning_pr_number, Some(42)); + + // Vote record should be cleared after consensus + assert!(contract.solution_votes.get(id).is_none()); +} + +#[ink::test] +fn three_validators_cancel_needs_two_votes() { + let (mut contract, id) = setup_3_validator_active_issue(); + + // First cancel vote: not enough + set_caller(account(3)); + contract.vote_cancel_issue(id, [0xCC; 32]).unwrap(); + + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.status, crate::IssueStatus::Active); + + // Second cancel vote: majority reached + set_caller(account(4)); + contract.vote_cancel_issue(id, [0xCC; 32]).unwrap(); + + let issue = contract.get_issue(id).unwrap(); + assert_eq!(issue.status, crate::IssueStatus::Cancelled); +} + +#[ink::test] +fn three_validators_third_vote_still_blocked_after_consensus() { + let (mut contract, id) = setup_3_validator_active_issue(); + + // Two votes complete the issue + set_caller(account(3)); + contract.vote_solution(id, account(6), account(5), 42).unwrap(); + set_caller(account(4)); + contract.vote_solution(id, account(6), account(5), 42).unwrap(); + + // Third validator tries to vote on now-Completed issue + set_caller(account(5)); + let result = contract.vote_solution(id, account(6), account(5), 42); + assert_eq!(result, Err(crate::Error::IssueNotActive)); +} + +// ============================================================================ +// Failed Payout → Harvest Recycling Protection +// ============================================================================ + +#[ink::test] +fn failed_payout_funds_not_recycled_by_harvest() { + // Simulates: issue completed with failed payout → harvest must not recycle those funds. + // + // call_runtime panics in the off-chain test env, so we can't drive the + // payout through vote_solution. Instead we manually set the post-failure + // state (Completed + bounty_amount > 0) which is exactly what complete_issue + // produces when execute_payout_internal returns TransferFailed. + + let bounty = MOCK_STAKE as u128; + register_mock_extension_with_stake(MOCK_STAKE); + let mut contract = create_default_contract(); + + let id = register_test_issue(&mut contract); + + // Simulate failed-payout state: Completed with bounty_amount still set + let mut issue = contract.issues.get(id).unwrap(); + issue.bounty_amount = bounty; + issue.status = crate::IssueStatus::Completed; + issue.solver_coldkey = Some(account(5)); + issue.solver_hotkey = Some(account(6)); + issue.winning_pr_number = Some(42); + contract.issues.insert(id, &issue); + + // get_total_committed must include the failed-payout funds + assert_eq!( + contract.get_total_committed(), + bounty, + "committed should include completed issue with unpaid bounty" + ); + + // Harvest: stake = bounty = committed → available = 0 → nothing recycled + set_caller(account(1)); + let result = contract.harvest_emissions().unwrap(); + assert_eq!( + result.recycled, 0, + "must not recycle funds reserved for retry payout" + ); + assert_eq!(result.harvested, 0); + + // Funds still committed after harvest + assert_eq!(contract.get_total_committed(), bounty); + let issue = contract.get_issue(id).unwrap(); + assert_eq!( + issue.bounty_amount, bounty, + "bounty_amount must survive harvest for retry via payout_bounty" + ); +} diff --git a/smart-contracts/issues-v0/types.rs b/smart-contracts/issues-v0/types.rs new file mode 100644 index 00000000..5a91202d --- /dev/null +++ b/smart-contracts/issues-v0/types.rs @@ -0,0 +1,128 @@ +use ink::prelude::string::String; +use ink::primitives::AccountId; +use scale::{Compact, Decode, Encode}; + +/// StakeInfo returned by chain extension function 0. +/// Must match subtensor's StakeInfo struct exactly for SCALE decoding. +/// The chain extension returns Option, so we decode Some(StakeInfo) +/// by skipping the Option discriminant byte. +#[derive(Debug, Clone, Decode, Encode)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct StakeInfo { + pub hotkey: AccountId, + pub coldkey: AccountId, + pub netuid: Compact, + pub stake: Compact, // THE VALUE WE NEED + pub locked: Compact, + pub emission: Compact, + pub tao_emission: Compact, + pub drain: Compact, + pub is_registered: bool, +} + +/// Status of an issue in its lifecycle +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, Default)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] +pub enum IssueStatus { + /// Issue registered, awaiting bounty fill + #[default] + Registered, + /// Issue has bounty filled, ready for solution + Active, + /// Issue has been completed (solution found) + Completed, + /// Issue was cancelled + Cancelled, +} + + +/// Represents a GitHub issue registered for bounty +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Default)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] +pub struct Issue { + /// Unique issue ID + pub id: u64, + /// Hash of the GitHub issue URL + pub github_url_hash: [u8; 32], + /// Repository in "owner/repo" format + pub repository_full_name: String, + /// Issue number within the repository + pub issue_number: u32, + /// Current bounty amount allocated + pub bounty_amount: u128, + /// Target bounty amount + pub target_bounty: u128, + /// Current status of the issue + pub status: IssueStatus, + /// Block number when registered + pub registered_at_block: u32, + /// Solver coldkey (set when issue is completed via consensus) - receives payout + pub solver_coldkey: Option, + /// Solver hotkey (set when issue is completed via consensus) - the miner identity + pub solver_hotkey: Option, + /// Winning PR number (set when issue is completed) - combined with repository_full_name to form URL + pub winning_pr_number: Option, +} + + +/// Votes for a solution on an issue +#[derive(Debug, Clone, Encode, Decode)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] +pub struct SolutionVote { + /// Issue this vote is for + pub issue_id: u64, + /// Proposed solver's hotkey + pub solver_hotkey: AccountId, + /// Proposed solver's coldkey (for payout) + pub solver_coldkey: AccountId, + /// PR number (combined with issue's repository_full_name to form URL) + pub pr_number: u32, + /// Number of votes cast + pub votes_count: u32, +} + +impl Default for SolutionVote { + fn default() -> Self { + Self { + issue_id: 0, + solver_hotkey: AccountId::from([0u8; 32]), + solver_coldkey: AccountId::from([0u8; 32]), + pr_number: 0, + votes_count: 0, + } + } +} + +/// Votes for cancelling an issue +#[derive(Debug, Clone, Encode, Decode, Default)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] +pub struct CancelVote { + /// Issue this vote is for + pub issue_id: u64, + /// Hash of the reason for cancellation + pub reason_hash: [u8; 32], + /// Number of votes cast + pub votes_count: u32, +} + +/// Result of a harvest_emissions call +#[derive(Debug, Clone, Encode, Decode, Default)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct HarvestResult { + /// Total amount harvested from emissions + pub harvested: u128, + /// Number of bounties filled + pub bounties_filled: u32, + /// Amount recycled to owner + pub recycled: u128, +} + +/// Contract configuration returned by get_config() +#[derive(Debug, Clone, Encode, Decode)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct ContractConfig { + /// Number of validator votes required for consensus + pub required_validator_votes: u32, + /// Subnet ID + pub netuid: u16, +} diff --git a/tests/utils/test_github_api_tools.py b/tests/utils/test_github_api_tools.py index 9f7bf01a..5b979504 100644 --- a/tests/utils/test_github_api_tools.py +++ b/tests/utils/test_github_api_tools.py @@ -492,5 +492,223 @@ def test_exponential_backoff_timing(self, mock_logging, mock_sleep, mock_get): assert mock_sleep.call_count == 3 +# ============================================================================ +# Solver Detection Tests +# ============================================================================ + +find_solver_from_timeline = github_api_tools.find_solver_from_timeline +find_solver_from_cross_references = github_api_tools.find_solver_from_cross_references + + +def _graphql_response(nodes): + """Helper to build a GraphQL cross-reference response.""" + return { + 'data': { + 'repository': { + 'issue': { + 'timelineItems': { + 'nodes': nodes, + }, + }, + }, + }, + } + + +def _pr_node( + number, merged=True, merged_at='2025-06-01T00:00:00Z', user_id=42, base_repo='owner/repo', closing_issues=None +): + """Helper to build a single cross-referenced PR node.""" + return { + 'source': { + 'number': number, + 'merged': merged, + 'mergedAt': merged_at, + 'author': {'databaseId': user_id}, + 'baseRepository': {'nameWithOwner': base_repo}, + 'closingIssuesReferences': { + 'nodes': [{'number': n} for n in (closing_issues or [])], + }, + }, + } + + +class TestFindSolverFromCrossReferences: + """Test suite for find_solver_from_cross_references (GraphQL-based solver detection).""" + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_single_merged_pr_closing_issue(self, mock_logging, mock_graphql): + """Single merged PR with closing reference returns correct solver.""" + mock_graphql.return_value = _graphql_response( + [ + _pr_node(number=14, user_id=42, closing_issues=[12]), + ] + ) + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id == 42 + assert pr_number == 14 + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_unmerged_pr_is_filtered_out(self, mock_logging, mock_graphql): + """Unmerged PRs are ignored even if they have closing references.""" + mock_graphql.return_value = _graphql_response( + [ + _pr_node(number=14, merged=False, user_id=42, closing_issues=[12]), + ] + ) + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id is None + assert pr_number is None + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_pr_from_different_repo_is_filtered_out(self, mock_logging, mock_graphql): + """PRs targeting a different base repo are rejected (prevents cross-repo gaming).""" + mock_graphql.return_value = _graphql_response( + [ + _pr_node(number=14, user_id=99, base_repo='attacker/evil-repo', closing_issues=[12]), + ] + ) + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id is None + assert pr_number is None + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_pr_mentioning_but_not_closing_issue_is_filtered_out(self, mock_logging, mock_graphql): + """PRs that mention the issue but don't have it in closingIssuesReferences are ignored.""" + mock_graphql.return_value = _graphql_response( + [ + _pr_node(number=14, user_id=42, closing_issues=[99]), # Closes #99, not #12 + ] + ) + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id is None + assert pr_number is None + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_multiple_candidates_picks_most_recent(self, mock_logging, mock_graphql): + """When multiple merged PRs close the issue, the most recently merged one is selected.""" + mock_graphql.return_value = _graphql_response( + [ + _pr_node(number=10, user_id=100, merged_at='2025-01-01T00:00:00Z', closing_issues=[12]), + _pr_node(number=20, user_id=200, merged_at='2025-06-15T00:00:00Z', closing_issues=[12]), + _pr_node(number=15, user_id=150, merged_at='2025-03-01T00:00:00Z', closing_issues=[12]), + ] + ) + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id == 200 + assert pr_number == 20 + mock_logging.warning.assert_called() # Should warn about multiple candidates + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_mixed_valid_and_invalid_candidates(self, mock_logging, mock_graphql): + """Only valid candidates survive all filters (merged + same repo + closing ref).""" + mock_graphql.return_value = _graphql_response( + [ + # Invalid: unmerged + _pr_node(number=10, merged=False, user_id=100, closing_issues=[12]), + # Invalid: wrong repo + _pr_node(number=11, user_id=101, base_repo='other/repo', closing_issues=[12]), + # Invalid: doesn't close this issue + _pr_node(number=13, user_id=103, closing_issues=[99]), + # Valid + _pr_node(number=14, user_id=42, merged_at='2025-06-01T00:00:00Z', closing_issues=[12]), + ] + ) + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id == 42 + assert pr_number == 14 + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_fork_pr_targeting_main_repo_is_accepted(self, mock_logging, mock_graphql): + """PRs from forks that target the main repo (baseRepository matches) are accepted.""" + mock_graphql.return_value = _graphql_response( + [ + _pr_node( + number=14, + user_id=42, + base_repo='owner/repo', # PR targets the main repo + closing_issues=[12], + ), + ] + ) + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id == 42 + assert pr_number == 14 + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_base_repo_check_is_case_insensitive(self, mock_logging, mock_graphql): + """Base repo comparison is case-insensitive (GitHub repos are case-insensitive).""" + mock_graphql.return_value = _graphql_response( + [ + _pr_node(number=14, user_id=42, base_repo='Owner/Repo', closing_issues=[12]), + ] + ) + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id == 42 + assert pr_number == 14 + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_no_cross_references_returns_none(self, mock_logging, mock_graphql): + """Empty timeline nodes returns (None, None).""" + mock_graphql.return_value = _graphql_response([]) + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id is None + assert pr_number is None + + @patch('gittensor.utils.github_api_tools.execute_graphql_query') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_graphql_query_failure_returns_none(self, mock_logging, mock_graphql): + """GraphQL query failure returns (None, None).""" + mock_graphql.return_value = None + + solver_id, pr_number = find_solver_from_cross_references('owner/repo', 12, 'fake_token') + + assert solver_id is None + assert pr_number is None + + +class TestFindSolverFromTimeline: + """Test that find_solver_from_timeline delegates to cross-references.""" + + @patch('gittensor.utils.github_api_tools.find_solver_from_cross_references') + @patch('gittensor.utils.github_api_tools.bt.logging') + def test_delegates_to_cross_references(self, mock_logging, mock_cross_ref): + """find_solver_from_timeline delegates directly to find_solver_from_cross_references.""" + mock_cross_ref.return_value = (42, 14) + + solver_id, pr_number = find_solver_from_timeline('owner/repo', 12, 'fake_token') + + assert solver_id == 42 + assert pr_number == 14 + mock_cross_ref.assert_called_once_with('owner/repo', 12, 'fake_token') + + if __name__ == '__main__': pytest.main([__file__, '-v'])