Platform: Code4rena
Start Date: 18/09/2023
Pot Size: $31,310 USDC
Total HM: 15
Participants: 5
Period: 7 days
Judge: 0xLeastwood
Total Solo HM: 6
Id: 286
League: ETH
Rank: 2/5
Findings: 3
Award: $0.00
🌟 Selected for report: 2
🚀 Solo Findings: 1
Data not available
https://github.com/code-423n4/2023-09-asymmetry/blob/main/contracts/AfEth.sol#L272-L293
Upon claiming Votium rewards, applyRewards()
is intended to be invoked in order to exchange the tokens for eth and put the eth received back into the strategies. Based on the current ratio
it either stakes the amount into safETH or obtains some CVX by selling eth on Curve and then locks them to get vlCVX.
Since afETH.depositRewards()
or VotiumStrategy.depositRewards()
can be called by anyone, an adversary is able to manipulate the CVX/ETH pool in such a way that the CVX tokens will be bought at inflated rates creating an arbitrage opportunity for an adversary.
afETH
or VotiumStrategy
.
But when exactly afETH
or VotiumStrategy
will hold some eth apart from accidental eth transfers? This has been discussed with the sponsors and we have the following context:
Possibility to boost user rewards by dropping additional eth and re-investing them after the rewards being received. The time difference between additional eth being dropped and rewards from Votium being claimed is exactly what an adversary needs to steal that additional eth.
In a case, where the ratio > safEthRatio
rewarder oracle will not be able to invoke applyRewards()
after rewards being claimed due to the min deposit amount limits setted by safETH, which ultimately results in tx reversion.
Upon further supporting more strategies.
afETH.depositRewards()
/VotiumStrategy.depositRewards()
while the rates are inflated.depositRewards()
being invoked internally only by underlying strategies.x%
, the whole tx will be reverted. Basically, setting the slippage control by utilizing price oracles.Context
#0 - c4-judge
2023-10-03T18:39:34Z
0xleastwood marked the issue as primary issue
#1 - c4-judge
2023-10-03T18:40:28Z
0xleastwood marked the issue as duplicate of #39
#2 - c4-judge
2023-10-04T07:49:41Z
0xleastwood marked the issue as duplicate of #23
#3 - c4-judge
2023-10-04T07:54:05Z
0xleastwood changed the severity to 3 (High Risk)
#4 - c4-judge
2023-10-04T10:05:37Z
0xleastwood marked the issue as satisfactory
#5 - c4-judge
2023-10-05T07:33:26Z
0xleastwood marked the issue as partial-25
#6 - 0xleastwood
2023-10-05T07:34:02Z
Giving partial credit because it only outlines sandwich attack on depositRewards()
and not the two other functions as outlined in the primary issue.
#7 - c4-judge
2023-10-05T07:47:49Z
0xleastwood marked the issue as partial-50
#8 - elmutt
2023-10-05T23:04:09Z
I dont think our planned fixes for #23 will necessarily fix this issue (I see it marked as a dupe of #23)
#9 - toshiSat
2023-10-09T15:39:03Z
- Make
depositRewards()
being invoked internally only by underlying strategies.
I agree, putting a min_out
on depositRewards
would not prevent this attack
#10 - elmutt
2023-10-12T22:35:24Z
https://github.com/asymmetryfinance/afeth/pull/190
this should solve it
🌟 Selected for report: adriro
Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev
Data not available
totalLockedBalancePlusUnlockable
is being used to calculate an amount that's ready to be withdrawn. In case, if totalLockedBalancePlusUnlockable >= cvxUnlockObligations
already before iterating over the lockedBalances
, the withdrawal request is intended to be finalized right away, since the main criteria by design is whether the system has enough unlocked liquidity to finalize the request or not. If not, then it has to iterate and search for a locked position in order to cover uncovered portion of a withdrawal being requested.ratio = 1e18
function requestWithdraw( uint256 _amount ) public override returns (uint256 withdrawId) { ... cvxUnlockObligations += cvxAmount; uint256 totalLockedBalancePlusUnlockable = unlockable + IERC20(CVX_ADDRESS).balanceOf(address(this)); ++ if (totalLockedBalancePlusUnlockable>=cvxUnlockObligations) { ++ uint256 withdrawEpoch = currentEpoch; ++ withdrawIdToWithdrawRequestInfo[ ++ latestWithdrawId ++ ] = WithdrawRequestInfo({ ++ cvxOwed: cvxAmount, ++ withdrawn: false, ++ epoch: withdrawEpoch, ++ owner: msg.sender ++ }); ++ ++ emit WithdrawRequest(msg.sender, cvxAmount, latestWithdrawId); ++ return latestWithdrawId; ++ } for (uint256 i = 0; i < lockedBalances.length; i++) { totalLockedBalancePlusUnlockable += lockedBalances[i].amount; // we found the epoch at which there is enough to unlock this position if (totalLockedBalancePlusUnlockable >= cvxUnlockObligations) { (, uint32 currentEpochStartingTime) = ILockedCvx(VLCVX_ADDRESS) .epochs(currentEpoch); uint256 timeDifference = lockedBalances[i].unlockTime - currentEpochStartingTime; uint256 epochOffset = timeDifference / ILockedCvx(VLCVX_ADDRESS).rewardsDuration(); uint256 withdrawEpoch = currentEpoch + epochOffset; withdrawIdToWithdrawRequestInfo[ latestWithdrawId ] = WithdrawRequestInfo({ cvxOwed: cvxAmount, withdrawn: false, epoch: withdrawEpoch, owner: msg.sender }); emit WithdrawRequest(msg.sender, cvxAmount, latestWithdrawId); return latestWithdrawId; } } // should never get here revert InvalidLockedAmount(); }
relock()
manually, but what if that hasn't been invoked and the withdrawal request has been submitted which could be covered with the current balance without any unlocks needed?Context
#0 - c4-judge
2023-10-03T18:37:37Z
0xleastwood marked the issue as duplicate of #63
#1 - c4-judge
2023-10-04T10:07:42Z
0xleastwood marked the issue as satisfactory
🌟 Selected for report: m_Rassska
Data not available
Upon claiming Votium rewards, applyRewards()
is intended to be invoked bi-weekly in order to exchange the tokens for eth and put the eth received back into the strategies. Based on the current ratio
it either stakes the amount into safETH or obtains some CVX by selling eth on Curve and then locks them to get vlCVX.
uint256 safEthRatio = (safEthTvl * 1e18) / totalTvl; if (safEthRatio < ratio) { ISafEth(SAF_ETH_ADDRESS).stake{value: amount}(0); } else { votiumStrategy.depositRewards{value: amount}(amount); }
Let's say the safEthRatio < ratio
, which triggers ISafEth(SAF_ETH_ADDRESS).stake{value: amount}
being invoked. And if the amount < ISafEth(SAF_ETH_ADDRESS).minAmount
. The whole re-investing strategy collapses.
As of Sep. 2023, the Votium rewards are 0,000016 eth for 1 vlCVX per round, it means, we need at least 3125 vlCVX being delegated to the Votium in order to pass the threshold.
IAfEth(manager).depositRewards
into the try/catch block. And if one of the following conditions arises:
VotiumStrategy.depositRewards()
.
if (address(manager) == address(0)) { depositRewards(ethReceived); } else { try IAfEth(manager).depositRewards{value: ethReceived}(ethReceived) {} catch {depositRewards(ethReceived);} }
Context
#0 - elmutt
2023-09-28T00:45:22Z
Nice find. At first glance it doesnt seem to matter but when you pointed out out early market conditions resulting in less rewards it makes total sense that it will be a problem we will likely encounter
#1 - Rassska
2023-10-01T09:45:38Z
Ooppss, the reward threshold defined here should be changed to:
Meaning that:
ratio = 1e18
=> the rewards > 0,05ethratio = 5e17
=> the rewards > 0,1eth#2 - elmutt
2023-10-01T13:06:03Z
Thanks! After discussing internally we decided to solve this by calling setMinAmount(0) on the safEth contract.
Will update this issue when thats done
#3 - c4-judge
2023-10-03T18:13:03Z
0xleastwood marked the issue as primary issue
#4 - c4-judge
2023-10-04T09:58:18Z
0xleastwood marked the issue as selected for report
#5 - c4-sponsor
2023-10-04T14:32:51Z
elmutt (sponsor) confirmed
#6 - elmutt
2023-10-12T23:12:19Z
🌟 Selected for report: adriro
Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev
Data not available
Asymmetry Finance provides an opportunity for stakers to diversify their staked eth across many liquid staking derivatives. It's not a doubt that the Lido has about 80% of the liquid staking market and Asymmetry Finance introduces a great solution to make the LSM more decentralized.
ratio
<a name="L-01"></a>ratio
could be setted to any number. Instead, validate it before setting. Make sure, safETH.minAmount doesn't backfire, when users start to deposit.#0 - c4-judge
2023-10-04T06:12:44Z
0xleastwood marked the issue as grade-b
🌟 Selected for report: adriro
Also found by: d3e4, m_Rassska, rvierdiiev
Data not available
msg.sender/msg.value
instead of caching it&&
address(0)
require()/if()
checks should be refactored to a modifier or functionaddress(this)
>=
costs less gas than >
type(uintX).max
mload
costs only 3 units of gas, sload
(warm access) is about 100 units. Therefore, cache, when it's possible, otherwise - a lot of intrinsic gas wasted for users.ratio
and SAF_ETH_ADDRESS
could be cached here:latestWithdrawId
after an increment and vEthAddress
could be cached here:withdrawInfo.amount
here:vEthAddress
, SAF_ETH_ADDRESS
could be cached here:VLCVX_ADDRESS
could be cached here:VLCVX_ADDDRESS
, latestWithdrawId
, cvxUnlockObligations
could be cached outside of the loop here:cvxUnlockObligations + cvxAmount
could be cached before the loop here:VLCVX_ADDRESS
, CVX_ADDRESS
could be cached here:withdrawInfo
, it means we have 2 sloads which incur an overhead here:msg.sender/msg.value
instead of caching it<a name="G-03"></a>msg.value
here:&&
<a name="G-04"></a>address(0)
<a name="G-05"></a>It’s generally more gas-efficient to use assembly to check for a zero address (address(0)) than to use the == operator.
The reason for this is that the == operator generates additional instructions in the EVM bytecode, which can increase the gas cost of your contract. By using assembly, you can perform the zero address check more efficiently and reduce the overall gas cost of your contract.
Here’s an example of how you can use assembly to check for a zero address:
contract MyContract { function isZeroAddress(address addr) public pure returns (bool) { uint256 addrInt = uint256(addr); assembly { // Load the zero address into memory let zero := mload(0x00) // Compare the address to the zero address let isZero := eq(addrInt, zero) // Return the result mstore(0x00, isZero) return(0, 0x20) } } }
In the above example, we have a function isZeroAddress that takes an address as input and returns a boolean value, indicating whether the address is equal to the zero address. Inside the function, we convert the address to an integer using uint256(addr), and then use assembly to compare the integer to the zero address.
By using assembly to perform the zero address check, we can make our code more gas-efficient and reduce the overall cost of our contract. It’s important to note that assembly can be more difficult to read and maintain than Solidity code, so it should be used with caution and only when necessary
claimVLCVxRewards()
and claimVotiumRewards()
could be inlined during rewards claiming here:return data (bool success,) has to be stored due to EVM architecture, but in a usage like below, ‘out’ and ‘outsize’ values are given (0,0), this storage disappears and gas optimization is provided.
Ref: https://twitter.com/pashovkrum/status/1607024043718316032?t=xs30iD6ORWtE2bTTYsCFIQ&s=19
require()/if()
checks should be refactored to a modifier or function<a name="G-08"></a>The reason for this is that using address(this) requires an additional EXTCODESIZE operation to retrieve the contract’s address from its bytecode, which can increase the gas cost of your contract. By pre-calculating and using a hardcoded address, you can avoid this additional operation and reduce the overall gas cost of your contract.
Here’s the ref, how to procompute it: https://book.getfoundry.sh/reference/forge-std/compute-create-address
>=
costs less gas than >
<a name="G-10"></a>if (amountToMint <= _minout + 1)
here:We can use assembly to emit events efficiently by utilizing scratch space and the free memory pointer. This will allow us to potentially avoid memory expansion costs. Note: In order to do this optimization safely, we will need to cache and restore the free memory pointer.
For example, for a generic emit event for eventSentAmountExample
:
// uint256 id, uint256 value, uint256 amount emit eventSentAmountExample(id, value, amount);
We can use the following assembly emit events:
assembly { let memptr := mload(0x40) mstore(0x00, calldataload(0x44)) mstore(0x20, calldataload(0xa4)) mstore(0x40, amount) log1( 0x00, 0x60, // keccak256("eventSentAmountExample(uint256,uint256,uint256)") 0xa622cf392588fbf2cd020ff96b2f4ebd9c76d7a4bc7f3e6b2f18012312e76bc3 ) mstore(0x40, memptr) }
type(uintX).max
<a name="G-12"></a>The reason for this, is that the type(uintX).max expression involves a computation at runtime, whereas a constant is evaluated at compile-time. This means, that using type(uintX).max can result in additional gas costs for each transaction that involves the expression.
By using a constant instead of type(uintX).max, you can avoid these additional gas costs and make your code more efficient.
withdrawId
is not being used here:#0 - c4-judge
2023-10-04T05:59:47Z
0xleastwood marked the issue as grade-a
#1 - c4-sponsor
2023-10-04T15:01:40Z
elmutt (sponsor) confirmed
Data not available
Asymmetry Finance provides an opportunity for stakers to diversify their staked eth across many liquid staking derivatives. It's not a doubt that the Lido has about 80% of the liquid staking market and Asymmetry Finance introduces a great solution to make the LSM more decentralized.
Description:
Potential Improvements:
Description:
Potential Improvements:
The following list includes some manually generated invariants failed, which have been used during an audit to report the issues:
afETH should return the correct withdrawTimeBefore, when _amount in afETH has been provided
chainlink oracle should return the fresh rates for the pair
CVX/ETH
pool manipulation should not provide any oppotunities for an adversory to exploit the systemapplyRewards()
has to be called right after claiming the votium rewardsrequestWithdraw()
should check whether the current balance can cover the request being submittedOfc, the list could contain some unsuccessfull scenarios being generated(>100), but at this point, it doesn't make any sense.
70 hours
#0 - c4-judge
2023-10-04T05:55:45Z
0xleastwood marked the issue as selected for report
#1 - c4-judge
2023-10-04T05:56:22Z
0xleastwood marked the issue as grade-a