Core Contracts

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

The `RToken::calculateDustAmount()` function contains a calculation error that prevents the expected extraction of dust balances.

Summary

The RToken::calculateDustAmount() function contains a calculation error that prevents the expected extraction of dust balances.

Vulnerability Details

The Lending::deposit() function primarily relies on RToken::mint() -> RToken::_update() to properly scale the deposited amount. The assets transferred in ReserveLibrary::deposit() and the expected amount of RToken are in a 1:1 ratio:

// LendingPool::deposit()
function deposit(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// SNIP...
// Perform the deposit through ReserveLibrary
@> uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
// SNIP...
}
// ReserveLibrary::deposit()
function deposit(ReserveData storage reserve,ReserveRateData storage rateData,uint256 amount,address depositor) internal returns (uint256 amountMinted) {
// SNIP...
// Transfer asset from caller to the RToken contract
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender, // from
reserve.reserveRTokenAddress, // to
@> amount // amount
);
// Mint RToken to the depositor (scaling handled inside RToken)
(bool isFirstMint, uint256 amountScaled, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).mint(
address(this), // caller
depositor, // onBehalfOf
@> amount, // amount
reserve.liquidityIndex // index
);
// SNIP...
}
// Lending::deposit() -> ReserveLibrary::deposit() -> RToken::mint()
// RToken::mint()
function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256, uint256) {
// SNIP...
@> _mint(onBehalfOf, amountToMint.toUint128());
// SNIP...
}
// RToken::_update()
function _update(address from, address to, uint256 amount) internal override {
// Scale amount by normalized income for all operations (mint, burn, transfer)
@> uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}

Similarly, the Lending::withdraw() function relies on RToken::burn() to handle asset transfers and token destruction. The amount scaling is also performed in RToken::_update(), maintaining a 1:1 ratio.

// LendingPool::withdraw()
function withdraw(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// SNIP...
// Perform the withdrawal through ReserveLibrary
(uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) = ReserveLibrary.withdraw(
reserve, // ReserveData storage
rateData, // ReserveRateData storage
amount, // Amount to withdraw
msg.sender // Recipient
);
// SNIP...
}
// ReserveLibrary::withdraw()
function withdraw(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address recipient
) internal returns (uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) {
// SNIP...
(uint256 burnedScaledAmount, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).burn(
recipient, // from
recipient, // receiverOfUnderlying
amount, // amount
reserve.liquidityIndex // index
);
// SNIP...
}
// RToken::burn()
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
// SNIP...
@> uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
@> amount = userBalance;
}
// SNIP...
@> _burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
@> IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
// SNIP...
}
// RToken::_update()
function _update(address from, address to, uint256 amount) internal override {
// Scale amount by normalized income for all operations (mint, burn, transfer)
@> uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}

As the @> mark indicates, the key issue arises due to the double scaling of totalRealBalance, which results in totalSupply() being lower than the actual balance in the contract. Consequently, contractBalance becomes inaccurately smaller than the true asset balance, leading to an incorrect calculation of the dust amount. This prevents the extraction of dust, causing it to remain locked in the contract.

// RToken::calculateDustAmount()
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();
// Calculate the total real balance equivalent to the total supply
@> uint256 totalRealBalance = currentTotalSupply.rayMul(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;
}
function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
return super.totalSupply().rayMul(ILendingPool(_reservePool).getNormalizedIncome());
}

Poc

Add the following test case to test/unit/core/pools/LendingPool/LendingPool.test.js and execute it:

