Core Contracts

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

StabilityPool Depositors Receive Fewer RTokens Than Their Initial Deposit Upon Withdrawal

Summary

When users deposit into the StabilityPool, they expect to earn raac rewards. However, upon withdrawal, the system and RAAC documentation claim to return the equivalent amount in RTokens, but this is not the case.

Vulnerability Details

In RToken.sol, the transfer function is overriden as such

function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}

Additionally, the _update() function is overidden as such:

function _update(address from, address to, uint256 amount) internal override {
// Scale amount by normalized income for all operations (mint, burn, transfer)
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}

When ILendingPool(_reservePool).getNormalizedIncome() is greater than 1e27, any transfer of RToken will undergo division twice, thus causing the recipient of the transfer to receive less tokens.

This transfer occurs in the withdraw() function in StabilityPool, when Stability Depositors want to withdraw their deposited RTokens, using safeTransfer here.

Impact

This causes StabilityPool Depositors to immediately lose RTokens when depositing into the Stability Pool when normalized income is greater than 1e27, which is an eventuality. Additionally, since normalized income increases over time, the amount of RTokens that can be withdrawn over time will continue to decrease.

PoC

Use This Foundry Setup:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
// OpenZeppelin Imports
// import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// Primitives Import
import { RAACHousePrices } from "../../contracts/core/primitives/RAACHousePrices.sol";
// Token Imports
import { DebtToken } from "../../contracts/core/tokens/DebtToken.sol";
import { DEToken } from "../../contracts/core/tokens/DEToken.sol";
import { IndexToken } from "../../contracts/core/tokens/IndexToken.sol";
import { LPToken } from "../../contracts/core/tokens/LPToken.sol";
import { RAACNFT } from "../../contracts/core/tokens/RAACNFT.sol";
import { RAACToken } from "../../contracts/core/tokens/RAACToken.sol";
import { RToken } from "../../contracts/core/tokens/RToken.sol";
import { veRAACToken } from "../../contracts/core/tokens/veRAACToken.sol";
// Collector Imports
import { Treasury } from "../../contracts/core/collectors/Treasury.sol";
import { FeeCollector } from "../../contracts/core/collectors/FeeCollector.sol";
// Governance Imports
import { Governance } from "../../contracts/core/governance/proposals/Governance.sol";
import { TimelockController } from "../../contracts/core/governance/proposals/TimelockController.sol";
// Gauge Imports
import { BaseGauge } from "../../contracts/core/governance/gauges/BaseGauge.sol";
import { GaugeController } from "../../contracts/core/governance/gauges/GaugeController.sol";
import { RAACGauge } from "../../contracts/core/governance/gauges/RAACGauge.sol";
import { RWAGauge } from "../../contracts/core/governance/gauges/RWAGauge.sol";
// Boost Import
import { BoostController } from "../../contracts/core/governance/boost/BoostController.sol";
// Minter Imports
import { RAACMinter } from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import { RAACReleaseOrchestrator } from "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
// Oracle Imports
import { BaseChainlinkFunctionsOracle } from "../../contracts/core/oracles/BaseChainlinkFunctionsOracle.sol";
import { RAACHousePriceOracle } from "../../contracts/core/oracles/RAACHousePriceOracle.sol";
import { RAACPrimeRateOracle } from "../../contracts/core/oracles/RAACPrimeRateOracle.sol";
// Pool Imports
import { LendingPool } from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import { StabilityPool } from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import { MarketCreator } from "../../contracts/core/pools/StabilityPool/MarketCreator.sol";
import { NFTLiquidator } from "../../contracts/core/pools/StabilityPool/NFTLiquidator.sol";
contract BaseSetup is Test {
IERC20 baseUSDC = IERC20(address(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913));
address baseUSDCWhale = address(0x20FE51A9229EEf2cF8Ad9E89d91CAb9312cF3b7A);
// Collectors
Treasury treasury;
Treasury repairFund;
FeeCollector feeCollector;
// Tokens
IERC20 crvUSD = IERC20(address(0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E));// main
IERC20 basecrvUSD = IERC20(address(0x417Ac0e078398C154EdFadD9Ef675d30Be60Af93)); // base
RAACToken raacToken;
veRAACToken veRaacToken;
RAACNFT raacNFT;
RToken rToken;
DebtToken debtToken;
DEToken deToken;
address mainnetChainlinkRouter = address(0x65Dcc24F8ff9e51F10DCc7Ed1e4e2A61e6E14bd6);
//m Orchestatro
RAACReleaseOrchestrator raacReleaseOrchestrator;
RAACHousePrices housePrices;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACMinter minter;
address owner;
address user1;
address user2;
address user3;
function setUp() public virtual {
owner = makeAddr("owner");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
vm.startPrank(owner);
// collectors
//
// Setup Tokens
raacToken = new RAACToken(owner, 100,50);
veRaacToken = new veRAACToken(address(raacToken));
// veRAACToken
// RAACNFT
// DeToken
// Setup contracts
raacReleaseOrchestrator = new RAACReleaseOrchestrator(address(raacToken));
housePrices = new RAACHousePrices(owner);
housePrices.setOracle(owner);
raacNFT = new RAACNFT(address(crvUSD),address(housePrices),owner);
rToken = new RToken("RToken", "RT", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
lendingPool = new LendingPool(address(crvUSD), address(rToken), address(debtToken), address(raacNFT), address(housePrices), 0.1e27);
console.log("lendingPool:",address(lendingPool));
treasury = new Treasury(owner);
repairFund = new Treasury(owner);
deToken = new DEToken("DEToken","DEToken",owner, address(rToken));
stabilityPool = new StabilityPool(owner);
minter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner); // should be owner
feeCollector = new FeeCollector(address(raacToken), address(veRaacToken), address(treasury), address(repairFund),owner);
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veRaacToken), true);
raacToken.manageWhitelist(owner, true);
raacToken.setMinter(owner);
raacToken.mint(user2, 1000e18);
raacToken.mint(user3, 1000e18);
feeCollector.grantRole(feeCollector.FEE_MANAGER_ROLE(), owner);
feeCollector.grantRole(feeCollector.EMERGENCY_ROLE(), owner);
feeCollector.grantRole(feeCollector.DISTRIBUTOR_ROLE(), owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
//
raacToken.transferOwnership(address(minter));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool.initialize(address(rToken), address(deToken), address(raacToken), address(minter), address(crvUSD), address(lendingPool));
lendingPool.setStabilityPool(address(stabilityPool));
vm.stopPrank();
}
}

