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
1415interface 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+
2530interface 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 }
0 commit comments