Skip to content

Commit 77c18fa

Browse files
committed
feat: redeem reserve push base with burner
1 parent 015eedf commit 77c18fa

File tree

9 files changed

+387
-297
lines changed

9 files changed

+387
-297
lines changed

contracts/0.4.24/Lido.sol

Lines changed: 108 additions & 102 deletions
Large diffs are not rendered by default.

contracts/0.8.9/Accounting.sol

Lines changed: 49 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,9 @@ import {IPostTokenRebaseReceiver} from "./interfaces/IPostTokenRebaseReceiver.so
1515

1616
import {WithdrawalQueue} from "./WithdrawalQueue.sol";
1717

18-
interface IRedeemsReserveVault {
19-
function withdrawToLido(uint256 _amount) external;
20-
function getRedeemedShares() external view returns (uint256);
18+
interface IRedeemsBuffer {
2119
function getRedeemedEther() external view returns (uint256);
22-
function flushSharesToBurner() external;
23-
function resetRedeemedEther() external;
20+
function withdrawUnredeemed() external;
2421
}
2522

2623
interface IStakingRouter {
@@ -102,6 +99,8 @@ contract Accounting {
10299
uint256 postTotalShares;
103100
/// @notice amount of ether under the protocol after the report is applied
104101
uint256 postTotalPooledEther;
102+
/// @notice number of redeem shares to burn (outside the rebase limiter, rate-neutral)
103+
uint256 redeemSharesToBurn;
105104
}
106105

107106
/// @notice precalculated numbers of shares that should be minted as fee to NO
@@ -193,7 +192,7 @@ contract Accounting {
193192
// Principal CL balance is sum of previous balances and new deposits
194193
update.principalClBalance = _pre.clValidatorsBalance + _pre.clPendingBalance + _pre.depositedBalance;
195194

196-
// Read redemption counters from vault (on-chain, includes all redemptions)
195+
// Read redemption counters from buffer (on-chain, includes all redemptions)
197196
(uint256 redeemedShares, uint256 redeemedEther) = _getRedeemedCounters();
198197

199198
// Limit the rebase to avoid oracle frontrunning.
@@ -215,12 +214,13 @@ contract Accounting {
215214
update.sharesToFinalizeWQ
216215
);
217216

218-
// Add redemption shares outside the limiter — rate-neutral, must all burn on this report
219-
update.totalSharesToBurn += redeemedShares;
217+
// Redeem shares are burned via a separate commit (outside the rebase limiter, rate-neutral)
218+
update.redeemSharesToBurn = redeemedShares;
220219

221220
uint256 postInternalSharesBeforeFees = _pre.totalShares -
222221
_pre.externalShares - // internal shares before
223-
update.totalSharesToBurn; // shares to be burned (WQ + cover + redemptions)
222+
update.totalSharesToBurn - // shares to be burned (WQ + cover)
223+
update.redeemSharesToBurn; // redeem shares burned separately
224224

225225
update.postInternalEther =
226226
_pre.totalPooledEther - _pre.externalEther
@@ -386,16 +386,20 @@ contract Accounting {
386386
LIDO.internalizeExternalBadDebt(_pre.badDebtToInternalize);
387387
}
388388

389-
// Flush accumulated redemption shares from vault to Burner before commit
390-
_flushVaultSharesToBurner();
389+
// Burn all redeem shares (rate-neutral, outside limiter)
390+
// Shares are already on Burner — sent during each redeem() call
391+
_contracts.burner.commitRedeemSharesToBurn();
391392

393+
// Burn limiter-constrained cover/nonCover shares
392394
if (_update.totalSharesToBurn > 0) {
393395
_contracts.burner.commitSharesToBurn(_update.totalSharesToBurn);
394396
}
395397

396-
// Reconcile RedeemsReserveVault — tracked vault ETH updated to actual.
397-
_reconcileRedeemsReserveVault();
398+
// ETH round-trip: withdraw unredeemed → reconcile bufferedEther
399+
_withdrawAndReconcileRedeemsBuffer();
398400

401+
// Standard flow — runs on clean state (bufferedEther == Lido.balance)
402+
// _updateBufferedEtherAllocation() inside grows the redeems reserve snapshot
399403
LIDO.collectRewardsAndProcessWithdrawals(
400404
_report.timestamp,
401405
_report.clValidatorsBalance + _report.clPendingBalance,
@@ -407,8 +411,8 @@ contract Accounting {
407411
_update.etherToFinalizeWQ
408412
);
409413

410-
// Replenish RedeemsReserveVault: push/pull ETH to match target
411-
_replenishRedeemsReserveVault();
414+
// Push new reserve to buffer (bufferedEther NOT decremented — soft-reserved)
415+
_pushRedeemsReserveToBuffer();
412416

413417
if (_update.sharesToMintAsFees > 0) {
414418
// this is a final action that changes share rate.
@@ -437,70 +441,45 @@ contract Accounting {
437441
);
438442
}
439443

440-
/// @dev Reads redemption counters from the vault. Returns (0, 0) if no vault configured.
444+
/// @dev Reads redemption counters. Shares from Burner redeem track, ether from buffer.
445+
/// Returns (0, 0) if no buffer configured.
441446
function _getRedeemedCounters() internal view returns (uint256 redeemedShares, uint256 redeemedEther) {
442-
address vault = LIDO.getRedeemsReserveVault();
443-
if (vault == address(0)) return (0, 0);
447+
address buffer = LIDO.getRedeemsBuffer();
448+
if (buffer == address(0)) return (0, 0);
444449

445-
redeemedShares = IRedeemsReserveVault(vault).getRedeemedShares();
446-
redeemedEther = IRedeemsReserveVault(vault).getRedeemedEther();
450+
redeemedShares = IBurner(LIDO_LOCATOR.burner()).getRedeemSharesRequestedToBurn();
451+
redeemedEther = IRedeemsBuffer(buffer).getRedeemedEther();
447452
}
448453

449-
/// @dev Flushes accumulated redemption shares from vault to Burner.
450-
/// Called before commitSharesToBurn so redeemed shares are included in the burn.
451-
function _flushVaultSharesToBurner() internal {
452-
address vault = LIDO.getRedeemsReserveVault();
453-
if (vault == address(0)) return;
454+
/// @dev ETH round-trip: withdraw unredeemed ETH from buffer back to Lido,
455+
/// then reconcile bufferedEther by subtracting redeemedEther.
456+
/// After this call: bufferedEther == Lido.balance (clean state).
457+
function _withdrawAndReconcileRedeemsBuffer() internal {
458+
address buffer = LIDO.getRedeemsBuffer();
459+
if (buffer == address(0)) return;
454460

455-
IRedeemsReserveVault(vault).flushSharesToBurner();
456-
}
461+
uint256 redeemedEther = IRedeemsBuffer(buffer).getRedeemedEther();
457462

458-
/// @dev Reconciles the RedeemsReserveVault tracked ETH to the actual vault balance
459-
/// and resets the redeemed ether counter.
460-
function _reconcileRedeemsReserveVault() internal {
461-
address vault = LIDO.getRedeemsReserveVault();
462-
if (vault == address(0)) return;
463+
// Withdraw unredeemed ETH back to Lido (resets buffer counters)
464+
IRedeemsBuffer(buffer).withdrawUnredeemed();
463465

464-
LIDO.reconcileRedeemsReserveVault(vault.balance);
465-
IRedeemsReserveVault(vault).resetRedeemedEther();
466+
// Reconcile: bufferedEther -= redeemedEther
467+
LIDO.reconcileRedeemedEther(redeemedEther);
466468
}
467469

468-
/// @dev Replenishes or drains the RedeemsReserveVault to match the reserve target.
469-
/// Called after collectRewardsAndProcessWithdrawals when the buffer is finalized.
470-
/// Fills to target from unreserved surplus first. When surplus is insufficient,
471-
/// splits the shared allocation (withdrawalsReserve + unreserved) by growthShareBP.
472-
function _replenishRedeemsReserveVault() internal {
473-
address vault = LIDO.getRedeemsReserveVault();
474-
if (vault == address(0)) return;
475-
476-
uint256 target = LIDO.getRedeemsReserveTarget();
477-
uint256 actual = vault.balance;
478-
479-
if (target > actual) {
480-
uint256 deficit = target - actual;
481-
uint256 depositableEther = LIDO.getDepositableEther();
482-
uint256 toPush;
483-
484-
if (depositableEther >= deficit) {
485-
toPush = deficit;
486-
} else {
487-
uint256 shareBP = LIDO.getRedeemsReserveGrowthShare();
488-
if (shareBP == 0) {
489-
toPush = depositableEther;
490-
} else {
491-
uint256 withdrawalsReserve = LIDO.getWithdrawalsReserve();
492-
uint256 sharedAllocation = withdrawalsReserve + depositableEther;
493-
uint256 reserveShare = sharedAllocation * shareBP / TOTAL_BASIS_POINTS;
494-
toPush = reserveShare > depositableEther ? reserveShare : depositableEther;
495-
if (toPush > deficit) toPush = deficit;
496-
}
497-
}
498-
499-
if (toPush > 0) {
500-
LIDO.pushToRedeemsReserveVault(toPush);
501-
}
502-
} else if (actual > target) {
503-
LIDO.pullFromRedeemsReserveVault(actual - target);
470+
/// @dev After standard flow + _updateBufferedEtherAllocation() grew the reserve,
471+
/// push the new reserve amount to the buffer.
472+
/// bufferedEther is NOT decremented — ETH is soft-reserved.
473+
/// @dev After collectRewardsAndProcessWithdrawals, _updateBufferedEtherAllocation()
474+
/// has set the new REDEEMS_RESERVE_POSITION via _growRedeemsReserve().
475+
/// Push that amount to the buffer. bufferedEther is NOT decremented.
476+
function _pushRedeemsReserveToBuffer() internal {
477+
address buffer = LIDO.getRedeemsBuffer();
478+
if (buffer == address(0)) return;
479+
480+
uint256 toPush = LIDO.getRedeemsReserve();
481+
if (toPush > 0) {
482+
LIDO.pushToRedeemsBuffer(toPush);
504483
}
505484
}
506485

contracts/0.8.9/Burner.sol

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
8181

8282
uint256 totalCoverSharesBurnt;
8383
uint256 totalNonCoverSharesBurnt;
84+
85+
uint256 redeemSharesBurnRequested;
86+
uint256 totalRedeemSharesBurnt;
8487
}
8588

8689
/// @custom:storage-location erc7201:Lido.Core.Burner.IsMigrationAllowed-v3Upgrade
@@ -116,6 +119,16 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
116119
*/
117120
event StETHBurnt(bool indexed isCover, uint256 amountOfStETH, uint256 amountOfShares);
118121

122+
/**
123+
* Emitted when a new redeem burn request is added.
124+
*/
125+
event RedeemStETHBurnRequested(address indexed requestedBy, uint256 amountOfStETH, uint256 amountOfShares);
126+
127+
/**
128+
* Emitted when redeem shares are burnt.
129+
*/
130+
event RedeemStETHBurnt(uint256 amountOfStETH, uint256 amountOfShares);
131+
119132
/**
120133
* Emitted when the excessive stETH `amount` (corresponding to `amountOfShares` shares) recovered (i.e. transferred)
121134
* to the Lido treasure address by `requestedBy` sender.
@@ -280,6 +293,29 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
280293
_requestBurn(_sharesAmountToBurn, stETHAmount, false /* _isCover */);
281294
}
282295

296+
/**
297+
* @notice BE CAREFUL, the provided stETH shares will be burnt permanently.
298+
*
299+
* Transfers `_sharesAmountToBurn` stETH shares from `_from` and irreversibly locks these
300+
* on the burner contract address. Marks the shares amount for burning on the isolated
301+
* redeem track (burned outside the rebase limiter).
302+
*
303+
* @param _from address to transfer shares from
304+
* @param _sharesAmountToBurn stETH shares to burn
305+
*/
306+
function requestBurnSharesForRedeem(
307+
address _from,
308+
uint256 _sharesAmountToBurn
309+
) external onlyRole(REQUEST_BURN_SHARES_ROLE) {
310+
if (_sharesAmountToBurn == 0) revert ZeroBurnAmount();
311+
312+
uint256 stETHAmount = LIDO.transferSharesFrom(_from, address(this), _sharesAmountToBurn);
313+
314+
emit RedeemStETHBurnRequested(msg.sender, stETHAmount, _sharesAmountToBurn);
315+
316+
_storage().redeemSharesBurnRequested += _sharesAmountToBurn;
317+
}
318+
283319
/**
284320
* Transfers the excess stETH amount (e.g. belonging to the burner contract address
285321
* but not marked for burning) to the Lido treasury address set upon the
@@ -392,6 +428,35 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
392428
assert(sharesToBurnNow == _sharesToBurn);
393429
}
394430

431+
/**
432+
* Commit all pending redeem shares to burn. Burns everything — no budget argument.
433+
*
434+
* Redeem shares are burned outside the rebase limiter (rate-neutral),
435+
* so they are always fully burned on each oracle report.
436+
*
437+
* Increments `totalRedeemSharesBurnt` counter.
438+
* Resets `redeemSharesBurnRequested` counter.
439+
* Does nothing if zero shares are pending.
440+
*/
441+
function commitRedeemSharesToBurn() external virtual override {
442+
if (msg.sender != LOCATOR.accounting()) revert AppAuthFailed();
443+
444+
Storage storage $ = _storage();
445+
uint256 redeemShares = $.redeemSharesBurnRequested;
446+
447+
if (redeemShares == 0) {
448+
return;
449+
}
450+
451+
$.redeemSharesBurnRequested = 0;
452+
$.totalRedeemSharesBurnt += redeemShares;
453+
454+
uint256 stETHAmount = LIDO.getPooledEthByShares(redeemShares);
455+
emit RedeemStETHBurnt(stETHAmount, redeemShares);
456+
457+
LIDO.burnShares(redeemShares);
458+
}
459+
395460
/**
396461
* Returns the current amount of shares locked on the contract to be burnt.
397462
*/
@@ -421,6 +486,20 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
421486
return _storage().totalNonCoverSharesBurnt;
422487
}
423488

489+
/**
490+
* Returns the current amount of redeem shares locked on the contract to be burnt.
491+
*/
492+
function getRedeemSharesRequestedToBurn() external view virtual override returns (uint256) {
493+
return _storage().redeemSharesBurnRequested;
494+
}
495+
496+
/**
497+
* Returns the total redeem shares ever burnt.
498+
*/
499+
function getRedeemSharesBurnt() external view virtual override returns (uint256) {
500+
return _storage().totalRedeemSharesBurnt;
501+
}
502+
424503
/**
425504
* Returns the stETH amount belonging to the burner contract address but not marked for burning.
426505
*/
@@ -430,7 +509,9 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
430509

431510
function _getExcessStETHShares() internal view returns (uint256) {
432511
Storage storage $ = _storage();
433-
uint256 sharesBurnRequested = ($.coverSharesBurnRequested + $.nonCoverSharesBurnRequested);
512+
uint256 sharesBurnRequested = $.coverSharesBurnRequested
513+
+ $.nonCoverSharesBurnRequested
514+
+ $.redeemSharesBurnRequested;
434515
uint256 totalShares = LIDO.sharesOf(address(this));
435516

436517
// sanity check, don't revert

0 commit comments

Comments
 (0)