Summary
The Protocol allowed the owner to remove the dust amount if not backed by rToken , However to Find the Assets in the pool we only check the assets owned by pool and did not count the assets deposited in crvUSD vault.
Vulnerability Details
the Core flow of assets is that we only keep the required buffer amount in the pool and deposit the rest of assets in crvUSD pool for yield generation purpose. The logic to remove the Dust from vault is based on the assets users has deposited and the rToken totalSupply if both of them are different than we send out the extra assets to out from the rToken address as rToken holds all the assets for pool operation.
The Root cause :
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;
}
Here we only fetch the assets owned by this contract. but we also deposit the extra assets on crvUsd vault here:
/contracts/core/pools/LendingPool/LendingPool.sol:817
817: function _rebalanceLiquidity() internal {
818:
819: if (address(curveVault) == address(0)) {
820: return;
821: }
822:
823: uint256 totalDeposits = reserve.totalLiquidity;
824: uint256 desiredBuffer = totalDeposits.percentMul(liquidityBufferRatio);
825: uint256 currentBuffer = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
826:
827: if (currentBuffer > desiredBuffer) {
828: uint256 excess = currentBuffer - desiredBuffer;
829:
830: _depositIntoVault(excess);
831: } else if (currentBuffer < desiredBuffer) {
...
837: emit LiquidityRebalanced(currentBuffer, totalVaultDeposits);
838: }
As from the above code it can be seen that we deposit the extra token in crvUSd vault and only check that assets owned by rToken and does not include the assets deposited on crvUSd vault So the final Dust amount will not correct or it will be less than the actual Dust.
As there are few issues in these code which I reported seperated I will share the code here to run the POC:
import {ICurveCrvUSDVault} from "../interfaces/curve/ICurveCrvUSDVault.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockUsdVault is ICurveCrvUSDVault {
address assetAddress;
constructor(address _assetAddress) {
assetAddress = _assetAddress;
}
function deposit(
uint256 assets,
address receiver
) external returns (uint256 shares) {
IERC20(assetAddress).transferFrom(msg.sender, address(this), assets);
return shares;
}
function withdraw(
uint256 assets,
address receiver,
address owner,
uint256 maxLoss,
address[] calldata strategies
) external returns (uint256 shares) {
IERC20(assetAddress).transfer(msg.sender, assets);
return shares;
}
function asset() external view returns (address) {
return address(this);
}
function totalAssets() external view returns (uint256) {
return 0;
}
function pricePerShare() external view returns (uint256) {
return 1e18;
}
function totalIdle() external view returns (uint256) {
return 0;
}
function totalDebt() external view returns (uint256) {
return 0;
}
function isShutdown() external view returns (bool) {
return false;
}
}
Add following line to _depositIntoVault:
IRToken(reserve.reserveRTokenAddress).transferAsset(address(this), amount);
Add this POC to LendingPool.test.js in describe("Full sequence", section:
it.only("should transfer accrued dust correctly", async function () {
const MockVault = await ethers.getContractFactory("MockUsdVault");
await crvusd.connect(user1).approve(lendingPool.target, ethers.parseEther("100"));
await lendingPool.connect(user1).deposit(ethers.parseEther("100"));
await crvusd.connect(owner).mint(owner.address, ethers.parseEther("1000"));
await crvusd.connect(owner).transfer(rToken.target, ethers.parseEther("0.00000001"));
await rToken.connect(owner).setReservePool(lendingPool.target);
let index = await lendingPool.getNormalizedIncome();
console.log("index" , index);
const dustAmount = await rToken.calculateDustAmount();
console.log("Dust amount:", dustAmount);
});
it.only("should not transfer accrued dust correctly because of not checking the crv vault balance", async function () {
const MockVault = await ethers.getContractFactory("MockUsdVault");
let vault = await MockVault.deploy(crvusd.target);
await lendingPool.connect(owner).setCurveVault(vault.target);
await crvusd.connect(user1).approve(lendingPool.target, ethers.parseEther("100"));
await lendingPool.connect(user1).deposit(ethers.parseEther("100"));
await crvusd.connect(owner).mint(owner.address, ethers.parseEther("1000"));
await crvusd.connect(owner).transfer(rToken.target, ethers.parseEther("1"));
const dustAmount = await rToken.calculateDustAmount();
console.log("Dust amount:", dustAmount);
});
The above to Test case when show Dust Amount 2717422610 when all the assets owned by rToken and other show DustAmount 0 when 80% assets hold by crvUsd vault.
Impact
Due not including the assets owned by crvUSd vault , the Dust amount calculation will return less or 0 value in Dust.
Tools Used
Unit Testing, Manual Review
Recommendations
On Fix Which i suggest could be to also add all the assets deposited in crvUSd vault here , So that we will get the actual Dust Amount we have in Pool.
Add this function in ILendingPool.sol:
+ function getTotalVaultDeposits() external view returns (uint256);
Add this line to
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()); // @audit : this calculation is not correct as the assets will be deposit into crvVault
+ contractBalance +=ILendingPool(_reservePool).getTotalVaultDeposits().rayDiv(ILendingPool(_reservePool).getNormalizedIncome());