Summary
Due to incorrect calculation of totalRealBalance inside of the calculateDustAmount function that is part of the RToken contract we are never able to transfer the dust amount of tokens that are accumulated due to the linear vs exponential difference in the LendingPool
function calculateDustAmount() public view returns (uint256) {
uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
console.log("Contract balance:", contractBalance);
uint256 currentTotalSupply = totalSupply();
console.log("Current total supply:", currentTotalSupply.rayDiv(ILendingPool(_reservePool).getNormalizedIncome()));
In fact it should be division to begin with instead of doulbe multiplications
The share of RTokens minted is decided by doing <amount deposited>/<liquidity index>
So we should do the same here for the total balance
*/
uint256 totalRealBalance = currentTotalSupply.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
console.log("Total real balance:", totalRealBalance);
return contractBalance <= totalRealBalance ? 0 : contractBalance - totalRealBalance;
}
Vulnerability Details
Whenever we mint the RTokens when an user deposits we use the formula <amount deposited>/<liquidity index> this gives the user share of the RTokens that accurately represent what amount of the reserve token he/she owns in the LendingPool. This means that when we calculate the contractBalance value we receive the amount of RTokens that are equivalet to the current collaterall token amount. We then need to get the current supply of the RToken(how much of it is minted) and divide it by the current liquidity index, that way we receive what would the supply balance look if we minted the tokens for the current liquidity index as of the current index. This is the total reserve equivalent of the balance of the collateral token we have in the RToken contract(normalizes the RToken supply as if it were minted at today's liquidity index).
Total Reserve Equivalent=How many reserve tokens would exist if all RTokens were withdrawn today?
Impact
Since we do a double multiplication of the current totalSupply by the liquidity index instead of dividing totalRealBalance will be a bigger value compared to contractBalance and we would never be able to transfer the dust amount of tokens that are in the contract.
Tools Used
PoC
Place the test in LeandingPool.test.js in the describe("Full sequence") test suite group
it("PoC dust amount not transfered due to inaccurate calculation of balance", async function () {
await crvusd.connect(user1).approve(lendingPool.target, ethers.parseEther("300"));
await lendingPool.connect(user1).deposit(ethers.parseEther("300"));
const tokenId = 1;
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await lendingPool.connect(user1).depositNFT(tokenId);
console.log("User deposited NFT")
const borrowAmount = ethers.parseEther("250");
await crvusd.connect(user1).approve(lendingPool.target, borrowAmount + ethers.parseEther("10"));
await lendingPool.connect(user1).borrow(borrowAmount);
console.log("Borrowed rToken");
await ethers.provider.send("evm_increaseTime", [90 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine");
let debtAmount = await debtToken.balanceOf(user1.address)
console.log("Debt amount of user:", debtAmount);
await crvusd.connect(user1).approve(rToken.target, debtAmount + ethers.parseEther("10"));
await lendingPool.connect(user1).repay(debtAmount + ethers.parseEther("10"));
console.log("Debt amount after repay: ", await debtToken.balanceOf(user1.address));
const dustAmount = await rToken.calculateDustAmount();
console.log("Dust amount:", dustAmount);
const dustRecipient = owner.address;
await expect(lendingPool.connect(owner).transferAccruedDust(dustRecipient, dustAmount)).to.revertedWithCustomError(rToken, "NoDust");
});
Recommendations
Fetch the non-scaled totalSupply of the RTokens and divide by the liquidity index when calculating the totalRealBalancevalue:
Firstly we need to change access scope of the scaledTotalSupply in the RToken contract as this is the functon that returns the original total supply and we want to use it in the calculateDustAmount function.
-function scaledTotalSupply() external view returns (uint256) {
+function scaledTotalSupply() public view returns (uint256) {
return super.totalSupply();
}
Then we update the calculation function to handle the output calculations:
function calculateDustAmount() public view returns (uint256) {
// Calculate the actual balance of the underlying asset held by this contract
uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
// Calculate the total real obligations to the token holders
- uint256 currentTotalSupply = totalSupply();
+ uint256 currentTotalSupply = scaledTotalSupply();
- uint256 totalRealBalance = currentTotalSupply.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
+ uint256 totalRealBalance = currentTotalSupply.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
// All balance, that is not tied to rToken are dust (can be donated or is the rest of exponential vs linear)
return contractBalance <= totalRealBalance ? 0 : contractBalance - totalRealBalance;
}
Updated PoC after changes
it("PoC dust amount transfered to recepient", async function () {
await crvusd.connect(user1).approve(lendingPool.target, ethers.parseEther("300"));
await lendingPool.connect(user1).deposit(ethers.parseEther("300"));
const tokenId = 1;
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await lendingPool.connect(user1).depositNFT(tokenId);
console.log("User deposited NFT")
const borrowAmount = ethers.parseEther("250");
await crvusd.connect(user1).approve(lendingPool.target, borrowAmount + ethers.parseEther("10"));
await lendingPool.connect(user1).borrow(borrowAmount);
console.log("Borrowed rToken");
await ethers.provider.send("evm_increaseTime", [90 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine");
let debtAmount = await debtToken.balanceOf(user1.address)
console.log("Debt amount of user:", debtAmount);
await crvusd.connect(user1).approve(rToken.target, debtAmount + ethers.parseEther("10"));
await lendingPool.connect(user1).repay(debtAmount + ethers.parseEther("10"));
console.log("Debt amount after repay: ", await debtToken.balanceOf(user1.address));
const dustAmount = await rToken.calculateDustAmount();
console.log("Dust amount:", dustAmount);
const dustRecipient = owner.address;
await lendingPool.connect(owner).transferAccruedDust(dustRecipient, dustAmount);
expect(await crvusd.balanceOf(dustRecipient)).to.equal(dustAmount);
await lendingPool.connect(user1).withdraw(ethers.parseEther("100"));
const dustAmountPostWithdraw = await rToken.calculateDustAmount();
expect(dustAmountPostWithdraw).to.equal(0n);
});