diff --git a/gittensor/validator/issue_competitions/forward.py b/gittensor/validator/issue_competitions/forward.py index cc6debb0..67fd46f7 100644 --- a/gittensor/validator/issue_competitions/forward.py +++ b/gittensor/validator/issue_competitions/forward.py @@ -28,8 +28,8 @@ async def issue_competitions( 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 eligible miner -> vote_solution - - If closed but not by eligible miner -> vote_cancel_issue + - If solved by a registered miner with a non-failed identity -> vote_solution + - If closed without a registered solver identity -> vote_cancel_issue Args: self: The validator instance @@ -61,13 +61,15 @@ async def issue_competitions( 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 every registered miner. Bounty - # payouts are not eligibility-gated — any miner who solves a bounty - # issue can receive the reward. + # Build mapping of github_id->hotkey for every non-failed registered + # miner. Bounty payouts are not normal eligibility-gated, but failed + # evaluations are not valid payout targets. Duplicate GitHub IDs are + # penalized in oss_contributions() before this pass and arrive here as + # failed evaluations. registered_miners = { eval.github_id: eval.hotkey for eval in miner_evaluations.values() - if eval.github_id and eval.github_id != '0' + if eval.github_id and eval.github_id != '0' and eval.failed_reason is None } bt.logging.info( f'Issue bounties: {len(registered_miners)} registered miners out of {len(miner_evaluations)} total' diff --git a/tests/validator/test_issue_competitions_forward.py b/tests/validator/test_issue_competitions_forward.py index 9547953d..328b9f27 100644 --- a/tests/validator/test_issue_competitions_forward.py +++ b/tests/validator/test_issue_competitions_forward.py @@ -135,3 +135,42 @@ def test_ineligible_miner_still_receives_solution_vote(): contract_client.vote_solution.assert_called_once() contract_client.vote_cancel_issue.assert_not_called() + + +def test_failed_miner_does_not_receive_solution_vote(): + validator = _make_validator() + contract_client = MagicMock() + contract_client.harvest_emissions.return_value = None + contract_client.get_issues_by_status.return_value = [_make_issue()] + contract_client.vote_cancel_issue.return_value = True + + miner_eval = MinerEvaluation(uid=5, hotkey='hk5', github_id='999') + miner_eval.failed_reason = 'Penalized: duplicate GitHub account' + + with ( + patch('gittensor.validator.issue_competitions.forward.GITTENSOR_VALIDATOR_PAT', 'ghp_validator'), + patch('gittensor.validator.issue_competitions.forward.get_contract_address', return_value='5Contract'), + patch( + 'gittensor.validator.issue_competitions.forward.check_github_issue_closed', + return_value={ + 'is_closed': True, + 'solver_github_id': '999', + 'pr_number': 42, + 'solver_lookup_failed': False, + }, + ), + patch('gittensor.validator.issue_competitions.forward.get_miner_coldkey') as mock_get_coldkey, + patch( + 'gittensor.validator.issue_competitions.forward.IssueCompetitionContractClient', + return_value=contract_client, + ), + ): + _run(issue_competitions(cast(Any, validator), {5: miner_eval})) + + mock_get_coldkey.assert_not_called() + contract_client.vote_solution.assert_not_called() + contract_client.vote_cancel_issue.assert_called_once_with( + issue_id=7, + reason='Issue closed externally (not by a registered miner, solver: 999)', + wallet=validator.wallet, + )