Skip to content

Commit eb3c8a3

Browse files
committed
feat: redeems reserve push approach
1 parent bb6584b commit eb3c8a3

File tree

6 files changed

+501
-19
lines changed

6 files changed

+501
-19
lines changed

contracts/0.4.24/Lido.sol

Lines changed: 210 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,14 @@ interface IStakingRouter {
3434
interface IWithdrawalQueue {
3535
function unfinalizedStETH() external view returns (uint256);
3636
function isBunkerModeActive() external view returns (bool);
37+
function isPaused() external view returns (bool);
3738
function finalize(uint256 _lastIdToFinalize, uint256 _maxShareRate) external payable;
3839
}
3940

41+
interface IRedeemsReserveVault {
42+
function withdrawToLido(uint256 _amount) external;
43+
}
44+
4045
interface ILidoExecutionLayerRewardsVault {
4146
function withdrawRewards(uint256 _maxAmount) external returns (uint256 amount);
4247
}
@@ -170,6 +175,34 @@ contract Lido is Versioned, StETHPermit, AragonApp {
170175
bytes32 internal constant DEPOSITS_RESERVE_TARGET_POSITION =
171176
0x3d3e9bd6e90e5d1f1c6839835bcbe5746a47c9a013d1eae6e80c248264c06a81;
172177

178+
/// @dev Storage slot for redeems reserve target ratio.
179+
/// Stores governance-configured ratio (in basis points) of internal ether.
180+
/// Set via `setRedeemsReserveTargetRatio()`, gated by `BUFFER_RESERVE_MANAGER_ROLE`
181+
/// keccak256("lido.Lido.redeemsReserveTargetRatio")
182+
bytes32 internal constant REDEEMS_RESERVE_TARGET_RATIO_POSITION =
183+
0xa3ab8c45cc56567e890b52bdd4f310aacdc4a7b9a4384808e34fb3b77524a729;
184+
185+
/// @dev Storage slot for the RedeemsReserveVault contract address.
186+
/// The vault physically holds reserve ETH. Set via `setRedeemsReserveVault()`.
187+
/// keccak256("lido.Lido.redeemsReserveVault")
188+
bytes32 internal constant REDEEMS_RESERVE_VAULT_POSITION =
189+
0x8f3a06db2c1a07e3a5e3b5e3bfc1e2b4c7a8f9d0e1c2b3a4f5e6d7c8b9a0f1e2;
190+
191+
/// @dev Storage slot for tracked ETH balance of the RedeemsReserveVault.
192+
/// Updated exclusively during oracle reports via `reconcileRedeemsReserveVault()`.
193+
/// Reading vault.balance directly would expose a donation attack vector.
194+
/// keccak256("lido.Lido.redeemsReserveVaultEth")
195+
bytes32 internal constant REDEEMS_RESERVE_VAULT_ETH_POSITION =
196+
0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2;
197+
198+
/// @dev Storage slot for redeems reserve replenishment share.
199+
/// Basis points determining how shared allocation (withdrawalsReserve + unreserved) is split
200+
/// between reserve growth and WQ finalization when surplus is insufficient.
201+
/// 0 (default) = reserve grows only from surplus. 8000 = 80% to reserve, 20% to WQ.
202+
/// keccak256("lido.Lido.redeemsReserveReplenishmentShare")
203+
bytes32 internal constant REDEEMS_RESERVE_REPLENISHMENT_SHARE_POSITION =
204+
0xf0700d4e4a89999085a7b5d94e2e6697101ac2553a2f01512a848b9140605dfb;
205+
173206
// Staking was paused (don't accept user's ether submits)
174207
event StakingPaused();
175208
// Staking was resumed (accept user's ether submits)
@@ -258,6 +291,24 @@ contract Lido is Versioned, StETHPermit, AragonApp {
258291
// Emitted even if the new value equals the previous one
259292
event DepositsReserveTargetSet(uint256 depositsReserveTarget);
260293

294+
// Emitted when redeems reserve target ratio is set via `setRedeemsReserveTargetRatio()`
295+
event RedeemsReserveTargetRatioSet(uint256 ratioBP);
296+
297+
// Emitted when redeems reserve replenishment share is set
298+
event RedeemsReserveReplenishmentShareSet(uint256 shareBP);
299+
300+
// Emitted when the RedeemsReserveVault address is set
301+
event RedeemsReserveVaultSet(address vault);
302+
303+
// Emitted when tracked RedeemsReserveVault ETH is reconciled to actual balance on oracle report
304+
event RedeemsReserveVaultReconciled(uint256 actualBalance);
305+
306+
// Emitted when ETH is pushed from buffer to RedeemsReserveVault
307+
event RedeemsReserveVaultFunded(uint256 amount);
308+
309+
// Emitted when ETH is returned from RedeemsReserveVault to buffer
310+
event RedeemsReserveVaultDrained(uint256 amount);
311+
261312
/**
262313
* @notice Initializer function for scratch deploy of Lido contract
263314
*
@@ -558,6 +609,7 @@ contract Lido is Versioned, StETHPermit, AragonApp {
558609
*/
559610
struct BufferedEtherAllocation {
560611
uint256 total;
612+
uint256 redeemsReserve;
561613
uint256 unreserved;
562614
uint256 depositsReserve;
563615
uint256 withdrawalsReserve;
@@ -571,6 +623,9 @@ contract Lido is Versioned, StETHPermit, AragonApp {
571623
* 2. withdrawalsReserve - covers unfinalized withdrawal requests
572624
* 3. unreserved - excess, available for additional CL deposits
573625
*
626+
* redeemsReserve is a view-only field: tracked RedeemsReserveVault ETH (physically outside buffer).
627+
* total = bufferedEther + tracked RedeemsReserveVault ETH.
628+
*
574629
* ┌─────────── Total Buffered Ether ───────────┐
575630
* ├────────────────────┬───────────────────────┼─────┬──────────────┐
576631
* │●●●●●●●●●●●●●●●●●●●●│●●●●●●●●●●●●●●●●●●●●●●●●○○○○○│○○○○○○○○○○○○○○│
@@ -586,8 +641,12 @@ contract Lido is Versioned, StETHPermit, AragonApp {
586641
* unreserved = total - depositsReserve - withdrawalsReserve
587642
*/
588643
function _getBufferedEtherAllocation() internal view returns (BufferedEtherAllocation allocation) {
589-
uint256 remaining = _getBufferedEther();
590-
allocation.total = remaining;
644+
uint256 buffered = _getBufferedEther();
645+
allocation.redeemsReserve = REDEEMS_RESERVE_VAULT_ETH_POSITION.getStorageUint256();
646+
allocation.total = buffered.add(allocation.redeemsReserve);
647+
648+
// Remaining layers allocated from bufferedEther only (RedeemsReserveVault ETH is physically separate)
649+
uint256 remaining = buffered;
591650

592651
allocation.depositsReserve = Math256.min(remaining, DEPOSITS_RESERVE_POSITION.getStorageUint256());
593652
remaining -= allocation.depositsReserve;
@@ -598,6 +657,14 @@ contract Lido is Versioned, StETHPermit, AragonApp {
598657
allocation.unreserved = remaining;
599658
}
600659

660+
/**
661+
* @notice Returns the current redeems reserve — tracked ETH balance of the RedeemsReserveVault
662+
* @dev RedeemsReserveVault ETH is physically outside the buffer. May be stale between reports.
663+
*/
664+
function getRedeemsReserve() external view returns (uint256) {
665+
return _getBufferedEtherAllocation().redeemsReserve;
666+
}
667+
601668
/**
602669
* @notice Returns the currently effective deposits reserve — buffer portion available for CL deposits, protected
603670
* from withdrawals demand
@@ -654,6 +721,139 @@ contract Lido is Versioned, StETHPermit, AragonApp {
654721
}
655722
}
656723

724+
/**
725+
* @notice Sets redeems reserve target ratio as basis points of internal ether.
726+
* The vault is replenished to this target on each oracle report.
727+
* @param _ratioBP Target ratio in basis points [0-10000]
728+
*/
729+
function setRedeemsReserveTargetRatio(uint256 _ratioBP) external {
730+
_auth(BUFFER_RESERVE_MANAGER_ROLE);
731+
require(_ratioBP <= TOTAL_BASIS_POINTS, "INVALID_RATIO");
732+
REDEEMS_RESERVE_TARGET_RATIO_POSITION.setStorageUint256(_ratioBP);
733+
emit RedeemsReserveTargetRatioSet(_ratioBP);
734+
}
735+
736+
/**
737+
* @return the redeems reserve target ratio in basis points
738+
*/
739+
function getRedeemsReserveTargetRatio() external view returns (uint256) {
740+
return REDEEMS_RESERVE_TARGET_RATIO_POSITION.getStorageUint256();
741+
}
742+
743+
/**
744+
* @notice Sets the replenishment share for redeems reserve.
745+
* When unreserved surplus is insufficient to fill the reserve, this share (in BP)
746+
* determines what fraction of the shared allocation (withdrawalsReserve + unreserved)
747+
* goes to reserve growth vs WQ finalization.
748+
* 0 (default) = reserve grows only from genuine surplus. 10000 = 100% to reserve.
749+
* @param _shareBP Replenishment share in basis points [0-10000]
750+
*/
751+
function setRedeemsReserveReplenishmentShare(uint256 _shareBP) external {
752+
_auth(BUFFER_RESERVE_MANAGER_ROLE);
753+
require(_shareBP <= TOTAL_BASIS_POINTS, "INVALID_SHARE");
754+
REDEEMS_RESERVE_REPLENISHMENT_SHARE_POSITION.setStorageUint256(_shareBP);
755+
emit RedeemsReserveReplenishmentShareSet(_shareBP);
756+
}
757+
758+
/**
759+
* @return the redeems reserve replenishment share in basis points
760+
*/
761+
function getRedeemsReserveReplenishmentShare() external view returns (uint256) {
762+
return REDEEMS_RESERVE_REPLENISHMENT_SHARE_POSITION.getStorageUint256();
763+
}
764+
765+
/**
766+
* @notice Returns the current redeems reserve target in absolute ETH,
767+
* computed from the ratio and current internal ether.
768+
*/
769+
function getRedeemsReserveTarget() external view returns (uint256) {
770+
return _getRedeemsReserveTarget();
771+
}
772+
773+
function _getRedeemsReserveTarget() internal view returns (uint256) {
774+
return _getInternalEther()
775+
* REDEEMS_RESERVE_TARGET_RATIO_POSITION.getStorageUint256()
776+
/ TOTAL_BASIS_POINTS;
777+
}
778+
779+
/**
780+
* @notice Sets the RedeemsReserveVault contract address
781+
* @dev Setting to address(0) disables reserve; vault won't receive ETH on reports
782+
* @param _vault Address of the RedeemsReserveVault contract
783+
*/
784+
function setRedeemsReserveVault(address _vault) external {
785+
_auth(BUFFER_RESERVE_MANAGER_ROLE);
786+
REDEEMS_RESERVE_VAULT_POSITION.setStorageAddress(_vault);
787+
emit RedeemsReserveVaultSet(_vault);
788+
}
789+
790+
/**
791+
* @return the RedeemsReserveVault address
792+
*/
793+
function getRedeemsReserveVault() external view returns (address) {
794+
return REDEEMS_RESERVE_VAULT_POSITION.getStorageAddress();
795+
}
796+
797+
/**
798+
* @return the tracked ETH balance of the RedeemsReserveVault (snapshot, may be stale between reports)
799+
*/
800+
function getRedeemsReserveVaultEth() external view returns (uint256) {
801+
return REDEEMS_RESERVE_VAULT_ETH_POSITION.getStorageUint256();
802+
}
803+
804+
/**
805+
* @notice Updates tracked RedeemsReserveVault ETH to actual balance. Called by Accounting on report.
806+
* @param _actualBalance The actual ETH balance of the RedeemsReserveVault
807+
*/
808+
function reconcileRedeemsReserveVault(uint256 _actualBalance) external {
809+
_auth(_accounting());
810+
REDEEMS_RESERVE_VAULT_ETH_POSITION.setStorageUint256(_actualBalance);
811+
emit RedeemsReserveVaultReconciled(_actualBalance);
812+
}
813+
814+
/**
815+
* @notice Pushes ETH from buffer to RedeemsReserveVault. Called by Accounting on report.
816+
* @param _amount Amount of ETH to push
817+
*/
818+
function pushToRedeemsReserveVault(uint256 _amount) external {
819+
_auth(_accounting());
820+
address vault = REDEEMS_RESERVE_VAULT_POSITION.getStorageAddress();
821+
require(vault != address(0), "VAULT_NOT_SET");
822+
823+
_setBufferedEther(_getBufferedEther().sub(_amount));
824+
REDEEMS_RESERVE_VAULT_ETH_POSITION.setStorageUint256(
825+
REDEEMS_RESERVE_VAULT_ETH_POSITION.getStorageUint256().add(_amount)
826+
);
827+
828+
vault.transfer(_amount);
829+
emit RedeemsReserveVaultFunded(_amount);
830+
}
831+
832+
/**
833+
* @notice Pulls excess ETH from RedeemsReserveVault back to the buffer. Called by Accounting on report.
834+
* @param _amount Amount of ETH to pull from the vault
835+
*/
836+
function pullFromRedeemsReserveVault(uint256 _amount) external {
837+
_auth(_accounting());
838+
address vault = REDEEMS_RESERVE_VAULT_POSITION.getStorageAddress();
839+
require(vault != address(0), "VAULT_NOT_SET");
840+
IRedeemsReserveVault(vault).withdrawToLido(_amount);
841+
}
842+
843+
/**
844+
* @notice Receives ETH back from RedeemsReserveVault (excess return on report).
845+
*/
846+
function receiveRedeemsReserve() external payable {
847+
_auth(REDEEMS_RESERVE_VAULT_POSITION.getStorageAddress());
848+
849+
_setBufferedEther(_getBufferedEther().add(msg.value));
850+
REDEEMS_RESERVE_VAULT_ETH_POSITION.setStorageUint256(
851+
REDEEMS_RESERVE_VAULT_ETH_POSITION.getStorageUint256().sub(msg.value)
852+
);
853+
854+
emit RedeemsReserveVaultDrained(msg.value);
855+
}
856+
657857
/**
658858
* @return the amount of ether held by external sources to back external shares
659859
*/
@@ -1048,12 +1248,12 @@ contract Lido is Versioned, StETHPermit, AragonApp {
10481248
}
10491249

10501250
/**
1051-
* @dev Syncs stored deposits reserve to configured target after oracle report processing
1251+
* @dev Syncs stored deposits reserve to configured target after oracle report processing.
1252+
* Redeems reserve is managed externally via RedeemsReserveVault push/pull in Accounting.
10521253
*/
10531254
function _updateBufferedEtherAllocation() internal {
10541255
uint256 depositsReserveTarget = getDepositsReserveTarget();
10551256
uint256 depositsReserve = DEPOSITS_RESERVE_POSITION.getStorageUint256();
1056-
10571257
if (depositsReserve != depositsReserveTarget) {
10581258
_setDepositsReserve(depositsReserveTarget);
10591259
}
@@ -1195,14 +1395,18 @@ contract Lido is Versioned, StETHPermit, AragonApp {
11951395
}
11961396

11971397
/// @dev Get the total amount of ether controlled by the protocol internally
1198-
/// (buffered ether + CL validators balance + CL pending balance + deposited since last report)
1398+
/// (buffered ether + CL validators balance + CL pending balance + deposited since last report + redeems reserve vault)
11991399
function _getInternalEther() internal view returns (uint256) {
12001400
(uint256 bufferedEther, uint256 depositedPostReport) = _getBufferedEtherAndDepositedPostReport();
12011401
(uint256 clValidatorsBalance, uint256 clPendingBalance) = _getClValidatorsBalanceAndClPendingBalance();
12021402

12031403
// With balance-based accounting, we don't need to calculate transientEther
12041404
// as pending deposits are already included in clPendingBalance
1205-
return bufferedEther.add(clValidatorsBalance).add(clPendingBalance).add(depositedPostReport);
1405+
return bufferedEther
1406+
.add(clValidatorsBalance)
1407+
.add(clPendingBalance)
1408+
.add(depositedPostReport)
1409+
.add(REDEEMS_RESERVE_VAULT_ETH_POSITION.getStorageUint256());
12061410
}
12071411

12081412
/// @dev Calculate the amount of ether controlled by external entities

0 commit comments

Comments
 (0)