Security Audit Report
Belief Market Audit Report
Reviewed by: 0x52 (@IAm0x52)
Prepared For: Belief Market
Review Date(s): 4/1/25 - 4/3/25
Fix Review Date(s): 4/10/25
0x52 Background
As a professional smart contract auditor, I have conducted over 100 security reviews for public and private clients. With 30+ first-place finishes in public contests on platforms like Code4rena and Sherlock, I have been recognized as a top-performing security expert. By prioritizing rigorous analysis and providing actionable recommendations, I have contributed to securing over $1 billion in TVL across 100+ protocols. Throughout my career I have collaborated with many organizations including the prestigious Blackthorn as a founding security researcher and as a Lead Security researcher at SpearbitDAO.
Protocol Summary
Belief Market is a prediction market platform that allows users to bet on the outcome of events using a weighted parimutuel betting system.
Scope
Repo: SportsFI-UBet/upredict-contracts
Review Hash: 06da56c
Fix Review Hash: 40f6609
In-Scope Contracts
contract/*.sol
Deployment Chain(s)
- Base mainnet
Summary of Findings
| High | Med | |
|---|---|---|
| 2 | 1 | |
| 0 | 0 |
High Risk Findings
Any bet can be refunded by supplying invalid betBlob
Details
function requestRefund(BetRequest calldata request, BetBlob calldata betBlob)
external
returns (IERC20 token, address to, uint256 amount)
{
RequestCommitment requestCommitment = getCommitment(request);
BetState storage betState = bets[requestCommitment];
require(betState.amount == request.amount, MarketsBetDoesntExist(requestCommitment));
betState.amount = 0;
require(
block.number >= request.refundStartBlock,
MarketsRefundTooEarly(requestCommitment, request.refundStartBlock, block.number)
);
@> MarketCommitment marketCommitment = _getMarketFromBet(betBlob);
ResultCommitment resultCommitment = marketResults[marketCommitment];
require(
resultCommitment == nullResultCommitment, MarketsResultAlreadyRevealed(marketCommitment, resultCommitment)
);
token = request.token;
to = request.from;
amount = request.amount;
token.safeTransfer(to, amount);
emit MarketsRefundIssued(requestCommitment, marketCommitment, token, to, amount);
} When requesting a refund, the marketCommitment is retrieved from the betBlob data. However the commitment hash of the supplied betBlob is never validated against the BetCommitment stored in the betRequest. This allows any arbitrary betBlob to be supplied during the refund process. To exploit this, a malicious user can supply a betBlob that links to an invalid market. This satisfies the resultCommitment == nullResultCommitment requirement, allowing users to receive an invalid refund and cause a shortfall in the protocol.
Lines of Code
Recommendation
The commitment hash of the supplied betBlob should be validated against the BetCommitment in the betRequest. This ensures that the betBlob corresponds to the originally committed BetCommitment, preventing the use of arbitrary or malicious betBlob data during refund requests.
Remediation
Fixed in commit 40f6609. The commitment of the supplied betBlob is now calculated and compared with the that of the request to ensure the supplied betBlob is legitimate.
Malicious request with amount == 0 can be used to drain market
Details
{
BetState storage betState = bets[requestCommitment];
BetCommitment betCommitment = getCommitment(betBlob);
require(
request.betCommitment == betCommitment,
MarketsInvalidBetRequest(requestCommitment, betCommitment, request.betCommitment)
);
@> require(betState.amount == request.amount, MarketsBetDoesntExist(requestCommitment));
// Since the bet is revealed, no amount should remain to be revealed
betState.amount = 0;
} When revealing a bet, the above lines are designed to prevent invalid bets from being revealed. By checking that betState.amount == request.amount then setting betState.amount to 0, it simultaneously prevents most invalid bets as well as double reveals. However, it misses the edge case in which request.amount == 0. This allows a malicious user to submit an invalid betRequest with request.amount == 0 and a corresponding betBlob that will successfully bypass this check.
WeightedParimutuelMarkets.sol#L115-L139
function _getPayout(
MarketBlob calldata marketBlob,
ResultBlob calldata resultBlob,
BetRequest calldata request,
BetBlob calldata betBlob
)
internal
pure
override
returns (uint256 winningPotAmount, uint256 losingPotAmount, uint256 marketDeadlineBlock, address creator)
{
MarketInfo memory marketInfo = abi.decode(marketBlob.data, (MarketInfo));
BetHiddenInfo memory hiddenInfo = abi.decode(betBlob.data, (BetHiddenInfo));
ResultInfo memory resultInfo = abi.decode(resultBlob.data, (ResultInfo));
marketDeadlineBlock = marketInfo.deadlineBlock;
creator = abi.decode(marketBlob.data, (MarketInfo)).creator;
uint256 betOutcomeMask = (1 << hiddenInfo.outcome);
if ((betOutcomeMask & resultInfo.winningOutcomeMask) != 0) {
winningPotAmount = request.amount;
losingPotAmount =
@> Math.mulDiv(hiddenInfo.betWeight, resultInfo.losingTotalPot, resultInfo.winningTotalWeight);
}
} Since the betBlob is arbitrary, any value for hiddenInfo.betWeight can be supplied. As a result, the entire resultInfo.losingTotalPot can be stolen via this attack vector.
Lines of Code
Recommendation
revealBet should revert if request.amount == 0.
Remediation
Fixed in commit 40f6609. revealBet now requires that request.amount > 0
Medium Risk Findings
Changes to creatorFeeDecimal and operatorFeeDecimals will retroactively apply to older bets
Details
function setFees(uint16 _creatorFeeDecimal, uint16 _operatorFeeDecimal) external onlyRole(DEFAULT_ADMIN_ROLE) {
creatorFeeDecimal = _creatorFeeDecimal;
operatorFeeDecimal = _operatorFeeDecimal;
emit MarketsFeesChanged(_creatorFeeDecimal, _operatorFeeDecimal);
}
if (losingPotAmount > 0) {
uint256 currentlyAvailable = availableLosingPot[marketCommitment];
require(currentlyAvailable >= losingPotAmount, MarketsInvalidResult(marketCommitment, resultCommitment));
availableLosingPot[marketCommitment] = currentlyAvailable - losingPotAmount;
// only charge fees on the losing pot, to discourage markets that
// are heavily imbalanced. If the losing pot is small (because it's
// a very unlikely result), then creator fees are also small
@> uint256 creatorFee = (creatorFeeDecimal * losingPotAmount) / FEE_DIVISOR;
@> uint256 operatorFee = (operatorFeeDecimal * losingPotAmount) / FEE_DIVISOR;
creatorFees[token][creator] += creatorFee;
operatorFees[token] += operatorFee;
emit MarketsBetFeeCollected(marketCommitment, token, creator, creatorFee, operatorFee);
losingPotAmount -= (creatorFee + operatorFee);
}Above we see that the creator and operator fee are applied in real time whenever a bet is revealed. The result is that after the values are updated, the new fee percentages will be immediately applied to all revealed bets. This retroactively applies the updated fees to all bets even those for markets that closed well before the updated fees. To ensure fairness to all bettors, fees should be taken according to the percentage at the time of the bet.
Lines of Code
Recommendation
creatorFeeDecimal and operatorFeeDecimal should be cached upon market resolution and cached values should be read upon redemption rather than using the current values.
Remediation
Fixed in commit 40f6609. creatorFeeDecimal and operatorFeeDecimal are now cached in the betState when the bet is placed.