This is the Foundry PoC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "../../contracts/libraries/math/PercentageMath.sol";
import "../../contracts/libraries/math/WadRayMath.sol";
import {BaseSetup} from "./BaseSetup.t.sol";
import "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
contract DoubleDivisionRToken is BaseSetup{
using WadRayMath for uint256;
using PercentageMath for uint256;
using SafeCast for uint256;
using SafeERC20 for IERC20;
// IERC20 baseUSDC = IERC20(address(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913));
// address baseUSDCWhale = address(0x20FE51A9229EEf2cF8Ad9E89d91CAb9312cF3b7A);
IERC20 mainnetUSDC = IERC20(address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48));
address mainnetUsdcCurveUSDVault = address(0x7cA00559B978CFde81297849be6151d3ccB408A9);
address curveUSDWhale = address(0x4e59541306910aD6dC1daC0AC9dFB29bD9F15c67);
address newUser = makeAddr("new user");
address newUser2 = makeAddr("new user2");
address newUser3 = makeAddr("new user3");
address newUser4 = makeAddr("new user4");
function setUp() public override {
// string memory BASE_RPL_URL = vm.envString("BASE_RPC_URL");
// uint256 baseFork = vm.createFork(BASE_RPL_URL, 20265816);
// vm.selectFork(baseFork);
string memory MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
uint256 mainnetFork = vm.createFork(MAINNET_RPC_URL,19614507);
vm.selectFork(mainnetFork);
super.setUp();
vm.startPrank(owner);
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidationThreshold, 8000); // 80%
lendingPool.setParameter(ILendingPool.OwnerParameter.HealthFactorLiquidationThreshold, 1e18); // 1.0e18
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidationGracePeriod, 3 days);
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidityBufferRatio, 2000); // 20%
lendingPool.setParameter(ILendingPool.OwnerParameter.WithdrawalStatus, 0); // Allow withdrawals
lendingPool.setParameter(ILendingPool.OwnerParameter.CanPaybackDebt, 1); // Enable payback
// house is 1000 with 18 decimals, same as crvUSD
uint256 housePrice = 1_000_000e18;
housePrices.setHousePrice(1,housePrice);
housePrices.setHousePrice(2,housePrice);
housePrices.setHousePrice(3,housePrice);
housePrices.setHousePrice(4,housePrice);
housePrices.setHousePrice(5,housePrice);
housePrices.setHousePrice(6,housePrice);
housePrices.setHousePrice(7,housePrice);
housePrices.setHousePrice(8,housePrice);
vm.stopPrank();
vm.startPrank(curveUSDWhale);
crvUSD.transfer(newUser, 15_000_000e18);
crvUSD.transfer(newUser2, 1_000_000e18); // newUser2 has 1M crvUSD
crvUSD.transfer(newUser3, 5_000_000e18);
crvUSD.transfer(newUser4,5_000_000e18);
//crvUSD.transfer(address(stabilityPool), 10_000_000e18);
vm.stopPrank();
}
function test_DoubleDivision() public {
// 1. New User deposits and borrows to make normalizedIncome greater than 1e27
console.log("testDoubleDivision entered");
vm.startPrank(newUser);
crvUSD.approve(address(raacNFT),type(uint256).max);
raacNFT.mint(2,1_000_000e18);
raacNFT.approve(address(lendingPool), 2);
lendingPool.depositNFT(2);
//deposit some crvTokens as well
crvUSD.approve(address(lendingPool),type(uint256).max);
lendingPool.deposit(7_000_000e18);
lendingPool.borrow(1_000_000e18);
vm.stopPrank();
skip(365 days);
lendingPool.updateState();
assertTrue(lendingPool.getNormalizedIncome() > 1e27);
// 2. New User2 deposits some crvUSD for Rtoken
vm.startPrank(newUser2);
crvUSD.approve(address(lendingPool),type(uint256).max);
lendingPool.deposit(1_000_000e18);
// 3. New User2 deposits Rtoken into StabilityPool
uint256 rTokenBalOfUserTwoStart = rToken.balanceOf(newUser2);
rToken.approve(address(stabilityPool), type(uint256).max);
// deposit in StabilityPool
stabilityPool.deposit(rTokenBalOfUserTwoStart);
// 4. New User2 withdraws Rtoken from StabilityPool
deToken.approve(address(stabilityPool), type(uint256).max);
uint256 balOfDeTokenUserTwo = deToken.balanceOf(newUser2);
// console.log("withdrawing from stability pool now");
stabilityPool.withdraw(balOfDeTokenUserTwo);
// 5. Double division causes a loss for newUser2 even without time passing
uint256 rTokenBalOfUserTwoEnd = rToken.balanceOf(newUser2);
// assert that end bal is less than start bal
assertTrue(rTokenBalOfUserTwoEnd < rTokenBalOfUserTwoStart);
// assertEq on how to fix the problem - to not divide twice
assertEq(rTokenBalOfUserTwoEnd.rayMul(lendingPool.getNormalizedIncome()), rTokenBalOfUserTwoStart);
}
}

Tools Used

Manual Review

Recommendations

Remove the overridden transfer function so that it only divides once.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

RToken::transfer and transferFrom double-scale amounts by dividing in both external functions and _update, causing users to transfer significantly less than intended

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

RToken::transfer and transferFrom double-scale amounts by dividing in both external functions and _update, causing users to transfer significantly less than intended

Support

FAQs

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