Foresight Contracts
Cantina Security Report
Organization
- @foresightnow
Engagement Type
Cantina Reviews
Period
-
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
Attackers can arbitrage through splitPosition()
State
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 callsplitPosition()
to mint 1 collateralToken into 1 YES + 1 NO, or callmergePositions()
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 thesplitPosition()/mergePositions()
functions.
Medium Risk3 findings
Improper alpha will cause calcNetCost() to always revert
State
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
cccz
Description
exp()
limits the value ofx
. Whenx
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()
callsexp()
,input_fixed (i.e. x) = q * 1e18 / ( Q * alpah) * 2 ^ 64 <= 2454971259878909886679
, whereq / 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 ofexp()
,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 overflowAdditionally, 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 limitalpha
to a reasonable range.trade() does not handle the case where rewardsManager is address(0)
State
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
cccz
Description
The
rewardsManager
was allowed to beaddress(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));
Overcounting of referral fees
State
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
cccz
Description
_handleReferralTrade()
directly usestradingFeeAmount
to calculate the referral fee.tradingFeeAmount
refers to the fee for all shares bought by the user, not just theoutcomeIndex
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
The protocol does not support multiple conditions
State
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
increateLSLMSRMarketMaker()
.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%");
Logic flaws in registering referral codes
State
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, sinceoutcomeIndex
defaults to 0, the protocol will register a referral code withoutcomeIndex == 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)); } }
Incorrect check for alpha * funding in createLSLMSRMarketMaker()
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
cccz
Description
The check for
alpha * funding
increateLSLMSRMarketMaker()
is to prevent locals.b from being rounded down to 0. The problem here is that it assumesatomicOutcomeSlotCount
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 checkalpha * funding
.Missing Deadline Check in reportPayouts Function
State
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 currentblock.timestamp
is less than the market deadline, preventing premature payout reporting and eliminating potential arbitrage opportunities.
Informational5 findings
RewardsManager should change OWNER_ROLE to DEFAULT_ADMIN_ROLE
State
Severity
- Severity: Informational
Submitted by
cccz
Description
The roles in RewardsManager do not match the documentation,
OWNER_ROLE
should be changed toDEFAULT_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, althoughADMIN_ROLE
can callauthorizeMarket()
to grantMARKET_ROLE
,ADMIN_ROLE
cannot revoke it. Furthermore, sinceDEFAULT_ADMIN_ROLE
is not set,MARKET_ROLE
cannot be revoked.Recommendation
It is recommended to change
OWNER_ROLE
toDEFAULT_ADMIN_ROLE
in RewardsManager.grantRole() calls should be changed to _grantRole() calls
State
Severity
- Severity: Informational
Submitted by
cccz
Description
The protocol sometimes mixes
grantRole()
and_grantRole()
. The difference is thatgrantRole()
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.Incorrect param description
State
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
againsttimePassed = 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".
Automatic Referral Outcome Selection Issue
State
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
Inconsistent Token Transfer Method in ammMint and other places
State
Severity
- Severity: Informational
Submitted by
Arno
Description
The
ammMint
function usestransferFrom
instead ofsafeTransferFrom
, creating inconsistency with other functions in the same contract. TheammBurn
function correctly usessafeTransfer
, and other functions likemergePositions
andredeemPositions
also use the safe variants.Recommendation
Replace
transferFrom
withsafeTransferFrom
in theammMint
function to ensure consistency and use the safer transfer method that properly handles all ERC20 token implementations.