First staker can block staking by making exchangeRate() == 0.
Proof of Concept
As can be seen
function exchangeRate() public view returns (uint256) {
/// @dev if there are no tokens minted, return the initial exchange rate
uint256 _totalSupply = totalSupply();
if (_totalSupply == 0) {
return INITIAL_EXCHANGE_RATE;
}
/// @dev calculate the cash on hand by removing locked funds from the total xETH balance
/// @notice this balanceOf call will include any lockedFunds,
/// @notice as the locked funds are also in xETH
uint256 cashMinusLocked = xETH.balanceOf(address(this)) - lockedFunds;
/// @dev return the exchange rate by dividing the cash on hand by the total supply
return (cashMinusLocked * BASE_UNIT) / _totalSupply;
}
exchangeRate() will return 0 if totalSupply() > 0 and totalSupply() > (xETH.balanceOf(address(this)) - lockedFunds) * BASE_UNIT. If this is the case, then stake()
function stake(uint256 xETHAmount) external drip returns (uint256) {
/// @dev calculate the amount of wxETH to mint
uint256 mintAmount = previewStake(xETHAmount);
/// @dev transfer xETH from the user to the contract
xETH.safeTransferFrom(msg.sender, address(this), xETHAmount);
/// @dev emit event
emit Stake(msg.sender, xETHAmount, mintAmount);
/// @dev mint the wxETH to the user
_mint(msg.sender, mintAmount);
return mintAmount;
}
will revert in its call to previewStake()
function previewStake(uint256 xETHAmount) public view returns (uint256) {
/// @dev if xETHAmount is 0, revert.
if (xETHAmount == 0) revert AmountZeroProvided();
/// @dev calculate the amount of wxETH to mint before transfer
return (xETHAmount * BASE_UNIT) / exchangeRate();
}
from division by zero.
Stake 3 xETH. Let drip until balance is 5 xETH. Exchange rate is now 1.666...e18. This causes a rounding error in stake such that if 6e18 xETH is staked 1.8e18 + 1 wxETH is returned. Unstake all and balance is 0 but totalSupply is 1.
Lines of code
https://github.com/code-423n4/2023-05-xeth/blob/d86fe0a9959c2b43c62716240d981ae95224e49e/src/wxETH.sol#L1
Vulnerability details
Impact
First staker can block staking by making
exchangeRate() == 0
.Proof of Concept
As can be seen
exchangeRate()
will return0
iftotalSupply() > 0
andtotalSupply() > (xETH.balanceOf(address(this)) - lockedFunds) * BASE_UNIT
. If this is the case, thenstake()
will revert in its call to
previewStake()
from division by zero.
Stake 3 xETH. Let drip until balance is 5 xETH. Exchange rate is now 1.666...e18. This causes a rounding error in stake such that if 6e18 xETH is staked 1.8e18 + 1 wxETH is returned. Unstake all and balance is 0 but totalSupply is 1.
Assessed type
call/delegatecall