Core Contracts

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

Stability pool does not consider RToken balance increase when DEToken is withdrawn

Summary

RToken is interest baring token and its balance increases overtime. DEToken does not accrue interest and its balance is fixed. However protocol exchanges RToken and DEToken 1:1.

This inconsistency will lead to fund loss of DeToken holders and permanent lock of unclaimed RToken in StabilityPool.

Vulnerability Details

Root Cause Analysis

Users receive RToken when they deposit assets into the Reserve Pool. RToken balances increase over time due to interest accrual, represented by the liquidity index.

RToken.balanceOf implementation

function balanceOf(address account) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
}

DEToken is a Debitum Emptor token of StabilityPool. Users receive DEToken when they deposit RToken into the StabilityPool. Apparently, DEToken is redeemable 1:1 with RToken.

Stability.deposit

function deposit(uint256 amount) external nonReentrant whenNotPaused validAmount(amount) {
_update();
rToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 deCRVUSDAmount = calculateDeCRVUSDAmount(amount);
@> deToken.mint(msg.sender, deCRVUSDAmount); // @audit minted detoken amount is equal to rtoken amount
userDeposits[msg.sender] += amount;
_mintRAACRewards();
emit Deposit(msg.sender, amount, deCRVUSDAmount);
}

StabilityPool.withdraw

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); // @audit deCRVUSDAmount = rcrvUSDAmount
rToken.safeTransfer(msg.sender, rcrvUSDAmount);

We can notice the inconsistency in the following way:

  • When RToken is deposited, minted DEToken is RToken.underlyingBalance * liquidityIndexAtDeposit

  • When DEToken is withdrawed, redeemd RToken is equal to previously minted DEToken.

  • However StabilityPool's RToken balance has grown into RToken.underlyingBalance * liquidityIndexAtWithdraw

  • So RToken.underlyingBalance * (liquidityIndexAtWithdraw - liquidityIndexAtDeposit) amount of RToken will be stuck at StabilityPool

  • For the depositor, underlyingBalanceshrank to RToken.underlyingBalance * liquidityIndexAtDeposit / liquidityIndexAtWithdraw

Let's do a thought experiment to understand the problem:

  • Current liquidity index is 1

  • User deposits 10000 crvUSD into lending pool and mints 10000 RToken

    • Underlying balance is 10000 = 10000 / liquidityIndex = 10000

    • RToken balance is 10000

  • User deposits 10000 RToken into stability pool and mints 10000 DEToken

    • StabilityPool's RToken balance is 10000

  • Liquidity index increases to 2

  • User withdraws 10000 DEToken from stability pool

    • Now, StabilityPool's RToken balance is 20000

    • StabilityPool transfers only 10000 RToken to user

  • User withdraws 10000 RToken from lending pool

    • Underlyiing balance is now 10000 / liquidityIndex = 5000

    • User is left with 5000 crvUSD

As we've seen above, there are two issues arised:

  • Depositor loses fund as liquidity index grows

  • StabilityPool is left with 10000 RToken, even after user fully burns his DEToken. This RToken is stuck at StabilityPool

POC

Scenario

  • User deposits 10000 USD to lending pool and mints 10000 RToken

  • User deposits 10000 RToken into stability pool and mints 10000 DEToken

  • Some time passes by

  • User withdraws full DEToken from stabiliy pool

  • User withdraws full RToken from lending pool

  • User losts fund and RToken is stuck at stability pool

How to run POC

Addtional Note

This vulnerailbity brings similar issue to this one: https://codehawks.cyfrin.io/c/2025-02-raac/s/cm71l80y80005tgxgs82kzuvi

In order to see this report shows a different root cause, you can optionally apply the patch mentioned in the above report and run the POC.

