Summary
Users can deposit RToken into StabilityPool and receive DEToken by which users are eligible to claim rewards. However, with the current implementation, users are unable to withdraw RToken's yield from the pool, leaving yield stay in the pool
Vulnerability Details
The function StabilityPool::deposit()
allows RToken holders to deposit RToken into the pool and receive DEToken with exchange rate 1:1. The user's deposit is increased such that userDeposits[msg.sender] += amount
, with amount
is the transferred amount of RToken.
The function StabilityPool::withdraw()
allows users burn DEToken and withdraw the deposited RToken with exchange rate 1:1 and also benefit rewards.
The problem arises when users are only able to withdraw the deposited amount recorded by state userDeposits[msg.sender]
when RToken is yield-bearing token that increases balance over time. This causes users unable to withdraw accrued yield that should belong to them.
For example, an user deposits 100 RToken to StabilityPool, receives 100 DEToken. After some time, that user can only burn 100 DEToken to withdraw 100 RToken and the deposited RToken's accrued yield stays in the pool. In short, it is expected that the user can withdraw more (100 + yield)
RToken but he can't with current implementation
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);
@> userDeposits[msg.sender] += amount;
_mintRAACRewards();
emit Deposit(msg.sender, amount, deCRVUSDAmount);
}
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);
}
PoC
Add the test below to test/unit/core/pools/StabilityPool/StabilityPool.test.js
describe("Deposits", function () {
it.only("RToken holders lose yield", async function(){
const depositAmount = ethers.parseEther("100");
const tokenId = 1;
const price = ethers.parseEther("100");
await raacHousePrices.setOracle(owner.address);
await raacHousePrices.setHousePrice(tokenId, price);
await crvusd.mint(user1.address, price)
await crvusd.connect(user1).approve(raacNFT.target, price)
await raacNFT.connect(user1).mint(tokenId, price);
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await lendingPool.connect(user1).depositNFT(tokenId);
const borrowAmount = ethers.parseEther("50");
await lendingPool.connect(user1).borrow(borrowAmount);
let poolInitialRTokenBalance = await rToken.balanceOf(stabilityPool.target);
await stabilityPool.connect(user2).deposit(depositAmount);
await ethers.provider.send("evm_increaseTime", [86400]);
await ethers.provider.send("evm_mine");
await lendingPool.connect(user1).updateState();
await stabilityPool.connect(user2).withdraw(depositAmount)
console.log(`initial balance\t ${poolInitialRTokenBalance}`)
console.log(`yield stays in the pool\t ${(await rToken.balanceOf(stabilityPool.target)) - poolInitialRTokenBalance}`)
})
Run the test and console shows:
StabilityPool
Core Functionality
Deposits
initial balance 0
yield stays in the pool 234309158145249
✔ RToken holders lose yield (47ms)
1 passing (2s)
It means that the yield which should belong to the user, stays in the pool
Impact
Tools Used
Manual
Recommendations