Summary
The RToken implementation prevents users from withdrawing accrued interest due to a mismatch between RToken amounts and underlying crvUSD value in the burn function. While users can withdraw their original deposit amount, they cannot access any earned interest.
Vulnerability Details
RToken handles token amounts in its burn function:
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
_userState[from].index = index.toUint128();
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}
Example scenario:
User deposits 1000 crvUSD using the deposit function in LendingPool. The same amount parameter will be passed to the _mint function in RToken:
function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) {
_mint(onBehalfOf, amountToMint.toUint128());
}
After interest accrues (index = 1.1 * 1e27), balanceOf will return 1000.rayMul(1.1 * 1e27) = 1100 (1100 crvUSD value):
function balanceOf(address account) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
}
User should be able to withdraw 1100 crvUSD. User calls withdraw in LendingPool with 1100 for amount. 1100 is passed to the withdraw function in ReserveLibrary, which calls burn with amount = 1100:
_burn(from, amount.toUint128());
User tries to withdraw full 1100 crvUSD balance, but _burn will revert because the user has only 1000 RTokens.
Impact
High: Users can only withdraw their original deposit amount. All accrued interest becomes inaccessible. The problem compounds over time as more interest accrues.
Recommendations
Modify the burn function to properly scale the burn amount (i.e., 1100 / 1.1 = 1000 RTokens to burn):
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
- uint256 amountScaled = amount.rayMul(index);
+ uint256 rTokenAmount = amount.rayDiv(index); // scale down to RToken amount
- _userState[from].index = index.toUint128();
- _burn(from, amount.toUint128());
+ _burn(from, rTokenAmount.toUint128()); // burns 1000 RTokens
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
- emit Burn(from, receiverOfUnderlying, amount, index);
+ emit Burn(from, receiverOfUnderlying, rTokenAmount, index);
- return (amount, totalSupply(), amount);
+ return (rTokenAmount, totalSupply(), amount);
}