describe("Dust Amount", function () {
beforeEach("Simulate real-world interest rates", async function () {
// user2 deposits 1000e18 crvusd
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await lendingPool.connect(user2).deposit(depositAmount);
// user1 deposits NFT
const tokenId = 1;
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await lendingPool.connect(user1).depositNFT(tokenId);
// user1 borrows 50e18 crvusd
const borrowAmount = ethers.parseEther("50");
await lendingPool.connect(user1).borrow(borrowAmount);
await lendingPool.connect(user1).updateState();
// After 365 days
await ethers.provider.send("evm_increaseTime", [365 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
await lendingPool.connect(user1).updateState();
// user1 repay all
await crvusd.connect(user1).approve(lendingPool.target, ethers.parseEther("1000"));
await lendingPool.connect(user1).repay(ethers.parseEther("1000"));
await ethers.provider.send("evm_mine");
});
it("Test RToken::calculateDustAmount()", async function () {
// User2 deposits assets again
await crvusd.connect(user2).approve(lendingPool.target, ethers.parseEther("100"));
await lendingPool.connect(user2).deposit(ethers.parseEther("100"));
await lendingPool.connect(user1).updateState();
await ethers.provider.send("evm_mine");
console.log("First check the amount of dust");
let totalSupply = await rToken.totalSupply();
let assetAmount = await crvusd.balanceOf(rToken.getAddress());
console.log("totalSupply:",totalSupply);
console.log("assetAmount:",assetAmount);
let dustAmount = await rToken.calculateDustAmount();
console.log("Dust amount:", dustAmount);
// Second check the amount of dust
console.log("Second check the amount of dust");
// user2 transfers more crvusd to the rToken address to increase the dust amount
await crvusd.connect(user2).transfer(rToken.getAddress(),1000000000000000000n);
await lendingPool.connect(user1).updateState();
await ethers.provider.send("evm_mine");
totalSupply = await rToken.totalSupply();
assetAmount = await crvusd.balanceOf(rToken.getAddress());
console.log("totalSupply:",totalSupply);
console.log("assetAmount:",assetAmount);
dustAmount = await rToken.calculateDustAmount();
console.log("Dust amount:", dustAmount);
// init recipient
const recipient = ethers.Wallet.createRandom().connect(ethers.provider);
// If dust exists, execute transferAccruedDust()
if(dustAmount !== 0n){
// owner calls transferAccruedDust()
await lendingPool.connect(owner).transferAccruedDust(recipient,ethers.parseEther("100"));
}
console.log("recipient crvusd balance:",await crvusd.balanceOf(recipient.address));
// user2 withdraw all asset
const user2CrvUSDBalanceStart = await crvusd.balanceOf(user2.address);
const user2RTokenBalance = await rToken.balanceOf(user2.address);
await lendingPool.connect(user2).withdraw(user2RTokenBalance + 100000000n);
const user2CrvUSDBalanceEnd = await crvusd.balanceOf(user2.address);
console.log("The amount withdrawn by user2 is:", user2CrvUSDBalanceEnd - user2CrvUSDBalanceStart);
console.log("Remaining dust amount:", await crvusd.balanceOf(rToken.getAddress()));
});
});

output:

LendingPool
Dust Amount
Promise { <pending> }
First check the amount of dust
totalSupply: 2101367187570703361360n
assetAmount: 2101386051145387902100n
Dust amount: 0n
Second check the amount of dust
totalSupply: 2101367187570703361360n
assetAmount: 2102386051145387902100n
Dust amount: 0n
recipient crvusd balance: 0n
The amount withdrawn by user2 is: 2101367187570703361360n
Remaining dust amount: 1018863574684540740n
✔ Test RToken::calculateDustAmount() (565ms)

Impact

Due to the incorrect calculation in calculateDustAmount(), the contract is unable to extract dust as intended. This can lead to the accumulation of dust over time, effectively locking excess funds within the contract and making them inaccessible.

Tools Used

Manual Review

Recommendations

Make sure the calculations are done correctly according to the expected proportions, for example:

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();
// Calculate the total real balance equivalent to the total supply
- uint256 totalRealBalance = currentTotalSupply.rayMul(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;
+ uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this));
+ return contractBalance <= currentTotalSupply ? 0 : contractBalance - currentTotalSupply;
}

test again:

LendingPool
Dust Amount
Promise { <pending> }
First check the amount of dust
totalSupply: 2101367187570703361360n
assetAmount: 2101386051145387902100n
Dust amount: 18863574684540740n
Second check the amount of dust
totalSupply: 2101367187570703361360n
assetAmount: 2102386051145387902100n
Dust amount: 1018863574684540740n
recipient crvusd balance: 1018863574684540740n
The amount withdrawn by user2 is: 2101367187570703361360n
Remaining dust amount: 0n
✔ Test RToken::calculateDustAmount() (526ms)

Dust is processed correctly

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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.