Redeems Reserve Push-based approach#1756
Redeems Reserve Push-based approach#1756bulbozaur wants to merge 6 commits intofeat/staking-router-3.0from
Conversation
| function redeem(uint256 _stETHAmount, address _ethRecipient) external onlyRole(REDEEMER_ROLE) whenResumed { | ||
| if (_stETHAmount == 0) revert ZeroAmount(); | ||
| if (_ethRecipient == address(0)) revert ZeroRecipient(); | ||
| if (LIDO.isStopped()) revert LidoStopped(); | ||
| if (WITHDRAWAL_QUEUE.isBunkerModeActive()) revert BunkerMode(); | ||
| if (WITHDRAWAL_QUEUE.isPaused()) revert WQPaused(); | ||
|
|
||
| uint256 sharesAmount = LIDO.getSharesByPooledEth(_stETHAmount); | ||
| uint256 etherAmount = LIDO.getPooledEthByShares(sharesAmount); | ||
|
|
||
| uint256 available = _reserveBalance - _redeemedEther; | ||
| if (etherAmount > available) { | ||
| revert InsufficientReserve(etherAmount, available); | ||
| } | ||
|
|
||
| LIDO.transferSharesFrom(msg.sender, address(this), sharesAmount); | ||
| BURNER.requestBurnSharesForRedeem(address(this), sharesAmount); | ||
| _redeemedEther += etherAmount; | ||
|
|
||
| (bool success,) = _ethRecipient.call{value: etherAmount}(""); | ||
| if (!success) revert ETHTransferFailed(_ethRecipient, etherAmount); | ||
|
|
||
| emit Redeemed(msg.sender, _ethRecipient, _stETHAmount, sharesAmount, etherAmount); | ||
| } |
Check failure
Code scanning / Slither
Functions that send Ether to arbitrary destinations High
| function recoverERC20(address _token, uint256 _amount) external onlyRole(DEFAULT_ADMIN_ROLE) { | ||
| if (_token == address(LIDO)) revert StETHRecoveryNotAllowed(); | ||
| IERC20(_token).transfer(msg.sender, _amount); | ||
| } |
Check failure
Code scanning / Slither
Unchecked transfer High
| function initialize(address _admin) external { | ||
| _initializeContractVersionTo(1); | ||
| LIDO.approve(address(BURNER), type(uint256).max); | ||
| _grantRole(DEFAULT_ADMIN_ROLE, _admin); | ||
| } |
Check warning
Code scanning / Slither
Unused return Medium
| function redeem(uint256 _stETHAmount, address _ethRecipient) external onlyRole(REDEEMER_ROLE) whenResumed { | ||
| if (_stETHAmount == 0) revert ZeroAmount(); | ||
| if (_ethRecipient == address(0)) revert ZeroRecipient(); | ||
| if (LIDO.isStopped()) revert LidoStopped(); | ||
| if (WITHDRAWAL_QUEUE.isBunkerModeActive()) revert BunkerMode(); | ||
| if (WITHDRAWAL_QUEUE.isPaused()) revert WQPaused(); | ||
|
|
||
| uint256 sharesAmount = LIDO.getSharesByPooledEth(_stETHAmount); | ||
| uint256 etherAmount = LIDO.getPooledEthByShares(sharesAmount); | ||
|
|
||
| uint256 available = _reserveBalance - _redeemedEther; | ||
| if (etherAmount > available) { | ||
| revert InsufficientReserve(etherAmount, available); | ||
| } | ||
|
|
||
| LIDO.transferSharesFrom(msg.sender, address(this), sharesAmount); | ||
| BURNER.requestBurnSharesForRedeem(address(this), sharesAmount); | ||
| _redeemedEther += etherAmount; | ||
|
|
||
| (bool success,) = _ethRecipient.call{value: etherAmount}(""); | ||
| if (!success) revert ETHTransferFailed(_ethRecipient, etherAmount); | ||
|
|
||
| emit Redeemed(msg.sender, _ethRecipient, _stETHAmount, sharesAmount, etherAmount); | ||
| } |
Check warning
Code scanning / Slither
Reentrancy vulnerabilities Medium
| function redeem(uint256 _stETHAmount, address _ethRecipient) external onlyRole(REDEEMER_ROLE) whenResumed { | ||
| if (_stETHAmount == 0) revert ZeroAmount(); | ||
| if (_ethRecipient == address(0)) revert ZeroRecipient(); | ||
| if (LIDO.isStopped()) revert LidoStopped(); | ||
| if (WITHDRAWAL_QUEUE.isBunkerModeActive()) revert BunkerMode(); | ||
| if (WITHDRAWAL_QUEUE.isPaused()) revert WQPaused(); | ||
|
|
||
| uint256 sharesAmount = LIDO.getSharesByPooledEth(_stETHAmount); | ||
| uint256 etherAmount = LIDO.getPooledEthByShares(sharesAmount); | ||
|
|
||
| uint256 available = _reserveBalance - _redeemedEther; | ||
| if (etherAmount > available) { | ||
| revert InsufficientReserve(etherAmount, available); | ||
| } | ||
|
|
||
| LIDO.transferSharesFrom(msg.sender, address(this), sharesAmount); | ||
| BURNER.requestBurnSharesForRedeem(address(this), sharesAmount); | ||
| _redeemedEther += etherAmount; | ||
|
|
||
| (bool success,) = _ethRecipient.call{value: etherAmount}(""); | ||
| if (!success) revert ETHTransferFailed(_ethRecipient, etherAmount); | ||
|
|
||
| emit Redeemed(msg.sender, _ethRecipient, _stETHAmount, sharesAmount, etherAmount); | ||
| } |
Check warning
Code scanning / Slither
Unused return Medium
| function recoverStETHShares() external onlyRole(DEFAULT_ADMIN_ROLE) { | ||
| uint256 shares = LIDO.sharesOf(address(this)); | ||
| if (shares > 0) { | ||
| LIDO.transferShares(msg.sender, shares); | ||
| } | ||
| } |
Check warning
Code scanning / Slither
Unused return Medium
Hardhat Unit Tests Coverage SummaryDetailsDiff against masterResults for commit: 13ae3e5 Minimum allowed coverage is ♻️ This comment has been updated with latest results |
Summary
Redeems reserve — governance-configurable portion of buffered ether protected from CL deposits and WQ finalization,
enabling instant stETH→ETH redemptions. Full spec.
In the push-based approach, reserve ETH is physically held in a dedicated
RedeemsBuffercontract. Shares are forwardedto Burner's isolated redeem track immediately during
redeem()and burned outside the rebase limiter on the next oraclereport.
RedeemsBuffer
OssifiableProxywithinitialize()+Versionedredeem()— transfers shares to Burner viarequestBurnSharesForRedeem, sends ETH to recipient, tracks_redeemedEtherwithdrawUnredeemed()— returns unredeemed ETH to Lido, resets counters. Gated by LidofundReserve()— accepts ETH from Lido during report pushPausableUntilfor Gate Seal compatibilitysetRedeemsBuffer(address(0))requirescurrent.balance == 0Lido
collectRewardsAndProcessWithdrawalsgains_redeemedEtherparameter, handles full ETH round-trip internally: withdrawunredeemed → reconcile → standard flow → grow reserve → push
setRedeemsReserveTargetRatiostores ratio only, no immediate reduction — deferred to next report_growRedeemsReserve()replenishes reserve toward target on each reportreceiveFromRedeemsBuffer()accepts returned ETHBurner: isolated redeem track
requestBurnSharesForRedeem— gated byREQUEST_BURN_SHARES_ROLEcommitRedeemSharesToBurn— burns all pending redeem shares unconditionally_getExcessStETHSharesincludesredeemSharesBurnRequestedgetSharesRequestedToBurnreturns(cover, nonCover, redeem)Accounting
burner.getRedeemSharesRequestedToBurn()andbuffer.getRedeemedEther()redeemedEtherfromsmoothenTokenRebasebasesmoothedSharesToBurn(limiter-constrained) andredeemSharesToBurn(unconditional) tracked separatelyredeemedEthertocollectRewardsAndProcessWithdrawals