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 {
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:
pragma solidity ^0.8.19;
import "forge-std/Test.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";
import { RAACHousePrices } from "../../contracts/core/primitives/RAACHousePrices.sol";
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";
import { Treasury } from "../../contracts/core/collectors/Treasury.sol";
import { FeeCollector } from "../../contracts/core/collectors/FeeCollector.sol";
import { Governance } from "../../contracts/core/governance/proposals/Governance.sol";
import { TimelockController } from "../../contracts/core/governance/proposals/TimelockController.sol";
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";
import { BoostController } from "../../contracts/core/governance/boost/BoostController.sol";
import { RAACMinter } from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import { RAACReleaseOrchestrator } from "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
import { BaseChainlinkFunctionsOracle } from "../../contracts/core/oracles/BaseChainlinkFunctionsOracle.sol";
import { RAACHousePriceOracle } from "../../contracts/core/oracles/RAACHousePriceOracle.sol";
import { RAACPrimeRateOracle } from "../../contracts/core/oracles/RAACPrimeRateOracle.sol";
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);
Treasury treasury;
Treasury repairFund;
FeeCollector feeCollector;
IERC20 crvUSD = IERC20(address(0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E));
IERC20 basecrvUSD = IERC20(address(0x417Ac0e078398C154EdFadD9Ef675d30Be60Af93));
RAACToken raacToken;
veRAACToken veRaacToken;
RAACNFT raacNFT;
RToken rToken;
DebtToken debtToken;
DEToken deToken;
address mainnetChainlinkRouter = address(0x65Dcc24F8ff9e51F10DCc7Ed1e4e2A61e6E14bd6);
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);
raacToken = new RAACToken(owner, 100,50);
veRaacToken = new veRAACToken(address(raacToken));
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);
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
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 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 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);
lendingPool.setParameter(ILendingPool.OwnerParameter.HealthFactorLiquidationThreshold, 1e18);
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidationGracePeriod, 3 days);
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidityBufferRatio, 2000);
lendingPool.setParameter(ILendingPool.OwnerParameter.WithdrawalStatus, 0);
lendingPool.setParameter(ILendingPool.OwnerParameter.CanPaybackDebt, 1);
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);
crvUSD.transfer(newUser3, 5_000_000e18);
crvUSD.transfer(newUser4,5_000_000e18);
vm.stopPrank();
}
function test_DoubleDivision() public {
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);
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);
vm.startPrank(newUser2);
crvUSD.approve(address(lendingPool),type(uint256).max);
lendingPool.deposit(1_000_000e18);
uint256 rTokenBalOfUserTwoStart = rToken.balanceOf(newUser2);
rToken.approve(address(stabilityPool), type(uint256).max);
stabilityPool.deposit(rTokenBalOfUserTwoStart);
deToken.approve(address(stabilityPool), type(uint256).max);
uint256 balOfDeTokenUserTwo = deToken.balanceOf(newUser2);
stabilityPool.withdraw(balOfDeTokenUserTwo);
uint256 rTokenBalOfUserTwoEnd = rToken.balanceOf(newUser2);
assertTrue(rTokenBalOfUserTwoEnd < rTokenBalOfUserTwoStart);
assertEq(rTokenBalOfUserTwoEnd.rayMul(lendingPool.getNormalizedIncome()), rTokenBalOfUserTwoStart);
}
}
Tools Used
Manual Review
Recommendations
Remove the overridden transfer
function so that it only divides once.