OlympusDAO Single Sided Liquidity Vault System (SSLV) Audit
Introduction & Scope
This audit looks into Pull Request 103 as seen here. This includes the following contracts:
This audit was conducted by kebabsec members sai, FlameHorizon and okkothejawa.
As SSLV can affect the overall OHM supply, its design itself may have unexpected consequences for the economics of OHM ecosystem. As the authors of this report lack the expertise and proper information to audit the economic design of the system, this audit should not be treated as a design audit.
Findings:
1. [MED] Wrong decimal value for stETH/USD price feed in StethLiquidityVault.sol
- In
StethLiquidityVault.sol
, line 215, a comment affirms that the oracle price feed for stETH/USD reports a price in 18 decimals, after double checking it, we noticed this was not accurate, and that the price was in fact being reported in 8 decimals. Incorrectly assuming the decimal count of a feed would complicate the calculations to come after contained in the same function, more specifically the math for the return value in line 223. - Recommendation: Change the calculations to take into account that stETH/USD price feed reports in 8 decimals.
2. [LOW] safeTransferFrom
and safeTransfer
not present
- As
pairToken
andrewardToken
tokens might not be standard ERC20 tokens that revert on failure (they may return false and silently pass as opposed to more common way of reverting) or they may not return a boolean like USDT, consider utilizing aSafeERC20
library for token transfers throughout the contract to account for non ERC20 compliant tokens. - Recommendation: Use
safeTransfer
andsafeTransferFrom
instead oftransfer
andtransferFrom
while handling tokens other thanOHM
, especially in the abstract contract as the tokens are not known beforehand. See these related C4 issues.
3. [HIGH] Faulty math in _canDeposit
leads to Denial of Service
- Due to faulty math, the sanity check
_canDeposit
checks ifohmMinted - ohmBurned + amount_ > LIMIT
in both branches as the variableactiveOhm
evaluates toohmMinted - ohmBurned
in each of the branches. As Solidity >0.8 reverts when auint
is evaluated as negative, the states in whichohmBurned > ohmMinted
results in_canDeposit
reverting. As the functiondeposit
is the only path that can increase the variableohmMinted
and asdeposit
utilizes_canDeposit
sanity check, once the contract gets into a state in whichohmBurned > ohmMinted
which can occur naturally due to price fluctations in the Balancer pool, no further deposits are possible after that point, resulting in denial of service. As the only way to resume operations in such scenario is upgrading/replacing the contract, see issue 8 [INFORMATIONAL] No emergency protections present. - Recommendation: Fix the faulty math.
4. [MED] deposit
does not conform to checks-effects-interactions pattern
- As can be seen here, state changes happen after
transfer
andtransferFrom
calls topairToken
indeposit
. This is against the safe pattern of checks-effects-interactions, and it can lead to reentrancy attacks utilizing apairToken
with callback capabilities (e.g an ERC777 or a modified ERC20). Even though thenonReentrant
modifier is used in both external functions of the contract, this modifier can only prevent reentrancy attacks caused by calling these functions mid-function, yet the attacker can manipulate the state by either tampering with the liquidity pool or dumpingOHM
directly into the contract in the callback.- A) Tampering with the liquidity pool
The attacker can bypass the check
_isPoolSafe
by having an initially safe LP pool state to pass_isPoolSafe
, and then proceeding to manipulate the pool price in the callback frompairToken.transferFrom
call before_deposit
, resulting in a deposit to an unsafe pool. The current Balancer implementation is not likely to be susceptible to this as all external state-changing functions of Balancer has thenonReentrant
modifier, meaning that any price manipulation during the transaction would lock the Balancer vault, resulting in revert in the_deposit
call. Yet, further implementations utilizing different liquidity pool designs might be susceptible to bypassing of_isPoolSafe
, if the underlying liquidity pools don’t implement a mutex in the necessary places. - B) Dumping
OHM
directly into contract during execution As the variableohmMinted
is increased by the difference in theOHM
balance before and after thetransferFrom
calls, the attacker can utilize the callback to directly sendOHM
into the contract to inflateunusedOhm
, causingohmMinted
to increase less than what is supposed to. This path can be utilized to force issue 3 by inflating the difference betweenohmBurned
andohmMinted
. Other than causing denial of service by issue 3 and tampering with the variablesohmMinted
orpairTokenDeposits
, no direct exploitation possibility for this path was found in the audit.
- A) Tampering with the liquidity pool
The attacker can bypass the check
- Recommendation: Refactor
deposit
so that it conforms to CEI pattern.
5. [MED] Truncations and unsafe casts in the rewards logic can lead to weird states such as infinite rewards
- PoC Foundry test can be found here.
- As a
MasterChef
variation, the rewards logic of SSLV includes potential truncations (division results in 0 in Solidity if numerator is smaller than denominator as Solidity does not support floats) and unsafe casts (casting aint
to anuint
is dangerous as underflow/overflow in casting is not patched after Solidity >0.8). While our research didn’t yield a path that is directly exploitable, we found a interesting enough weird state that is accessible through a valid path in which a first depositor deposits a large amount ofpairToken
so thattotalLP
starts large, resulting in a smallaccumulatedRewardsPerShare
. Then a second depositor makes a deposit of usual1e18
, and proceeds to withdraw only1
. As rewards are calculated as in the snippet below ininternalRewardsForToken
, onceuserRewardDebts[user_][rewardToken.token]
is larger thanint256((lpPositions[user_] * accumulatedRewardsPerShare) / 1e18)
the outermostint
value becomes negative, underflowinguint
and resulting in infinite rewards value. This behavior happens as the withdrawn amount truncates when its subtracted from theuserRewardDebts
in_withdrawUpdateRewardState
due to both withdrawn LP amount and rewards per share being small and their product is smaller than1e18
, which leads touserRewardDebts
staying the same whileint256((lpPositions[user_] * accumulatedRewardsPerShare) / 1e18)
is decreased.
|
|
- Even though the above scenario highlights the problems associated with truncations and unsafe casts, it is not practically exploitable as the contract wouldn’t have an infinite amount of rewards and the fee calculation would overflow and revert. We extensively studied the contract to see if an arbitrary negative number can be reached through the means explained above, yet we couldn’t find such a path. Yet, this does not mean such a path does not exist, and thus usage of potential truncations and unsafe casts should be minimized in order to prevent potential rewards drainage paths.
- Recommendation: Minimize truncations and unsafe casts.
6. [LOW] Add sanity check when setting variables THRESHOLD and FEE, specially THRESHOLD since it breaks _isPoolSafe
- The function
setThreshold
should be bound within the parameters to stay compliant with the comment above, specially since it cannot be bigger thanPRECISION
, which is hardcoded to the value of 1000, and accidentally adding another zero accidentally when using this function would break_isPoolSafe
and revert and all the functions that depend on this check to function. - Recommendation: Implement sanity checks in potentially contract breaking setters.
7. [INFO] No emergency protections present
- This module does not have emergency protections present, and in case of a critical event there should be a way to prevent withdraws and other sensitive functions.
- There is also no way to initialize the contract with a pre-set mapping of LP positions, thus upgrading the contract with a new one as a response to a critical event is tricky. An emergency migration function can be designed as the following:
- Withdraw LP tokens from Aura.
- Transfer LP tokens to the new implementation.
- Initialize the new contract with the old state mappings.
- Deposit the LP tokens into Aura again.
- Resume operation as usual.
- Recommendation: Consider implementing permissioned emergency migration and pause/unpause functions.
8. [LOW] Event not emitted due to dead code in withdraw
- In line 228 of
SingleSidedLiquidityVault.sol
there is a return statement that prevents the posterior event in line 230 to not be emitted. - Recommendation: Swap the return line with the line firing the event.
9. [INFO] Insufficient test coverage
Upon inspection, the contract’s test unit doesn’t simulate the correct functionality of production, and has the following issues:
- The withdraw testing only applies a condition of warping time, but has no test of a condition without passing time.
- No fuzzing of deposit and withdraw amount inputs, leading to problems.
Mock contracts do not provide the correct functionality compared to mainnet deployment environment and can’t be verified:
MockAuraRewardPool
andBalancerMockVault
mock by naive token minting. This can not provide sufficient verification of Vault’s external function calls.
Recommendation: Extend the test coverage and improve the accuracy of mocks.
10. [INFO] Claim reward functions does not return value
- The functions
_claimInternalRewards
and_claimExternalRewards
are declared to return uint256, but does not return anything. - Recommendation: Function should return claimed tokens amount or be declared without return.
11. [INFO] _accumulateInternalRewards
does not make state changes and can be set to view
- Recommendation: Set function to be
view
.
12. [MED] Possible reentrancy in claimRewards
- The claim function makes several external calls, presented for external token accumulation in _accumulateExternalRewards:
|
|
- While
withdraw
function is protected against reentrancy that allows claiming rewards,claimRewards
can be claimed. - Recommendation: Function
claimRewards
should havenonReentrant
to ensure reentrancy safety.
13. [INFO] Tokens rewarding is not accounted for tokens less or more than 18 decimals
- The vault will miscalculate reward token amount if the token’s decimal is not 18. The calculation occurs in following functions: _updateInternalRewardState, _updateExternalRewardState, _depositUpdateRewardDebts, _withdrawUpdateRewardState,
internalRewardsForToken
andexternalRewardsForToken
- Recommendation: Make sure all reward tokens have 18 decimals, or extend the logic to support decimals other than 18.
14. [LOW] withdraw
insufficient amount input checking
- The function doesn’t fully ensure the correctness of passed inputs:
- A user is able to call withdraw with zero amount in
lpAmount_
andminTokenAmounts_
.
- A user is able to call withdraw with zero amount in
withdraw
makes external calls to Balancer and Aura, checks current token balance and makes update states to reward tokens. Which shouldn’t occur.- Recommendation: Add sanity checks for
lpAmount_
andminTokenAmounts_
.
15. [HIGH] Vault receiving reward tokens outside _accumulateExternalRewards
from AURA pool can’t be accounted and claimed
- The accumulation is done by checking current’s balance before and after pool call rewarded upon updating reward state when depositing and withdrawing.
- According to the Aura’s contract
BaseRewardPool
implementation,getReward
can be called by anyone, passing the vault’s address:
|
|
- Assuming this is the correct pool,
getReward()
call can be triggered by anyone which will result in rewards will be transferred to the vault without getting recorded as it won’t be accounted as a balance change in the function_accumulateExternalRewards
. - The vault also does not have a general token sweep function, and the only way to transfer the rewards out are through reading the recorded
accumulatedExternalRewards
values, thus the rewards will be stuck in the contract. - This path can be used as a griefing attack, as it results in monetary loss for both the users of SSLV and the protocol in the form of potential fees.
- Recommendation: Change accumulation logic so that it covers rewards accumulation happening outside of
_accumulateExternalRewards
.