Platform: Code4rena
Start Date: 16/02/2023
Pot Size: $144,750 USDC
Total HM: 17
Participants: 154
Period: 19 days
Judge: Trust
Total Solo HM: 5
Id: 216
League: ETH
Rank: 8/154
Findings: 3
Award: $4,281.76
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: hyh
Also found by: Koolex, Parad0x, chaduke, hansfriese, jasonxiale, koxuan
1482.6553 USDC - $1,482.66
ReaperVaultV2's withdrawMaxLoss
isn't honoured when there are any locked funds in the strategy. Locked funds mean that there is a gap between requested and returned amount other than the loss reported. This is valid behavior of a strategy, but in this case realized loss is miscalculated in _withdraw() and a withdrawing user will receive less funds, while having all the shares burned.
Users can lose up to the whole asset amount due as all their requested shares can be burned, while only available amount be transferred to them. This amount can be arbitrary low.
The behaviour is not controlled by withdrawMaxLoss
limit and is conditional only on a strategy having some funds locked (i.e. strategy experiencing liquidity squeeze).
_withdraw() resets value
to be token.balanceOf(address(this))
when the balance isn't enough for withdrawal:
// Internal helper function to burn {_shares} of vault shares belonging to {_owner} // and return corresponding assets to {_receiver}. Returns the number of assets that were returned. function _withdraw( uint256 _shares, address _receiver, address _owner ) internal nonReentrant returns (uint256 value) { ... vaultBalance = token.balanceOf(address(this)); if (value > vaultBalance) { value = vaultBalance; } require( totalLoss <= ((value + totalLoss) * withdrawMaxLoss) / PERCENT_DIVISOR, "Withdraw loss exceeds slippage" ); } token.safeTransfer(_receiver, value); emit Withdraw(msg.sender, _receiver, _owner, value, _shares); }
Each strategy can return less than requested - loss
as some funds can be temporary frozen:
/** * @dev Withdraws funds and sends them back to the vault. Can only * be called by the vault. _amount must be valid and security fee * is deducted up-front. */ function withdraw(uint256 _amount) external override returns (uint256 loss) { require(msg.sender == vault, "Only vault can withdraw"); require(_amount != 0, "Amount cannot be zero"); require(_amount <= balanceOf(), "Ammount must be less than balance"); uint256 amountFreed = 0; (amountFreed, loss) = _liquidatePosition(_amount); IERC20Upgradeable(want).safeTransfer(vault, amountFreed); }
The invariant there is liquidatedAmount + loss <= _amountNeeded
, so liquidatedAmount + loss < _amountNeeded
is a valid state (due to the funds locked):
/** * Liquidate up to `_amountNeeded` of `want` of this strategy's positions, * irregardless of slippage. Any excess will be re-invested with `_adjustPosition()`. * This function should return the amount of `want` tokens made available by the * liquidation. If there is a difference between them, `loss` indicates whether the * difference is due to a realized loss, or if there is some other sitution at play * (e.g. locked funds) where the amount made available is less than what is needed. * * NOTE: The invariant `liquidatedAmount + loss <= _amountNeeded` should always be maintained */ function _liquidatePosition(uint256 _amountNeeded) internal virtual returns (uint256 liquidatedAmount, uint256 loss);
_liquidatePosition() is called in strategy withdraw():
/** * @dev Withdraws funds and sends them back to the vault. Can only * be called by the vault. _amount must be valid and security fee * is deducted up-front. */ function withdraw(uint256 _amount) external override returns (uint256 loss) { require(msg.sender == vault, "Only vault can withdraw"); require(_amount != 0, "Amount cannot be zero"); require(_amount <= balanceOf(), "Ammount must be less than balance"); uint256 amountFreed = 0; (amountFreed, loss) = _liquidatePosition(_amount); IERC20Upgradeable(want).safeTransfer(vault, amountFreed); }
This way there can be lockedAmount = _amountNeeded - (liquidatedAmount + loss) >= 0
, which is neither a loss, nor withdraw-able at the moment.
As ReaperVaultV2's _withdraw() updates value
per if (value > vaultBalance) {value = vaultBalance;}
, the following totalLoss <= ((value + totalLoss) * withdrawMaxLoss) / PERCENT_DIVISOR
check do not control for the real loss and allows user to lose up to the whole amount due as _withdraw() first burns the full amount of the _shares
requested and this total loss check for the rebased value
is the only guard in place:
function _withdraw( uint256 _shares, address _receiver, address _owner ) internal nonReentrant returns (uint256 value) { require(_shares != 0, "Invalid amount"); value = (_freeFunds() * _shares) / totalSupply(); _burn(_owner, _shares); if (value > token.balanceOf(address(this))) { ... vaultBalance = token.balanceOf(address(this)); if (value > vaultBalance) { value = vaultBalance; } require( totalLoss <= ((value + totalLoss) * withdrawMaxLoss) / PERCENT_DIVISOR, "Withdraw loss exceeds slippage" ); } token.safeTransfer(_receiver, value); emit Withdraw(msg.sender, _receiver, _owner, value, _shares); }
Suppose there is only one strategy and 90
of the 100
tokens requested is locked at the moment, and there is no loss, just a temporal liquidity squeeze. Say there is no tokens on the vault balance before strategy withdrawal.
ReaperBaseStrategyv4's withdraw() will transfer 10
, report 0
loss, 0 = totalLoss <= ((value + totalLoss) * withdrawMaxLoss) / PERCENT_DIVISOR = (10 + 0) * withdrawMaxLoss / PERCENT_DIVISOR
check will be satisfied for any viable withdrawMaxLoss
setting.
Bob the withdrawing user will receive 10
tokens and have 100
tokens worth of the shares burned.
Consider rewriting the controlling logic so the check be based on initial value:
Now:
vaultBalance = token.balanceOf(address(this)); if (value > vaultBalance) { value = vaultBalance; } require( totalLoss <= ((value + totalLoss) * withdrawMaxLoss) / PERCENT_DIVISOR, "Withdraw loss exceeds slippage" );
To be, as an example, if treat the loss attributed to the current user only as they have requested the withdrawal:
require( totalLoss <= (value * withdrawMaxLoss) / PERCENT_DIVISOR, "Withdraw loss exceeds slippage" ); value -= totalLoss; vaultBalance = token.balanceOf(address(this)); require( value <= vaultBalance, "Not enough funds" );
Also, shares
can be updated according to the real value obtained as it is done in yearn:
https://github.com/yearn/yearn-vaults/blob/master/contracts/Vault.vy#L1147-L1151
if value > vault_balance: value = vault_balance # NOTE: Burn # of shares that corresponds to what Vault has on-hand, # including the losses that were incurred above during withdrawals shares = self._sharesForAmount(value + totalLoss)
#0 - c4-judge
2023-03-10T13:41:11Z
trust1995 marked the issue as duplicate of #723
#1 - c4-judge
2023-03-10T13:41:16Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2023-03-20T15:55:32Z
trust1995 marked the issue as selected for report
2737.8355 USDC - $2,737.84
For 18 decimals case the F_Collateral
approach of storing rewards per unit staked has enough precision, but for lower decimal assets, for example WBTC, such rounding starts being significant and leads to the substantial amount of collateral fees being frozen.
As an example, let's say WBTC is USD 100k, while it is 1_000_001 OATH staked in LQTYStaking contract.
Suppose 0.01
WBTC came in as a fee to accrue. As WBTC has 8 decimals it will be accounted by increaseF_Collateral() as _collFee.mul(DECIMAL_PRECISION).div(totalLQTYStaked) = (0.01 * 10^8 * 10^18) / (1_000_001 * 10^18)
= 0
, while having value of USD 1000
. As fees are typically small enough, such a situation will be common, which means that significant cumulative share of fee funds will be missed by an accumulator and become frozen on the contract balance over time.
There looks to be no way to recover such remainder funds, so it's permanent freeze of the substantial part of rewards, at the expense of all the stakers.
Collateral fee accrual is performed as accumulation of the collFeePerLQTYStaked = _collFee.mul(DECIMAL_PRECISION).div(totalLQTYStaked)
:
function increaseF_Collateral(address _collateral, uint _collFee) external override { _requireCallerIsTroveManagerOrActivePool(); uint collFeePerLQTYStaked; if (totalLQTYStaked > 0) {collFeePerLQTYStaked = _collFee.mul(DECIMAL_PRECISION).div(totalLQTYStaked);} F_Collateral[_collateral] = F_Collateral[_collateral].add(collFeePerLQTYStaked); emit F_CollateralUpdated(_collateral, F_Collateral[_collateral]); }
F_Collateral
is reward amount per unit staked, whose difference with user's snapshot is then distributed:
function _getPendingCollateralGain(address _user) internal view returns (address[] memory assets, uint[] memory amounts) { assets = collateralConfig.getAllowedCollaterals(); amounts = new uint[](assets.length); for (uint i = 0; i < assets.length; i++) { address collateral = assets[i]; uint F_Collateral_Snapshot = snapshots[_user].F_Collateral_Snapshot[collateral]; amounts[i] = stakes[_user].mul(F_Collateral[collateral].sub(F_Collateral_Snapshot)).div(DECIMAL_PRECISION); } }
_getPendingCollateralGain() -> _sendCollGainToUser()
is the only mechanics of collateral rewards distribution in LQTYStaking.
This way after each increaseF_Collateral() rounding the remainder that was truncated is effectively frozen as there are no other means to recover those funds, i.e. only the funds that are accounted in F_Collateral
are retrievable.
Consider adding a precision enhancing multiplier to collateral rewards accumulators.
For example, DECIMAL_PRECISION
can be used there:
function increaseF_Collateral(address _collateral, uint _collFee) external override { _requireCallerIsTroveManagerOrActivePool(); uint collFeePerLQTYStaked; - if (totalLQTYStaked > 0) {collFeePerLQTYStaked = _collFee.mul(DECIMAL_PRECISION).div(totalLQTYStaked);} + if (totalLQTYStaked > 0) {collFeePerLQTYStaked = _collFee.mul(DECIMAL_PRECISION).mul(DECIMAL_PRECISION).div(totalLQTYStaked);} F_Collateral[_collateral] = F_Collateral[_collateral].add(collFeePerLQTYStaked); emit F_CollateralUpdated(_collateral, F_Collateral[_collateral]); }
function _getPendingCollateralGain(address _user) internal view returns (address[] memory assets, uint[] memory amounts) { assets = collateralConfig.getAllowedCollaterals(); amounts = new uint[](assets.length); for (uint i = 0; i < assets.length; i++) { address collateral = assets[i]; uint F_Collateral_Snapshot = snapshots[_user].F_Collateral_Snapshot[collateral]; - amounts[i] = stakes[_user].mul(F_Collateral[collateral].sub(F_Collateral_Snapshot)).div(DECIMAL_PRECISION); + amounts[i] = stakes[_user].mul(F_Collateral[collateral].sub(F_Collateral_Snapshot)).div(DECIMAL_PRECISION).div(DECIMAL_PRECISION); } }
#0 - c4-judge
2023-03-10T11:31:32Z
trust1995 marked the issue as duplicate of #305
#1 - c4-judge
2023-03-10T11:31:46Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2023-03-20T16:14:29Z
trust1995 marked the issue as unsatisfactory: Invalid
#3 - dmitriia
2023-03-22T18:29:24Z
Looks like a dup for #482
#4 - c4-judge
2023-03-23T10:11:02Z
trust1995 marked the issue as not a duplicate
#5 - c4-judge
2023-03-23T10:11:12Z
trust1995 changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-03-23T10:11:24Z
trust1995 changed the severity to 3 (High Risk)
#7 - c4-judge
2023-03-23T10:11:32Z
trust1995 marked the issue as duplicate of #482
#8 - c4-judge
2023-03-28T08:48:11Z
trust1995 marked the issue as satisfactory