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: 5/5
Findings: 8
Award: $0.00
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: d3e4
Data not available
The up to 2 % price discrepancy from Chainlink creates an intrinsic arbitrage. Especially, it makes withdrawals worth more than deposits in the sense that one can immediately withdraw more than just deposited.
When depositing ETH into AfEth, the ETH is split according to ratio
and sold for safEth and vAfEth. The received share of afEth is then determined by the value in ETH of the resulting amounts of safEth and vAfEth. Note that there are two prices involved here: the true price at which ETH is traded for safEth and vAfEth (in sellCvx()
and buyCvx()
), and the estimated value in ETH that safEth and vAfEth is considered to have (ISafEth.approxPrice()
and VotiumStrategy.price()
). These are not necessarily the same.
If the ratio by which the deposited ETH is split is not the same as the ratio of the true values of the underlying assets, this implies that a deposit implicitly makes a trade between safEth and vAfEth according to the estimated price which may thus differ from the true price obtained when withdrawing. This presents an arbitrage opportunity. Note that if all prices were the same it would not matter if safEth is "traded" for vAfEth within a deposit as the trade then makes no change in the total value deposited.
The conditions for this issue is thus that VotiumStrategy.price()
is different from the price obtained by sellCvx()
and buyCvx()
, and that the deposit ratio is not the same as the withdrawal ratio.
VotiumStrategy.price()
in particular is based on a Chainlink oracle with a 2 % deviation threshold, which means that the true price is allowed to deviate up to 2 %, within 24 hours, from the reported price. ISafEth.approxPrice()
may perhaps be similarly inaccurate (I have not looked into this).
The ratio can skew in this way for two reasons. One is when the ratio
is different from the ratio of the underlying balances, such as when ratio
is changed. Deposits are made according to ratio
but withdrawals are made proportionally from the extant balances. In this case the implicit trade between safEth and vAfEth can happen in either direction, either beneficial or detrimental to the depositor (if there is a price discrepancy).
The other is caused by the price discrepancy itself when depositing. In this case it is always beneficial to the depositor (and detrimental to the holders).
Example 1a - reconverging ratio, vAfEth is actually more expensive
Suppose the contract holds 100 safEth and 0 vAfEth, but that the ratio
has now been changed to 0. Further suppose that the contract thinks all prices are 1, but that 100 ETH actually trades for 102 vAfEth.
Then depositing 100 ETH will convert it to 102 vAfEth, which will be valued as 102 ETH. This mints 102 afEth.
The balances are now 100 safEth and 102 vAfEth and the total supply is 202 afEth.
Withdrawing 102 afEth converts 102/202 of the underlying, i.e. 50.495 safEth and 51.505 vAfEth, into 50.495 + 51.505/1.02 = 100.99 ETH.
Example 1b - reconverging ratio, vAfEth is actually cheaper
Suppose the contract holds 100 safEth and 0 vAfEth, but that the ratio
has now been changed to 0. Further suppose that the contract thinks all prices are 1, but that 100 ETH actually trades for 98 vAfEth.
Then depositing 100 ETH will convert it to 98 vAfEth, which will be valued as 98 ETH. This mints 98 afEth.
The balances are now 100 safEth and 98 vAfEth and the total supply is 198 afEth.
Withdrawing 98 afEth converts 98/198 of the underlying, i.e. 49.495 safEth and 48.505 vAfEth, into 49.495 + 48.505/0.98 = 98.99 ETH.
Example 2a - stable ratio, vAfEth is actually more expensive
Suppose the contract holds 50 safEth and 50 vAfEth and that the ratio
is 0.5. Further suppose that the contract thinks all prices are 1 but that 50 ETH actually trades for 51 vAfEth.
Then depositing 100 ETH will convert 50 ETH to 50 safEth and 50 ETH to 51 vAfEth, which will be valued as 101 ETH. This mints 101 afEth.
The balances are now 100 safEth and 101 vAfEth and the total supply is 201 afEth.
Withdrawing 101 afEth converts 101/201 of the underlying, i.e. 50.249 safEth and 50.751 vAfEth, into 50.249 + 50.751/1.02 = 100.005 ETH.
Example 2b - stable ratio, vAfEth is actually cheaper
Suppose the contract holds 50 safEth and 50 vAfEth and that the ratio
is 0.5. Further suppose that the contract thinks all prices are 1 but that 50 ETH actually trades for 49 vAfEth.
Then depositing 100 ETH will convert 50 ETH to 50 safEth and 50 ETH to 49 vAfEth, which will be valued as 99 ETH. This mints 99 afEth.
The balances are now 100 safEth and 99 vAfEth and the total supply is 199 afEth.
Withdrawing 99 afEth converts 99/199 of the underlying, i.e. 49.749 safEth and 49.251 vAfEth, into 49.749 + 49.251/0.98 = 100.005 ETH.
Thus one can make a profit by depositing and immediately withdrawing. Immediate withdrawals are possible at the moment locks expire (and before they have been relocked), but it may be enough to just immediately request a withdrawal if the true price is the same (or better) when eventually withdrawn.
The price discrepancy will appear whenever there are price fluctuations of up to 2 % within 24 hours, which seems quite likely.
Regarding the case where the underlying is reconverging after a change of ratio
it is worth noting that convergence is quite slow. Several times the entire balances must be traded before the new ratio is approached.
I would have to think more about this.
Math
#0 - c4-judge
2023-10-03T13:04:58Z
0xleastwood marked the issue as primary issue
#1 - c4-judge
2023-10-04T08:03:19Z
0xleastwood marked the issue as selected for report
#2 - c4-sponsor
2023-10-04T14:30:49Z
elmutt (sponsor) confirmed
#3 - d3e4
2023-10-05T13:28:53Z
We want that a deposit not diminish the value of previous deposits. That is, withdrawing $w$ shares should return at least as much if withdrawn after a deposit which mints $m$ shares as if withdrawn before. Note that letting a share represent each underlying in equal proportions is the only way to guarantee fairness and fungibility, so we must leave the withdrawal calculation as it is.
Let $d_s$ and $d_v$ be the ether amounts deposited in SafEth and VotiumStrategy, respectively. Let $B_s$ and $B_v$ be the respective balances in AfEth and $T$ the total supply of afEth. Let $P_s(x)$ be the amount of safEth obtained by selling $x$ ether for safEth, and $P_s^{-1}(y)$ the amount of ether obtained by selling safEth for ether (note the abuse of notation and that $P_s^{-1}(P_s(x)) \leq x$ because of fees, spread, slippage etc.), and similarly $P_v$ and $P_v^{-1}$ for vAfEth.
Withdrawing $w$ now should return at most what it would return after a deposit of $d_s + d_v$, i.e.
P_s^{-1}(\frac{w}{T}B_s) + P_v^{-1}(\frac{w}{T}B_v) \leq P_s^{-1}(\frac{w}{T+m}(B_s + P_s(d_s))) + P_v^{-1}(\frac{w}{T+m}(B_v + P_v(d_v)))
For small deposits and withdrawals the price functions are approximately linear and we can write e.g. $P_s(d_s)$ as $P_s d_s$, i.e. $P_s$ is just a price point, and we get
\frac{1}{T}(P_s^{-1}B_s + P_v^{-1}B_v) \leq \frac{1}{T+m}(P_s^{-1}(B_s + P_s d_s) + P_v^{-1}(B_v + P_v d_v))$
Solving for $m$ we get
m \leq \frac{P_s^{-1}P_s d_s + P_v^{-1}P_v d_v}{P_s^{-1}B_s + P_v^{-1}B_v}T
The difference from the current implementation is that instead of the true prices $P_s^{-1}$ and $P_v^{-1}$, the oracle prices, which we will denote $\hat{P}^{-1}_s$ and $\hat{P}^{-1}_v$, are used instead, i.e.
m = \frac{\hat{P}^{-1}_s P_s d_s + \hat{P}^{-1}_v P_v d_v} {\hat{P}^{-1}_s B_s + \hat{P}^{-1}_v B_v}T
Since we know the true prices up to within certain bounds, we can minimise $m$, as a function of $P_s^{-1}$ and $P_v^{-1}$, within these bounds. The gradient of $m(P_s^{-1}, P_v^{-1})$ is
\frac{P_s d_s B_v - P_v d_v B_s} {(P^{-1}_s B_s + P^{-1}_v B_v)^2} (P^{-1}_v, -P^{-1}_s)
so if $P_s d_s B_v - P_v d_v B_s > 0$ we pick the lower right corner (maximal $P_s^{-1}$, minimal $P_v^{-1}$), and if $P_s d_s B_v - P_v d_v B_s < 0$ we pick the upper left corner (minimal $P_s^{-1}$, maximal $P_v^{-1}$). In the case of equality we can use any (non-zero) prices.
We minimise within the bounds, but we of course want the bounds to be as tight as possible, so that this minimum is as high as possible.
The oracle prices provide us with good bounds, namely $P_s^{-1} \in [0.98 \cdot \hat{P}^{-1}_s, 1.02 \cdot \hat{P}^{-1}_s]$ and $P_v^{-1} \in [0.98 \cdot \hat{P}^{-1}_v, 1.02 \cdot \hat{P}^{-1}_v]$.
There are a few ways to further improve the bounds. During the deposit we learn $P_s$ and $P_v$, from which we can infer $P_s^{-1} \leq 1/P_s$ and $P_v^{-1} \leq 1/P_v$ (equality in the case of no exchange losses). If we know that there is some minimum percentage lost (e.g. exchange fees, slippage, spread) we can refine these to $P_s^{-1} \leq k_s/P_s$ and $P_v^{-1} \leq k_v/P_v$, where $k_s, k_v < 1$ is some factor adjusting for these losses (e.g. $k_s = 0.99$ if there is at least (another) 1 % lost when selling for ether).
If the trading losses are significant it may be necessary to take these into account even for the bounds from the oracle prices, such that both upper and lower bounds are slightly reduced.
#4 - romeroadrian
2023-10-08T14:19:34Z
@0xleastwood I'm really sorry to do this at this stage, but did you have the chance to go through these scenarios? Seems there are lot of suppositions, some of which the same warden is confusingly invalidating in other discussions.
I think examples 1a and 1b have invalid assumptions ("the contract holds 100 safEth and 0 vAfEth, but that the ratio has now been changed to 0"). In 2a and 2b, what is "the contract thinks all prices are.."? How is the contract thinking prices?
It seems this has nothing to do with the stated impact. The chainlink response is used to price vAfEth, but the core here is a discrepancy of the ratio with the underlying assets (which again confusingly the author is trying to invalidate in other issues). Furthermore the deposit/withdraw cycle can't be executed without exposure to the underlying assets due to the locking mechanism.
#5 - 0xleastwood
2023-10-08T20:50:43Z
@0xleastwood I'm really sorry to do this at this stage, but did you have the chance to go through these scenarios? Seems there are lot of suppositions, some of which the same warden is confusingly invalidating in other discussions.
I think examples 1a and 1b have invalid assumptions ("the contract holds 100 safEth and 0 vAfEth, but that the ratio has now been changed to 0"). In 2a and 2b, what is "the contract thinks all prices are.."? How is the contract thinking prices?
It seems this has nothing to do with the stated impact. The chainlink response is used to price vAfEth, but the core here is a discrepancy of the ratio with the underlying assets (which again confusingly the author is trying to invalidate in other issues). Furthermore the deposit/withdraw cycle can't be executed without exposure to the underlying assets due to the locking mechanism.
I'll look into these, I do believe it is possible to deposit and withdraw atomically as long as there is an unlocked amount of tokens in the votium strategy contract. That would be the extent at which a withdrawal could be made "instantly".
#6 - 0xleastwood
2023-10-08T20:51:49Z
I do agree that example 1a and 1b are somewhat infeasible because the protocol team has already stated that such a ratio would not exist in the first place. However, there is validity in the other examples.
#7 - elmutt
2023-10-12T20:14:18Z
Thanks for the excellent analysis @d3e4, this one really got us thinking.
After extensive analysis we came to the following conclusions:
It can only happen if the true ratio of safEth to vStrategy in the contract is not equal to the target ratio. We were unable to reproduce this issue if targetRatio ~= trueRatio (see test https://github.com/asymmetryfinance/afeth/pull/180/files#diff-761b4f70c9a884d0e0afc44238412de69eaa5b3037a2490c6d5ba5d34d061218R207).
We were able reproduce the issue by mocking the chainlink oracle to significantly changing the cvxPerEth price and changing the ratio to be very different from the true ratio (see test https://github.com/asymmetryfinance/afeth/pull/180/files#diff-761b4f70c9a884d0e0afc44238412de69eaa5b3037a2490c6d5ba5d34d061218R267)
It appears that a 2% chainlink difference can cause small differences if the ratio is far enough apart. We wrote 2 tests demonstrating this: https://github.com/asymmetryfinance/afeth/pull/180/files#diff-761b4f70c9a884d0e0afc44238412de69eaa5b3037a2490c6d5ba5d34d061218R331 https://github.com/asymmetryfinance/afeth/pull/180/files#diff-761b4f70c9a884d0e0afc44238412de69eaa5b3037a2490c6d5ba5d34d061218R398
These are a little bit concerning We believe this is an acceptable risk because:
amountToMint
calculation in afEth.deposit() as follows:amountToMint = totalValue / priceBeforeDeposit
or
((safEthMinted * safEthPrice) + (vStratMinted * vStratPrice)) / priceBeforeDeposit
or
((safEthMinted * safEthPrice) + (vStratMinted * vStratPrice)) / ((totalVStratValue + totalSafEthValue) / totalAfEthSupply)
or
((safEthMinted * safEthPrice) + (vStratMinted * vStratPrice)) / (((totalVstratBalance * vStratPrice) + (totalSafEthBalance * safEthPrice)) / totalAfEthSupply)
If we assume safEthPrice is constant (in terms of eth it goes up very slowly) so we can pretty much ignore it (replace it with a constant)
or
((safEthValueMinted) + (vStratMinted * vStratPrice)) / (((totalVstratBalance * vStratPrice) + (totalSafEthValue)) / totalAfEthSupply)
Assigning values generated when ratios were roughly equal to everything except vStratPrice and plotting it looks like:
<img width="1052" alt="Screen_Shot_2023-10-12_at_11 09 44_AM" src="https://github.com/code-423n4/2023-09-asymmetry-findings/assets/6187559/b0ce7af1-226e-4af8-b817-ae0d94999ca3">We think this graph helps show that if ratios are equal it is hard to mess things up with a bad chainlink price. We understand ratios will often not be equal but we wanted to see the base case of ratios being equal
Based on this analysis we think a 2% chainlink variance is an acceptable risk, even when the ratios are far apart. However its possible we missed something and if anyone can demonstrate in a unit test that its a bigger problem we would love to see it!
#8 - elmutt
2023-10-12T20:34:06Z
Also wanted to point out the ratios can easily change if cvx price changes, so it it a real concern. We initially didnt think about it right and assumed ratios would be more stable
🌟 Selected for report: adriro
Also found by: d3e4, rvierdiiev
Data not available
A withdrawal cannot be finalised if requested at a time when AfEth had only safEth, and that owed share of safEth is permanently lost.
It is possible that AfEth holds at most dust amounts of vAfEth (if ratio
= 100 %).
The amounts of vAfEth and safEth to withdraw is set at the time of the request. So if a withdrawal request is placed at this time then AfEth.requestWithdraw()
will call VotiumStrategy.requestWithdraw(0)
AfEth.sol#L190-L193
uint256 votiumWithdrawAmount = (withdrawRatio * votiumBalance) / 1e18; uint256 vEthWithdrawId = AbstractStrategy(vEthAddress).requestWithdraw( votiumWithdrawAmount );
which naturally records a cvxOwed: 0
.
When later attempting to withdraw, in VotiumStrategy.withdraw()
VotiumStrategy.sol#L119-L122
uint256 cvxWithdrawAmount = withdrawIdToWithdrawRequestInfo[_withdrawId] .cvxOwed; uint256 ethReceived = sellCvx(cvxWithdrawAmount);
sellCvx(0)
is called.
The issue is that this attempts to VotiumStrategyCore.sol#L258-L263
ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying( 1, 0, 0, //@audit dx 0 // this is handled at the afEth level );
which reverts as dx == 0
.
Since a withdrawal request is permanent and cannot be amended the user will never be able to withdraw his safEth share.
For gas considerations this has to be addressed by checks along the following pathway.
In AfEth.requestWithdraw()
, skip the call to VotiumStrategy.requestWithdraw()
if votiumWithdrawAmount == 0
.
I don't think cvxPerVotium()
can be less than 1e18
, but if it can then one also has to check that cvxAmount
is non-zero in VotiumStrategy.requestWithdraw()
and return without placing the request in VotiumStrategy. This would place an empty request which has to not revert when later called in VotiumStrategy.withdraw()
.
It is of course possible to let everything run and just amend sellCvx(0)
to immediately return 0
.
Invalid Validation
#0 - c4-judge
2023-10-03T12:39:05Z
0xleastwood marked the issue as primary issue
#1 - c4-judge
2023-10-03T13:07:54Z
0xleastwood marked the issue as duplicate of #36
#2 - c4-judge
2023-10-04T10:08:16Z
0xleastwood marked the issue as satisfactory
#3 - c4-judge
2023-10-04T18:57:23Z
0xleastwood changed the severity to 3 (High Risk)
🌟 Selected for report: adriro
Also found by: MiloTruck, d3e4, rvierdiiev
Data not available
AfEth.deposit()
may mint an incorrect amount of afEth.
VotiumStrategy.price()
may return an incorrect price of vAfEth.
AfEth.price()
may return an incorrect price of afEth.
function price() external view override returns (uint256) { return (cvxPerVotium() * ethPerCvx(false)) / 1e18; }
calls ethPerCvx(false)
where false
implies that the Chainlink response is not validated. VotiumStrategy.price()
may thus return an invalid value.
VotiumStrategy.price()
is used by AfEth.price()
in the calculation of the price of afEth. Both of these price()
are used in AfEth.deposit()
to calculate the amount of afEth to mint. If the Chainlink response is invalid an incorrect amount of afEth may thus be minted, instead of reverting.
ethPerCvx(true)
is used in the far less critical AfEth.depositRewards()
. It should be used here as well.
Invalid Validation
#0 - elmutt
2023-09-29T20:06:37Z
#1 - c4-judge
2023-10-03T13:54:36Z
0xleastwood marked the issue as duplicate of #34
#2 - c4-judge
2023-10-04T08:20:36Z
0xleastwood marked the issue as satisfactory
#3 - c4-judge
2023-10-04T08:21:35Z
0xleastwood changed the severity to 3 (High Risk)
#4 - c4-judge
2023-10-04T08:22:18Z
0xleastwood marked the issue as partial-50
#5 - 0xleastwood
2023-10-04T08:22:55Z
Partial credit because it is lacking additional information about impact.
#6 - c4-judge
2023-10-04T08:23:16Z
0xleastwood removed the grade
#7 - d3e4
2023-10-05T17:10:44Z
Partial credit because it is lacking additional information about impact.
What information about impact is missing? The main issue #34 explains the calculation chain such that the invalid Chainlink response implies an invalid VotiumStrategy price, which implies an invalid afEth price, which implies an invalid mint amount. This is precisely the impact stated here as well.
#8 - 0xleastwood
2023-10-07T12:43:30Z
Noted.
#9 - c4-judge
2023-10-07T12:43:37Z
0xleastwood marked the issue as full credit
#10 - c4-judge
2023-10-07T12:49:31Z
0xleastwood marked the issue as satisfactory
🌟 Selected for report: MiloTruck
Also found by: adriro, d3e4, rvierdiiev
Data not available
AfEth.price()
may be calculated as too low.
AfEth.requestWithdraw()
does not burn the afEth but only transfers it to itself. Hence the withdrawRatio
is calculated using only the free supply of afEth:
AfEth.sol#L180-L185
// ratio of afEth being withdrawn to totalSupply // we are transfering the afEth to the contract when we requestWithdraw // we shouldn't include that in the withdrawRatio uint256 afEthBalance = balanceOf(address(this)); uint256 withdrawRatio = (_amount * 1e18) / (totalSupply() - afEthBalance);
Later the afEth is burned in withdraw()
(AfEth.sol#L255).
Similar accounting is done for safEth: AfEth.sol#L195-L197
uint256 safEthBalance = safEthBalanceMinusPending(); uint256 safEthWithdrawAmount = (withdrawRatio * safEthBalance) / 1e18;
Whereas for vAfEth AfEth.sol#L191-L193
uint256 vEthWithdrawId = AbstractStrategy(vEthAddress).requestWithdraw( votiumWithdrawAmount );
VotiumStrategy.requestWithdraw()
just burns the vAfEth immediately (VotiumStrategy.sol#L60).
However, the AfEth.price()
calculation does not use the same accounting for the effective supply of afEth:
AfEth.sol#L133-L141
function price() public view returns (uint256) { if (totalSupply() == 0) return 1e18; AbstractStrategy vEthStrategy = AbstractStrategy(vEthAddress); uint256 safEthValueInEth = (ISafEth(SAF_ETH_ADDRESS).approxPrice(true) * safEthBalanceMinusPending()) / 1e18; uint256 vEthValueInEth = (vEthStrategy.price() * vEthStrategy.balanceOf(address(this))) / 1e18; return ((vEthValueInEth + safEthValueInEth) * 1e18) / totalSupply(); }
It uses totalSupply()
instead of totalSupply() - balanceOf(address(this))
.
This means that with withdrawal requests outstanding the price()
will be too low, and deposit()
will thus mint too much afEth.
Amending the price()
calculation to use totalSupply() - balanceOf(address(this))
in the denominator would resolve this issue.
But a better solution is to just burn the afEth immediately in requestWithdraw()
. Since requestWithdraw()
immediately fixes the withdrawal in terms of safEth and CVX, it doesn't matter for the withdrawal when the afEth is burned.
ERC4626
#0 - elmutt
2023-09-29T19:14:39Z
#1 - c4-judge
2023-10-03T12:59:51Z
0xleastwood marked the issue as primary issue
#2 - c4-judge
2023-10-03T13:10:19Z
0xleastwood marked the issue as duplicate of #25
#3 - c4-judge
2023-10-04T10:06:38Z
0xleastwood marked the issue as satisfactory
🌟 Selected for report: MiloTruck
Also found by: adriro, d3e4, rvierdiiev
Data not available
AfEth.deposit()
can be bricked.
AfEth
makes use of its own balance of afEth as a temporary store of afEth for withdrawal requests. On requestWithdraw()
afEth is transferred to the AfEth contract and these are then burned on withdraw()
. The contract's balance of afEth is excluded from the calculation of the withdrawRatio
. One can thus cause the contract to withdraw all its underlying with less than its total supply. This implies a price of zero by which is divided in deposit()
.
deposit()
some ETH for 2 afEth.requestWithdraw()
the remaining 1 afEth. This is precisely totalSupply() - afEthBalance
inuint256 afEthBalance = balanceOf(address(this)); uint256 withdrawRatio = (_amount * 1e18) / (totalSupply() - afEthBalance);
so withdrawRatio
is set to 100 % and the entire safEth and vAfEth balances are withdrawn.
The request may or may not be finalised by withdraw()
; the withdrawals are still implicit in the accounting. Only the 1 afEth transferred with the withdrawal request would be burned; the totalSupply()
still carries the 1 afEth transferred directly to the contract.
Now price()
function price() public view returns (uint256) { if (totalSupply() == 0) return 1e18; AbstractStrategy vEthStrategy = AbstractStrategy(vEthAddress); uint256 safEthValueInEth = (ISafEth(SAF_ETH_ADDRESS).approxPrice(true) * safEthBalanceMinusPending()) / 1e18; uint256 vEthValueInEth = (vEthStrategy.price() * vEthStrategy.balanceOf(address(this))) / 1e18; return ((vEthValueInEth + safEthValueInEth) * 1e18) / totalSupply(); }
returns 0
.
This causes deposit()
to revert at uint256 amountToMint = totalValue / priceBeforeDeposit;
.
deposit()
is the only intended way to bring the safEth and vAfEth balances to non-zero so the only way out would be to send safEth or vAfEth directly to the contract. But all this would of course be claimable by the very next deposit, and so we return to the same state. The contract is effectively bricked.
The afEth transferred by requestWithdraw()
could be specifically accounted instead of just using the contract balance.
But a better solution is to just burn the afEth immediately in requestWithdraw()
. Since requestWithdraw()
immediately fixes the withdrawal in terms of safEth and CVX, it doesn't matter for the withdrawal when the afEth is burned.
ERC4626
#0 - elmutt
2023-09-29T19:14:27Z
#1 - c4-judge
2023-10-03T11:38:32Z
0xleastwood marked the issue as primary issue
#2 - c4-judge
2023-10-04T08:05:59Z
0xleastwood marked the issue as duplicate of #25
#3 - 0xleastwood
2023-10-04T08:09:04Z
The fix and impact is technically the same as #25 as we are incorrectly calculating the circulating supply of tokens which is used to calculate price()
.
#4 - c4-judge
2023-10-04T10:06:54Z
0xleastwood marked the issue as satisfactory
Data not available
https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/strategies/votium/VotiumStrategyCore.sol#L239 https://github.com/code-423n4/2023-09-asymmetry/blob/6b4867491350f8327d0ac4f496f263642cf3c1be/contracts/strategies/votium/VotiumStrategyCore.sol#L272
Rewards deposit is not slippage protected and susceptible to MEV-attacks.
VotiumCoreStrategy.buyCvx()
is not slippage protected, as even acknowledged by the comment in
ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying{ value: _ethAmountIn }( 0, 1, _ethAmountIn, 0 // this is handled at the afEth level );
This is handled at the afEth level only for deposits (when it is called via VotiumStrategy.deposit()
by AfEth.deposit()
which has a _minout
), but NOT for rewards deposits.
Rewards are initiated by VotiumStrategyCore.applyRewards()
which calls VotiumStrategyCore.depositRewards()
either directly or via AfEth.depositRewards()
. VotiumStrategyCore.depositRewards()
then buyCvx()
.
None of these functions has a _minout
or similar for slippage protection (it is sufficient to note that applyRewards()
doesn't have such a parameter).
Add a if (ethReceived < _minout) revert BelowMinOut()
slippage protection in VotiumStrategyCore.applyRewards()
.
MEV
#0 - c4-judge
2023-09-30T15:15:20Z
0xleastwood marked the issue as duplicate of #39
#1 - elmutt
2023-10-02T19:14:33Z
@0xleastwood I dont think this is a dupe of 39
this ticket is talking about applyRewards(). 39 is talking about other functions
#2 - 0xleastwood
2023-10-03T11:08:28Z
@0xleastwood I dont think this is a dupe of 39
this ticket is talking about applyRewards(). 39 is talking about other functions
It's addressing the same function, the buyCvx()
. Where the call is originating from is not important.
#3 - elmutt
2023-10-03T13:05:14Z
My plan to fix this and #24 is pass _minOut to afEth.depositRewards() (in an upcoming PR).
Together with the fix for #31 (https://github.com/asymmetryfinance/afeth/pull/169) I think this issue will then be fixed
#4 - 0xleastwood
2023-10-04T07:37:21Z
Giving partial credit to this because it does not consider the case for withdraw()
/sellCvx()
#5 - c4-judge
2023-10-04T07:37:37Z
0xleastwood marked the issue as partial-50
#6 - c4-judge
2023-10-04T07:49:42Z
0xleastwood marked the issue as duplicate of #23
#7 - d3e4
2023-10-05T17:11:19Z
I mentioned the case for withdraw()
/sellCvx()
in #67 (QA) under L-07. I considered those of low severity since the user is not supposed to interact directly with VotiumStrategy, which implies that no security guarantees are given and thus none are violated. The case of buyCvx()
is more severe because it involves a lack of slippage protection in AfEth. But even here the issue is not in buyCvx()
itself. As @elmutt correctly points out, the focal point of the issue is applyRewards()
, which is where the _minOut
parameter must be supplied.
The main issue #23 doesn't even mention the impact on AfEth, but rather the same warden reported this in #24.
#8 - elmutt
2023-10-06T00:38:30Z
My plan to fix this and #24 is pass _minOut to afEth.depositRewards() (in an upcoming PR).
Together with the fix for #31 (asymmetryfinance/afeth#169) I think this issue will then be fixed
https://github.com/asymmetryfinance/afeth/pull/176 https://github.com/asymmetryfinance/afeth/pull/178
#9 - 0xleastwood
2023-10-07T12:45:17Z
Noted, restoring full credit to account for the added context outlined in the warden's QA report #67.
#10 - c4-judge
2023-10-07T12:45:21Z
0xleastwood marked the issue as full credit
#11 - c4-judge
2023-10-07T12:49:24Z
0xleastwood marked the issue as satisfactory
Data not available
VotiumStrategyCore.applyRewards()
gives unlimited allowance on its claimed rewards tokens. It is not thereafter reset and there is not even any way to reset the allowance.
It is dangerous to trust the spenders indefinitely in case they are compromised or otherwise turns undesirable.
Reset the allowance to 0 at the end of applyRewards()
.
ERC20
#0 - c4-judge
2023-10-03T13:54:04Z
0xleastwood marked the issue as primary issue
#1 - 0xleastwood
2023-10-04T08:40:07Z
It's unclear to me, but I don't think VotiumStrategy
holds any tokens and therefore, the rewarder in VotiumStrategyCore
would be unable to rug by approving arbitrary tokens where they are the spender right? @elmutt
#2 - c4-judge
2023-10-04T08:40:15Z
0xleastwood marked the issue as selected for report
#3 - 0xleastwood
2023-10-04T09:31:59Z
It's unclear to me, but I don't think
VotiumStrategy
holds any tokens and therefore, the rewarder inVotiumStrategyCore
would be unable to rug by approving arbitrary tokens where they are the spender right? @elmutt
Actually it is pretty clear that there will be tokens held by the contract.
#4 - c4-judge
2023-10-04T09:32:17Z
0xleastwood marked the issue as duplicate of #54
#5 - c4-judge
2023-10-04T09:32:22Z
0xleastwood marked the issue as not selected for report
#6 - c4-judge
2023-10-04T09:32:26Z
0xleastwood marked the issue as satisfactory
#7 - c4-judge
2023-10-04T09:32:35Z
0xleastwood marked the issue as partial-50
#8 - 0xleastwood
2023-10-04T09:33:01Z
Giving partial credit because it is missing a lot of context as compared to the primary issue.
🌟 Selected for report: adriro
Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev
Data not available
A user might be given an unnecessarily late withdrawal epoch.
VotiumStrategy.requestWithdraw()
might revert altogether.
VotiumStrategy.requestWithdraw()
finds the epoch at which withdrawal is possible by the following logic:
( , uint256 unlockable, , ILockedCvx.LockedBalance[] memory lockedBalances ) = ILockedCvx(VLCVX_ADDRESS).lockedBalances(address(this)); uint256 cvxAmount = (_amount * _priceInCvx) / 1e18; cvxUnlockObligations += cvxAmount; uint256 totalLockedBalancePlusUnlockable = unlockable + IERC20(CVX_ADDRESS).balanceOf(address(this)); 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) { /* Withdraw epoch set to the unlock time of lockedBalances[i]. */ } } // should never get here revert InvalidLockedAmount();
This fails to account for when totalLockedBalancePlusUnlockable
is sufficient for immediate withdrawal (before the for-loop), and at best sets it to the epoch of the first locked balance. Withdrawal which should be possible immediately is thus artificially given an extended lock time.
If all locks have expired then lockedBalances
is a zero-length array and the for-loop is skipped and it reaches revert InvalidLockedAmount()
.
Check whether totalLockedBalancePlusUnlockable >= cvxUnlockObligations
before adding any lockedBalances[i].amount
and set the withdrawal to the current epoch or immediately effectuate a withdrawal.
Invalid Validation
#0 - c4-judge
2023-10-03T18:37:22Z
0xleastwood marked the issue as primary issue
#1 - c4-judge
2023-10-04T08:49:15Z
0xleastwood marked issue #49 as primary and marked this issue as a duplicate of 49
#2 - c4-judge
2023-10-04T10:08:05Z
0xleastwood marked the issue as satisfactory
#3 - c4-sponsor
2023-10-04T14:51:08Z
elmutt (sponsor) confirmed
🌟 Selected for report: adriro
Also found by: MiloTruck, d3e4, m_Rassska, rvierdiiev
Data not available
Dust may be lost in AfEth.deposit()
by calculating
uint256 sValue = (amount * ratio) / 1e18;
and then
uint256 vValue = (amount * (1e18 - ratio)) / 1e18;
.
The second should instead be calculated as
uint256 vValue = amount - sValue;
.
This is also cheaper.
AfEth.price()
loses some precision in the form of (a/k + b/k)*k <= a + b
. Refactor:
function price() public view returns (uint256) { if (totalSupply() == 0) return 1e18; AbstractStrategy vEthStrategy = AbstractStrategy(vEthAddress); uint256 safEthValueInEth = (ISafEth(SAF_ETH_ADDRESS).approxPrice(true) * - safEthBalanceMinusPending()) / 1e18; + safEthBalanceMinusPending()); uint256 vEthValueInEth = (vEthStrategy.price() * - vEthStrategy.balanceOf(address(this))) / 1e18; + vEthStrategy.balanceOf(address(this))); - return ((vEthValueInEth + safEthValueInEth) * 1e18) / totalSupply(); + return (vEthValueInEth + safEthValueInEth) / totalSupply(); }
manager
in VotiumStrategyCore.initialize()
manager
is initialised without any check. Unlike for other initialised values there is no function to later change/set manager
.
setRatio()
ratio > 1e18
breaks deposit()
. AfEth.setRatio(_newRatio)
should check that _newRatio <= 1e18
.
AfEth.canWithdraw()
does not consider pauseWithdraw
AfEth.canWithdraw()
is inconsistent with pauseWithdraw
which is checked in AfEth.withdraw()
. This is the only place where it is internally used, so it may just be inlined instead. If this function still needs external visibility it should be amended to also check pauseWithdraw
itself.
VotiumStrategy.canWithdraw()
returns true
for non-existent withdraw requestsVotiumStrategy.canWithdraw()
should return false
when withdrawIdToWithdrawRequestInfo[_withdrawId].epoch == 0
which means that there is no withdraw request with that id.
Rather slippage is handled by AfEth.deposit()
and by AfEth.withdraw()
. VotiumStrategy()
can be deposited into and withdrawn from directly. If this is not the intention consider preventing user mistakes by restricting calls to AfEth
, or, if VotiumStrategy()
ever is to be accessed directly, add a slippage protection.
depositRewards(_amount)
do not check that _amount == msg.value
This goes for both AfEth.depositRewards()
and VotiumStrategyCore.depositRewards()
. Any discrepancy would be lost.
AfEth.depositRewards()
is called only by VotiumStrategyCore.applyRewards()
(and may therefore be changed to external
), whereas VotiumStrategyCore.depositRewards()
is called either by VotiumStrategyCore.applyRewards()
or by AfEth.depositRewards()
.
VotiumStrategyCore.applyRewards()
is not payable
but obtains ether by itself. VotiumStrategyCore.depositRewards()
can therefore not check msg.value
in the case it is used internally. It can be checked when called by AfEth.depositRewards()
, and AfEth.depositRewards()
can be checked in any case.
Consider checking that _amount == msg.value
when possible or restricting the call flow. Alternatively, use msg.value
or address(this).balance
, whichever is applicable.
VotiumStrategyCore
has a withdrawStuckTokens()
, but no function to unstuck ether. Nor does AfEth
. Both have receive() external payable {}
. Consider implementing functions to retrieve stuck ether.
The comment Sets the target ratio of safEth to votium.
is misleading. The ratio is not "safEth to votium" i.e. safEth/votium, but safEth to total balances, i.e. safEth/(safEth + votium).
VotiumStrategy.requestWithdraw()
does not burn afEth tokens as stated in a notice. It burns its own vAfEth tokens. The afEth tokens are burned in AfEth.withdraw()
.
cvxPerVotium
returns the price of CVX per vAfEth, not per afEth as stated in the comments.
#0 - c4-judge
2023-10-04T06:33:45Z
0xleastwood marked the issue as grade-b
#1 - c4-judge
2023-10-08T12:16:54Z
0xleastwood marked the issue as grade-a
#2 - c4-sponsor
2023-10-09T16:36:35Z
elmutt (sponsor) confirmed
🌟 Selected for report: adriro
Also found by: d3e4, m_Rassska, rvierdiiev
In `VotiumStrategy.relock()``
(, uint256 unlockable, , ) = ILockedCvx(VLCVX_ADDRESS).lockedBalances( address(this) ); if (unlockable > 0) ILockedCvx(VLCVX_ADDRESS).processExpiredLocks(false);
unlockable
is retrieved simply to avoid calling processExpiredLocks
, which reverts if there are no expired locks.
lockedBalances()
is not a trival function and can be avoided here by just putting processExpiredLocks()
in a try-catch instead.
VotiumStrategy.withdrawTime()
in VotiumStrategy.requestWithdraw()
instead of identical inline codeVotiumStrategy.requestWithdraw()
performs the exact same calculation as VotiumStrategy.withdrawTime()
to find the epoch at which there is enough to unlock this position. withdrawTime()
can be reused in requestWithdraw()
for finding the lockedBalances[i].unlockTime
. The other calculations do not have to be performed in the loop, and withdrawTime()
has the same revert condition.
Note that cvxUnlockObligations += cvxAmount;
would have to be performed after withdrawTime()
.
uint256 cvxAmountToRelock = cvxBalance > cvxUnlockObligations ? cvxBalance - cvxUnlockObligations : 0; if (cvxAmountToRelock > 0) { IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmountToRelock); ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmountToRelock, 0); }
can be refactored into
if (cvxBalance > cvxUnlockObligations) { uint256 cvxAmountToRelock = cvxBalance - cvxUnlockObligations; IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmountToRelock); ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmountToRelock, 0); }
ratio
, then remainderuint256 vValue = (amount * (1e18 - ratio)) / 1e18;
, can be calculated as
uint256 vValue = amount - sValue;
, where
uint256 sValue = (amount * ratio) / 1e18;
.
This is also prevents dust loss.
#0 - c4-judge
2023-10-04T05:58:34Z
0xleastwood marked the issue as grade-b
Deposits and withdrawals to AfEth can be independently paused by the owner. Especially pausing withdrawals could be leveraged to harm users.
The protocol fee can be set up to 100 % by the owner, which combined with paused withdrawals effectively steals the users stake.
The owner could also just steal all CVX, or any other reward tokens, from VotiumStrategy by using withdrawStuckTokens()
.
The rewarder can also exploit applyRewards()
to give anyone unlimited allowance on any of VotiumStrategy's tokens.
The ratio
can be freely set by the owner (even to > 100 % such that it causes deposits to revert). The ratio between the held safEth and vAfEth is adjusted by depositing in the set ratio
. Withdrawals are done in proportion to whatever the balances are. Only the rewards adjusts the balances actively towards the intended ratio
by depositing only on the side where there is a shortage. The rewards are relatively small compared to the balances however. This means that the balance ratio will converge approximately exponentially but quite slowly. Several times the balances have to be deposited and withdrawn in order to reach close to the new ratio.
Several issues were found related to how AfEth exchanges its afEth for its underlying safEth and vAfEth. A complicating factor is that unlike a vault with only one asset, this one has two. That means that afEth cannot simply represent a direct share of its underlying, but there must be an intermediary valuation by which they can be measured together. Here their estimated values in ETH is used. An issue arises from this when the estimated value is not the same as the effective value when actually deposited or withdrawn.
40 hours
#0 - c4-judge
2023-10-04T05:41:19Z
0xleastwood marked the issue as grade-b