Core Contracts

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

Incorrect calculation of the real balance of the RToken supply prevents acquired dust to be transferred

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) {
// Calculate the actual balance of the underlying asset held by this contract
uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
console.log("Contract balance:", contractBalance);
// Calculate the total real obligations to the token holders
uint256 currentTotalSupply = totalSupply();
console.log("Current total supply:", currentTotalSupply.rayDiv(ILendingPool(_reservePool).getNormalizedIncome()));
// Calculate the total real balance equivalent to the total supply
/*@Audit incorrect double multiplication for by the liquidity index
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);
// 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;
}

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

  • Manual Review

  • Unit test

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 () {
//User deposits into the reserve pool to provide liquidity
await crvusd.connect(user1).approve(lendingPool.target, ethers.parseEther("300"));
await lendingPool.connect(user1).deposit(ethers.parseEther("300"));
// User1 deposits NFT and borrows some of the deposited amount(user1 borrows here because the test suite was already setup
//with the user1 to own the NFT so it was easier to re-use)
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");
//We simulate the passage of time to accrue some debt
await ethers.provider.send("evm_increaseTime", [90 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine");
//We repay the debt after some debt has been accrued
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"));
//We send more than the needed value to repay but that is fine as the repay function
//Takse either amount to repay or current debt if amount is bigger than debt
await lendingPool.connect(user1).repay(debtAmount + ethers.parseEther("10"));
console.log("Debt amount after repay: ", await debtToken.balanceOf(user1.address));
// Calculate dust amount
const dustAmount = await rToken.calculateDustAmount();
console.log("Dust amount:", dustAmount);
// Set up recipient and transfer dust
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 () {
//User deposits into the reserve pool to provide liquidity
await crvusd.connect(user1).approve(lendingPool.target, ethers.parseEther("300"));
await lendingPool.connect(user1).deposit(ethers.parseEther("300"));
// User1 deposits NFT and borrows some of the deposited amount(user1 borrows here because the test suite was already setup
//with the user1 to own the NFT so it was easier to re-use)
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");
//We simulate the passage of time to accrue some debt
await ethers.provider.send("evm_increaseTime", [90 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine");
//We repay the debt after some debt has been accrued
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));
// Calculate dust amount
const dustAmount = await rToken.calculateDustAmount();
console.log("Dust amount:", dustAmount);
// Set up recipient and transfer dust
const dustRecipient = owner.address;
await lendingPool.connect(owner).transferAccruedDust(dustRecipient, dustAmount);
expect(await crvusd.balanceOf(dustRecipient)).to.equal(dustAmount);
// Withdraw initial deposit
await lendingPool.connect(user1).withdraw(ethers.parseEther("100"));
//We no longer have any dust in the contract
const dustAmountPostWithdraw = await rToken.calculateDustAmount();
expect(dustAmountPostWithdraw).to.equal(0n);
});
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RToken::calculateDustAmount incorrectly applies liquidity index, severely under-reporting dust amounts and permanently trapping crvUSD in contract

Support

FAQs

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

Give us feedback!