# Redeems Reserve - Tech Research
> Previous version of this document: https://hackmd.io/@bkovtun/BkaJkDOD-e
>
> Key changes in this version:
> - Renamed from «Buffer Reserve» to «Redeems Reserve» with updated terminology throughout
> - Unified buffer allocation model (`BufferedEtherAllocation`) with consistent naming, replacing layered independent mechanisms
> - Redeems reserve integrated into allocation as the highest-priority layer, with inverted replenishment priority
> - Configurable growth share (`redeemsReserveGrowthShare`) for gradual reserve recovery under WQ pressure
> - `redeemStETH()` no longer increases staking limit
> - Emergency pause moved from Lido to the redeemer contract (`PausableUntil`, Gate Seal compatible)
## TL;DR
All ether received by the Lido protocol is either deposited to validators or used for withdrawal finalization — currently there is no mechanism to keep part of it unstaked.
This document proposes a redeems reserve: a governance-configurable portion of buffered ether protected from both CL deposits and withdrawal queue finalization. An authorized redeemer contract can redeem stETH and receive ETH from the reserve without affecting the protocol's share rate.
To ensure the redeems reserve does not starve Withdrawal Queue finalization, the reserve is replenished primarily from unreserved buffer surplus. When the surplus is insufficient and WQ demand competes with the reserve, governance can configure a growth share (basis points) that determines the split of shared ether between the reserve and WQ finalization. The reserve can be disabled by the DAO by setting the reserve target ratio to zero.

## Problem Statement
The Lido buffer currently operates as a prioritized pass-through: incoming ETH is first reserved to cover unfinalized stETH in the Withdrawal Queue, and any excess may be deposited to the Consensus Layer. There is no mechanism to set aside a portion of it for immediate use.
This limits the protocol's ability to serve use cases that require readily available ETH — instant withdrawals, liquidity provision, etc.
## Context
The Lido protocol maintains an ETH buffer — native ETH held on the `Lido` contract, tracked internally as `bufferedEther`.
The buffer receives ether from three sources: user deposits via `submit()`, vault rebalancing via `rebalanceExternalEtherToInternal()`, and Accounting Oracle report processing via `collectRewardsAndProcessWithdrawals()`, which transfers funds from the `WithdrawalVault` and `LidoExecutionLayerRewardsVault` into the buffer.
Two mechanisms consume the buffer. The Staking Router pulls ether to deposit into the Beacon Chain. The Withdrawal Queue (WQ) consumes the buffer during Accounting Oracle reports, when a portion is sent to finalize pending withdrawal requests.
Two off-chain components supplement the on-chain logic. The Accounting Oracle daemon submits withdrawal finalization batches to the `AccountingOracle` contract roughly every 24 hours. The Validators Exit Bus Oracle daemon monitors unfinalized withdrawal requests and submits exit requests to the `ValidatorsExitBusOracle` contract when the buffer cannot cover the demand. Exited validator ether flows to the `WithdrawalVault` and enters the buffer on a subsequent Accounting Oracle report.
## Proposed Solution
A governance-configurable portion of buffered ether is set aside as the redeems reserve, protected from CL deposits and withdrawal queue finalization. An authorized redeemer contract can redeem stETH and receive ETH from the reserve without affecting the share rate.
The reserve ratio is stored as basis points and snapshotted as an absolute ETH value during each Accounting Oracle report. Between reports the snapshot remains stable — only redemptions reduce it. The reserve is replenished primarily from unreserved buffer surplus. When the surplus is insufficient and WQ demand competes with the reserve, governance can configure a growth share (basis points) that determines the split of shared ether between the reserve and WQ finalization.
## Technical Implementation
The changes span two areas: on-chain — scoped to the `Lido` contract (buffer allocation, reserve snapshotting, redemption interface), and off-chain — the Validators Exit Bus Oracle daemon (exit demand calculation accounting for the reserve).
### Redeems Reserve Target Ratio
The redeems reserve target ratio determines the desired size of the redeems reserve. The ratio is stored as basis points of internal ether (`bufferedEther + clValidatorsBalance + clPendingBalance + depositedBalance`) at `REDEEMS_RESERVE_TARGET_RATIO_POSITION`. Only internal ether is used because external ether (virtual ETH backing stVault-minted stETH) is not held by the protocol and cannot back the reserve.
The ratio is set by governance via `setRedeemsReserveTargetRatio()`, gated by `BUFFER_RESERVE_MANAGER_ROLE`. The update behavior mirrors `setDepositsReserveTarget()`: if the new target is lower, the stored reserve is immediately reduced; if higher, the reserve grows only on the next oracle report.
```solidity
function setRedeemsReserveTargetRatio(uint256 _ratioBP) external {
_auth(BUFFER_RESERVE_MANAGER_ROLE);
require(_ratioBP <= TOTAL_BASIS_POINTS, "INVALID_RATIO");
REDEEMS_RESERVE_TARGET_RATIO_POSITION.setStorageUint256(_ratioBP);
uint256 newTarget = _getRedeemsReserveTarget();
uint256 currentReserve = REDEEMS_RESERVE_POSITION.getStorageUint256();
if (newTarget < currentReserve) {
_setRedeemsReserve(newTarget);
}
emit RedeemsReserveTargetRatioSet(_ratioBP);
}
function getRedeemsReserveTargetRatio() external view returns (uint256) {
return REDEEMS_RESERVE_TARGET_RATIO_POSITION.getStorageUint256();
}
/// @notice Returns the current redeems reserve target in absolute ETH,
/// computed from the ratio and current internal ether.
function getRedeemsReserveTarget() external view returns (uint256) {
return _getRedeemsReserveTarget();
}
function _getRedeemsReserveTarget() internal view returns (uint256) {
return _getInternalEther()
* REDEEMS_RESERVE_TARGET_RATIO_POSITION.getStorageUint256()
/ TOTAL_BASIS_POINTS;
}
```
### Buffer Allocation
The buffered ether is currently allocated across the deposits reserve, withdrawals reserve, and the unreserved remainder. The redeems reserve integrates as a new highest-priority allocation layer:
1. **Redeems reserve** ← new
2. **Deposits reserve**
3. **Withdrawals reserve**
4. **Unreserved** — remaining ether, available for additional CL deposits

