Skip to main content

StETHEarlyExitVault

Inherits: ERC4626, ERC165, Ownable, StETHRedeemManager

Title: StETHEarlyExitVault

Author: roamingRahi

An ERC4626 vault that accepts WETH deposits and allows users to swap stETH for WETH at a discount, based on the vault owner's expected ETH APY and Lido withdrawal wait time. Vault depositors earn the discount as yield, taking on the responsibility of waiting for Lido withdrawals to complete.

Swap mechanics (high level)

  1. User calls swap(stEthAmount, receiver, minEthAmountOut).
  2. The vault computes discount = stEthAmount × apyBps × days / (10_000 × 365) and feeAmount = (stEthAmount − discount) × feeBps / 10_000.
  3. The user receives stEthAmount − discount − feeAmount WETH.
  4. The vault collects the discount (yield for LPs) and the fee (minted as owner shares).
  5. The received stETH is forwarded to the Lido withdrawal queue (if above the per-swap minimum). Only enough finalized withdrawals to cover the swap output (plus any pending LP redemptions) are auto-claimed on each swap call.

LP exit flow (EIP-7540 async redemption) LPs exit via a FIFO async queue managed by StETHRedeemManager:

  1. requestRedeem(shares, controller, owner) - burns shares immediately, records WETH owed.
  2. When Lido claims arrive, _serveRedeemRequests allocates WETH to the queue FIFO.
  3. claimRedeem(receiver, controller) - transfers allocated WETH to the receiver. Direct redeem() / withdraw() against live shares is not supported (maxRedeem = 0).

Hint computation strategy Lido's findCheckpointHints() does O(log C) binary search per request ID, where C is the number of finalization checkpoints. To narrow the search, each queued withdrawal records getLastCheckpointIndex() + 1 at submission time as a lower bound: the finalization checkpoint is guaranteed to have an index ≥ this value (because finalization always creates a new checkpoint strictly after the current last one).

State Variables

ST_ETH

IStETH public immutable ST_ETH

WITHDRAWAL_QUEUE

IWithdrawalQueue public immutable WITHDRAWAL_QUEUE

WST_ETH

IWstETH public immutable WST_ETH

expectedApyBps

Expected ETH staking APY, expressed in basis points (100 = 1%)

uint256 public expectedApyBps

expectedWithdrawalDaysInSeconds

Expected Lido withdrawal queue wait time, in seconds

uint256 public expectedWithdrawalDaysInSeconds

minStEthForQueue

Minimum stETH per swap required to submit to the withdrawal queue. Swaps below this threshold still execute but the stETH is held in the contract until the owner batches it via queueAccumulatedStEth.

uint256 public minStEthForQueue

minRedeemShares

Minimum vault shares required to submit a requestRedeem call. Bounding the minimum share size bounds the number of requests per controller, keeping the claimRedeem loop gas costs bounded. Default: 0 (no minimum).

uint256 public minRedeemShares

totalPendingStEth

Running total of stETH (≈ ETH) currently locked in the Lido queue. Used in totalAssets() so share price reflects pending ETH.

uint256 public totalPendingStEth

feeBps

Protocol fee charged on the gross WETH output of each swap, in basis points. Fee is taken from the swapper's gross output: feeAmount = grossOut × feeBps / 10_000. The vault retains the fee WETH; the owner is minted an equivalent amount of shares. Default: 0 (no fee).

uint256 public feeBps

withdrawalFeeBps

Fee charged when an LP withdraws their claimable WETH from the async redemption queue, in basis points. Deducted from the withdrawn amount; the fee WETH stays in the vault as liquidity and the owner is minted equivalent shares. Default: 0 (no fee).

uint256 public withdrawalFeeBps

BPS_DENOMINATOR

uint256 public constant BPS_DENOMINATOR = 10_000

SECONDS_PER_YEAR

uint256 public constant SECONDS_PER_YEAR = 365 days

MAX_FEE_BPS

Hard cap on feeBps (30%) to prevent the owner from confiscating all swap output.

uint256 public constant MAX_FEE_BPS = 3_000

MAX_WITHDRAWAL_FEE_BPS

