Skip to content

Commit dcb62c8

Browse files
committed
feat: wip
1 parent 9e101d5 commit dcb62c8

21 files changed

+186
-289
lines changed

contracts/0.4.24/Lido.sol

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,16 @@ contract Lido is Versioned, StETHPermit, AragonApp {
782782
*/
783783
function setRedeemsBuffer(address _buffer) external {
784784
_auth(BUFFER_RESERVE_MANAGER_ROLE);
785+
786+
address oldBuffer = REDEEMS_BUFFER_POSITION.getStorageAddress();
787+
if (oldBuffer != address(0)) {
788+
uint256 oldRedeemedEther = IRedeemsBuffer(oldBuffer).getRedeemedEther();
789+
IRedeemsBuffer(oldBuffer).withdrawUnredeemed();
790+
if (oldRedeemedEther > 0) {
791+
_setBufferedEther(_getBufferedEther().sub(oldRedeemedEther));
792+
}
793+
}
794+
785795
REDEEMS_BUFFER_POSITION.setStorageAddress(_buffer);
786796
emit RedeemsBufferSet(_buffer);
787797
}
Lines changed: 56 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
// SPDX-FileCopyrightText: 2024 Lido <info@lido.fi>
1+
// SPDX-FileCopyrightText: 2025 Lido <info@lido.fi>
22
// SPDX-License-Identifier: GPL-3.0
33

4-
/* See contracts/COMPILERS.md */
5-
pragma solidity 0.8.9;
4+
pragma solidity 0.8.25;
65

7-
import {IERC20} from "@openzeppelin/contracts-v4.4/token/ERC20/IERC20.sol";
8-
import {AccessControlEnumerable} from "./utils/access/AccessControlEnumerable.sol";
9-
import {PausableUntil} from "./utils/PausableUntil.sol";
10-
import {Versioned} from "./utils/Versioned.sol";
11-
import {IBurner} from "../common/interfaces/IBurner.sol";
12-
import {ILidoLocator} from "../common/interfaces/ILidoLocator.sol";
6+
import {IERC20} from "@openzeppelin/contracts-v5.2/token/ERC20/IERC20.sol";
7+
import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.2/access/extensions/AccessControlEnumerable.sol";
8+
9+
import {PausableUntil} from "contracts/common/utils/PausableUntil.sol";
10+
import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol";
11+
import {IHashConsensus} from "contracts/common/interfaces/IHashConsensus.sol";
12+
13+
import {RefSlotCache} from "./vaults/lib/RefSlotCache.sol";
1314

1415
interface ILidoForRedeemsBuffer {
1516
function getSharesByPooledEth(uint256 _pooledEthAmount) external view returns (uint256);
@@ -22,6 +23,10 @@ interface ILidoForRedeemsBuffer {
2223
function receiveFromRedeemsBuffer() external payable;
2324
}
2425

26+
interface IBurnerForRedeemsBuffer {
27+
function requestBurnShares(address _from, uint256 _sharesAmountToBurn) external;
28+
}
29+
2530
interface IWithdrawalQueueForRedeemsBuffer {
2631
function isBunkerModeActive() external view returns (bool);
2732
function isPaused() external view returns (bool);
@@ -31,26 +36,27 @@ interface IWithdrawalQueueForRedeemsBuffer {
3136
* @title RedeemsBuffer
3237
* @notice Holds reserve ETH for stETH-to-ETH redemptions.
3338
*
34-
* On each oracle report, Lido funds the vault via `fundReserve()` to match the
35-
* reserve target, or pulls excess back via `withdrawUnredeemed()`. Between reports,
36-
* REDEEMER_ROLE holders invoke `redeem()` to exchange stETH for ETH. The stETH
37-
* shares are held locally and flushed to the Burner during the next oracle report,
38-
* where they are burned outside the rebase limiter (rate-neutral).
39+
* Uses RefSlotCache to snapshot redeem counters at frame boundaries,
40+
* so Accounting reads the same values that the oracle daemon sees at refSlot.
3941
*
4042
* Gate Seal compatible via PausableUntil.
4143
*/
42-
contract RedeemsBuffer is PausableUntil, AccessControlEnumerable, Versioned {
44+
contract RedeemsBuffer is PausableUntil, AccessControlEnumerable {
45+
using RefSlotCache for RefSlotCache.Uint104WithCache;
46+
4347
bytes32 public constant PAUSE_ROLE = keccak256("RedeemsBuffer.PauseRole");
4448
bytes32 public constant RESUME_ROLE = keccak256("RedeemsBuffer.ResumeRole");
4549
bytes32 public constant REDEEMER_ROLE = keccak256("RedeemsBuffer.RedeemerRole");
4650

4751
ILidoLocator public immutable LOCATOR;
4852
ILidoForRedeemsBuffer public immutable LIDO;
49-
IBurner public immutable BURNER;
53+
IBurnerForRedeemsBuffer public immutable BURNER;
5054
IWithdrawalQueueForRedeemsBuffer public immutable WITHDRAWAL_QUEUE;
55+
IHashConsensus public immutable HASH_CONSENSUS;
5156

5257
uint256 private _reserveBalance;
53-
uint256 private _redeemedEther;
58+
RefSlotCache.Uint104WithCache private _redeemedEther;
59+
RefSlotCache.Uint104WithCache private _redeemedShares;
5460

5561
event Redeemed(
5662
address indexed caller,
@@ -68,31 +74,26 @@ contract RedeemsBuffer is PausableUntil, AccessControlEnumerable, Versioned {
6874
error WQPaused();
6975
error InsufficientReserve(uint256 requested, uint256 available);
7076
error NotLido();
71-
error InsufficientBalance(uint256 requested, uint256 available);
7277
error ETHTransferFailed(address recipient, uint256 amount);
7378
error StETHRecoveryNotAllowed();
7479
error DirectETHTransferNotAllowed();
7580

76-
constructor(address _locator)
77-
Versioned()
78-
{
81+
constructor(address _locator, address _hashConsensus) {
7982
LOCATOR = ILidoLocator(_locator);
8083
LIDO = ILidoForRedeemsBuffer(LOCATOR.lido());
81-
BURNER = IBurner(LOCATOR.burner());
84+
BURNER = IBurnerForRedeemsBuffer(LOCATOR.burner());
8285
WITHDRAWAL_QUEUE = IWithdrawalQueueForRedeemsBuffer(LOCATOR.withdrawalQueue());
86+
HASH_CONSENSUS = IHashConsensus(_hashConsensus);
8387
}
8488

85-
/// @notice Initializes the contract. Called once after proxy deployment.
8689
function initialize(address _admin) external {
87-
_initializeContractVersionTo(1);
90+
if (_admin == address(0)) revert ZeroRecipient();
8891
LIDO.approve(address(BURNER), type(uint256).max);
8992
_grantRole(DEFAULT_ADMIN_ROLE, _admin);
9093
}
9194

9295
/**
9396
* @notice Redeem stETH for ETH from the reserve.
94-
* Checks against tracked reserve balance (not address(this).balance)
95-
* to prevent force-sent ETH from being redeemable.
9697
* @param _stETHAmount Amount of stETH to redeem
9798
* @param _ethRecipient Address to receive ETH
9899
*/
@@ -106,30 +107,47 @@ contract RedeemsBuffer is PausableUntil, AccessControlEnumerable, Versioned {
106107
uint256 sharesAmount = LIDO.getSharesByPooledEth(_stETHAmount);
107108
uint256 etherAmount = LIDO.getPooledEthByShares(sharesAmount);
108109

109-
uint256 available = _reserveBalance - _redeemedEther;
110+
uint256 available = _reserveBalance - _redeemedEther.value;
110111
if (etherAmount > available) {
111112
revert InsufficientReserve(etherAmount, available);
112113
}
113114

114115
LIDO.transferSharesFrom(msg.sender, address(this), sharesAmount);
115-
BURNER.requestBurnSharesForRedeem(address(this), sharesAmount);
116-
_redeemedEther += etherAmount;
116+
BURNER.requestBurnShares(address(this), sharesAmount);
117+
118+
// RefSlotCache auto-snapshots: on first increment in a new frame,
119+
// caches the pre-increment value as the refSlot snapshot.
120+
_redeemedEther = _redeemedEther.withValueIncrease(HASH_CONSENSUS, uint104(etherAmount));
121+
_redeemedShares = _redeemedShares.withValueIncrease(HASH_CONSENSUS, uint104(sharesAmount));
117122

118123
(bool success,) = _ethRecipient.call{value: etherAmount}("");
119124
if (!success) revert ETHTransferFailed(_ethRecipient, etherAmount);
120125

121126
emit Redeemed(msg.sender, _ethRecipient, _stETHAmount, sharesAmount, etherAmount);
122127
}
123128

124-
/// @notice ETH sent to redeemers since the last report.
129+
// ── Report interface ─────────────────────────────────────────────────
130+
131+
/// @notice Redeemed ether as of the current refSlot (for Accounting).
132+
function getRedeemedEtherForReport() external view returns (uint256) {
133+
return _redeemedEther.getValueForLastRefSlot(HASH_CONSENSUS);
134+
}
135+
136+
/// @notice Redeemed shares as of the current refSlot (for Accounting).
137+
function getRedeemedSharesForReport() external view returns (uint256) {
138+
return _redeemedShares.getValueForLastRefSlot(HASH_CONSENSUS);
139+
}
140+
141+
/// @notice Current total redeemed ether (including post-refSlot).
125142
function getRedeemedEther() external view returns (uint256) {
126-
return _redeemedEther;
143+
return _redeemedEther.value;
144+
}
145+
146+
/// @notice Current total redeemed shares (including post-refSlot).
147+
function getRedeemedShares() external view returns (uint256) {
148+
return _redeemedShares.value;
127149
}
128150

129-
/**
130-
* @notice Accept ETH from Lido and update tracked reserve balance.
131-
* Called by Lido during report to fund the reserve.
132-
*/
133151
function fundReserve() external payable {
134152
if (msg.sender != address(LIDO)) revert NotLido();
135153
_reserveBalance += msg.value;
@@ -138,30 +156,25 @@ contract RedeemsBuffer is PausableUntil, AccessControlEnumerable, Versioned {
138156

139157
/**
140158
* @notice Returns unredeemed ETH to Lido and resets counters.
141-
* Called by Lido during collectRewardsAndProcessWithdrawals, before the standard flow.
142-
* Sends back `_reserveBalance - _redeemedEther` (unredeemed portion).
143-
* Resets both `_reserveBalance` and `_redeemedEther` to 0.
144159
*/
145160
function withdrawUnredeemed() external {
146161
if (msg.sender != address(LIDO)) revert NotLido();
147-
uint256 amount = _reserveBalance - _redeemedEther;
162+
uint256 amount = _reserveBalance - _redeemedEther.value;
148163
_reserveBalance = 0;
149-
_redeemedEther = 0;
164+
_redeemedEther = RefSlotCache.Uint104WithCache(0, 0, 0);
165+
_redeemedShares = RefSlotCache.Uint104WithCache(0, 0, 0);
150166
if (amount > 0) {
151167
LIDO.receiveFromRedeemsBuffer{value: amount}();
152168
}
153169
}
154170

155171
// ── Recovery ─────────────────────────────────────────────────────────
156172

157-
/// @notice Recover accidentally sent ERC20 tokens (except stETH).
158173
function recoverERC20(address _token, uint256 _amount) external onlyRole(DEFAULT_ADMIN_ROLE) {
159174
if (_token == address(LIDO)) revert StETHRecoveryNotAllowed();
160175
IERC20(_token).transfer(msg.sender, _amount);
161176
}
162177

163-
/// @notice Recover accidentally sent stETH shares.
164-
/// No pending shares on the buffer — all are forwarded to Burner during redeem().
165178
function recoverStETHShares() external onlyRole(DEFAULT_ADMIN_ROLE) {
166179
uint256 shares = LIDO.sharesOf(address(this));
167180
if (shares > 0) {
@@ -183,7 +196,6 @@ contract RedeemsBuffer is PausableUntil, AccessControlEnumerable, Versioned {
183196
_resume();
184197
}
185198

186-
/// @notice Reject direct ETH transfers. Use fundReserve() instead.
187199
receive() external payable {
188200
revert DirectETHTransferNotAllowed();
189201
}

contracts/0.8.9/Accounting.sol

Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import {WithdrawalQueue} from "./WithdrawalQueue.sol";
1717

1818
interface IRedeemsBuffer {
1919
function getRedeemedEther() external view returns (uint256);
20-
function withdrawUnredeemed() external;
20+
function getRedeemedShares() external view returns (uint256);
21+
function getRedeemedEtherForReport() external view returns (uint256);
22+
function getRedeemedSharesForReport() external view returns (uint256);
2123
}
2224

2325
interface IStakingRouter {
@@ -64,6 +66,8 @@ contract Accounting {
6466
uint256 externalShares;
6567
uint256 externalEther;
6668
uint256 badDebtToInternalize;
69+
uint256 redeemedEther;
70+
uint256 redeemedShares;
6771
}
6872

6973
/// @notice precalculated values that is used to change the state of the protocol during the report
@@ -99,10 +103,6 @@ contract Accounting {
99103
uint256 postTotalShares;
100104
/// @notice amount of ether under the protocol after the report is applied
101105
uint256 postTotalPooledEther;
102-
/// @notice number of redeem shares to burn (outside the rebase limiter, rate-neutral)
103-
uint256 redeemSharesToBurn;
104-
/// @notice amount of ether redeemed since last report (from RedeemsBuffer)
105-
uint256 redeemedEther;
106106
}
107107

108108
/// @notice precalculated numbers of shares that should be minted as fee to NO
@@ -150,10 +150,6 @@ contract Accounting {
150150
Contracts memory contracts = _loadOracleReportContracts();
151151
if (msg.sender != contracts.accountingOracle) revert NotAuthorized("handleOracleReport", msg.sender);
152152

153-
// Vault redemption counters are read on-chain during _simulateOracleReport.
154-
// redeemedEther is subtracted from the smoothenTokenRebase base,
155-
// and redeemedShares are added outside the rebase limiter.
156-
157153
PreReportState memory pre = _snapshotPreReportState(contracts, false);
158154
CalculatedValues memory update = _simulateOracleReport(contracts, pre, _report);
159155
_applyOracleReportContext(contracts, _report, pre, update);
@@ -174,6 +170,8 @@ contract Accounting {
174170
} else {
175171
pre.badDebtToInternalize = _contracts.vaultHub.badDebtToInternalizeForLastRefSlot();
176172
}
173+
174+
(pre.redeemedShares, pre.redeemedEther) = _getRedeemedCounters(isSimulation);
177175
}
178176

179177
/// @dev calculates all the state changes that is required to apply the report
@@ -194,46 +192,39 @@ contract Accounting {
194192
// Principal CL balance is sum of previous balances and new deposits
195193
update.principalClBalance = _pre.clValidatorsBalance + _pre.clPendingBalance + _pre.depositedBalance;
196194

197-
// Read redemption counters from buffer (on-chain, includes all redemptions)
198-
(uint256 redeemedShares, uint256 redeemedEther) = _getRedeemedCounters();
199-
update.redeemedEther = redeemedEther;
200-
201195
// Limit the rebase to avoid oracle frontrunning.
202196
// The base is reduced by redeemed ether AND shares so the limiter sees
203197
// a rate-neutral pre-state (as if the redeem never happened).
204-
// Without the shares adjustment the pre-share-rate fed to the limiter
205-
// would be artificially low, shrinking the shares-burn budget.
198+
// sharesRequestedToBurn from oracle includes redeem shares (nonCover on Burner) —
199+
// subtract them so the limiter only governs cover + regular nonCover.
206200
(
207201
update.withdrawalsVaultTransfer,
208202
update.elRewardsVaultTransfer,
209203
update.sharesToBurnForWithdrawals,
210-
update.totalSharesToBurn // shares to burn from Burner balance (WQ + cover)
204+
update.totalSharesToBurn
211205
) = _contracts.oracleReportSanityChecker.smoothenTokenRebase(
212-
_pre.totalPooledEther - _pre.externalEther - redeemedEther,
213-
_pre.totalShares - _pre.externalShares - redeemedShares,
206+
_pre.totalPooledEther - _pre.externalEther - _pre.redeemedEther,
207+
_pre.totalShares - _pre.externalShares - _pre.redeemedShares,
214208
update.principalClBalance,
215209
_report.clValidatorsBalance + _report.clPendingBalance,
216210
_report.withdrawalVaultBalance,
217211
_report.elRewardsVaultBalance,
218-
_report.sharesRequestedToBurn,
212+
_report.sharesRequestedToBurn - _pre.redeemedShares,
219213
update.etherToFinalizeWQ,
220214
update.sharesToFinalizeWQ
221215
);
222216

223-
// Redeem shares are burned via a separate commit (outside the rebase limiter, rate-neutral)
224-
update.redeemSharesToBurn = redeemedShares;
225-
226217
uint256 postInternalSharesBeforeFees = _pre.totalShares -
227218
_pre.externalShares - // internal shares before
228219
update.totalSharesToBurn - // shares to be burned (WQ + cover)
229-
update.redeemSharesToBurn; // redeem shares burned separately
220+
_pre.redeemedShares; // redeem shares burned separately (outside limiter)
230221

231222
update.postInternalEther =
232223
_pre.totalPooledEther - _pre.externalEther
233224
+ _report.clValidatorsBalance + _report.clPendingBalance + update.withdrawalsVaultTransfer - update.principalClBalance
234225
+ update.elRewardsVaultTransfer
235226
- update.etherToFinalizeWQ
236-
- redeemedEther;
227+
- _pre.redeemedEther;
237228

238229
// Pre-calculate total amount of protocol fees as the amount of shares that will be minted to pay it
239230
(update.sharesToMintAsFees, update.feeDistribution) = _calculateProtocolFees(
@@ -392,15 +383,13 @@ contract Accounting {
392383
LIDO.internalizeExternalBadDebt(_pre.badDebtToInternalize);
393384
}
394385

395-
// Burn all redeem shares (rate-neutral, outside limiter)
396-
// Shares are already on Burner — sent during each redeem() call
397-
if (_update.redeemSharesToBurn > 0) {
398-
_contracts.burner.commitRedeemSharesToBurn();
399-
}
400-
401-
// Burn limiter-constrained cover/nonCover shares
402-
if (_update.totalSharesToBurn > 0) {
403-
_contracts.burner.commitSharesToBurn(_update.totalSharesToBurn);
386+
// Burn shares: limiter-constrained cover/nonCover + all settled redeem shares.
387+
// Redeem shares sit on Burner as nonCover — burned together in one call.
388+
{
389+
uint256 totalBurn = _update.totalSharesToBurn + _pre.redeemedShares;
390+
if (totalBurn > 0) {
391+
_contracts.burner.commitSharesToBurn(totalBurn);
392+
}
404393
}
405394

406395
// collectRewardsAndProcessWithdrawals handles ETH round-trip internally:
@@ -414,7 +403,7 @@ contract Accounting {
414403
lastWithdrawalRequestToFinalize,
415404
_report.simulatedShareRate,
416405
_update.etherToFinalizeWQ,
417-
_update.redeemedEther
406+
_pre.redeemedEther
418407
);
419408

420409
if (_update.sharesToMintAsFees > 0) {
@@ -444,14 +433,21 @@ contract Accounting {
444433
);
445434
}
446435

447-
/// @dev Reads redemption counters. Shares from Burner redeem track, ether from buffer.
436+
/// @dev Reads redemption counters from RedeemsBuffer.
437+
/// Simulation (daemon at refSlot): reads raw current values.
438+
/// Execution (delivery): reads RefSlotCache snapshots (excludes post-refSlot redeems).
448439
/// Returns (0, 0) if no buffer configured.
449-
function _getRedeemedCounters() internal view returns (uint256 redeemedShares, uint256 redeemedEther) {
440+
function _getRedeemedCounters(bool _isSimulation) internal view returns (uint256 redeemedShares, uint256 redeemedEther) {
450441
address buffer = LIDO.getRedeemsBuffer();
451442
if (buffer == address(0)) return (0, 0);
452443

453-
redeemedShares = IBurner(LIDO_LOCATOR.burner()).getRedeemSharesRequestedToBurn();
454-
redeemedEther = IRedeemsBuffer(buffer).getRedeemedEther();
444+
if (_isSimulation) {
445+
redeemedShares = IRedeemsBuffer(buffer).getRedeemedShares();
446+
redeemedEther = IRedeemsBuffer(buffer).getRedeemedEther();
447+
} else {
448+
redeemedShares = IRedeemsBuffer(buffer).getRedeemedSharesForReport();
449+
redeemedEther = IRedeemsBuffer(buffer).getRedeemedEtherForReport();
450+
}
455451
}
456452

457453
/// @dev checks the provided oracle data internally and against the sanity checker contract

0 commit comments

Comments
 (0)