```solidity
struct BufferedEtherAllocation {
...
uint256 redeemsReserve; // ← new field
}
function _getBufferedEtherAllocation()
internal view returns (BufferedEtherAllocation memory allocation)
{
uint256 remaining = _getBufferedEther();
allocation.total = remaining;
allocation.redeemsReserve = Math256.min(remaining, REDEEMS_RESERVE_POSITION.getStorageUint256()); // ← new
remaining -= allocation.redeemsReserve;
allocation.depositsReserve = Math256.min(remaining, DEPOSITS_RESERVE_POSITION.getStorageUint256());
remaining -= allocation.depositsReserve;
...
}
```
Since the redeems reserve is allocated first, it is protected from both CL deposits and withdrawal queue finalization. The `getDepositableEther()` calculation remains `depositsReserve + unreserved` — the redeems reserve is naturally excluded.
Unlike the deposits and withdrawals reserves, which grow as `bufferedEther` increases from new deposits, the redeems reserve is a snapshot updated only on oracle reports — incoming ETH does not increase it.
Similar to `getDepositsReserve()` and `getWithdrawalsReserve()`, a `getRedeemsReserve()` getter is introduced:
```solidity
function getRedeemsReserve() external view returns (uint256) {
return _getBufferedEtherAllocation().redeemsReserve;
}
```
### Reserve Replenishment
Computing the redeems reserve directly from the target ratio on every read would cause fluctuations, requiring handling of additional edge cases. Instead, the absolute ETH value is snapshotted at `REDEEMS_RESERVE_POSITION` and updated on each Accounting Oracle report.
In contrast to the buffer allocation, where the redeems reserve has the **highest allocation priority**, it has the **lowest replenishment priority** — it primarily grows from unreserved surplus after all other reserves are satisfied.
When unreserved surplus is insufficient and the reserve is below target, the remaining ether (withdrawals reserve + unreserved) becomes a shared allocation split between WQ finalization and reserve replenishment. Governance configures the split via `setRedeemsReserveGrowthShare()`, stored at `REDEEMS_RESERVE_GROWTH_SHARE_POSITION` as basis points. For example, 8000 BP means 80% of the shared allocation goes to reserve growth, 20% to WQ. Setting it to zero (default) disables forced growth — the reserve only grows from genuine surplus. See [Appendix A](#Appendix-A-Replenishment-Examples) for worked examples.
```solidity
function setRedeemsReserveGrowthShare(uint256 _shareBP) external {
_auth(BUFFER_RESERVE_MANAGER_ROLE);
require(_shareBP <= TOTAL_BASIS_POINTS, "INVALID_SHARE");
REDEEMS_RESERVE_GROWTH_SHARE_POSITION.setStorageUint256(_shareBP);
emit RedeemsReserveGrowthShareSet(_shareBP);
}
function getRedeemsReserveGrowthShare() external view returns (uint256) {
return REDEEMS_RESERVE_GROWTH_SHARE_POSITION.getStorageUint256();
}
```

On each oracle report, `_updateBufferedEtherAllocation()` refills the reserves:
1. **Deposits reserve** resets unconditionally to its configured target.
2. **Withdrawals reserve** is not stored — it is computed dynamically from `unfinalizedStETH()` in the allocation on every read.
3. **Redeems reserve** replenishes toward its target. If unreserved surplus covers the deficit, it is used directly. Otherwise, the shared allocation (withdrawals reserve + unreserved) is split by `growthShareBP`: the reserve gets `growthShareBP%`, WQ gets the rest.
```solidity
function _updateBufferedEtherAllocation() internal {
_resetDepositsReserve();
_growRedeemsReserve();
}
function _resetDepositsReserve() internal {
uint256 target = getDepositsReserveTarget();
if (DEPOSITS_RESERVE_POSITION.getStorageUint256() != target) {
_setDepositsReserve(target);
}
}
function _growRedeemsReserve() internal {
uint256 target = _getRedeemsReserveTarget();
BufferedEtherAllocation memory allocation = _getBufferedEtherAllocation();
uint256 growthShareBP = REDEEMS_RESERVE_GROWTH_SHARE_POSITION.getStorageUint256();
uint256 availableEther = allocation.withdrawalsReserve + allocation.unreserved;
uint256 minGrowth = availableEther * growthShareBP / TOTAL_BASIS_POINTS;
uint256 growth = Math256.max(allocation.unreserved, minGrowth);
uint256 newRedeemsReserve = Math256.min(allocation.redeemsReserve + growth, target);
if (newRedeemsReserve != allocation.redeemsReserve) {
_setRedeemsReserve(newRedeemsReserve);
}
}
function _setRedeemsReserve(uint256 _redeemsReserve) internal {
REDEEMS_RESERVE_POSITION.setStorageUint256(_redeemsReserve);
emit RedeemsReserveSet(_redeemsReserve);
}
```
Note that `_resetDepositsReserve()` must execute before `_growRedeemsReserve()` so that `_getBufferedEtherAllocation()` sees the fresh deposits reserve value.
When `minGrowth > unreserved`, the stored redeems reserve grows beyond what the unreserved ether covers. On the next allocation read, `_getBufferedEtherAllocation()` caps `redeemsReserve` by the actual buffered ether — this effectively shifts ether from lower-priority layers (withdrawals reserve, deposits reserve) into the redeems reserve. The impact on WQ is bounded: WQ always retains at least `(TOTAL_BASIS_POINTS - growthShareBP) / TOTAL_BASIS_POINTS` of the shared allocation (see [Appendix A](#Appendix-A-Replenishment-Examples) for worked examples).
### Reserve Usage Interface
Allocating a portion of the buffer as a reserve is only useful if there is an interface to use it. The mechanism described below allows an authorized redeemer contract to burn stETH and receive ETH from the reserve in a single transaction without affecting the stETH share rate (up to a ~1 wei rounding shift).
#### Redeemer Contract
The authorized redeemer address is stored at `STETH_REDEEMER_POSITION`. Only the redeemer can call `redeemStETH()`. The redeemer must implement the `IEtherReceiver` interface to receive ETH. The redeemer contract should also implement `PausableUntil` to allow the Gate Seal or Emergency Break Committee to freeze redemption without pausing the entire Lido contract:
```solidity
interface IEtherReceiver {
function receiveEther(uint256 _etherAmount) external payable;
}
```
#### Setting the Redeemer
The redeemer address is set by governance via `setStETHRedeemer()`, gated by `BUFFER_RESERVE_MANAGER_ROLE` — the same role that manages other reserve parameters. Setting the redeemer to `address(0)` effectively disables redemption: `redeemStETH()` will revert because `_auth(address(0))` always fails. This provides a kill switch without a separate pause mechanism on the Lido side.
The redeemer address defaults to `address(0)` (uninitialized storage). Redemption is unavailable until governance explicitly sets a redeemer via a DAO vote. Since `redeemStETH()` is synchronous and atomic, changing the redeemer mid-operation is not a concern — there is no in-flight state between calls.
```solidity
function setStETHRedeemer(address _redeemer) external {
_auth(BUFFER_RESERVE_MANAGER_ROLE);
STETH_REDEEMER_POSITION.setStorageAddress(_redeemer);
emit StETHRedeemerSet(_redeemer);
}
```
#### Redeeming stETH
The `redeemStETH()` method accepts stETH from the redeemer, burns it at the current share rate, and sends the equivalent ETH back — all in a single transaction. Both `bufferedEther` and `redeemsReserve` decrease by the actual ETH amount derived from the burned shares. The `totalSupply()` of stETH decreases, but the share rate is unaffected. The burn is direct (no deferred `Burner` contract), analogous to `burnExternalShares` in the VaultHub flow.
Shares are burned directly from `msg.sender` (the redeemer) via `_burnShares(msg.sender, sharesAmount)`. An intermediate `_transferShares(msg.sender, address(this), ...)` is not possible because `StETH._transferShares` reverts on transfers to `address(this)` (`StETH.sol:497`). The direct burn pattern follows `burnExternalShares` (`Lido.sol:862`), which also calls `_burnShares(msg.sender, ...)` without an intermediate transfer.
```solidity
function getStETHRedeemer() external view returns (address) {
return STETH_REDEEMER_POSITION.getStorageAddress();
}
function redeemStETH(uint256 _stETHAmount) external {
require(_stETHAmount != 0, "ZERO_AMOUNT");
_auth(STETH_REDEEMER_POSITION.getStorageAddress());
_whenNotStopped();
require(!_withdrawalQueue().isBunkerModeActive(), "BUNKER_MODE");
require(!_withdrawalQueue().isPaused(), "WQ_PAUSED");
uint256 redeemsReserve = _getBufferedEtherAllocation().redeemsReserve;
require(_stETHAmount <= redeemsReserve, "RESERVE_LIMIT_REACHED");
uint256 sharesAmount = getSharesByPooledEth(_stETHAmount);
uint256 etherAmount = getPooledEthByShares(sharesAmount);
_burnShares(msg.sender, sharesAmount);
_setBufferedEther(_getBufferedEther() - etherAmount);
_setRedeemsReserve(
REDEEMS_RESERVE_POSITION.getStorageUint256() - etherAmount
);
_emitSharesBurnt(msg.sender, etherAmount, etherAmount, sharesAmount);
IEtherReceiver(msg.sender).receiveEther.value(etherAmount)(etherAmount);
emit StETHRedeemed(msg.sender, _stETHAmount, sharesAmount, etherAmount);
}
```
> **Note on rounding:** The `getSharesByPooledEth()` conversion may round down by 1 wei, causing a ~1 wei positive shift in the share rate — consistent with rounding elsewhere in the protocol.
#### Staking Limit and Looping Safety
The VaultHub's `burnExternalShares()` increases the staking limit by the burned stETH amount. The same approach could be applied to `redeemStETH()`, but for the redeems reserve it may be excessive. Looping `redeemStETH() → submit()` is bounded within a single report period — the reserve only replenishes on the next oracle report. Governance can increase the staking limit to compensate if `redeemStETH()` usage consistently impacts it. Alternatively, the implementation may include a flag (e.g. `shouldRedeemsRestoreStakingLimit`) to allow the DAO to enable staking limit restoration on redeems if needed.
#### Retrieval Checks
Before each redemption, `redeemStETH()` in Lido checks the following conditions. If any is violated, the attempt will revert.
1. **Protocol stopped** — `Lido.isStopped()`. The protocol is fully halted.
2. **Bunker mode active** — `WithdrawalQueue.isBunkerModeActive()`. Share rate is uncertain; allowing redemption could operate at an incorrect rate.
3. **Withdrawal Queue paused** — `WithdrawalQueue.isPaused()`. The withdrawal system is stopped.
All three are external state checks — they block while the condition is active and unblock automatically when it resolves.
Emergency pause of redemption is handled by the redeemer contract itself via `PausableUntil` (see [Redeemer Contract](#Redeemer-Contract)). This keeps the Gate Seal interface compatible — it pauses the redeemer contract as a whole, without requiring a per-feature pause within Lido.
### On-chain Changes Summary
| Method | Change |
|---|---|
| `_getBufferedEtherAllocation()` | New `redeemsReserve` field, allocated as highest-priority layer |
| `_updateBufferedEtherAllocation()` | Resets deposits reserve and replenishes redeems reserve toward target |
| `getDepositableEther()` | Unchanged — returns `depositsReserve + unreserved`, redeems reserve naturally excluded |
| `withdrawDepositableEther()` | Unchanged — deposits reserve consumption logic unaffected |
| `collectRewardsAndProcessWithdrawals()` | Unchanged — already calls `_updateBufferedEtherAllocation()` |
| `_submit()`, `rebalanceExternalEtherToInternal()` | Unchanged |
New methods: `setRedeemsReserveTargetRatio()`, `getRedeemsReserveTargetRatio()`, `getRedeemsReserveTarget()`, `setRedeemsReserveGrowthShare()`, `getRedeemsReserveGrowthShare()`, `getRedeemsReserve()`, `setStETHRedeemer()`, `getStETHRedeemer()`, `redeemStETH()`.
### Off-chain Components
**Accounting Oracle daemon** — no changes needed. The daemon uses `getWithdrawalsReserve()` as the finalization budget, which already accounts for both the redeems and deposits reserves.
**Validators Exit Bus Oracle daemon** computes exit demand — how many validators need to be ejected so the buffer can cover all claims. With the redeems reserve, the target buffer must cover three components: the redeems reserve target, the deposits reserve, and unfinalized withdrawals:
```
exitDemand = max(0,
getRedeemsReserveTarget()
+ getDepositsReserve()
+ unfinalizedStETH()
- getBufferedEther()
)
```
The formula uses a snapshot of the current state. If more precise buffer demand coverage is needed, a prediction algorithm can be implemented on the VEBO side as a separate task.
**Other off-chain components** — monitoring systems, bots, and alerts connected to withdrawals or deposits logic may require updates. The full list of affected services will be compiled during implementation. Known external integrations relying on buffer assumptions (e.g. [Mellow LRT StakingModule](https://github.com/mellow-finance/mellow-lrt/blob/c37fad554231b6ce63f7416e408dba165222a3af/src/modules/obol/StakingModule.sol#L69), which assumes `depositable = buffered - unfinalized`) will produce incorrect results with the redeems reserve and should migrate to `getDepositableEther()`.
### Edge Case: Last Withdrawers
The redeems and deposits reserves are soft allocations. The Accounting Oracle daemon normally uses the withdrawals reserve as the finalization budget, which is reduced by both reserves. When nearly all stETH is being withdrawn and the buffer must cover the remaining obligations, the default budget may be insufficient for full finalization.
The redeems reserve is self-correcting: it is a ratio of internal ether, which shrinks as TVL decreases. The deposits reserve is an absolute value and does not scale down.
If TVL drops significantly and the protocol enters a shutting-down state, the DAO should set both reserves to zero. However, during a Dual Governance rage quit governance is frozen and cannot adjust the reserves.
**Off-chain fallback.** The Accounting Oracle daemon can override the default budget by using the full buffered ether for WQ finalization, bypassing the soft reservation. For this to work, deposits to the Beacon Chain must also be blocked — either staking is paused or there are no depositable validators — otherwise the buffer could be consumed by deposits before finalization.
## Protocol Impact
The oracle daemon computes the report from protocol state at a reference slot, but on-chain the report is applied to the state at execution time. Redemptions between the two reduce the on-chain base, creating a mismatch that affects two sanity mechanisms.
### Rebase Smoothing
The maximum rewards the protocol can distribute in a single report is `maxPositiveTokenRebase × internalEther / LIMITER_PRECISION_BASE`. Redemptions shrink `internalEther`, so the allowed increase shrinks proportionally — a 1% redemption reduces it by 1%. Rewards exceeding the reduced limit are deferred to the next report, never lost.
**Example.** 9M ETH protocol, 0.5% max rebase, 1% reserve fully drained (90k ETH redeemed). The allowed increase shrinks from 45,000 to 44,550 ETH. With 50k ETH of pending rewards, 44,550 are distributed instead of 45,000 — an extra 450 ETH deferred. With only 9k ETH of rewards, both scenarios distribute everything; the redemption has no effect.
### Simulated Share Rate Deviation
The oracle daemon pre-calculates the expected post-report share rate at the reference slot and the protocol rejects reports where the actual rate diverges too much. Redemption itself doesn't change the share rate (ether and shares decrease proportionally), but it shrinks the base to which rewards are applied, so the same rewards produce a slightly higher rate than the daemon predicted.
When rewards exceed the smoothing limit, smoothing caps the rate by percentage, making it independent of the base — deviation is effectively zero. When rewards fit within the limit, the full amount is applied to a smaller base: `deviation ≈ etherRedeemed / internalEther × rebasePercent`.
**Example.** 9M ETH protocol, 90k ETH redeemed (1%), 9k ETH rewards (0.1% rebase): actual rate ≈ 1.001010 vs simulated 1.001000 — about 0.1 BP deviation, well within the 250 BP limit. Reaching that limit would require simultaneously draining a very large reserve with a very large rebase, both bounded by their respective protocol caps.
## Alternatives Considered
**Asynchronous retrieve-return interface.** An alternative approach where the redeemer retrieves ETH from the reserve and returns it later — either as ETH or by burning stETH. This is primarily useful for yield-on-buffer strategies where the redeemer deploys ETH externally and returns it after generating yield. Since this use case is currently out of scope, and the async approach significantly complicates the implementation (outstanding debt tracking, effective reserve edge case handling, DAO-callable force-return on the redeemer), it was deferred in favor of the simpler synchronous redeem.
**ETH-only interface (no stETH burn).** Limits the redeemer to retrieving and returning ETH only. This eliminates the cycling concern — the cap `redeemed ≤ reserve` becomes a hard ceiling on total exposure. Simpler interface, no share-burning logic in `Lido`. Works well for yield strategies where the redeemer deploys and returns ETH without stETH involvement. However, it breaks the instant withdrawal cycle: the redeemer receives stETH from users but cannot convert it back into reserve capacity without selling on the market (slippage) or going through the Withdrawal Queue (delay). The burn method closes this loop atomically at the exact share rate.
**Deferred stETH burning.** Instead of burning stETH synchronously, the redeemer accumulates stETH and defers the burn to the next oracle report. This moves redeem logic to a dedicated `RedeemsBuffer` contract and avoids modifying Lido's core burn path, but introduces complexity in the oracle report pipeline. See [Appendix B](#Appendix-B-Deferred-stETH-Burn-Push-Based-Alternative) for the full design.
**Dynamic reserve (no snapshot).** The reserve is computed on every call as `redeemsReserveTargetRatio * internalEther / 10000` without snapshotting on oracle reports. Simpler storage — no snapshot variable needed. However, the reserve fluctuates as internal ether changes, making the available redemption capacity unpredictable for the redeemer and requiring handling of additional edge cases. Snapshotting on oracle reports provides a stable reserve for the inter-report period, with only redemptions reducing it.
**Fixed ETH target instead of ratio.** Governance sets the reserve as an absolute ETH amount rather than basis points of internal ether. Simpler calculation — no ratio, no dependency on protocol size. However, the reserve does not scale with protocol growth or contraction, requiring periodic governance updates to remain proportional. A ratio-based approach with periodic snapshotting adapts automatically while still providing a stable value between reports.
**Constant-growth replenishment.** The reserve grows by a fixed ETH amount per report (e.g. `redeemsReserveMinGrowth`). Simple to reason about — recovery is predictable regardless of buffer state. However, a fixed amount does not scale with protocol size, requiring periodic governance updates as TVL changes. It also lacks a natural upper bound on WQ impact — without careful configuration, growth can exceed available ether and starve WQ finalization indefinitely.
**Separate reserve contract.** Reserve ETH is physically moved to a dedicated contract instead of being soft-reserved within the buffer. Stronger isolation but requires modifying `_getInternalEther()` and bidirectional ETH reallocation on each report. The [deferred stETH burn](#Appendix-B-Deferred-stETH-Burn-Push-Based-Alternative) approach achieves physical isolation without modifying `_getInternalEther()` by treating buffer ETH as part of `bufferedEther`.
## Appendix A: Replenishment Examples
The examples below illustrate allocation and replenishment under different buffer/WQ/reserve conditions. Each example shows the allocation read followed by the replenishment step during an oracle report.
### A.1. All reserves fit, no replenishment needed
| buffered | target | redeems | deposits | unfinalized | growthShareBP |
|---|---|---|---|---|---|
| 1000 | 200 | 200 | 300 | 400 | 8000 |
```
— Allocation —
redeems = min(1000, 200) = 200 ← full reserve
remaining = 1000 - 200 = 800
deposits = min(800, 300) = 300 ← full deposits reserve
remaining = 800 - 300 = 500
withdrawals = min(500, 400) = 400 ← WQ fully covered
unreserved = 500 - 400 = 100 ← excess available
— Replenishment —
availableEther = 400 + 100 = 500
minGrowth = 500 * 8000 / 10000 = 400
growth = max(100, 400) = 400
newRedeems = min(200 + 400, 200) = 200 ← unchanged (capped at target)
Result: reserve stays at 200. Unreserved 100 available for deposits.
```
### A.2. WQ blocks surplus, growth share splits shared allocation
| buffered | target | redeems | deposits | unfinalized | growthShareBP |
|---|---|---|---|---|---|
| 600 | 200 | 50 (150 redeemed) | 300 | 500 | 8000 (80%) |
```
— Allocation —
redeems = min(600, 50) = 50 ← unspent reserve
remaining = 600 - 50 = 550
deposits = min(550, 300) = 300 ← full deposits reserve
remaining = 550 - 300 = 250
withdrawals = min(250, 500) = 250 ← WQ partially covered
unreserved = 250 - 250 = 0 ← nothing left
— Replenishment —
availableEther = 250 + 0 = 250
minGrowth = 250 * 8000 / 10000 = 200
growth = max(0, 200) = 200
newRedeems = min(50 + 200, 200) = 200 ← fully restored
Result: reserve restored to 200. WQ effectively gets 50 (250 - 200).
```
### A.3. Buffer scarcity — no growth possible
| buffered | target | redeems | deposits | unfinalized | growthShareBP |
|---|---|---|---|---|---|
| 400 | 200 | 200 | 300 | 500 | 8000 |
```
— Allocation —
redeems = min(400, 200) = 200 ← full reserve
remaining = 400 - 200 = 200
deposits = min(200, 300) = 200 ← deposits partially filled
remaining = 200 - 200 = 0
withdrawals = min(0, 500) = 0 ← WQ gets nothing
unreserved = 0 ← nothing left
— Replenishment —
availableEther = 0 + 0 = 0
minGrowth = 0 * 8000 / 10000 = 0
growth = max(0, 0) = 0
newRedeems = min(200 + 0, 200) = 200 ← unchanged
Result: reserve preserved at 200. WQ gets 0; deficit triggers validator ejections.
```
### A.4. growthShareBP = 0 — reserve only grows from surplus
| buffered | target | redeems | deposits | unfinalized | growthShareBP |
|---|---|---|---|---|---|
| 600 | 200 | 50 (150 redeemed) | 300 | 500 | 0 |
```
— Allocation —
redeems = min(600, 50) = 50
remaining = 600 - 50 = 550
deposits = min(550, 300) = 300
remaining = 550 - 300 = 250
withdrawals = min(250, 500) = 250
unreserved = 0
— Replenishment —
availableEther = 250 + 0 = 250
minGrowth = 250 * 0 / 10000 = 0
growth = max(0, 0) = 0 ← no forced growth
newRedeems = min(50 + 0, 200) = 50 ← unchanged
Result: reserve stays at 50. WQ gets full 250. Reserve only recovers
when surplus appears (WQ demand drops or buffer grows).
```
### A.5. Reserve exceeds target (negative rebase reduced target)
| buffered | target | redeems | deposits | unfinalized | growthShareBP |
|---|---|---|---|---|---|
| 600 | 150 (was 200) | 200 (stored) | 300 | 400 | 8000 |
```
— Allocation —
redeems = min(600, 200) = 200 ← stored exceeds new target
remaining = 600 - 200 = 400
deposits = min(400, 300) = 300
remaining = 400 - 300 = 100
withdrawals = min(100, 400) = 100
unreserved = 0
— Replenishment —
availableEther = 100 + 0 = 100
minGrowth = 100 * 8000 / 10000 = 80
growth = max(0, 80) = 80
newRedeems = min(200 + 80, 150) = 150 ← capped at reduced target
Result: reserve automatically reduced from 200 to 150.
Freed 50 ETH becomes available to lower-priority layers.
```
## Appendix B: Deferred stETH Burn (Push-Based Alternative)
> This section describes an alternative implementation where redeems reserve ETH is held in a dedicated `RedeemsBuffer` contract instead of being soft-reserved within the Lido buffer. Buffer allocation, target ratio, and replenishment logic remain unchanged — only the redeem mechanism and the burn pipeline differ.
In the pull-based model, redemptions are handled by Lido directly: Lido burns stETH shares synchronously via `_burnShares()`, and sends ETH back to the `IRedeemer` contract. The share burn is atomic — `totalShares` and `totalPooledEther` both decrease in the same transaction, so the share rate is trivially preserved.
The push-based approach, in contrast to pull-based, doesn't update `totalPooledEther` and `totalShares` at the time of redemption. Instead, the redeemed stETH shares are held by the `RedeemsBuffer` contract, and `bufferedEther` is left unchanged — both the share burn and the ether decrease are deferred to the next Accounting Oracle report, where they are applied together to preserve the share rate.
### RedeemsBuffer Contract
Since the reserve ETH is physically held on a separate contract, the redeem logic moves there as well — Lido no longer needs `redeemStETH()` or the `IRedeemer` callback interface.
Access to `redeem()` is gated by `REDEEMER_ROLE`. The buffer itself doesn't enforce fees or user-level limits — it provides the atomic redeem-and-burn primitive that higher-level contracts build on.
The contract implements `PausableUntil` for Gate Seal compatibility. Redemptions are also blocked when the protocol is stopped, bunker mode is active, or the Withdrawal Queue is paused.
The buffer tracks redeemed ETH and the reserve balance via internal counters. Shares are forwarded to the Burner's isolated redeem track immediately during each `redeem()` call via `requestBurnSharesForRedeem()`. On each oracle report, Accounting reads pending redeem shares from the Burner via `getRedeemSharesRequestedToBurn()` and redeemed ether from the buffer via `getRedeemedEther()`, then calls `withdrawUnredeemed()` which returns unredeemed ETH to Lido and resets both counters.
The contract resolves Lido, Burner, WithdrawalQueue, and Accounting via `LidoLocator` at construction time and stores them as immutables. Deployed behind `OssifiableProxy` — the contract holds ~1% of protocol TVL, so in-place logic upgrades are necessary to avoid ETH migration risk.
```solidity
contract RedeemsBuffer is PausableUntil, AccessControlEnumerable, Versioned {
ILidoLocator public immutable LOCATOR;
ILido public immutable LIDO;
IBurner public immutable BURNER;
IWithdrawalQueue public immutable WITHDRAWAL_QUEUE;
address public immutable ACCOUNTING;
bytes32 public constant REDEEMER_ROLE = keccak256("RedeemsBuffer.RedeemerRole");
uint256 private _reserveBalance; // ETH received from Lido (protocol reserve)
uint256 private _redeemedEther;
constructor(address _locator) {
LOCATOR = ILidoLocator(_locator);
LIDO = ILido(LOCATOR.lido());
BURNER = IBurner(LOCATOR.burner());
WITHDRAWAL_QUEUE = IWithdrawalQueue(LOCATOR.withdrawalQueue());
ACCOUNTING = LOCATOR.accounting();
}
/// @notice Initializes the contract. Called once after proxy deployment.
function initialize(address _admin) external {
_initializeContractVersionTo(1);
LIDO.approve(address(BURNER), type(uint256).max);
_grantRole(DEFAULT_ADMIN_ROLE, _admin);
}
/// @notice Redeem stETH for ETH from the reserve.
/// Shares are forwarded to Burner's isolated redeem track immediately.
/// @param _stETHAmount Amount of stETH to redeem
/// @param _ethRecipient Address to receive ETH
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);
}
/// @notice ETH sent to redeemers since the last report.
function getRedeemedEther() external view returns (uint256) {
return _redeemedEther;
}
/// @notice Accept ETH from Lido and update tracked reserve balance.
function fundReserve() external payable {
if (msg.sender != address(LIDO)) revert NotLido();
_reserveBalance += msg.value;
emit ReserveFunded(msg.value);
}
/// @notice Returns unredeemed ETH to Lido and resets counters.
/// Called by Lido during collectRewardsAndProcessWithdrawals.
function withdrawUnredeemed() external {
if (msg.sender != address(LIDO)) revert NotLido();
uint256 amount = _reserveBalance - _redeemedEther;
_reserveBalance = 0;
_redeemedEther = 0;
if (amount > 0) {
LIDO.receiveFromRedeemsBuffer{value: amount}();
}
}
// PausableUntil: pauseFor(), pauseUntil(), resume()
/// @notice Recover accidentally sent ERC20 tokens (except stETH).
function recoverERC20(address _token, uint256 _amount) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (_token == address(LIDO)) revert StETHRecoveryNotAllowed();
IERC20(_token).transfer(msg.sender, _amount);
}
/// @notice Recover accidentally sent stETH shares.
/// Shares are forwarded to Burner atomically during redeem().
/// This recovers shares sent to the buffer outside of redeem() (e.g., accidental transfers).
function recoverStETHShares() external onlyRole(DEFAULT_ADMIN_ROLE) {
uint256 shares = LIDO.sharesOf(address(this));
if (shares > 0) {
LIDO.transferShares(msg.sender, shares);
}
}
/// @notice Reject direct ETH transfers. Use fundReserve() instead.
receive() external payable {
revert DirectETHTransferNotAllowed();
}
}
```
### Oracle Report — Resolving the Accounting Gap
During each Accounting Oracle report, the protocol updates its TVL: CL balance changes are applied, execution layer rewards are collected, and stETH shares corresponding to finalized withdrawals are burned. Share burning is handled by the `Burner` contract, which operates in two phases. Between reports, callers invoke `requestBurnShares()` to queue shares for burning — the Burner holds them without affecting `totalShares`. During the report, Accounting calls `commitSharesToBurn()`, and the Burner executes the actual burn via `Lido.burnShares()`. The number of shares burned per report is capped by `smoothenTokenRebase()` in the `OracleReportSanityChecker`, which applies a token rebase limiter to prevent excessive rate changes.
#### Why Redeem Shares Need an Isolated Burn Track
Redeem shares are sent to the Burner immediately during each `redeem()` call — but via a dedicated `requestBurnSharesForRedeem()`, not the standard `requestBurnShares()`. The reason is the rebase limiter interaction.
The `sharesRequestedToBurn` value used by `smoothenTokenRebase()` is snapshotted at `refSlot` — a point in time before the report is executed. If redeem shares were added to the standard burn track via `requestBurnShares()`, redemptions after `refSlot` would increase the Burner's pending queue without being reflected in `sharesRequestedToBurn`. The rebase limiter would not account for them, and `commitSharesToBurn()` would only burn the number of shares determined by `smoothenTokenRebase()` — leaving the post-refSlot redemption shares unburned.
Meanwhile, `bufferedEther` would be decreased by the full redeemed amount (including post-refSlot redemptions) during reconciliation. The result is a negative rebase: `totalPooledEther` decreases without a matching decrease in `totalShares`. For a buffer of 1% TVL fully redeemed after refSlot, this would produce a ~1% negative rebase.
To address this, the Burner is extended with an **isolated redeem track** — a dedicated `requestBurnSharesForRedeem()` / `commitRedeemSharesToBurn()` pair, separate from the existing cover/non-cover tracks. During each `redeem()` call, shares are forwarded to this isolated track immediately. On the next oracle report, Accounting calls `commitRedeemSharesToBurn()` (no budget argument — burns everything unconditionally) before the limiter-constrained `commitSharesToBurn()`. This guarantees all redemption shares are burned on the same report as the ether reconciliation, regardless of when the redemptions occurred relative to `refSlot`.
#### Burner — Isolated Redeem Track
Authorization: `requestBurnSharesForRedeem` is gated by the existing `REQUEST_BURN_SHARES_ROLE`. The RedeemsBuffer is granted this role during deployment — no LidoLocator changes needed.
```solidity
/// @notice Locks shares from RedeemsBuffer on the isolated redeem track.
/// Called during each RedeemsBuffer.redeem().
function requestBurnSharesForRedeem(
address _from,
uint256 _sharesAmountToBurn
) external onlyRole(REQUEST_BURN_SHARES_ROLE) {
if (_sharesAmountToBurn == 0) revert ZeroBurnAmount();
uint256 stETHAmount = LIDO.transferSharesFrom(_from, address(this), _sharesAmountToBurn);
_storage().redeemSharesBurnRequested += _sharesAmountToBurn;
emit RedeemStETHBurnRequested(msg.sender, stETHAmount, _sharesAmountToBurn);
}
/// @notice Burns all pending redeem shares unconditionally.
/// Called by Accounting during oracle report, before commitSharesToBurn.
/// No budget argument — all pending redeem shares are burned.
function commitRedeemSharesToBurn() external {
if (msg.sender != LOCATOR.accounting()) revert AppAuthFailed();
Storage storage $ = _storage();
uint256 redeemSharesBurn = $.redeemSharesBurnRequested;
if (redeemSharesBurn == 0) return;
$.redeemSharesBurnRequested = 0;
$.totalRedeemSharesBurnt += redeemSharesBurn;
uint256 stETHAmount = LIDO.getPooledEthByShares(redeemSharesBurn);
LIDO.burnShares(redeemSharesBurn);
emit RedeemStETHBurnt(stETHAmount, redeemSharesBurn);
}
function getRedeemSharesRequestedToBurn() external view returns (uint256) {
return _storage().redeemSharesBurnRequested;
}
function getRedeemSharesBurnt() external view returns (uint256) {
return _storage().totalRedeemSharesBurnt;
}
function getSharesRequestedToBurn()
external view returns (uint256 coverShares, uint256 nonCoverShares, uint256 redeemShares)
{
Storage storage $ = _storage();
coverShares = $.coverSharesBurnRequested;
nonCoverShares = $.nonCoverSharesBurnRequested;
redeemShares = $.redeemSharesBurnRequested;
}
```
`_getExcessStETHShares` must include redeem shares — otherwise `recoverExcessStETH()` would treat pending redeem shares as "excess" and transfer them to treasury:
```solidity
function _getExcessStETHShares() internal view returns (uint256) {
Storage storage $ = _storage();
uint256 totalBurnRequested = $.coverSharesBurnRequested
+ $.nonCoverSharesBurnRequested
+ $.redeemSharesBurnRequested; // ← include redeem track
uint256 sharesOnBalance = LIDO.sharesOf(address(this));
if (sharesOnBalance <= totalBurnRequested) return 0;
return sharesOnBalance - totalBurnRequested;
}
```
#### Report Flow
The report flow is split between Accounting and Lido. Accounting handles the share side: reading counters, adjusting the limiter base, committing redeem shares (already on Burner) via the isolated track, and committing cover/non-cover shares within the limiter budget. The ETH round-trip is handled inside `collectRewardsAndProcessWithdrawals`, which gains a `_redeemedEther` parameter. Lido performs the withdraw-reconcile-standard-push sequence internally, guaranteeing correct ordering.
**Accounting — `_simulateOracleReport` (additions):**
```solidity
function _simulateOracleReport(
Contracts memory _contracts,
PreReportState memory _pre,
ReportValues calldata _report
) internal view returns (CalculatedValues memory update) {
update.preTotalShares = _pre.totalShares;
update.preTotalPooledEther = _pre.totalPooledEther;
// Read redemption counters: shares from Burner redeem track, ether from buffer
uint256 redeemedShares;
uint256 redeemedEther;
address buffer = LIDO.getRedeemsBuffer();
if (buffer != address(0)) {
redeemedShares = IBurner(LIDO_LOCATOR.burner()).getRedeemSharesRequestedToBurn();
redeemedEther = IRedeemsBuffer(buffer).getRedeemedEther();
}
(update.etherToFinalizeWQ, update.sharesToFinalizeWQ) = _calculateWithdrawals(
_contracts, _report
);
// principalClBalance calculated as usual (existing code, shown for context)
update.principalClBalance = _pre.clValidatorsBalance + _pre.clPendingBalance + _pre.depositedBalance;
// smoothenTokenRebase — subtract redeemedEther from the base so the limiter
// sees the actual protocol ether (without ETH already sent to redeemers)
(
update.withdrawalsVaultTransfer,
update.elRewardsVaultTransfer,
update.sharesToBurnForWithdrawals,
update.smoothedSharesToBurn
) = _contracts.oracleReportSanityChecker.smoothenTokenRebase(
_pre.totalPooledEther - _pre.externalEther - redeemedEther,
_pre.totalShares - _pre.externalShares,
update.principalClBalance,
_report.clValidatorsBalance + _report.clPendingBalance,
_report.withdrawalVaultBalance,
_report.elRewardsVaultBalance,
_report.sharesRequestedToBurn,
update.etherToFinalizeWQ,
update.sharesToFinalizeWQ
);
// redeemSharesToBurn is tracked separately from smoothedSharesToBurn because
// redeem shares are burned unconditionally via commitRedeemSharesToBurn(),
// bypassing the rebase limiter. smoothedSharesToBurn is the limiter-constrained
// budget for cover/non-cover shares via commitSharesToBurn().
// Both are subtracted from postInternalSharesBeforeFees.
update.redeemSharesToBurn = redeemedShares;
// postInternalEther accounts for redeemed ETH
update.postInternalEther =
_pre.totalPooledEther - _pre.externalEther
+ _report.clValidatorsBalance + _report.clPendingBalance + update.withdrawalsVaultTransfer - update.principalClBalance
+ update.elRewardsVaultTransfer
- update.etherToFinalizeWQ
- redeemedEther;
// smoothedSharesToBurn = limiter-constrained cover/non-cover (from smoothenTokenRebase)
// redeemSharesToBurn = unconditional redeem shares (burned via commitRedeemSharesToBurn)
uint256 postInternalSharesBeforeFees =
_pre.totalShares - _pre.externalShares
- update.smoothedSharesToBurn
- update.redeemSharesToBurn;
// ... existing fee calculation (unchanged) — computes update.sharesToMintAsFees
// from postInternalEther and postInternalSharesBeforeFees ...
update.postInternalShares = postInternalSharesBeforeFees + update.sharesToMintAsFees + _pre.badDebtToInternalize;
uint256 postExternalShares = _pre.externalShares - _pre.badDebtToInternalize;
update.postTotalShares = update.postInternalShares + postExternalShares;
update.postTotalPooledEther = update.postInternalEther
+ postExternalShares * update.postInternalEther / update.postInternalShares;
}
```
**Accounting — `_applyOracleReportContext` (additions):**
```solidity
function _applyOracleReportContext(
Contracts memory _contracts,
ReportValues calldata _report,
PreReportState memory _pre,
CalculatedValues memory _update
) internal {
// ... sanity checks ...
// ... WQ sharesToFinalizeWQ → burner.requestBurnShares ...
// Burn all redeem shares (already on Burner — sent during each redeem() call)
if (_update.redeemSharesToBurn > 0) {
_contracts.burner.commitRedeemSharesToBurn();
}
// Burn limiter-constrained cover/non-cover shares
if (_update.smoothedSharesToBurn > 0) {
_contracts.burner.commitSharesToBurn(_update.smoothedSharesToBurn);
}
// collectRewardsAndProcessWithdrawals handles ETH round-trip internally:
// withdraw unredeemed → reconcile → standard flow → grow reserve → push
LIDO.collectRewardsAndProcessWithdrawals(
// ... existing parameters ...,
_update.redeemedEther // ← new parameter for buffer round-trip
);
// ... mint fees, distribute, emit rebase ...
}
```
**Lido side — `collectRewardsAndProcessWithdrawals`:**
The method gains one parameter (`_redeemedEther`) and handles the full ETH round-trip internally, guaranteeing correct ordering:
```solidity
function collectRewardsAndProcessWithdrawals(
// ... existing parameters ...,
uint256 _redeemedEther
) external {
_auth(_accounting());
// --- 1. Buffer round-trip: withdraw unredeemed ETH, reconcile bufferedEther ---
address buffer = REDEEMS_BUFFER_POSITION.getStorageAddress();
if (buffer != address(0) && _redeemedEther > 0) {
IRedeemsBuffer(buffer).withdrawUnredeemed();
_setBufferedEther(_getBufferedEther().sub(_redeemedEther));
} else if (buffer != address(0)) {
IRedeemsBuffer(buffer).withdrawUnredeemed();
}
// After this: bufferedEther reflects actual Lido.balance
// --- 2. Standard flow (existing) ---
// ... collect from WithdrawalVault, ELRewardsVault ...
// ... finalize WQ ...
// ... _updateBufferedEtherAllocation() → _growRedeemsReserve() ...
// --- 3. Push new reserve to buffer ---
if (buffer != address(0)) {
uint256 reserve = _getBufferedEtherAllocation().redeemsReserve;
if (reserve > 0) {
IRedeemsBuffer(buffer).fundReserve.value(reserve)();
}
}
}
```
**Lido — supporting methods:**
```solidity
/// @notice Receives ETH back from RedeemsBuffer (unredeemed return on report).
/// bufferedEther is NOT modified — ETH was already counted in bufferedEther.
function receiveFromRedeemsBuffer() external payable {
_auth(REDEEMS_BUFFER_POSITION.getStorageAddress());
}
/// @dev Reserve growth logic — called from _updateBufferedEtherAllocation()
function _growRedeemsReserve() internal {
uint256 target = _getRedeemsReserveTarget();
BufferedEtherAllocation memory a = _getBufferedEtherAllocation();
uint256 growthShareBP = REDEEMS_RESERVE_GROWTH_SHARE_POSITION.getStorageUint256();
uint256 availableEther = a.withdrawalsReserve.add(a.unreserved);
uint256 minGrowth = availableEther.mul(growthShareBP) / TOTAL_BASIS_POINTS;
uint256 growth = Math256.max(a.unreserved, minGrowth);
uint256 newRedeemsReserve = Math256.min(a.redeemsReserve.add(growth), target);
if (newRedeemsReserve != a.redeemsReserve) {
_setRedeemsReserve(newRedeemsReserve);
}
}
```
The ETH round-trip is handled inside `collectRewardsAndProcessWithdrawals` in three phases:
1. **Withdraw + reconcile** — `buffer.withdrawUnredeemed()` sends unredeemed ETH back to Lido, then `bufferedEther -= redeemedEther`. After this: `bufferedEther` reflects actual `Lido.balance`.
2. **Standard flow** — collect rewards, finalize WQ, `_updateBufferedEtherAllocation()` calls `_growRedeemsReserve()` to set the new `REDEEMS_RESERVE_POSITION`.
3. **Push** — send the new reserve to the buffer via `fundReserve()`. `bufferedEther` is NOT decremented — ETH is soft-reserved.
```
AccountingOracle.submitReportData()
└─► Accounting.handleOracleReport()
│
├─ 1. redeemedShares = burner.getRedeemSharesRequestedToBurn()
│ redeemedEther = buffer.getRedeemedEther()
│
├─ 2. smoothenTokenRebase(preTPE - externalEther - redeemedEther, ...)
│ └─ Returns smoothedSharesToBurn (cover/non-cover, limiter-constrained)
│
├─ 3. update.redeemSharesToBurn = redeemedShares — outside limiter (separate field)
│
├─ 4. Sanity checks, WQ finalization (requestBurnShares for WQ shares),
│ processClStateUpdate, internalizeExternalBadDebt — existing steps
│
├─ 5a. Burner.commitRedeemSharesToBurn() — burn ALL redeem shares (if any)
├─ 5b. Burner.commitSharesToBurn(smoothedSharesToBurn) — cover/non-cover (limiter budget)
│
├─ 6. LIDO.collectRewardsAndProcessWithdrawals(..., redeemedEther)
│ ├─ 6a. buffer.withdrawUnredeemed() — return unredeemed ETH to Lido
│ ├─ 6b. bufferedEther -= redeemedEther — reconcile
│ ├─ 6c. Standard flow (collect rewards, finalize WQ, update allocation)
│ │ └─ _growRedeemsReserve() — set new REDEEMS_RESERVE_POSITION
│ └─ 6d. buffer.fundReserve(reserve) — push new reserve to buffer
│
└─ 7. Mint fees, distribute, emit rebase
```
### Changes Summary (Push vs Pull)
**New contract:** `RedeemsBuffer` (PausableUntil, AccessControlEnumerable, Versioned), deployed behind `OssifiableProxy`
**Lido — new methods:**
| Method | Access | Description |
|---|---|---|
| `setRedeemsBuffer(address)` | `BUFFER_RESERVE_MANAGER_ROLE` | Set buffer address |
| `getRedeemsBuffer()` | view | Current buffer address |
| `getRedeemsReserve()` | view | Redeems reserve snapshot (from allocation) |
| `receiveFromRedeemsBuffer()` | Buffer only, payable | Accept returned ETH. `bufferedEther` NOT modified |
**Lido — removed (vs pull-based):**
| Removed | Reason |
|---|---|
| `redeemStETH()` | Moved to buffer |
| `IRedeemer` interface | Buffer pushes ETH directly |
**Lido — modified (push-specific only, shared changes with pull omitted):**
| Method | Change |
|---|---|
| `collectRewardsAndProcessWithdrawals()` | + `_redeemedEther` parameter. Handles full ETH round-trip internally: withdraw → reconcile → standard flow → grow reserve → push |
**OracleReportSanityChecker:** No changes. `smoothenTokenRebase()` receives adjusted `preTotalPooledEther` (minus `redeemedEther`) but its signature and logic are unchanged.
**Accounting:**
| Change |
|---|
| `CalculatedValues` struct: + `redeemSharesToBurn` field |
| Reads `burner.getRedeemSharesRequestedToBurn()` and `buffer.getRedeemedEther()` in `_simulateOracleReport` |
| Subtracts `redeemedEther` from `preTotalPooledEther` before `smoothenTokenRebase` |
| Tracks `redeemedShares` in `update.redeemSharesToBurn` (separate from `smoothedSharesToBurn`) |
| Subtracts both `smoothedSharesToBurn` and `redeemSharesToBurn` from `postInternalSharesBeforeFees` |
| Calls `burner.commitRedeemSharesToBurn()` if `redeemSharesToBurn > 0` |
| Calls `burner.commitSharesToBurn(smoothedSharesToBurn)` — limiter-constrained cover/non-cover |
| Passes `redeemedEther` to `collectRewardsAndProcessWithdrawals()` — Lido handles round-trip internally |
| Subtracts `redeemedEther` from `postInternalEther` |
**Burner — modified:**
| Change |
|---|
| Storage: + `redeemSharesBurnRequested`, `totalRedeemSharesBurnt` |
| `requestBurnSharesForRedeem(address, uint256)` — isolated redeem track request |
| `commitRedeemSharesToBurn()` — burns all pending redeem shares, no budget arg |
| `getRedeemSharesRequestedToBurn()` — pending redeem shares |
| `getRedeemSharesBurnt()` — total historical |
| Events: `RedeemStETHBurnRequested`, `RedeemStETHBurnt` |
| `_getExcessStETHShares()` — includes `redeemSharesBurnRequested` |
| `getSharesRequestedToBurn()` — returns (cover, nonCover, redeem) — added third return value |
### Comparison: Pull vs Push
| Aspect | Pull (in-buffer) | Push (dedicated buffer) |
|---|---|---|
| Where reserve ETH lives | Lido contract (soft-reserved) | `RedeemsBuffer` (physically separate) |
| Share burn timing | Synchronous (`_burnShares`) | Shares sent to Burner redeem track during `redeem()`, burned on next report |
| Burner changes | None | Isolated redeem track: `requestBurnSharesForRedeem`, `commitRedeemSharesToBurn` |
| Reentrancy surface | IRedeemer callback into Lido | Buffer is isolated; `receiveFromRedeemsBuffer` is minimal |
| New contracts | None (redeemer is external) | `RedeemsBuffer` (behind `OssifiableProxy`) |
| Lido modifications | `redeemStETH()`, allocation | `collectRewardsAndProcessWithdrawals` + `_growRedeemsReserve()` + allocation. `_getInternalEther()` unchanged |
| Accounting changes | None | Read counters, adjust limiter base, two commits, pass `redeemedEther` to Lido |
| Report flow additions | None | 7 steps: read → adjust base → existing steps → commits → collectRewards (round-trip inside) → fees |
| Share rate correctness | By construction (atomic burn) | By cancellation (deferred burn, overcounts cancel) |
| State between reports | Always consistent | Intentionally inconsistent (`bufferedEther` overstated by reserve, shares overcounted — cancels out) |
| ETH funding | N/A (buffer is Lido) | ETH round-trip on each report via `collectRewardsAndProcessWithdrawals` |
### Open Questions
**1. `setRedeemsBuffer(address(0))` with non-empty buffer.** The setter requires `current.balance == 0` and resets the reserve snapshot to zero. Governance sequence: `setRedeemsReserveTargetRatio(0)` → wait for oracle report (returns ETH, skips push) → `setRedeemsBuffer(address(0))`. The balance check also catches force-sent ETH — it must be recovered via proxy upgrade before the buffer can be unset.
**2. Force-sent ETH.** ETH force-sent to the buffer (via `selfdestruct`) is not redeemable — `redeem()` checks against `_reserveBalance - _redeemedEther`, not `address(this).balance`. Recovery can be added via governance migration to a new buffer.
**3. Rounding in `redeem()`.** `getSharesByPooledEth` → `getPooledEthByShares` may produce `etherAmount < _stETHAmount` due to integer division.
## References
- Pull-based implementation: [PR 1746](https://github.com/lidofinance/core/pull/1746)
- Push-based implementation: [PR 1756](https://github.com/lidofinance/core/pull/1756)
- Test plan (edge cases & stress scenarios): [link TBD]