Direct deposits and withdrawals within VotiumStrategy lack any slippage controls, which opens up the possibility of sandwich attacks and Miner Extractable Value (MEV) exploits.
Interactions in the AfEth protocol often require the exchange of ETH for other assets. Users deposit ETH, and the protocol needs to convert it to other LSD tokens while staking in SafEth, and also to CVX tokens while depositing in the VotiumStrategy. Similarly, during withdrawals, the protocol converts those assets back to ETH.
While the AfEth contract implements slippage control in deposit()
and withdraw()
through a _minout
parameter to ensure the return of a minimum number of assets after execution, the same is not true for the VotiumStrategy
contract, which can be used in a standalone fashion.
The deposit()
function in VotiumStrategy simple takes the received ETH amount and swaps it to CVX using buyCvx()
:
39: function deposit() public payable override returns (uint256 mintAmount) {
40: uint256 priceBefore = cvxPerVotium();
41: uint256 cvxAmount = buyCvx(msg.value);
42: IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmount);
43: ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmount, 0);
44: mintAmount = ((cvxAmount * 1e18) / priceBefore);
45: _mint(msg.sender, mintAmount);
46: }
227: function buyCvx(
228: uint256 _ethAmountIn
229: ) internal returns (uint256 cvxAmountOut) {
230: address CVX_ETH_CRV_POOL_ADDRESS = 0xB576491F1E6e5E62f1d8F26062Ee822B40B0E0d4;
231: // eth -> cvx
232: uint256 cvxBalanceBefore = IERC20(CVX_ADDRESS).balanceOf(address(this));
233: ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying{
234: value: _ethAmountIn
235: }(
236: 0,
237: 1,
238: _ethAmountIn,
239: 0 // this is handled at the afEth level
240: );
241: uint256 cvxBalanceAfter = IERC20(CVX_ADDRESS).balanceOf(address(this));
242: cvxAmountOut = cvxBalanceAfter - cvxBalanceBefore;
243: }
In the previous snippet of code, line 239, we can see that the last argument to exchange_underlying()
is zero, which is the minimum output amount. The comment reads "this is handled at the afEth level" which is partially true: it is only checked when being used from AfEth, but not for direct depositors of the VotiumStrategy.
The withdraw()
function has the same issue when swapping CVX for ETH in sellCvx()
:
109: function withdraw(uint256 _withdrawId) external override {
110: if (withdrawIdToWithdrawRequestInfo[_withdrawId].owner != msg.sender)
111: revert NotOwner();
112: if (!this.canWithdraw(_withdrawId)) revert WithdrawNotReady();
113:
114: if (withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn)
115: revert AlreadyWithdrawn();
116:
117: relock();
118:
119: uint256 cvxWithdrawAmount = withdrawIdToWithdrawRequestInfo[_withdrawId]
120: .cvxOwed;
121:
122: uint256 ethReceived = sellCvx(cvxWithdrawAmount);
123: cvxUnlockObligations -= cvxWithdrawAmount;
124: withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn = true;
125:
126: // solhint-disable-next-line
127: (bool sent, ) = msg.sender.call{value: ethReceived}("");
128: if (!sent) revert FailedToSend();
129: }
250: function sellCvx(
251: uint256 _cvxAmountIn
252: ) internal returns (uint256 ethAmountOut) {
253: address CVX_ETH_CRV_POOL_ADDRESS = 0xB576491F1E6e5E62f1d8F26062Ee822B40B0E0d4;
254: // cvx -> eth
255: uint256 ethBalanceBefore = address(this).balance;
256: IERC20(CVX_ADDRESS).approve(CVX_ETH_CRV_POOL_ADDRESS, _cvxAmountIn);
257:
258: ICrvEthPool(CVX_ETH_CRV_POOL_ADDRESS).exchange_underlying(
259: 1,
260: 0,
261: _cvxAmountIn,
262: 0 // this is handled at the afEth level
263: );
264: ethAmountOut = address(this).balance - ethBalanceBefore;
265: }
Again, in line 262 we see the same issue as before, the minimum output is configured to zero.
We can see that there is no slippage control at any level, the swap output amount is configured to zero, and there is no check of the number of minted tokens or the amount of ETH sent back to the user.
This implies that direct deposits or withdrawals in the VotiumStrategy contract are susceptible to arbitrary sandwich attacks by MEV bots, which could potentially result in a loss of funds for the user. In the case of a sandwiched deposit, there will be a reduced issuance of VotiumStrategy tokens, as the swap may yield fewer CVX tokens than anticipated. Similarly, a sandwiched withdrawal may lead to a reduced amount of ETH being returned to the user.
- A user sends a transaction to deposit in VotiumStrategy.
- A MEV bot sees the transaction, and swaps a large amount of ETH for CVX in the Curve Pool.
- The user's transaction goes through and swap is executed in the manipulated pool.
- The MEV bot swaps CVX back to ETH, and pockets the slippage difference.
- The user gets less CVX tokens, which means less minted VotiumStrategy tokens.
Add a minimum output parameter to both deposit()
and withdraw()
in the VotiumStrategy contract.
The AfEth contract can still set these arguments as zero, since it already has slippage control, while allowing direct users of the VotiumStrategy contract to protect against MEV attacks.
- function deposit() public payable override returns (uint256 mintAmount) {
+ function deposit(uint256 minOut) public payable override returns (uint256 mintAmount) {
uint256 priceBefore = cvxPerVotium();
uint256 cvxAmount = buyCvx(msg.value);
IERC20(CVX_ADDRESS).approve(VLCVX_ADDRESS, cvxAmount);
ILockedCvx(VLCVX_ADDRESS).lock(address(this), cvxAmount, 0);
mintAmount = ((cvxAmount * 1e18) / priceBefore);
+ require(mintAmount >= minOut);
_mint(msg.sender, mintAmount);
}
- function withdraw(uint256 _withdrawId) external override {
+ function withdraw(uint256 _withdrawId, uint256 minOut) external override {
if (withdrawIdToWithdrawRequestInfo[_withdrawId].owner != msg.sender)
revert NotOwner();
if (!this.canWithdraw(_withdrawId)) revert WithdrawNotReady();
if (withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn)
revert AlreadyWithdrawn();
relock();
uint256 cvxWithdrawAmount = withdrawIdToWithdrawRequestInfo[_withdrawId]
.cvxOwed;
uint256 ethReceived = sellCvx(cvxWithdrawAmount);
cvxUnlockObligations -= cvxWithdrawAmount;
withdrawIdToWithdrawRequestInfo[_withdrawId].withdrawn = true;
+ require(ethReceived >= minOut);
// solhint-disable-next-line
(bool sent, ) = msg.sender.call{value: ethReceived}("");
if (!sent) revert FailedToSend();
}