Inconsistent division of fixed-side withdrawals is unfair
Description
When fixed-side depositors deposit into the LidoVault, both the amount of stETH shares minted due to their native ETH deposit and the quantity of native ETH deposited are recorded:
function deposit(uint256 side) external payable {
// [...]
if (side == FIXED) {
// [...]
// Stake on Lido
/// returns stETH, and returns amount of Lido shares issued for the staked ETH
uint256 stETHBalanceBefore = stakingBalance();
uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address
require(shares > 0, "ISS");
// stETH transfered from Lido != ETH deposited to Lido - some rounding error
uint256 stETHReceived = (stakingBalance() - stETHBalanceBefore);
require((stETHReceived >= amount) || (amount - stETHReceived <= LIDO_ERROR_TOLERANCE_ETH), "ULD");
emit FundsStaked(amount, shares, msg.sender);
// Mint claim tokens
fixedClaimToken[msg.sender] += shares;
fixedClaimTokenTotalSupply += shares;
fixedETHDepositToken[msg.sender] += amount;
Next, when they claim their fixed premium, the amount of native ETH claimed from the variable side's deposit is dependent on the amount of ETH they deposited, which is fixedETHDepositToken
:
function claimFixedPremium() external {
// [...]
uint256 claimBal = fixedClaimToken[msg.sender];
require(claimBal > 0, "NCT");
// Send a proportional share of the total variable side deposits (premium) to the fixed side depositor
uint256 sendAmount = fixedETHDepositToken[msg.sender].mulDiv(variableSideCapacity, fixedSideCapacity);
// Track premiums
userToFixedUpfrontPremium[msg.sender] = sendAmount;
(bool sent, ) = msg.sender.call{value: sendAmount}("");
Then, before the vault ends, if they withdraw, the amount of fixed deposit returned to them is also proportional to their fixedETHDepositToken
:
function withdraw(uint256 side) external {
require(side == FIXED || side == VARIABLE, "IS");
// Vault has not started
if (!isStarted()) {
// [...]
// Vault started and in progress
} else if (!isEnded()) {
if (side == FIXED) {
// [...]
uint256 initialDepositAmount = fixedETHDepositToken[msg.sender];
require(initialDepositAmount > 0, "NET");
// since the vault has started only withdraw their initial fixed deposit - unless we are in a loss
uint256 withdrawAmount = initialDepositAmount;
uint256 lidoStETHBalance = stakingBalance();
uint256 fixedETHDeposits = fixedETHDepositTokenTotalSupply;
// [...] [assume stETH did not default]
fixedToVaultOngoingWithdrawalRequestIds[msg.sender] = WithdrawalRequest({
requestIds: requestWithdrawViaETH(msg.sender, withdrawAmount),
timestamp: block.timestamp
});
But, if they instead withdraw after the vault ends, the amount of fixed deposit returned to them is proportional to the shares they originally deposited, a quantity now tracked in fixedBearerToken
:
function vaultEndedWithdraw(uint256 side) internal {
// [...]
uint256 bearerBalance = fixedBearerToken[msg.sender];
require(bearerBalance > 0, "NBT");
sendAmount = fixedBearerToken[msg.sender].mulDiv(
vaultEndedFixedDepositsFunds,
fixedLidoSharesTotalSupply()
);
// [...]
transferWithdrawnFunds(msg.sender, sendAmount);
Impact
This inconsistent division of assets causes discontinuities across the time periods of the vault. For example, consider the case where Alice and Bob each deposit 1 ETH when the share price was one share = 1 ETH, then time passes and yields are earned, and then Charlie deposits 2 ETH when the share price is at one share = 2 ETH. Based on the shares deposited, Charlie is entitled to a third of the value of the total principal, but based on ETH value deposited, Charlie is entitled to half.
So, Charlie will want to do an ongoing withdraw close to the end of the vault, instead of letting his share be finalized, because then he gets more of the fixed premium returned to him.
This effect is even worse if Lido defaults after some fixed depositors deposit but before the vault starts. If this happens, the vault essentially becomes a trap, because future fixed depositors will deposit many more shares due to the lower ETH price of a share, and therefore pay for the value the pre-default fixed depositors already lost.
Recommendations
We recommend always dividing the fixed principal by shares.
Additionally, consider allowing fixed depositors to hold their fixed principal in ETH rather than in stETH before the vault begins and having the option for those depositors to withdraw ETH immediately if they want to back out before the vault starts.
This is reasonable because, before a vault begins, if the partial-fixed premium gains yields or loses value, it is not clear who those yields belong to and who should pay for any losses, since anyone can permissionlessly start the vault.
For instance, if the vault fixed-side capacity is 5 ETH, and 4 ETH has been deposited but that has grown to 6 ETH, and there are not yet any depositors on the variable side, then anyone can come along and deposit 1 ETH to the fixed side and fill up the variable side and instantly claim the entire 2 ETH yield that has already been gained from the variable side. Depending on the size of the variable side, this may be an instantaneous profit even if Lido defaults the very next block.
On the other hand, if the fixed side sends in withdrawals first, before the vault has a chance to start, then the fixed side gets all of the yield returned to them. In essence, yield is created by subjecting that native ETH to the counterparty risk, but whether or not the vault starts yet is unknown. Instead of making the claiming of that excess yield a gas auction, it makes more sense to not subject the native ETH to that risk yet, before the vault starts, and consequentially also allow fixed-side depositors to immediately withdraw their ETH if they wish.