Hard cap on withdrawalFeeBps (10%).

uint256 public constant MAX_WITHDRAWAL_FEE_BPS = 1_000

MAX_STETH_WITHDRAWAL_AMOUNT

Lido's per-request stETH cap (1000 ETH). Cached from the withdrawal queue contract to avoid an external call on every swap and queueAccumulatedStEth call.

uint256 public constant MAX_STETH_WITHDRAWAL_AMOUNT = 1_000 ether

MIN_STETH_WITHDRAWAL_AMOUNT

Lido's minimum stETH withdrawal amount (100 wei). minStEthForQueue must always be at or above this value so dust is never silently swallowed.

uint256 public constant MIN_STETH_WITHDRAWAL_AMOUNT = 100

Functions

constructor

constructor(
address _weth,
address _stEth,
address _wstEth,
address _withdrawalQueue,
uint256 _expectedApyBps,
uint256 _expectedWithdrawalDaysInSeconds,
uint256 _minStEthForQueue,
address _owner
) ERC4626(IERC20(_weth)) ERC20("stETH Early Exit Vault", "seevWETH") Ownable(_owner);

Parameters

NameTypeDescription
_wethaddressWETH token address (ERC4626 underlying asset)
_stEthaddressLido stETH token address
_wstEthaddressWrapped stETH (wstETH) token address
_withdrawalQueueaddressLido WithdrawalQueueERC721 address
_expectedApyBpsuint256Initial expected ETH APY in basis points (max 10_000 = 100%)
_expectedWithdrawalDaysInSecondsuint256Initial expected withdrawal wait time in seconds (max SECONDS_PER_YEAR = 365 days)
_minStEthForQueueuint256Minimum stETH amount to auto-queue per swap (Lido's minimum is 100 wei)
_owneraddressContract owner (receives fee shares; controls parameters)

receive

Accepts ETH forwarded by the withdrawal queue during claim operations.

receive() external payable;

totalAssets

Total assets under management for current LP shareholders, denominated in WETH.

totalAssets =
WETH balance in contract
+ stETH balance in contract (not yet queued to Lido)
+ (totalPendingStEth − finalizedStEth) (stETH queued to Lido, not yet finalized)
+ claimableEther (actual ETH receivable from finalized-but-unclaimed Lido NFTs)
− _reservedRedeemAssets (WETH allocated to claimable redeem requests)
− _pendingRedeemAssets (WETH owed to pending-but-not-yet-claimable redeems)
function totalAssets() public view override returns (uint256);

getSwapQuote

Calculate the output amounts for a given stETH swap input.

Formula:

discount   = stEthAmount × apyBps × waitSeconds / (10_000 × SECONDS_PER_YEAR)
grossOut = stEthAmount − discount
feeAmount = grossOut × feeBps / 10_000
userOut = grossOut − feeAmount
function getSwapQuote(uint256 stEthAmount)
public
view
returns (uint256 userOut, uint256 discount, uint256 feeAmount);

Parameters

NameTypeDescription
stEthAmountuint256Amount of stETH being swapped.

Returns

NameTypeDescription
userOutuint256Net WETH delivered to the swap receiver (after discount and fee).
discountuint256Discount retained by the vault as yield for LP share-price appreciation.
feeAmountuint256Protocol fee retained by the vault; owner receives equivalent shares.

pendingRequestCount

Returns the number of withdrawal requests still pending (head-to-tail).

function pendingRequestCount() external view returns (uint256);

getPendingRequestIds

Returns the pending withdrawal request IDs (from head to tail).

function getPendingRequestIds() external view returns (uint256[] memory ids);

maxWethAvailable

Upper bound on the stETH amount that can be accepted in a single swap() call given the vault's current WETH liquidity. Returns 0 if Lido's bunker mode is active.

function maxWethAvailable() external view returns (uint256);

setExpectedApy

Update the expected ETH staking APY.

function setExpectedApy(uint256 _apyBps) external onlyOwner;

Parameters

NameTypeDescription
_apyBpsuint256New APY in basis points (max 10_000 = 100%)

setExpectedWithdrawalDaysInSeconds

Update the expected Lido withdrawal wait time.

function setExpectedWithdrawalDaysInSeconds(uint256 _daysInSeconds) external onlyOwner;

Parameters

NameTypeDescription
_daysInSecondsuint256New expected wait time in days (max SECONDS_PER_YEAR = 365)

setMinStEthForQueue

Update the minimum stETH per swap to auto-queue. Must be at or above Lido's per-request minimum (MIN_STETH_WITHDRAWAL_AMOUNT = 100 wei).

function setMinStEthForQueue(uint256 _min) external onlyOwner;

setMinRedeemShares

Update the minimum shares required to submit a requestRedeem call.

function setMinRedeemShares(uint256 _min) external onlyOwner;

setFeeBps

Update the protocol fee rate charged on each swap's gross output.

function setFeeBps(uint256 _feeBps) external onlyOwner;

Parameters

NameTypeDescription
_feeBpsuint256New fee in basis points (max MAX_FEE_BPS = 3_000 = 30%)

setWithdrawalFeeBps

Update the fee charged when LPs withdraw claimable WETH.

function setWithdrawalFeeBps(uint256 _feeBps) external onlyOwner;

Parameters

NameTypeDescription
_feeBpsuint256New fee in basis points (max MAX_WITHDRAWAL_FEE_BPS = 1_000 = 10%)

queueAccumulatedStEth

Queue all accumulated stETH held in the contract into the Lido withdrawal queue. If the balance exceeds MAX_STETH_WITHDRAWAL_AMOUNT (1000 ETH), multiple requests are submitted in order to respect Lido's per-request cap.

function queueAccumulatedStEth() external onlyOwner;

swapStETHToWETH

Swaps stETH for WETH at a discount. Pulls stEthAmount stETH from msg.sender (caller must approve this vault first), then executes the swap.

function swapStETHToWETH(uint256 stEthAmount, address receiver, uint256 minEthAmountOut)
external
returns (uint256 wethOut);

Parameters

NameTypeDescription
stEthAmountuint256Amount of stETH to swap
receiveraddressAddress to receive the WETH payout
minEthAmountOutuint256Minimum WETH the caller is willing to receive. Pass the first return value of getSwapQuote to make the swap owner-parameter-proof.

Returns

NameTypeDescription
wethOutuint256Net WETH amount sent to receiver

swapWstEthToWeth

Swaps wstETH for WETH at a discount. Uses the vault's current wstETH balance first; if that is insufficient, pulls the remainder from msg.sender (caller must approve this vault for the shortfall). Unwraps the full wstETHAmount to stETH, then executes the swap.

function swapWstEthToWeth(uint256 wstETHAmount, address receiver, uint256 minEthAmountOut)
external
returns (uint256 wethOut);

Parameters

NameTypeDescription
wstETHAmountuint256Total amount of wstETH to unwrap and swap
receiveraddressAddress to receive the WETH payout
minEthAmountOutuint256Minimum WETH the caller is willing to receive.

Returns

NameTypeDescription
wethOutuint256Net WETH amount sent to receiver

claimPendingWithdrawalsAndServeRedeemRequests

Claim up to maxItems finalized Lido withdrawals and serve pending redeem requests with available WETH. Anyone can call this to proactively advance the redeem queue and replenish the vault's WETH swap liquidity. Checkpoint hints are derived on-chain - no off-chain data needed.

function claimPendingWithdrawalsAndServeRedeemRequests(uint256 maxItems) external;

Parameters

NameTypeDescription
maxItemsuint256Maximum number of Lido withdrawal requests to process in this call.

claimPendingWithdrawalsAndServeRedeemRequests

Claim all currently finalized Lido withdrawals and serve pending redeem requests. Processes every pending withdrawal request in one call.

function claimPendingWithdrawalsAndServeRedeemRequests() external;

share

ERC-7575: Returns the address of the share token (this contract itself, since the vault and its share are the same ERC-20 token).

function share() external view returns (address);

previewWithdraw

ERC-7540: MUST revert for async redemption vaults.

function previewWithdraw(uint256) public pure override returns (uint256);

previewRedeem

ERC-7540: MUST revert for async redemption vaults.

function previewRedeem(uint256) public pure override returns (uint256);

maxRedeem

ERC-7540: MUST return 0 for async redemption vaults (synchronous redeem is not supported; shares are burned via requestRedeem instead).

function maxRedeem(address) public pure override returns (uint256);

withdraw

Override ERC-4626 withdraw to skip the previewWithdraw call (which always reverts per ERC-7540). Assets are drawn from the async redeem claimable pool. Returns 0 because no shares are burned in this call (they were burned in requestRedeem).

function withdraw(uint256 assets, address receiver, address controller) public override returns (uint256);

maxWithdraw

Override to return total claimable WETH for owner as controller.

function maxWithdraw(address owner) public view override returns (uint256);

supportsInterface

ERC-165: Returns true for supported interface IDs. Implements ERC-7540 requirement that all async vaults expose ERC-165.

function supportsInterface(bytes4 interfaceId) public view override(ERC165) returns (bool);

Events

Swapped

Emitted on every successful swap.

event Swapped(
address indexed user,
address indexed receiver,
uint256 stEthIn,
uint256 wethOut,
uint256 discount,
uint256 feeAmount
);

Parameters

NameTypeDescription
useraddressAddress that called swap().
receiveraddressAddress that received the WETH payout.
stEthInuint256stETH transferred from the user.
wethOutuint256Net WETH delivered to receiver (after discount and fee).
discountuint256Yield retained by the vault for LP share-price appreciation.
feeAmountuint256Protocol fee retained by the vault; owner receives equivalent shares.

WithdrawalQueued

Emitted when stETH is submitted to the Lido withdrawal queue.

event WithdrawalQueued(uint256[] requestIds, uint256 stEthAmount);

APYUpdated

Emitted when the expected APY parameter is changed.

event APYUpdated(uint256 oldApyBps, uint256 newApyBps);

WithdrawalDaysUpdated

Emitted when the expected withdrawal days parameter is changed.

event WithdrawalDaysUpdated(uint256 oldDays, uint256 newDays);

MinStETHForQueueUpdated

event MinStETHForQueueUpdated(uint256 oldMin, uint256 newMin);

MinRedeemSharesUpdated

event MinRedeemSharesUpdated(uint256 oldMin, uint256 newMin);

FeeUpdated

event FeeUpdated(uint256 oldFeeBps, uint256 newFeeBps);

WithdrawalFeeUpdated

event WithdrawalFeeUpdated(uint256 oldFeeBps, uint256 newFeeBps);

Errors

ZeroAmount

error ZeroAmount();

ZeroAddress

error ZeroAddress();

InsufficientLiquidity

error InsufficientLiquidity(uint256 required, uint256 available);

APYTooHigh

error APYTooHigh(uint256 apyBps);

WithdrawalDaysTooHigh

Thrown when the wait time in seconds exceeds SECONDS_PER_YEAR.

error WithdrawalDaysTooHigh(uint256 days_);

FeeTooHigh

Thrown when _feeBps > MAX_FEE_BPS is passed to setFeeBps.

error FeeTooHigh(uint256 feeBps);

WithdrawalFeeTooHigh

Thrown when _feeBps > MAX_WITHDRAWAL_FEE_BPS is passed to setWithdrawalFeeBps.

error WithdrawalFeeTooHigh(uint256 feeBps);

MinStETHBelowLidoMinimum

error MinStETHBelowLidoMinimum(uint256 min, uint256 lidoMin);

SwapAmountTooLarge

error SwapAmountTooLarge(uint256 amount, uint256 maximum);

PreviewNotSupported

Thrown by previewWithdraw and previewRedeem, which MUST revert per ERC-7540.

error PreviewNotSupported();

BunkerModeActive

Thrown when a swap is attempted while Lido's bunker mode is active.

error BunkerModeActive();

SlippageExceeded

Thrown when the computed WETH output falls below the caller's minimum.

error SlippageExceeded(uint256 wethOut, uint256 minEthAmountOut);

Structs

PendingWithdrawal

Packed record for a single Lido withdrawal request owned by this vault.

struct PendingWithdrawal {
uint128 requestId;
uint128 stEthAmount;
uint256 checkpointHintLower;
}