Summary
This is quite different from the known issue.
>Yield rounding issues could temporarily prevent yield claims if aToken balance becomes smaller than wToken supply.
This will DOS the redeem/remove liquidity function.
When admin calls to claim yields in the Aave contract the contract then withdraws collateral and rounds up the amount of atokens burned to truncate 0.5 to 1 this ensures that we are correctly handling withdraws by rounding up to favour the protocol and rounding down if necessary. This issue although correctly handled before the call, the balance remaining for address(this) after this claim may become 1 wei or some wei less and this will temporarily DOS the last user who tries to redeem/remove liquidity, which can either be the diva fee recipient of the Last user.
>Invariants
>At any point in time, the following invariants have to hold true:
The first invariant will be broken.
Vulnerability Details
Temporary DOS of the last user to redeem and remove liquidity
function _claimYield(address _collateralToken, address _recipient) internal returns (uint256) {
if (_collateralTokenToWToken[_collateralToken] == address(0)) {
revert CollateralTokenNotRegistered();
}
if (_recipient == address(0)) revert ZeroAddress();
@audit>> uint256 _amountReturned = IAave(_aaveV3Pool).withdraw(
_collateralToken,
@audit>> _getAccruedYieldPrivate(_collateralToken),
_recipient
);
emit YieldClaimed(owner(), _recipient, _collateralToken, _amountReturned);
return _amountReturned;
}
We get the amount of interest earned
function _getAccruedYieldPrivate(address _collateralToken) private view returns (uint256) {
uint256 aTokenBalance = IERC20Metadata(IAave(_aaveV3Pool).getReserveData(_collateralToken).aTokenAddress)
.balanceOf(address(this));
uint256 wTokenSupply = IERC20Metadata(_collateralTokenToWToken[_collateralToken]).totalSupply();
@audit>>
@audit>>
@audit>> return aTokenBalance > wTokenSupply ? aTokenBalance - wTokenSupply : 0;
}
Also, this occurs during withdrawals
function _redeemWTokenPrivate(
address _wToken,
uint256 _wTokenAmount,
address _recipient,
address _burnFrom
) private returns (uint256) {
if (_recipient == address(0)) revert ZeroAddress();
IWToken(_wToken).burn(_burnFrom, _wTokenAmount);
address _collateralToken = _wTokenToCollateralToken[_wToken];
@audit>> uint256 _amountReturned = IAave(_aaveV3Pool).withdraw(
_collateralToken,
@audit>> _wTokenAmount,
_recipient
);
emit WTokenRedeemed(_wToken, _wTokenAmount, _collateralToken, _amountReturned, _recipient);
return _amountReturned;
}
Aave rounds up the amount burned in the user balance to handle rounding issues
https://github.com/aave/aave-v3-core/blob/782f51917056a53a2c228701058a6c3fb233684a/contracts/protocol/libraries/logic/SupplyLogic.sol#L139-L144
function executeWithdraw(
mapping(address => DataTypes.ReserveData) storage reservesData,
mapping(uint256 => address) storage reservesList,
mapping(uint8 => DataTypes.EModeCategory) storage eModeCategories,
DataTypes.UserConfigurationMap storage userConfig,
DataTypes.ExecuteWithdrawParams memory params
) external returns (uint256) {
DataTypes.ReserveData storage reserve = reservesData\[params.asset];
DataTypes.ReserveCache memory reserveCache = reserve.cache();
reserve.updateState(reserveCache);
uint256 userBalance = IAToken(reserveCache.aTokenAddress).scaledBalanceOf(msg.sender).rayMul(
reserveCache.nextLiquidityIndex
);
uint256 amountToWithdraw = params.amount;
if (params.amount == type(uint256).max) {
amountToWithdraw = userBalance;
}
ValidationLogic.validateWithdraw(reserveCache, amountToWithdraw, userBalance);
reserve.updateInterestRates(reserveCache, params.asset, 0, amountToWithdraw);
bool isCollateral = userConfig.isUsingAsCollateral(reserve.id);
if (isCollateral && amountToWithdraw == userBalance) {
userConfig.setUsingAsCollateral(reserve.id, false);
emit ReserveUsedAsCollateralDisabled(params.asset, msg.sender);
}
@audit >>> IAToken(reserveCache.aTokenAddress).burn(
msg.sender,
params.to,
amountToWithdraw,
reserveCache.nextLiquidityIndex
);
The burned amount can be rounded up in special cases
function _burnScaled(address user, address target, uint256 amount, uint256 index) internal {
@audit>> uint256 amountScaled = amount.rayDiv(index);
require(amountScaled != 0, Errors.INVALID\_BURN\_AMOUNT);
uint256 scaledBalance = super.balanceOf(user);
@audit>> uint256 balanceIncrease = scaledBalance.rayMul(index) -
scaledBalance.rayMul(_userState[user].additionalData);
_userState[user].additionalData = index.toUint128();
@audit>> _burn(user, amountScaled.toUint128());
if (balanceIncrease > amount) {
uint256 amountToMint = balanceIncrease - amount;
emit Transfer(address(0), user, amountToMint);
emit Mint(user, user, amountToMint, balanceIncrease, index);
} else {
uint256 amountToBurn = amount - balanceIncrease;
emit Transfer(user, address(0), amountToBurn);
emit Burn(user, target, amountToBurn, balanceIncrease, index);
}
Math operation
* @notice Divides two ray, rounding half up to the nearest ray
* @dev assembly optimized for improved gas savings, see https:
* @param a Ray
* @param b Ray
* @return c = a raydiv b
*/
function rayDiv(uint256 a, uint256 b) internal pure returns (uint256 c) {
assembly {
if or(iszero(b), iszero(iszero(gt(a, div(sub(not(0), div(b, 2)), RAY))))) {
revert(0, 0)
}
c := div(add(mul(a, RAY), div(b, 2)), b)
}
}
Does It Always Round Up?
Not always. It follows "round half up" behavior:
If the remainder is exactly 0.5 or greater, it rounds up.
Otherwise, it truncates (rounds down).
Impact
Not correctly handling the small rounding issue during withdrawals can cause a temporary DOS to the last user, who will have to wait for a while for the Aave index to increase the balance to be able to withdraw.
Tools Used
Manual Review
Recommendations
Admin can create 2 claim yield functions, The first should be callable when the wtoken total supply is greater than 0, this claim yield function should ensure leaving behind some tokens behind (1 or 1000) in the contract to prevent the temporary DOS.
The second claim function can be called only when the total supply of the wtoken is 0, and all token yield should only be sweepable then. This will help to maintain and keep this invariant unbroken.
Alternatively, we can add a check after claim and redeem calls to ensure that the invariant always holds.