Foresight

Foresight Contracts

Cantina Security Report

Organization

@foresightnow

Engagement Type

Cantina Reviews

Period

-

Researchers


Findings

High Risk

1 findings

1 fixed

0 acknowledged

Medium Risk

3 findings

3 fixed

0 acknowledged

Low Risk

4 findings

4 fixed

0 acknowledged

Informational

5 findings

5 fixed

0 acknowledged


High Risk1 finding

  1. Attackers can arbitrage through splitPosition()

    Severity

    Severity: High

    Likelihood: Medium

    ×

    Impact: High

    Submitted by

    cccz


    Description

    In LMSR, the sum of prices for disjoint assets equals 1. However, in LSLMSR, the sum of prices for disjoint assets can be greater than 1. That is, for markets with YES/NO outputs, 1 YES + 1 NO ≥ 1 collateralToken. Moreover, the sum of prices grows as the selected alpha grows.

    sum of prices = 1 + alpah * N * lnN (N is output number)

    However, in the ConditionalTokens contract, splitPosition()/mergePositions() still retain the assumptions from LMSR. Any user can call splitPosition() to mint 1 collateralToken into 1 YES + 1 NO, or call mergePositions() to merge 1 YES + 1 NO back into 1 collateralToken.

    This introduces arbitrage opportunities for attackers, who can call splitPosition() to mint cheap YES and NO shares, then sell them on the market for profit.

    Proof of Concept

    function testTradearbitrage() public {        vm.startPrank(USER_A);        collateralToken.approve(address(marketMaker), 1000e6);        int[] memory buyAmounts = new int[](2);        buyAmounts[0] = 5e6;        buyAmounts[1] = 5e6;        int buyCost = marketMaker.trade(buyAmounts, 0, "", false); // make volume positive        console2.log("Buy 5e6 YES, 5e6 NO, USER_A netCost:", buyCost); // 12968991 => 12.9e6        vm.stopPrank(); 
            uint balanceBefore = collateralToken.balanceOf(USER);        vm.startPrank(USER);        collateralToken.approve(address(conditionalTokens), 5e6);        uint[] memory partition = new uint[](2);        partition[0] = 1; // YES        partition[1] = 2; // NO        conditionalTokens.splitPosition(collateralToken, bytes32(0), conditionIds[0], partition, 5e6);
            // Approve market maker to transfer ERC1155 tokens (outcome tokens)        conditionalTokens.setApprovalForAll(address(marketMaker), true);
            // Then sell 5 YES tokens back        int[] memory sellAmounts = new int[](2);        sellAmounts[0] = -5e6;        sellAmounts[1] = -5e6;        int sellCost = marketMaker.trade(sellAmounts, 0, "", false);        console2.log("Sell 5e6 YES, 5e6 NO, USER netCost:", sellCost); // -10976953 => -10.9e6        vm.stopPrank();        uint balanceAfter = collateralToken.balanceOf(USER);        console2.log("USER profit", balanceAfter - balanceBefore); // 5976953 => 5.9e6    }

    Recommendation

    It is recommended that only the MARKET_ROLE be allowed to access the splitPosition()/mergePositions() functions.

Medium Risk3 findings

  1. Improper alpha will cause calcNetCost() to always revert

    Severity

    Severity: Medium

    Likelihood: Medium

    ×

    Impact: Medium

    Submitted by

    cccz


    Description

    exp() limits the value of x. When x exceeds it, exp() will revert.

    function exp(int x)        public        pure        returns (uint)    {        // revert if x is > MAX_POWER, where        // MAX_POWER = int(mp.floor(mp.log(mpf(2**256 - 1) / ONE) * ONE))        require(x <= 2454971259878909886679);

    When calcNetCost() calls exp(), input_fixed (i.e. x) = q * 1e18 / ( Q * alpah) * 2 ^ 64 <= 2454971259878909886679, where q / Q = 1/atomicOutcomeSlotCount.

    It can be derived that alpha > 1e18/133 / atomicOutcomeSlotCount

    When atomicOutcomeSlotCount is 2, alpha must be greater than 3.76e15.(Considering that math accumulates the value of exp(), alpah needs to be greater (such as 3.8e15) to avoid overflow).

    locals.Q_before = 0;        for (uint i = 0; i < atomicOutcomeSlotCount; i++) {            locals.qBefore[i] = pmSystem.totalSupply(generateAtomicPositionId(i));            locals.Q_before += locals.qBefore[i];        }        // b in 1e6 scale        locals.b = (alpha * locals.Q_before) / 1e18;        // b in fixed-point scale (ONE = 2^64)        locals.b_fixed_uint = (locals.b * ONE) / 1e6;        require(locals.b_fixed_uint <= uint(type(int).max), "b_fixed overflow");        locals.b_fixed = int(locals.b_fixed_uint);        // 2. Compute sumExpBefore (fixed-point)        locals.sumExpBefore = 0;        for (uint i = 0; i < atomicOutcomeSlotCount; i++) {            uint q_i_fixed_uint = (locals.qBefore[i] * ONE) / 1e6;            require(q_i_fixed_uint <= uint(type(int).max), "q_i_fixed overflow");            int q_i_fixed = int(q_i_fixed_uint);            int input_fixed = Fixed192x64Math.div(q_i_fixed, locals.b_fixed);            locals.sumExpBefore += Fixed192x64Math.exp(input_fixed);

    Recommendation

    It is recommended to set a lower limit for alpha that does not cause overflow

    Additionally, it is important to note that the larger the alpha, the faster the share price increases, which may cause the purchase price to exceed the winner's reward. So it is best to limit alpha to a reasonable range.

  2. trade() does not handle the case where rewardsManager is address(0)

    Severity

    Severity: Medium

    Likelihood: Medium

    ×

    Impact: Medium

    Submitted by

    cccz


    Description

    The rewardsManager was allowed to be address(0) when creating the market.

    // Set factory-level rewards manager if configured        if (address(factoryRewardsManager)!= address(0)) {            lmsrMarketMaker.setRewardsManager(address(factoryRewardsManager));            factoryRewardsManager.authorizeMarket(address(lmsrMarketMaker));        }

    But trade() did not handle it, which will cause buying to fail.

    if (!isReferralContract) {            // For buys (user pays collateral to market)            if (outcomeTokenNetCost > 0) {                uint netCostUint = (uint(outcomeTokenNetCost) * FEE_RANGE) / (FEE_RANGE - fee);                tradingFeeAmount = netCostUint - uint(outcomeTokenNetCost);                netCost = int(netCostUint);                volume += uint(outcomeTokenNetCost);                rewardsManager.recordBet(msg.sender, uint(outcomeTokenNetCost));

    Recommendation

    Change to

    if (!isReferralContract) {            // For buys (user pays collateral to market)            if (outcomeTokenNetCost > 0) {                uint netCostUint = (uint(outcomeTokenNetCost) * FEE_RANGE) / (FEE_RANGE - fee);                tradingFeeAmount = netCostUint - uint(outcomeTokenNetCost);                netCost = int(netCostUint);                volume += uint(outcomeTokenNetCost);-               rewardsManager.recordBet(msg.sender, uint(outcomeTokenNetCost));+               if(rewardsManager != address(0)) +                   rewardsManager.recordBet(msg.sender, uint(outcomeTokenNetCost));
  3. Overcounting of referral fees

    Severity

    Severity: Medium

    Likelihood: Medium

    ×

    Impact: Medium

    Submitted by

    cccz


    Description

    _handleReferralTrade() directly uses tradingFeeAmount to calculate the referral fee. tradingFeeAmount refers to the fee for all shares bought by the user, not just the outcomeIndex share in the referralCode.

    function _handleReferralTrade(        address trader,        string memory referralCode,        uint tradingFeeAmount    ) internal {        uint256 referralEarnings = (tradingFeeAmount * REFERRAL_FEE_PORTION) / 1e18;        address referrer = referralSystem.getUserThroughReferralCode(referralCode);
            referralSystem.processReferral(trader, referralCode, referralEarnings, address(this));
            emit ReferralTrade(trader, referrer, referralCode, referralEarnings);    }

    This means that for YES share referral code, when the user buys both YES and NO shares, the referrer will receive referral fees for both. Instead, if the user buys NO shares alone, the referrer is not eligible for the referral fee.

    Recommendation

    It is recommended to use only the cost of referral code shares as the base to calculate the referral fee.

Low Risk4 findings

  1. The protocol does not support multiple conditions

    Severity

    Severity: Low

    Likelihood: Low

    ×

    Impact: High

    Submitted by

    cccz


    Description

    When closing the market in close(), the protocol assumes that the market has only one condition. This means that if multiple conditions are used to create the market, the market will not be able to be closed.

    for (uint i = 0; i < atomicOutcomeSlotCount; i++) {            uint payoutNumerator = pmSystem.payoutNumerators(conditionIds[0], i);

    Proof of Concept

    function testMarketClose() public {        // Set factory-level addresses        vm.startPrank(DEPLOYER);        factory.setFactoryTreasuryAddress(TREASURY);        factory.setFactoryReferralSystemAddress(address(referralSystem));        referralSystem.grantFactoryRole(address(factory));        vm.stopPrank();
            // Prepare condition        vm.startPrank(ORACLE);        conditionalTokens.prepareCondition(ORACLE, QUESTION_ID_1, OUTCOME_SLOTS);        conditionalTokens.prepareCondition(ORACLE, QUESTION_ID_1, 3);        vm.stopPrank();
            bytes32 conditionId = conditionalTokens.getConditionId(ORACLE, QUESTION_ID_1, OUTCOME_SLOTS);        bytes32 conditionId2 = conditionalTokens.getConditionId(ORACLE, QUESTION_ID_1, 3);
            bytes32[] memory conditionIds = new bytes32[](2);        conditionIds[0] = conditionId;        conditionIds[1] = conditionId2;
            // Create market        vm.startPrank(DEPLOYER);        collateralToken.approve(address(factory), INITIAL_FUNDING);
            LSLMSRMarketMaker newMarket = factory.createLSLMSRMarketMaker(            conditionalTokens,            IERC20(address(collateralToken)),            conditionIds,            FEE,            whitelist,            INITIAL_FUNDING,            ALPHA,            block.timestamp + 1 days,            ORACLE        );        vm.stopPrank();        vm.startPrank(ORACLE);        uint[] memory payouts1 = new uint[](2);        payouts1[0] = 1;        payouts1[1] = 0;        uint[] memory payouts2 = new uint[](3);        payouts2[0] = 1;        payouts2[1] = 0;        payouts2[2] = 0;        conditionalTokens.reportPayouts(QUESTION_ID_1, payouts1);        conditionalTokens.reportPayouts(QUESTION_ID_1, payouts2);        vm.stopPrank();
            vm.startPrank(DEPLOYER);        newMarket.close();  // revert        vm.stopPrank();    }

    Recommendation

    It is recommended to add a check for conditionIds.length == 1 in createLSLMSRMarketMaker().

    function createLSLMSRMarketMaker(        ConditionalTokens pmSystem,        IERC20 collateralToken,        bytes32[] calldata conditionIds,        uint64 fee,        Whitelist whitelist,        uint funding,        uint256 alpha,        uint marketDeadline,        address oracle    )        external        onlyRole(DEFAULT_ADMIN_ROLE)        returns (LSLMSRMarketMaker lmsrMarketMaker)    {+       require(conditionIds.length == 1);        require(alpha * funding * 2 > 1e18, "alpha * funding * 2 must be greater than 1e18");        require(fee < FEE_RANGE, "Fee must be less than 100%");
  2. Logic flaws in registering referral codes

    Severity

    Severity: Low

    Likelihood: Medium

    ×

    Impact: Low

    Submitted by

    cccz


    Description

    In trade(), the referral code should only be registered when the user purchases shares.

    if (conditionIds.length > 0 && address(referralSystem) != address(0) && msg.sender != address(referralSystem)) {            uint256 outcomeIndex = 0;            // Find the first index in outcomeTokenAmounts that is > 0 (assume binary for now)            for (uint256 i = 0; i < outcomeTokenAmounts.length; i++) {                if (outcomeTokenAmounts[i] > 0) {                    outcomeIndex = i;                    break;                }            }            string memory generatedCode = referralSystem.generateReferralCode(msg.sender, conditionIds[0], outcomeIndex);            if (!referralSystem.isValidReferralCode(generatedCode)) {                referralSystem.registerReferralCode(msg.sender, conditionIds[0], outcomeIndex, address(this));            }        }

    But the exception is that even if all outcomeTokenAmounts are less than or equal to 0, since outcomeIndex defaults to 0, the protocol will register a referral code with outcomeIndex == 0 for the user.

    Proof of Concept

    function testGetReferralCodeDetails2() public {        bytes32 conditionId = conditionalTokens.getConditionId(ORACLE, QUESTION_ID, OUTCOME_SLOTS);        uint256 outcomeIndex = 1;        vm.startPrank(REFERRER);        int[] memory buyAmount = new int[](2);        lmsr.trade(buyAmount, 0, "", false);        string memory referralCode = referralSystem.generateReferralCode(REFERRER, conditionId, 0);
            (address user, bytes32 returnedConditionId, uint256 returnedOutcomeIndex) = referralSystem.getReferralCodeDetails(referralCode);        vm.stopPrank();        assertEq(user, REFERRER);        assertEq(returnedConditionId, conditionId);        assertEq(returnedOutcomeIndex, 0);    }

    Recommendation

    if (conditionIds.length > 0 && address(referralSystem) != address(0) && msg.sender != address(referralSystem)) {            uint256 outcomeIndex = 0;+           bool found;            // Find the first index in outcomeTokenAmounts that is > 0 (assume binary for now)            for (uint256 i = 0; i < outcomeTokenAmounts.length; i++) {                if (outcomeTokenAmounts[i] > 0) {+                   found = true;                    outcomeIndex = i;                    break;                }            }            string memory generatedCode = referralSystem.generateReferralCode(msg.sender, conditionIds[0], outcomeIndex);-           if (!referralSystem.isValidReferralCode(generatedCode)) {+           if (found && !referralSystem.isValidReferralCode(generatedCode)) {                referralSystem.registerReferralCode(msg.sender, conditionIds[0], outcomeIndex, address(this));            }        }
  3. Incorrect check for alpha * funding in createLSLMSRMarketMaker()

    Severity

    Severity: Low

    Likelihood: Low

    ×

    Impact: Medium

    Submitted by

    cccz


    Description

    The check for alpha * funding in createLSLMSRMarketMaker() is to prevent locals.b from being rounded down to 0. The problem here is that it assumes atomicOutcomeSlotCount is always 2.

    function createLSLMSRMarketMaker(        ConditionalTokens pmSystem,        IERC20 collateralToken,        bytes32[] calldata conditionIds,        uint64 fee,        Whitelist whitelist,        uint funding,        uint256 alpha,        uint marketDeadline,        address oracle    )        external        onlyRole(DEFAULT_ADMIN_ROLE)        returns (LSLMSRMarketMaker lmsrMarketMaker)    {        require(alpha * funding * 2 > 1e18, "alpha * funding * 2 must be greater than 1e18");...        locals.Q_before = 0;        for (uint i = 0; i < atomicOutcomeSlotCount; i++) {            locals.qBefore[i] = pmSystem.totalSupply(generateAtomicPositionId(i));            locals.Q_before += locals.qBefore[i];        }        // b in 1e6 scale        locals.b = (alpha * locals.Q_before) / 1e18;

    Recommendation

    It is recommended to use atomicOutcomeSlotCount instead of 2 to check alpha * funding.

  4. Missing Deadline Check in reportPayouts Function

    Severity

    Severity: Low

    Likelihood: Low

    ×

    Impact: Medium

    Submitted by

    Arno


    Description

    The reportPayouts function in ConditionalTokens allows oracles to report market outcomes without checking if the market deadline has passed. This creates an arbitrage opportunity where oracle could report payouts before the deadline, unintentionally manipulating market outcomes while trading is still active.

    Recommendation

    Add a deadline validation check in reportPayouts that reverts if the current block.timestamp is less than the market deadline, preventing premature payout reporting and eliminating potential arbitrage opportunities.

Informational5 findings

  1. RewardsManager should change OWNER_ROLE to DEFAULT_ADMIN_ROLE

    Severity

    Severity: Informational

    Submitted by

    cccz


    Description

    The roles in RewardsManager do not match the documentation, OWNER_ROLE should be changed to DEFAULT_ADMIN_ROLE.

    constructor(        address _factoryAddress    ) Ownable(msg.sender) {        require(            _factoryAddress != address(0),            "RewardsManager: Invalid address"        );        _grantRole(OWNER_ROLE, msg.sender);        _grantRole(ADMIN_ROLE, _factoryAddress);        _setRoleAdmin(ADMIN_ROLE, OWNER_ROLE);    }
    # RewardsManager
    ### Roles defined
    - DEFAULT_ADMIN_ROLE:    - Full control over Rewards Manager contract (Owner)    - Access:        - Deployer Wallet- ADMIN_ROLE:    - Can grant MARKET_ROLE    - Access:        - LMSRMarketMakerFactory- MARKET_ROLE:    - Can record bets    - Access:        - LMSRMarketMaker deployed by LMSRMarketMakerFactory

    Otherwise since _setRoleAdmin(MARKET_ROLE, ADMIN_ROLE) is not called, although ADMIN_ROLE can call authorizeMarket() to grant MARKET_ROLE, ADMIN_ROLE cannot revoke it. Furthermore, since DEFAULT_ADMIN_ROLE is not set, MARKET_ROLE cannot be revoked.

    Recommendation

    It is recommended to change OWNER_ROLE to DEFAULT_ADMIN_ROLE in RewardsManager.

  2. grantRole() calls should be changed to _grantRole() calls

    Severity

    Severity: Informational

    Submitted by

    cccz


    Description

    The protocol sometimes mixes grantRole() and _grantRole(). The difference is that grantRole() will check RoleAdmin.

    function grantMarketRole(address market) external onlyRole(FACTORY_ROLE) {      require(market != address(0), "Market address cannot be zero");      grantRole(MARKET_ROLE, market);
        }    ...    function grantRole(bytes32 role, address account) public virtual onlyRole(getRoleAdmin(role)) {        _grantRole(role, account);    }

    Currently, all cases work correctly because _setRoleAdmin() is called in advance. And to avoid potential unavailability, it is recommended to always use _grantRole() (because the check has been done).

    Recommendation

    it is recommended to always use _grantRole() calls.

  3. Incorrect param description

    Severity

    Severity: Informational

    Submitted by

    Arno


    Description

    The calcAuctionRate function parameter _duration is documented as "Auction duration in blocks" but the function implementation treats it as duration in seconds. The code compares _duration against timePassed = block.timestamp - _startTime which is in seconds, indicating the parameter should be in seconds rather than blocks.

    Recommendation

    Update the parameter documentation from "Auction duration in blocks" to "Auction duration in seconds".

  4. Automatic Referral Outcome Selection Issue

    Severity

    Severity: Informational

    Submitted by

    Arno


    Description

    When a user purchases multiple outcome shares in a single trade, the system automatically selects the first outcome with a non-zero amount to associate with the generated referral code. This prevents users from choosing which specific outcome they want to create their referral for, potentially limiting their referral marketing strategy.

    Recommendation

    Add a parameter to allow users to specify which outcome they want to create their referral code for when buying multiple outcomes

  5. Inconsistent Token Transfer Method in ammMint and other places

    Severity

    Severity: Informational

    Submitted by

    Arno


    Description

    The ammMint function uses transferFrom instead of safeTransferFrom, creating inconsistency with other functions in the same contract. The ammBurn function correctly uses safeTransfer, and other functions like mergePositions and redeemPositions also use the safe variants.

    Recommendation

    Replace transferFrom with safeTransferFrom in the ammMint function to ensure consistency and use the safer transfer method that properly handles all ERC20 token implementations.