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)
- User calls
swap(stEthAmount, receiver, minEthAmountOut). - The vault computes
discount = stEthAmount × apyBps × days / (10_000 × 365)andfeeAmount = (stEthAmount − discount) × feeBps / 10_000. - The user receives
stEthAmount − discount − feeAmountWETH. - The vault collects the discount (yield for LPs) and the fee (minted as owner shares).
- 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:
requestRedeem(shares, controller, owner)- burns shares immediately, records WETH owed.- When Lido claims arrive,
_serveRedeemRequestsallocates WETH to the queue FIFO. claimRedeem(receiver, controller)- transfers allocated WETH to the receiver. Directredeem()/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
| Name | Type | Description |
|---|---|---|
_weth | address | WETH token address (ERC4626 underlying asset) |
_stEth | address | Lido stETH token address |
_wstEth | address | Wrapped stETH (wstETH) token address |
_withdrawalQueue | address | Lido WithdrawalQueueERC721 address |
_expectedApyBps | uint256 | Initial expected ETH APY in basis points (max 10_000 = 100%) |
_expectedWithdrawalDaysInSeconds | uint256 | Initial expected withdrawal wait time in seconds (max SECONDS_PER_YEAR = 365 days) |
_minStEthForQueue | uint256 | Minimum stETH amount to auto-queue per swap (Lido's minimum is 100 wei) |
_owner | address | Contract 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
| Name | Type | Description |
|---|---|---|
stEthAmount | uint256 | Amount of stETH being swapped. |
Returns
| Name | Type | Description |
|---|---|---|
userOut | uint256 | Net WETH delivered to the swap receiver (after discount and fee). |
discount | uint256 | Discount retained by the vault as yield for LP share-price appreciation. |
feeAmount | uint256 | Protocol 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
| Name | Type | Description |
|---|---|---|
_apyBps | uint256 | New APY in basis points (max 10_000 = 100%) |
setExpectedWithdrawalDaysInSeconds
Update the expected Lido withdrawal wait time.
function setExpectedWithdrawalDaysInSeconds(uint256 _daysInSeconds) external onlyOwner;
Parameters
| Name | Type | Description |
|---|---|---|
_daysInSeconds | uint256 | New 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
| Name | Type | Description |
|---|---|---|
_feeBps | uint256 | New 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
| Name | Type | Description |
|---|---|---|
_feeBps | uint256 | New 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
| Name | Type | Description |
|---|---|---|
stEthAmount | uint256 | Amount of stETH to swap |
receiver | address | Address to receive the WETH payout |
minEthAmountOut | uint256 | Minimum WETH the caller is willing to receive. Pass the first return value of getSwapQuote to make the swap owner-parameter-proof. |
Returns
| Name | Type | Description |
|---|---|---|
wethOut | uint256 | Net 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
| Name | Type | Description |
|---|---|---|
wstETHAmount | uint256 | Total amount of wstETH to unwrap and swap |
receiver | address | Address to receive the WETH payout |
minEthAmountOut | uint256 | Minimum WETH the caller is willing to receive. |
Returns
| Name | Type | Description |
|---|---|---|
wethOut | uint256 | Net 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
| Name | Type | Description |
|---|---|---|
maxItems | uint256 | Maximum 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
| Name | Type | Description |
|---|---|---|
user | address | Address that called swap(). |
receiver | address | Address that received the WETH payout. |
stEthIn | uint256 | stETH transferred from the user. |
wethOut | uint256 | Net WETH delivered to receiver (after discount and fee). |
discount | uint256 | Yield retained by the vault for LP share-price appreciation. |
feeAmount | uint256 | Protocol 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;
}