pragma solidity ^0.8.19;
import "../lib/forge-std/src/Test.sol";
import {RToken} from "../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../contracts/core/tokens/DEToken.sol";
import {RAACToken} from "../contracts/core/tokens/RAACToken.sol";
import {veRAACToken} from "../contracts/core/tokens/veRAACToken.sol";
import {RAACNFT} from "../contracts/core/tokens/RAACNFT.sol";
import {LendingPool} from "../contracts/core/pools/LendingPool/LendingPool.sol";
import {StabilityPool} from "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {RAACMinter} from "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {crvUSDToken} from "../contracts/mocks/core/tokens/crvUSDToken.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract RAACHousePricesMock {
mapping(uint256 => uint256) public prices;
function getLatestPrice(uint256 tokenId) external view returns (uint256, uint256) {
return (prices[tokenId], block.timestamp);
}
function setTokenPrice(uint256 tokenId, uint256 price) external {
prices[tokenId] = price;
}
function tokenToHousePrice(uint256 tokenId) external view returns (uint256) {
return prices[tokenId];
}
}
contract StabilityPoolTest is Test {
RToken rtoken;
DebtToken debtToken;
RAACToken raacToken;
DEToken deToken;
veRAACToken veToken;
RAACNFT raacNft;
RAACMinter raacMinter;
crvUSDToken asset;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACHousePricesMock housePrice;
address depositor = makeAddr("depositor");
address borrower = makeAddr("borrower");
address user = makeAddr("user");
uint256 userAssetAmount = 10_000e18;
uint256 tokenId = 1;
uint256 initialBurnTaxRate = 50;
uint256 initialSwapTaxRate = 100;
uint256 initialPrimeRate = 0.1e27;
function setUp() external {
vm.warp(1e9); // warp time stamp to avoid underflow in RAACMinter constructor
asset = new crvUSDToken(address(this));
housePrice = new RAACHousePricesMock();
debtToken = new DebtToken("DebtToken", "DTK", address(this));
rtoken = new RToken("RToken", "RTK", address(this), address(asset));
raacNft = new RAACNFT(address(asset), address(housePrice), address(this));
lendingPool = new LendingPool(
address(asset), address(rtoken), address(debtToken), address(raacNft), address(housePrice), 0.1e27
);
rtoken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken = new DEToken("DEToken", "DET", address(this), address(rtoken));
raacToken = new RAACToken(address(this), initialSwapTaxRate, initialBurnTaxRate);
stabilityPool = new StabilityPool(address(this));
stabilityPool.initialize(
address(rtoken), address(deToken), address(raacToken), address(this), address(asset), address(lendingPool)
);
lendingPool.setStabilityPool(address(stabilityPool));
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(this));
stabilityPool.setRAACMinter(address(raacMinter));
raacToken.setMinter(address(raacMinter));
veToken = new veRAACToken(address(raacToken));
raacToken.manageWhitelist(address(veToken), true);
deToken.setStabilityPool(address(stabilityPool));
uint256 depositorAmount = userAssetAmount * 10;
deal(address(asset), depositor, depositorAmount);
vm.startPrank(depositor);
asset.approve(address(lendingPool), depositorAmount);
lendingPool.deposit(depositorAmount);
uint256 rtokenBalance = rtoken.balanceOf(depositor);
rtoken.approve(address(stabilityPool), rtokenBalance);
stabilityPool.deposit(rtokenBalance);
vm.stopPrank();
}
function testFullWithdraw() external {
// create some debt to accrue liquidity index
_createDebt(userAssetAmount * 5);
// some time passes by
skip(365 days);
vm.roll(365 days / 12);
lendingPool.updateState();
_repayDebt(lendingPool.getUserDebt(borrower));
vm.startPrank(depositor);
emit log_named_decimal_uint("pool rtoken", rtoken.balanceOf(address(stabilityPool)), 18);
emit log_named_decimal_uint("user detoken", deToken.balanceOf(depositor), 18);
// withdraw full detoken
stabilityPool.withdraw(deToken.balanceOf(depositor));
// withdraw full rtoken
lendingPool.withdraw(rtoken.balanceOf(depositor));
console.log("\nAfter full withdraw\n");
emit log_named_decimal_uint("user asset", asset.balanceOf(depositor), 18);
emit log_named_decimal_uint("pool rtoken", rtoken.balanceOf(address(stabilityPool)), 18);
vm.stopPrank();
}
function _createDebt(uint256 amount) internal {
uint256 price = amount * 8 / 10;
deal(address(asset), borrower, price);
housePrice.setTokenPrice(tokenId, price);
vm.startPrank(borrower);
asset.approve(address(raacNft), price);
raacNft.mint(tokenId, price);
raacNft.approve(address(lendingPool), tokenId);
lendingPool.depositNFT(tokenId);
tokenId++;
lendingPool.borrow(amount);
vm.stopPrank();
}
function _repayDebt(uint256 amount) internal {
deal(address(asset), borrower, amount);
vm.startPrank(borrower);
asset.approve(address(lendingPool), amount);
lendingPool.repay(amount);
vm.stopPrank();
}
}

Console Output

Logs:
pool rtoken: 103593.750000000000000000
user detoken: 100000.000000000000000000
After full withdraw
user asset: 96530.920060331825037706 // user fund loss
pool rtoken: 7062.829939668174962294 // rtoken is stuck at StabilityPool

If the patch from the other report is applied:

[PASS] testFullWithdraw() (gas: 1117833)
Logs:
pool rtoken: 103593.750000000000000000
user detoken: 100000.000000000000000000
After full withdraw
user asset: 100000.000000000000000000 // the user received only original asset
pool rtoken: 3593.750000000000000000 // rtoken that holds accrued interest is stuck at StabilityPool

Impact

  • Depositors lose their asset as time goes by

  • RToken and its underlying asset is stuck at StabilityPool

Tools Used

Manual Review

Recommendations

When DEToken is withdrawn, RToken balance increment should be handled correctly:

diff --git a/contracts/core/pools/StabilityPool/StabilityPool.sol b/contracts/core/pools/StabilityPool/StabilityPool.sol
index 9820fc6..5d6f0fb 100644
--- a/contracts/core/pools/StabilityPool/StabilityPool.sol
+++ b/contracts/core/pools/StabilityPool/StabilityPool.sol
@@ -235,7 +235,7 @@ contract StabilityPool is IStabilityPool, Initializable, ReentrancyGuard, Ownabl
}
deToken.burn(msg.sender, deCRVUSDAmount);
- rToken.safeTransfer(msg.sender, rcrvUSDAmount);
+ rToken.safeTransfer(msg.sender, rcrvUSDAmount * lendingPool.getNormalizedIncome() / 1e27);
if (raacRewards > 0) {
raacToken.safeTransfer(msg.sender, raacRewards);
}

If you apply the above patch and the patch from other report and run the poc again:

Logs:
pool rtoken: 103593.750000000000000000
user detoken: 100000.000000000000000000
After full withdraw
user asset: 103593.750000000000000000 // user received original asset and accrued debt
pool rtoken: 0.000000000000000000 // rtoken is not stuck
Updates

Lead Judging Commences

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

StabilityPool's userDeposits mapping doesn't update with DEToken transfers or interest accrual, and this combined with RToken transfers causes fund loss and permanent lockup

Support

FAQs

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