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:
function deposit(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
@> uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
}
function deposit(ReserveData storage reserve,ReserveRateData storage rateData,uint256 amount,address depositor) internal returns (uint256 amountMinted) {
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender,
reserve.reserveRTokenAddress,
@> amount
);
(bool isFirstMint, uint256 amountScaled, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).mint(
address(this),
depositor,
@> amount,
reserve.liquidityIndex
);
}
function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256, uint256) {
@> _mint(onBehalfOf, amountToMint.toUint128());
}
function _update(address from, address to, uint256 amount) internal override {
@> 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.
function withdraw(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
(uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) = ReserveLibrary.withdraw(
reserve,
rateData,
amount,
msg.sender
);
}
function withdraw(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address recipient
) internal returns (uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) {
(uint256 burnedScaledAmount, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).burn(
recipient,
recipient,
amount,
reserve.liquidityIndex
);
}
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
@> uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
@> amount = userBalance;
}
@> _burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
@> IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
}
function _update(address from, address to, uint256 amount) internal override {
@> 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.
function calculateDustAmount() public view returns (uint256) {
@> uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
@> uint256 currentTotalSupply = totalSupply();
@> uint256 totalRealBalance = currentTotalSupply.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
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 () {
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await lendingPool.connect(user2).deposit(depositAmount);
const tokenId = 1;
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);
await lendingPool.connect(user1).updateState();
await ethers.provider.send("evm_increaseTime", [365 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
await lendingPool.connect(user1).updateState();
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 () {
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);
console.log("Second check the amount of dust");
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);
const recipient = ethers.Wallet.createRandom().connect(ethers.provider);
if(dustAmount !== 0n){
await lendingPool.connect(owner).transferAccruedDust(recipient,ethers.parseEther("100"));
}
console.log("recipient crvusd balance:",await crvusd.balanceOf(recipient.address));
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