diff --git a/contracts/0.4.24/Lido.sol b/contracts/0.4.24/Lido.sol index 82c782270..fcd50361c 100644 --- a/contracts/0.4.24/Lido.sol +++ b/contracts/0.4.24/Lido.sol @@ -8,6 +8,7 @@ import {AragonApp, UnstructuredStorage} from "@aragon/os/contracts/apps/AragonAp import {SafeMath} from "@aragon/os/contracts/lib/math/SafeMath.sol"; import {ILidoLocator} from "../common/interfaces/ILidoLocator.sol"; +import {IRedeemsBuffer} from "../common/interfaces/IRedeemsBuffer.sol"; import {StETHPermit} from "./StETHPermit.sol"; import {Versioned} from "./utils/Versioned.sol"; @@ -170,6 +171,32 @@ contract Lido is Versioned, StETHPermit, AragonApp { bytes32 internal constant DEPOSITS_RESERVE_TARGET_POSITION = 0x3d3e9bd6e90e5d1f1c6839835bcbe5746a47c9a013d1eae6e80c248264c06a81; + /// @dev Storage slot for redeems reserve target ratio. + /// Stores governance-configured ratio (in basis points) of internal ether. + /// Set via `setRedeemsReserveTargetRatio()`, gated by `BUFFER_RESERVE_MANAGER_ROLE` + /// keccak256("lido.Lido.redeemsReserveTargetRatio") + bytes32 internal constant REDEEMS_RESERVE_TARGET_RATIO_POSITION = + 0xa3ab8c45cc56567e890b52bdd4f310aacdc4a7b9a4384808e34fb3b77524a729; + + /// @dev Storage slot for the redeems reserve snapshot (absolute ETH value). + /// Snapshotted during oracle reports via `_growRedeemsReserve()`. + /// Between reports, `bufferedEther` includes this amount even though ETH is + /// physically on the RedeemsBuffer contract. The allocation priority protects + /// the reserve from CL deposits and WQ finalization. + /// keccak256("lido.Lido.redeemsReserve") + bytes32 internal constant REDEEMS_RESERVE_POSITION = + 0xb7af1be1174094cbfa385246fd431e8a0c2e91a989378c7c6491265918dc3d58; + + /// @dev Storage slot for the redeems reserve growth floor (basis points). See `_growRedeemsReserve`. + /// keccak256("lido.Lido.redeemsReserveGrowthShare") + bytes32 internal constant REDEEMS_RESERVE_GROWTH_SHARE_POSITION = + 0x165efeb2acd150f40e68b22ff2e9492cf5007021f951e4109c407f04e4e36129; + + /// @dev Storage slot for the RedeemsBuffer contract address. + /// keccak256("lido.Lido.redeemsBuffer") + bytes32 internal constant REDEEMS_BUFFER_POSITION = + 0x7810ff65756f3e11a88a439949cb3ed187eb931bf70a02cfd97b01310f42eb2b; + // Staking was paused (don't accept user's ether submits) event StakingPaused(); // Staking was resumed (accept user's ether submits) @@ -258,6 +285,21 @@ contract Lido is Versioned, StETHPermit, AragonApp { // Emitted even if the new value equals the previous one event DepositsReserveTargetSet(uint256 depositsReserveTarget); + // Emitted when redeems reserve target ratio is set via `setRedeemsReserveTargetRatio()` + event RedeemsReserveTargetRatioSet(uint256 ratioBP); + + // Emitted when redeems reserve growth share is set via `setRedeemsReserveGrowthShare()` + event RedeemsReserveGrowthShareSet(uint256 shareBP); + + // Emitted when the redeems reserve snapshot is updated + event RedeemsReserveSet(uint256 reserve); + + // Emitted when ETH is returned from RedeemsBuffer + event EtherReceivedFromRedeemsBuffer(uint256 amount); + + // Emitted when the active RedeemsBuffer address is updated via `setRedeemsBuffer` + event RedeemsBufferSet(address newBuffer); + /** * @notice Initializer function for scratch deploy of Lido contract * @@ -549,7 +591,9 @@ contract Lido is Versioned, StETHPermit, AragonApp { /** * @notice Buffered ether split into reserve buckets. * @param total Total buffered ether, equal to `getBufferedEther()`. - * @param unreserved Buffer remainder after both reserves are filled. Available for additional CL deposits + * @param redeemsReserve Buffer portion soft-reserved for instant stETH redemptions; physically held on + * `RedeemsBuffer`, counted in `bufferedEther` + * @param unreserved Buffer remainder after all reserves are filled. Available for additional CL deposits * beyond the deposits reserve * @param depositsReserve Buffer portion available for CL deposits, protected from withdrawals demand. * Resets on each oracle report, decreases via `withdrawDepositableEther()` @@ -561,34 +605,39 @@ contract Lido is Versioned, StETHPermit, AragonApp { uint256 unreserved; uint256 depositsReserve; uint256 withdrawalsReserve; + uint256 redeemsReserve; } /** * @notice Calculates buffered ether allocation across reserves * @dev Buffer is split by priority: * - * 1. depositsReserve - per-frame CL deposit allowance, filled first - * 2. withdrawalsReserve - covers unfinalized withdrawal requests - * 3. unreserved - excess, available for additional CL deposits + * 1. redeemsReserve - snapshot, highest priority, protected from deposits and WQ + * 2. depositsReserve - per-frame CL deposit allowance + * 3. withdrawalsReserve - covers unfinalized withdrawal requests + * 4. unreserved - excess, available for additional CL deposits * - * ┌─────────── Total Buffered Ether ───────────┐ - * ├────────────────────┬───────────────────────┼─────┬──────────────┐ - * │●●●●●●●●●●●●●●●●●●●●│●●●●●●●●●●●●●●●●●●●●●●●●○○○○○│○○○○○○○○○○○○○○│ - * ├────────────────────┼───────────────────────┼─────┼──────────────┤ - * └─ Deposits Reserve ─┼─ Withdrawals Reserve ─┘ ├─ Unreserved ─┘ - * └───── Unfinalized stETH ─────┘ + * bufferedEther includes the redeems reserve (soft-reserved, physically on RedeemsBuffer). + * Between reports, bufferedEther > Lido.balance by the reserve amount. * - * ● - covered by Buffered Ether + * ┌──────────────── bufferedEther ────────────────┐ + * ├───────────┬────────────────────┬──────────────┼──────┬──────────────┐ + * │▓▓▓▓▓▓▓▓▓▓▓│●●●●●●●●●●●●●●●●●●●●│●●●●●●●●●●●●●●●○○○○○○│○○○○○○○○○○○○○○│ + * ├───────────┼────────────────────┼──────────────┼──────┼──────────────┤ + * └─ Redeems ─┼─ Deposits Reserve ─┼─ WQ Reserve ─┘ ├─ Unreserved ─┘ + * Reserve │ └── Unfinalized stETH ┘ + * │ + * ▓ - physically on RedeemsBuffer (soft-reserved in bufferedEther) + * ● - physically on Lido * ○ - not covered by Buffered Ether - * - * depositsReserve = min(total, stored deposits reserve) - * withdrawalsReserve = min(total - depositsReserve, unfinalizedStETH) - * unreserved = total - depositsReserve - withdrawalsReserve */ function _getBufferedEtherAllocation() internal view returns (BufferedEtherAllocation allocation) { uint256 remaining = _getBufferedEther(); allocation.total = remaining; + allocation.redeemsReserve = Math256.min(remaining, REDEEMS_RESERVE_POSITION.getStorageUint256()); + remaining -= allocation.redeemsReserve; + allocation.depositsReserve = Math256.min(remaining, DEPOSITS_RESERVE_POSITION.getStorageUint256()); remaining -= allocation.depositsReserve; @@ -598,6 +647,15 @@ contract Lido is Versioned, StETHPermit, AragonApp { allocation.unreserved = remaining; } + /** + * @notice Returns the currently effective redeems reserve — buffer portion soft-reserved for instant redemptions + * @dev Equals `min(bufferedEther, REDEEMS_RESERVE_POSITION)`. The stored slot is updated on oracle reports. + * @return Amount soft-reserved for redemptions in wei + */ + function getRedeemsReserve() external view returns (uint256) { + return _getBufferedEtherAllocation().redeemsReserve; + } + /** * @notice Returns the currently effective deposits reserve — buffer portion available for CL deposits, protected * from withdrawals demand @@ -654,6 +712,109 @@ contract Lido is Versioned, StETHPermit, AragonApp { } } + /** + * @notice Sets redeems reserve target ratio in basis points of internal ether + * @dev The buffer is replenished to this target on each oracle report. + * @param _ratioBP Target ratio in basis points [0-10000] + */ + function setRedeemsReserveTargetRatio(uint256 _ratioBP) external { + _auth(BUFFER_RESERVE_MANAGER_ROLE); + require(_ratioBP <= TOTAL_BASIS_POINTS, "INVALID_REDEEMS_RESERVE_RATIO"); + REDEEMS_RESERVE_TARGET_RATIO_POSITION.setStorageUint256(_ratioBP); + emit RedeemsReserveTargetRatioSet(_ratioBP); + } + + /** + * @notice Returns the redeems reserve target ratio in basis points + */ + function getRedeemsReserveTargetRatio() external view returns (uint256) { + return REDEEMS_RESERVE_TARGET_RATIO_POSITION.getStorageUint256(); + } + + /** + * @notice Sets the per-report reserve growth floor in basis points of `withdrawalsReserve + unreserved` + * @dev See `_growRedeemsReserve`. `_shareBP = 0` means reserve grows only from unreserved surplus. + * @param _shareBP Growth share in basis points [0-10000] + */ + function setRedeemsReserveGrowthShare(uint256 _shareBP) external { + _auth(BUFFER_RESERVE_MANAGER_ROLE); + require(_shareBP <= TOTAL_BASIS_POINTS, "INVALID_REDEEMS_RESERVE_GROWTH_SHARE"); + REDEEMS_RESERVE_GROWTH_SHARE_POSITION.setStorageUint256(_shareBP); + emit RedeemsReserveGrowthShareSet(_shareBP); + } + + /** + * @notice Returns the redeems reserve growth share in basis points + */ + function getRedeemsReserveGrowthShare() external view returns (uint256) { + return REDEEMS_RESERVE_GROWTH_SHARE_POSITION.getStorageUint256(); + } + + /** + * @notice Returns the current redeems reserve target in wei + * @dev 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; + } + + /** + * @notice Receives ETH back from RedeemsBuffer (unredeemed return on report). + * @dev `bufferedEther` is unchanged — ETH was already counted while it sat on the buffer. + */ + function receiveFromRedeemsBuffer() external payable { + address buffer = _getRedeemsBuffer(); + require(buffer != address(0), "REDEEMS_BUFFER_NOT_CONFIGURED"); + _auth(buffer); + if (msg.value > 0) { + emit EtherReceivedFromRedeemsBuffer(msg.value); + } + } + + /** + * @notice Returns the address of the active RedeemsBuffer + */ + function getRedeemsBuffer() external view returns (address) { + return _getRedeemsBuffer(); + } + + /** + * @notice Swaps the active RedeemsBuffer; the previous one must be fully reconciled. + * @param _newBuffer Address of the new RedeemsBuffer (or zero to disable the feature) + */ + function setRedeemsBuffer(address _newBuffer) external { + _auth(BUFFER_RESERVE_MANAGER_ROLE); + + address current = _getRedeemsBuffer(); + if (current != address(0)) { + IRedeemsBuffer(current).validateReconciledAndPause(); + } + + REDEEMS_BUFFER_POSITION.setLowUint160(uint160(_newBuffer)); + emit RedeemsBufferSet(_newBuffer); + } + + /** + * @dev Reads the active RedeemsBuffer address from storage. + */ + function _getRedeemsBuffer() internal view returns (address) { + return address(REDEEMS_BUFFER_POSITION.getLowUint160()); + } + + /** + * @dev Stores new redeems reserve value and emits RedeemsReserveSet event. + */ + function _setRedeemsReserve(uint256 _reserve) internal { + REDEEMS_RESERVE_POSITION.setStorageUint256(_reserve); + emit RedeemsReserveSet(_reserve); + } + /** * @return the amount of ether held by external sources to back external shares */ @@ -756,7 +917,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { /** * @return the amount of ether in the buffer that can be deposited to the Consensus Layer - * @dev Equals buffered ether minus withdrawals reserve from `_getBufferedEtherAllocation()` + * @dev Equals `depositsReserve + unreserved` from `_getBufferedEtherAllocation()`. */ function getDepositableEther() external view returns (uint256) { return _getDepositableEther(_getBufferedEtherAllocation()); @@ -996,6 +1157,8 @@ contract Lido is Versioned, StETHPermit, AragonApp { * @param _lastWithdrawalRequestToFinalize last withdrawal request ID to finalize * @param _withdrawalsShareRate share rate used to fulfill withdrawal requests * @param _etherToLockOnWithdrawalQueue amount of ETH to lock on the WithdrawalQueue to fulfill withdrawal requests + * @param _redeemedEther redeemed ether snapshot reconciled against `bufferedEther` and the buffer counters + * @param _redeemedShares redeemed shares snapshot consumed from the buffer for this report */ function collectRewardsAndProcessWithdrawals( uint256 _reportTimestamp, @@ -1005,13 +1168,25 @@ contract Lido is Versioned, StETHPermit, AragonApp { uint256 _elRewardsToWithdraw, uint256 _lastWithdrawalRequestToFinalize, uint256 _withdrawalsShareRate, - uint256 _etherToLockOnWithdrawalQueue + uint256 _etherToLockOnWithdrawalQueue, + uint256 _redeemedEther, + uint256 _redeemedShares ) external { _whenNotStopped(); ILidoLocator locator = _getLidoLocator(); _auth(_accounting(locator)); + // --- 1. Buffer round-trip: reconcile buffer state, reconcile bufferedEther --- + address buffer = _getRedeemsBuffer(); + if (buffer != address(0)) { + IRedeemsBuffer(buffer).reconcile(_redeemedEther, _redeemedShares); + if (_redeemedEther > 0) { + _setBufferedEther(_getBufferedEther().sub(_redeemedEther)); + } + } + + // --- 2. Standard flow --- // withdraw execution layer rewards and put them to the buffer if (_elRewardsToWithdraw > 0) { _elRewardsVault(locator).withdrawRewards(_elRewardsToWithdraw); @@ -1045,17 +1220,44 @@ contract Lido is Versioned, StETHPermit, AragonApp { _elRewardsToWithdraw, postBufferedEther ); + + // --- 3. Push new reserve to buffer --- + if (buffer != address(0)) { + uint256 reserve = _getBufferedEtherAllocation().redeemsReserve; + if (reserve > 0) { + IRedeemsBuffer(buffer).fundReserve.value(reserve)(); + } + } } - /** - * @dev Syncs stored deposits reserve to configured target after oracle report processing - */ + /// @dev Resets the deposits reserve slot to its target, then grows the redeems reserve. function _updateBufferedEtherAllocation() internal { - uint256 depositsReserveTarget = getDepositsReserveTarget(); - uint256 depositsReserve = DEPOSITS_RESERVE_POSITION.getStorageUint256(); + _resetDepositsReserve(); + _growRedeemsReserve(); + } + + function _resetDepositsReserve() internal { + uint256 target = getDepositsReserveTarget(); + if (DEPOSITS_RESERVE_POSITION.getStorageUint256() != target) { + _setDepositsReserve(target); + } + } + + /// @dev Per-report growth is `max(unreserved, growthShareBP × (withdrawalsReserve + unreserved) / 10000)`, + /// capped by `target = ratio × internalEther`. Also shrinks the reserve when target is lowered. + function _growRedeemsReserve() internal { + uint256 target = _getRedeemsReserveTarget(); + BufferedEtherAllocation memory allocation = _getBufferedEtherAllocation(); + + uint256 growthShareBP = REDEEMS_RESERVE_GROWTH_SHARE_POSITION.getStorageUint256(); + uint256 availableEther = allocation.withdrawalsReserve.add(allocation.unreserved); + uint256 minGrowth = availableEther.mul(growthShareBP) / TOTAL_BASIS_POINTS; + uint256 growth = Math256.max(allocation.unreserved, minGrowth); + + uint256 newRedeemsReserve = Math256.min(allocation.redeemsReserve.add(growth), target); - if (depositsReserve != depositsReserveTarget) { - _setDepositsReserve(depositsReserveTarget); + if (newRedeemsReserve != allocation.redeemsReserve) { + _setRedeemsReserve(newRedeemsReserve); } } diff --git a/contracts/0.8.25/RedeemsBuffer.sol b/contracts/0.8.25/RedeemsBuffer.sol new file mode 100644 index 000000000..a8dd76d03 --- /dev/null +++ b/contracts/0.8.25/RedeemsBuffer.sol @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.25; + +import {IERC20} from "@openzeppelin/contracts-v5.2/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts-v5.2/token/ERC20/utils/SafeERC20.sol"; +import {SafeCast} from "@openzeppelin/contracts-v5.2/utils/math/SafeCast.sol"; +import {AccessControlEnumerableUpgradeable} from + "contracts/openzeppelin/5.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; + +import {PausableUntil} from "contracts/common/utils/PausableUntil.sol"; +import {IHashConsensus} from "contracts/common/interfaces/IHashConsensus.sol"; +import {ILido} from "contracts/common/interfaces/ILido.sol"; +import {IBurner} from "contracts/common/interfaces/IBurner.sol"; +import {IWithdrawalQueue} from "contracts/common/interfaces/IWithdrawalQueue.sol"; + +import {RefSlotCache} from "./vaults/lib/RefSlotCache.sol"; + +/** + * @title RedeemsBuffer + * @author Lido + * @notice Holds reserve ETH for instant stETH-to-ETH redemptions + */ +contract RedeemsBuffer is PausableUntil, AccessControlEnumerableUpgradeable { + using SafeERC20 for IERC20; + using SafeCast for uint256; + using RefSlotCache for RefSlotCache.Uint104WithCache; + + bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); + bytes32 public constant RESUME_ROLE = keccak256("RESUME_ROLE"); + bytes32 public constant REDEEMER_ROLE = keccak256("REDEEMER_ROLE"); + bytes32 public constant RECOVER_ROLE = keccak256("RECOVER_ROLE"); + + ILido public immutable LIDO; + IBurner public immutable BURNER; + IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; + IHashConsensus public immutable HASH_CONSENSUS; + + uint256 private _reserveBalance; + RefSlotCache.Uint104WithCache private _redeemedEther; + RefSlotCache.Uint104WithCache private _redeemedShares; + + event Redeemed( + address indexed caller, + address indexed ethRecipient, + uint256 requestedStETH, + uint256 burnedShares, + uint256 paidEther + ); + event ReserveFunded(uint256 amount); + event ERC20Recovered(address indexed requestedBy, address indexed token, uint256 amount, address indexed recipient); + event StETHSharesRecovered(address indexed requestedBy, uint256 shares, address indexed recipient); + event EtherRecovered(address indexed requestedBy, uint256 amount, address indexed recipient); + + error AdminCannotBeZero(); + error ZeroAmount(); + error ZeroRecipient(); + error LidoStopped(); + error BunkerModeActive(); + error WithdrawalQueuePaused(); + error InsufficientReserve(uint256 requested, uint256 available); + error NotLido(); + error EthTransferFailed(address recipient, uint256 amount); + error StETHRecoveryNotAllowed(); + error DirectETHTransfer(); + error SnapshotExceedsLiveValue(uint256 snapshot, uint256 live); + error BufferNotReconciled(uint256 reserveBalance, uint256 redeemedEther, uint256 redeemedShares); + + modifier onlyLido() { + if (msg.sender != address(LIDO)) revert NotLido(); + _; + } + + constructor(address _lido, address _burner, address _withdrawalQueue, address _hashConsensus) { + LIDO = ILido(_lido); + BURNER = IBurner(_burner); + WITHDRAWAL_QUEUE = IWithdrawalQueue(_withdrawalQueue); + HASH_CONSENSUS = IHashConsensus(_hashConsensus); + _disableInitializers(); + } + + /// @notice One-time proxy initializer + /// @param _admin address granted `DEFAULT_ADMIN_ROLE` + function initialize(address _admin) external initializer { + if (_admin == address(0)) revert AdminCannotBeZero(); + __AccessControlEnumerable_init(); + LIDO.approve(address(BURNER), type(uint256).max); + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + } + + /// @notice Returns the current initialized version of the contract + function getContractVersion() external view returns (uint256) { + return _getInitializedVersion(); + } + + /// @notice Exchange stETH for ETH from the reserve + /// @param _stETHAmount amount of stETH to redeem + /// @param _ethRecipient address that receives the 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 BunkerModeActive(); + if (WITHDRAWAL_QUEUE.isPaused()) revert WithdrawalQueuePaused(); + + uint256 sharesAmount = LIDO.getSharesByPooledEth(_stETHAmount); + uint256 etherAmount = LIDO.getPooledEthByShares(sharesAmount); + + uint256 available = _reserveBalance - _redeemedEther.value; + if (etherAmount > available) { + revert InsufficientReserve(etherAmount, available); + } + + LIDO.transferSharesFrom(msg.sender, address(this), sharesAmount); + BURNER.requestBurnShares(address(this), sharesAmount); + + _redeemedEther = _redeemedEther.withValueIncrease(HASH_CONSENSUS, etherAmount.toUint104()); + _redeemedShares = _redeemedShares.withValueIncrease(HASH_CONSENSUS, sharesAmount.toUint104()); + + (bool success,) = _ethRecipient.call{value: etherAmount}(""); + if (!success) revert EthTransferFailed(_ethRecipient, etherAmount); + + emit Redeemed(msg.sender, _ethRecipient, _stETHAmount, sharesAmount, etherAmount); + } + + // ── Read interface ────────────────────────────────────────────────── + + /// @notice Redeemed ether and shares since the last reconciliation (live values) + function getRedeemed() external view returns (uint256 redeemedEther, uint256 redeemedShares) { + return (_redeemedEther.value, _redeemedShares.value); + } + + /// @notice Redeemed ether and shares as of the last oracle frame boundary (snapshot for Accounting) + function getRedeemedForLastRefSlot() external view returns (uint256 redeemedEther, uint256 redeemedShares) { + return ( + _redeemedEther.getValueForLastRefSlot(HASH_CONSENSUS), + _redeemedShares.getValueForLastRefSlot(HASH_CONSENSUS) + ); + } + + /// @notice Tracked reserve ETH for the current cycle; zeroed on `reconcile`, refilled by `fundReserve` + function getReserveBalance() external view returns (uint256) { + return _reserveBalance; + } + + // ── Lido callbacks ────────────────────────────────────────────────── + + /// @notice Receives ETH from Lido to replenish the reserve. Lido-only. + function fundReserve() external payable onlyLido { + _reserveBalance += msg.value; + emit ReserveFunded(msg.value); + } + + /// @notice Reconciles the buffer with the processed oracle report and returns unredeemed ETH to Lido. Lido-only. + /// @param _redeemedEtherForLastRefSlot ether snapshot Accounting consumed for this report + /// @param _redeemedSharesForLastRefSlot shares snapshot Accounting consumed for this report + function reconcile(uint256 _redeemedEtherForLastRefSlot, uint256 _redeemedSharesForLastRefSlot) + external + onlyLido + { + uint256 unredeemed = _reserveBalance - _redeemedEther.value; + _reserveBalance = 0; + + uint48 currentRefSlot = uint48(_currentRefSlot()); + _resetCounter(_redeemedEther, _redeemedEtherForLastRefSlot, currentRefSlot); + _resetCounter(_redeemedShares, _redeemedSharesForLastRefSlot, currentRefSlot); + + if (unredeemed > 0) { + LIDO.receiveFromRedeemsBuffer{value: unredeemed}(); + } + } + + /// @dev Subtracts `_consumed` from `_cache.value` and re-anchors the cache at `_refSlot`. + /// Reverts if `_consumed` exceeds the current live value. + function _resetCounter( + RefSlotCache.Uint104WithCache storage _cache, + uint256 _consumed, + uint48 _refSlot + ) private { + if (_consumed > _cache.value) revert SnapshotExceedsLiveValue(_consumed, _cache.value); + _cache.value = (_cache.value - _consumed).toUint104(); + _cache.valueOnRefSlot = 0; + _cache.refSlot = _refSlot; + } + + /// @notice Asserts the buffer is fully reconciled and infinitely pauses `redeem`. Lido-only. + function validateReconciledAndPause() external onlyLido { + uint256 reserveBalance = _reserveBalance; + uint256 redeemedEther = _redeemedEther.value; + uint256 redeemedShares = _redeemedShares.value; + if (reserveBalance != 0 || redeemedEther != 0 || redeemedShares != 0) { + revert BufferNotReconciled(reserveBalance, redeemedEther, redeemedShares); + } + + _pauseFor(PAUSE_INFINITELY); + } + + // ── Recovery ───────────────────────────────────────────────────────── + + /// @notice Recovers an arbitrary ERC20 token (except stETH) to `_recipient` + function recoverERC20(address _token, uint256 _amount, address _recipient) external onlyRole(RECOVER_ROLE) { + if (_recipient == address(0)) revert ZeroRecipient(); + if (_token == address(LIDO)) revert StETHRecoveryNotAllowed(); + emit ERC20Recovered(msg.sender, _token, _amount, _recipient); + IERC20(_token).safeTransfer(_recipient, _amount); + } + + /// @notice Recovers stETH shares stuck on the contract to `_recipient` + function recoverStETHShares(address _recipient) external onlyRole(RECOVER_ROLE) { + if (_recipient == address(0)) revert ZeroRecipient(); + uint256 shares = LIDO.sharesOf(address(this)); + if (shares > 0) { + emit StETHSharesRecovered(msg.sender, shares, _recipient); + LIDO.transferShares(_recipient, shares); + } + } + + /// @notice Recovers ether stuck on the contract (e.g. from selfdestruct) to `_recipient` + function recoverEther(address _recipient) external onlyRole(RECOVER_ROLE) { + if (_recipient == address(0)) revert ZeroRecipient(); + uint256 amount = address(this).balance + _redeemedEther.value - _reserveBalance; + if (amount > 0) { + emit EtherRecovered(msg.sender, amount, _recipient); + (bool success,) = _recipient.call{value: amount}(""); + if (!success) revert EthTransferFailed(_recipient, amount); + } + } + + // ── Pause ──────────────────────────────────────────────────────────── + + /// @notice Pauses `redeem` for `_duration` seconds + function pauseFor(uint256 _duration) external onlyRole(PAUSE_ROLE) { + _pauseFor(_duration); + } + + /// @notice Pauses `redeem` until (and including) `_pauseUntilInclusive` timestamp + function pauseUntil(uint256 _pauseUntilInclusive) external onlyRole(PAUSE_ROLE) { + _pauseUntil(_pauseUntilInclusive); + } + + /// @notice Resumes `redeem` + function resume() external onlyRole(RESUME_ROLE) { + _resume(); + } + + /// @dev Rejects direct ETH transfers; ETH enters via `fundReserve` (Lido-only). + receive() external payable { + revert DirectETHTransfer(); + } + + function _currentRefSlot() private view returns (uint256) { + (uint256 refSlot,) = HASH_CONSENSUS.getCurrentFrame(); + return refSlot; + } +} diff --git a/contracts/0.8.9/Accounting.sol b/contracts/0.8.9/Accounting.sol index 03e1ccc19..cdedd4113 100644 --- a/contracts/0.8.9/Accounting.sol +++ b/contracts/0.8.9/Accounting.sol @@ -11,6 +11,7 @@ import {ILido} from "contracts/common/interfaces/ILido.sol"; import {ReportValues} from "contracts/common/interfaces/ReportValues.sol"; import {IVaultHub} from "contracts/common/interfaces/IVaultHub.sol"; +import {IRedeemsBuffer} from "contracts/common/interfaces/IRedeemsBuffer.sol"; import {IPostTokenRebaseReceiver} from "./interfaces/IPostTokenRebaseReceiver.sol"; import {WithdrawalQueue} from "./WithdrawalQueue.sol"; @@ -44,6 +45,7 @@ contract Accounting { IPostTokenRebaseReceiver postTokenRebaseReceiver; IStakingRouter stakingRouter; IVaultHub vaultHub; + IRedeemsBuffer redeemsBuffer; } /// @notice snapshot of the protocol state that may be changed during the report @@ -57,6 +59,8 @@ contract Accounting { uint256 externalShares; uint256 externalEther; uint256 badDebtToInternalize; + uint256 redeemedEther; + uint256 redeemedShares; } /// @notice precalculated values that is used to change the state of the protocol during the report @@ -159,6 +163,18 @@ contract Accounting { } else { pre.badDebtToInternalize = _contracts.vaultHub.badDebtToInternalizeForLastRefSlot(); } + + (pre.redeemedEther, pre.redeemedShares) = _getRedeemed(_contracts, isSimulation); + } + + function _getRedeemed(Contracts memory _contracts, bool _isSimulation) + internal + view + returns (uint256 redeemedEther, uint256 redeemedShares) + { + if (address(_contracts.redeemsBuffer) == address(0)) return (0, 0); + if (_isSimulation) return _contracts.redeemsBuffer.getRedeemed(); + return _contracts.redeemsBuffer.getRedeemedForLastRefSlot(); } /// @dev calculates all the state changes that is required to apply the report @@ -188,23 +204,24 @@ contract Accounting { update.sharesToBurnForWithdrawals, update.totalSharesToBurn // shares to burn from Burner balance ) = _contracts.oracleReportSanityChecker.smoothenTokenRebase( - _pre.totalPooledEther - _pre.externalEther, // we need to change the base as shareRate is now calculated on - _pre.totalShares - _pre.externalShares, // internal ether and shares, but inside it's still total + _pre.totalPooledEther - _pre.externalEther - _pre.redeemedEther, // we need to change the base as shareRate is now calculated on + _pre.totalShares - _pre.externalShares - _pre.redeemedShares, // internal ether and shares, but inside it's still total update.principalClBalance, _report.clValidatorsBalance + _report.clPendingBalance, _report.withdrawalVaultBalance, _report.elRewardsVaultBalance, - _report.sharesRequestedToBurn, + _report.sharesRequestedToBurn - _pre.redeemedShares, update.etherToFinalizeWQ, update.sharesToFinalizeWQ ); uint256 postInternalSharesBeforeFees = _pre.totalShares - _pre.externalShares - // internal shares before - update.totalSharesToBurn; // shares to be burned for withdrawals and cover + update.totalSharesToBurn - // shares to be burned for withdrawals and cover + _pre.redeemedShares; update.postInternalEther = - _pre.totalPooledEther - _pre.externalEther // internal ether before + _pre.totalPooledEther - _pre.externalEther - _pre.redeemedEther // internal ether before + _report.clValidatorsBalance + _report.clPendingBalance + update.withdrawalsVaultTransfer - update.principalClBalance + update.elRewardsVaultTransfer - update.etherToFinalizeWQ; @@ -366,8 +383,11 @@ contract Accounting { LIDO.internalizeExternalBadDebt(_pre.badDebtToInternalize); } - if (_update.totalSharesToBurn > 0) { - _contracts.burner.commitSharesToBurn(_update.totalSharesToBurn); + { + uint256 totalBurn = _update.totalSharesToBurn + _pre.redeemedShares; + if (totalBurn > 0) { + _contracts.burner.commitSharesToBurn(totalBurn, _pre.redeemedShares); + } } LIDO.collectRewardsAndProcessWithdrawals( @@ -378,7 +398,9 @@ contract Accounting { _update.elRewardsVaultTransfer, lastWithdrawalRequestToFinalize, _report.simulatedShareRate, - _update.etherToFinalizeWQ + _update.etherToFinalizeWQ, + _pre.redeemedEther, + _pre.redeemedShares ); if (_update.sharesToMintAsFees > 0) { @@ -502,6 +524,8 @@ contract Accounting { address vaultHub ) = LIDO_LOCATOR.oracleReportComponents(); + address redeemsBuffer = LIDO.getRedeemsBuffer(); + return Contracts( accountingOracle, @@ -510,7 +534,8 @@ contract Accounting { WithdrawalQueue(withdrawalQueue), IPostTokenRebaseReceiver(postTokenRebaseReceiver), IStakingRouter(stakingRouter), - IVaultHub(vaultHub) + IVaultHub(vaultHub), + IRedeemsBuffer(redeemsBuffer) ); } diff --git a/contracts/0.8.9/Burner.sol b/contracts/0.8.9/Burner.sol index 0c320a9ce..51860feb4 100644 --- a/contracts/0.8.9/Burner.sol +++ b/contracts/0.8.9/Burner.sol @@ -345,8 +345,10 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned { * Does nothing if zero amount passed. * * @param _sharesToBurn amount of shares to be burnt + * @param _minNonCoverSharesToBurn floor on shares drawn from non-cover before the cover-first split applies to + * the remainder. At least this many shares will come from the non-cover counter (capped by what's requested). */ - function commitSharesToBurn(uint256 _sharesToBurn) external virtual override { + function commitSharesToBurn(uint256 _sharesToBurn, uint256 _minNonCoverSharesToBurn) external virtual override { if (msg.sender != LOCATOR.accounting()) revert AppAuthFailed(); if (_sharesToBurn == 0) { @@ -357,39 +359,31 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned { uint256 memCoverSharesBurnRequested = $.coverSharesBurnRequested; uint256 memNonCoverSharesBurnRequested = $.nonCoverSharesBurnRequested; - uint256 burnAmount = memCoverSharesBurnRequested + memNonCoverSharesBurnRequested; - - if (_sharesToBurn > burnAmount) { - revert BurnAmountExceedsActual(_sharesToBurn, burnAmount); + if (_sharesToBurn > memCoverSharesBurnRequested + memNonCoverSharesBurnRequested) { + revert BurnAmountExceedsActual(_sharesToBurn, memCoverSharesBurnRequested + memNonCoverSharesBurnRequested); } - uint256 sharesToBurnNow; - if (memCoverSharesBurnRequested > 0) { - uint256 sharesToBurnNowForCover = Math.min(_sharesToBurn, memCoverSharesBurnRequested); - - $.totalCoverSharesBurnt += sharesToBurnNowForCover; - uint256 stETHToBurnNowForCover = LIDO.getPooledEthByShares(sharesToBurnNowForCover); - emit StETHBurnt(true /* isCover */, stETHToBurnNowForCover, sharesToBurnNowForCover); - - $.coverSharesBurnRequested -= sharesToBurnNowForCover; - sharesToBurnNow += sharesToBurnNowForCover; + uint256 nonCoverFloor = Math.min(_minNonCoverSharesToBurn, memNonCoverSharesBurnRequested); + uint256 remaining = _sharesToBurn - nonCoverFloor; + uint256 coverToBurn = Math.min(remaining, memCoverSharesBurnRequested); + uint256 nonCoverToBurn = nonCoverFloor + Math.min( + remaining - coverToBurn, + memNonCoverSharesBurnRequested - nonCoverFloor + ); + + if (nonCoverToBurn > 0) { + $.totalNonCoverSharesBurnt += nonCoverToBurn; + $.nonCoverSharesBurnRequested -= nonCoverToBurn; + emit StETHBurnt(false /* isCover */, LIDO.getPooledEthByShares(nonCoverToBurn), nonCoverToBurn); } - if (memNonCoverSharesBurnRequested > 0 && sharesToBurnNow < _sharesToBurn) { - uint256 sharesToBurnNowForNonCover = Math.min( - _sharesToBurn - sharesToBurnNow, - memNonCoverSharesBurnRequested - ); - - $.totalNonCoverSharesBurnt += sharesToBurnNowForNonCover; - uint256 stETHToBurnNowForNonCover = LIDO.getPooledEthByShares(sharesToBurnNowForNonCover); - emit StETHBurnt(false /* isCover */, stETHToBurnNowForNonCover, sharesToBurnNowForNonCover); - - $.nonCoverSharesBurnRequested -= sharesToBurnNowForNonCover; - sharesToBurnNow += sharesToBurnNowForNonCover; + if (coverToBurn > 0) { + $.totalCoverSharesBurnt += coverToBurn; + $.coverSharesBurnRequested -= coverToBurn; + emit StETHBurnt(true /* isCover */, LIDO.getPooledEthByShares(coverToBurn), coverToBurn); } LIDO.burnShares(_sharesToBurn); - assert(sharesToBurnNow == _sharesToBurn); + assert(coverToBurn + nonCoverToBurn == _sharesToBurn); } /** diff --git a/contracts/common/interfaces/IBurner.sol b/contracts/common/interfaces/IBurner.sol index 0f71f5fb0..3f4b676c2 100644 --- a/contracts/common/interfaces/IBurner.sol +++ b/contracts/common/interfaces/IBurner.sol @@ -14,8 +14,11 @@ interface IBurner { * Commit cover/non-cover burning requests and logs cover/non-cover shares amount just burnt. * * NB: The real burn enactment to be invoked after the call (via internal Lido._burnShares()) + * + * @param _sharesToBurn total shares to burn this report + * @param _minNonCoverSharesToBurn floor on shares drawn from non-cover before the cover-first split */ - function commitSharesToBurn(uint256 _sharesToBurn) external; + function commitSharesToBurn(uint256 _sharesToBurn, uint256 _minNonCoverSharesToBurn) external; /** * Request burn shares diff --git a/contracts/common/interfaces/ILido.sol b/contracts/common/interfaces/ILido.sol index 725a3b949..c2188b0f6 100644 --- a/contracts/common/interfaces/ILido.sol +++ b/contracts/common/interfaces/ILido.sol @@ -64,7 +64,9 @@ interface ILido is IERC20, IVersioned { uint256 _elRewardsToWithdraw, uint256 _lastWithdrawalRequestToFinalize, uint256 _withdrawalsShareRate, - uint256 _etherToLockOnWithdrawalQueue + uint256 _etherToLockOnWithdrawalQueue, + uint256 _redeemedEther, + uint256 _redeemedShares ) external; function emitTokenRebase( @@ -82,4 +84,21 @@ interface ILido is IERC20, IVersioned { function mintShares(address _recipient, uint256 _sharesAmount) external; function internalizeExternalBadDebt(uint256 _amountOfShares) external; + + function isStopped() external view returns (bool); + + function receiveFromRedeemsBuffer() external payable; + + // --- buffer allocation getters (primarily for off-chain monitoring) --- + function getDepositableEther() external view returns (uint256); + + function getWithdrawalsReserve() external view returns (uint256); + + function getRedeemsReserve() external view returns (uint256); + + function getRedeemsReserveTarget() external view returns (uint256); + + function getRedeemsReserveGrowthShare() external view returns (uint256); + + function getRedeemsBuffer() external view returns (address); } diff --git a/contracts/common/interfaces/IRedeemsBuffer.sol b/contracts/common/interfaces/IRedeemsBuffer.sol new file mode 100644 index 000000000..2576158d9 --- /dev/null +++ b/contracts/common/interfaces/IRedeemsBuffer.sol @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +// solhint-disable-next-line lido/fixed-compiler-version +pragma solidity >=0.4.24; + +interface IRedeemsBuffer { + function fundReserve() external payable; + function reconcile(uint256 _redeemedEtherForLastRefSlot, uint256 _redeemedSharesForLastRefSlot) external; + function validateReconciledAndPause() external; + function getRedeemed() external view returns (uint256 redeemedEther, uint256 redeemedShares); + function getRedeemedForLastRefSlot() external view returns (uint256 redeemedEther, uint256 redeemedShares); + function getReserveBalance() external view returns (uint256); +} diff --git a/contracts/common/interfaces/IWithdrawalQueue.sol b/contracts/common/interfaces/IWithdrawalQueue.sol index 2f3a5b1e6..7b9550c7a 100644 --- a/contracts/common/interfaces/IWithdrawalQueue.sol +++ b/contracts/common/interfaces/IWithdrawalQueue.sol @@ -12,4 +12,6 @@ interface IWithdrawalQueue { ) external view returns (uint256 ethToLock, uint256 sharesToBurn); function isPaused() external view returns (bool); + + function isBunkerModeActive() external view returns (bool); } diff --git a/lib/protocol/helpers/accounting.ts b/lib/protocol/helpers/accounting.ts index 1f1a29bb9..fe95e06d8 100644 --- a/lib/protocol/helpers/accounting.ts +++ b/lib/protocol/helpers/accounting.ts @@ -620,6 +620,10 @@ export const simulateReport = async ( "El Rewards Vault Balance": formatEther(elRewardsVaultBalance), }); + // sharesRequestedToBurn must include redeem shares (they're nonCover on Burner) + const [simCoverShares, simNonCoverShares] = await ctx.contracts.burner.getSharesRequestedToBurn(); + const simSharesRequestedToBurn = simCoverShares + simNonCoverShares; + const reportValues: ReportValuesStruct = { timestamp: reportTimestamp, // timeElapsed: (await getReportTimeElapsed(ctx)).timeElapsed, @@ -628,7 +632,7 @@ export const simulateReport = async ( clPendingBalance, withdrawalVaultBalance, elRewardsVaultBalance, - sharesRequestedToBurn: 0n, + sharesRequestedToBurn: simSharesRequestedToBurn, withdrawalFinalizationBatches: [], simulatedShareRate: 10n ** 27n, }; diff --git a/lib/state-file.ts b/lib/state-file.ts index 608127a39..39d3e0ce0 100644 --- a/lib/state-file.ts +++ b/lib/state-file.ts @@ -114,6 +114,7 @@ export enum Sk { validatorConsolidationRequests = "validatorConsolidationRequests", lazyOracle = "lazyOracle", topUpGateway = "topUpGateway", + redeemsBuffer = "redeemsBuffer", v3TemporaryAdmin = "v3TemporaryAdmin", // Dual Governance dgDualGovernance = "dg:dualGovernance", @@ -160,6 +161,7 @@ export function getAddress(contractKey: Sk, state: DeploymentState): string { case Sk.dgDualGovernance: case Sk.dgEmergencyProtectedTimelock: case Sk.topUpGateway: + case Sk.redeemsBuffer: return state[contractKey].proxy.address; case Sk.apmRegistryFactory: case Sk.callsScript: diff --git a/scripts/scratch/steps.json b/scripts/scratch/steps.json index 7296dd6c8..00b22b68b 100644 --- a/scripts/scratch/steps.json +++ b/scripts/scratch/steps.json @@ -11,6 +11,7 @@ "scratch/steps/0080-issue-tokens", "scratch/steps/0083-deploy-core", "scratch/steps/0085-deploy-vaults", + "scratch/steps/0086-deploy-redeems-buffer", "scratch/steps/0090-upgrade-locator", "scratch/steps/0100-gate-seal", "scratch/steps/0110-finalize-dao", diff --git a/scripts/scratch/steps/0086-deploy-redeems-buffer.ts b/scripts/scratch/steps/0086-deploy-redeems-buffer.ts new file mode 100644 index 000000000..ff6ddeb07 --- /dev/null +++ b/scripts/scratch/steps/0086-deploy-redeems-buffer.ts @@ -0,0 +1,24 @@ +import { ethers } from "hardhat"; + +import { deployBehindOssifiableProxy } from "lib/deploy"; +import { getAddress, readNetworkState, Sk } from "lib/state-file"; + +export async function main() { + const deployer = (await ethers.provider.getSigner()).address; + const state = readNetworkState({ deployer }); + + const lidoAddress = getAddress(Sk.appLido, state); + const burnerAddress = getAddress(Sk.burner, state); + const withdrawalQueueAddress = getAddress(Sk.withdrawalQueueERC721, state); + const hashConsensusAddress = getAddress(Sk.hashConsensusForAccountingOracle, state); + + const redeemsBuffer_ = await deployBehindOssifiableProxy(Sk.redeemsBuffer, "RedeemsBuffer", deployer, deployer, [ + lidoAddress, + burnerAddress, + withdrawalQueueAddress, + hashConsensusAddress, + ]); + + const redeemsBuffer = await ethers.getContractAt("RedeemsBuffer", redeemsBuffer_.address); + await (await redeemsBuffer.initialize(deployer)).wait(); +} diff --git a/scripts/scratch/steps/0130-grant-roles.ts b/scripts/scratch/steps/0130-grant-roles.ts index ca7f6fd2e..14469202c 100644 --- a/scripts/scratch/steps/0130-grant-roles.ts +++ b/scripts/scratch/steps/0130-grant-roles.ts @@ -11,7 +11,7 @@ import { import { loadContract } from "lib/contract"; import { makeTx } from "lib/deploy"; import { log } from "lib/log"; -import { readNetworkState, Sk } from "lib/state-file"; +import { getAddress, readNetworkState, Sk } from "lib/state-file"; export async function main() { const deployer = (await ethers.provider.getSigner()).address; @@ -128,4 +128,9 @@ export async function main() { await makeTx(burner, "grantRole", [requestBurnSharesRole, accountingAddress], { from: deployer, }); + + const redeemsBufferAddress = getAddress(Sk.redeemsBuffer, state); + await makeTx(burner, "grantRole", [requestBurnSharesRole, redeemsBufferAddress], { + from: deployer, + }); } diff --git a/test/0.4.24/contracts/Burner__MockForAccounting.sol b/test/0.4.24/contracts/Burner__MockForAccounting.sol index a8a3bd36d..1efa06e3a 100644 --- a/test/0.4.24/contracts/Burner__MockForAccounting.sol +++ b/test/0.4.24/contracts/Burner__MockForAccounting.sol @@ -19,7 +19,7 @@ contract Burner__MockForAccounting { emit Mock__StETHBurnRequested(false, msg.sender, _stETHAmount, _sharesAmountToBurn); } - function commitSharesToBurn(uint256 _sharesToBurn) external { + function commitSharesToBurn(uint256 _sharesToBurn, uint256) external { _sharesToBurn; emit Mock__CommitSharesToBurnWasCalled(_sharesToBurn); diff --git a/test/0.4.24/contracts/Burner__MockForDistributeReward.sol b/test/0.4.24/contracts/Burner__MockForDistributeReward.sol index 88535c87a..58f6992af 100644 --- a/test/0.4.24/contracts/Burner__MockForDistributeReward.sol +++ b/test/0.4.24/contracts/Burner__MockForDistributeReward.sol @@ -19,7 +19,7 @@ contract Burner__MockForDistributeReward { emit StETHBurnRequested(false, msg.sender, _stETHAmount, _sharesAmountToBurn); } - function commitSharesToBurn(uint256 _sharesToBurn) external { + function commitSharesToBurn(uint256 _sharesToBurn, uint256) external { _sharesToBurn; emit Mock__CommitSharesToBurnWasCalled(); diff --git a/test/0.4.24/lido/lido.accounting.test.ts b/test/0.4.24/lido/lido.accounting.test.ts index 63a4eea0e..87230c206 100644 --- a/test/0.4.24/lido/lido.accounting.test.ts +++ b/test/0.4.24/lido/lido.accounting.test.ts @@ -397,7 +397,7 @@ describe("Lido:accounting", () => { expect(await lido.getWithdrawalsReserve()).to.equal(withdrawalsReserveBefore - lockAmount); }); - type ArgsTuple = [bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]; + type ArgsTuple = [bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]; interface Args { reportTimestamp: bigint; @@ -408,6 +408,8 @@ describe("Lido:accounting", () => { lastWithdrawalRequestToFinalize: bigint; simulatedShareRate: bigint; etherToLockOnWithdrawalQueue: bigint; + redeemedEther: bigint; + redeemedShares: bigint; } function args(overrides?: Partial): ArgsTuple { @@ -420,6 +422,8 @@ describe("Lido:accounting", () => { lastWithdrawalRequestToFinalize: 0n, simulatedShareRate: 0n, etherToLockOnWithdrawalQueue: 0n, + redeemedEther: 0n, + redeemedShares: 0n, ...overrides, }) as ArgsTuple; } diff --git a/test/0.4.24/lido/lido.misc.test.ts b/test/0.4.24/lido/lido.misc.test.ts index 1fe7283bd..54717a1d9 100644 --- a/test/0.4.24/lido/lido.misc.test.ts +++ b/test/0.4.24/lido/lido.misc.test.ts @@ -269,7 +269,7 @@ describe("Lido.sol:misc", () => { await lido.submit(ZeroAddress, { value: ether("100.0") }); await lido.setDepositsReserveTarget(ether("25.0")); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); const unfinalized = ether("50.0"); await withdrawalQueue.mock__unfinalizedStETH(unfinalized); @@ -302,7 +302,7 @@ describe("Lido.sol:misc", () => { await lido.submit(ZeroAddress, { value: ether("100.0") }); await lido.setDepositsReserveTarget(ether("25.0")); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); const depositsReserve = await lido.getDepositsReserve(); expect(depositsReserve).to.equal(ether("25.0")); @@ -320,7 +320,7 @@ describe("Lido.sol:misc", () => { await lido.submit(ZeroAddress, { value: ether("100.0") }); await lido.setDepositsReserveTarget(ether("10.0")); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); await withdrawalQueue.mock__unfinalizedStETH(ether("100.0")); const depositableBefore = await lido.getDepositableEther(); @@ -340,7 +340,7 @@ describe("Lido.sol:misc", () => { let accountingSigner: HardhatEthersSigner; const syncReserveWithOracleReport = async () => { - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); }; const assertDepositsReserveInvariants = async () => { @@ -590,7 +590,7 @@ describe("Lido.sol:misc", () => { await lido.connect(stakingRouterSigner).withdrawDepositableEther(ether("5.0"), 0n); expect(await lido.getDepositsReserve()).to.equal(ether("15.0")); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); expect(await lido.getDepositsReserveTarget()).to.equal(ether("20.0")); expect(await lido.getDepositsReserve()).to.equal(ether("20.0")); @@ -653,7 +653,7 @@ describe("Lido.sol:misc", () => { it("Returns 0 when unfinalizedStETH is zero", async () => { await lido.submit(ZeroAddress, { value: ether("100.0") }); await lido.setDepositsReserveTarget(ether("30.0")); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); await withdrawalQueue.mock__unfinalizedStETH(0n); expect(await lido.getWithdrawalsReserve()).to.equal(0n); @@ -663,7 +663,7 @@ describe("Lido.sol:misc", () => { const deposit = ether("100.0"); await lido.submit(ZeroAddress, { value: deposit }); await lido.setDepositsReserveTarget(ether("40.0")); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); await withdrawalQueue.mock__unfinalizedStETH(ether("80.0")); const buffered = await lido.getBufferedEther(); @@ -681,14 +681,14 @@ describe("Lido.sol:misc", () => { const highTarget = ether("50.0"); await lido.setDepositsReserveTarget(lowTarget); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); const withdrawalsReserveWithLowTarget = await lido.getWithdrawalsReserve(); expect(withdrawalsReserveWithLowTarget).to.equal(buffered - lowTarget); await lido.setDepositsReserveTarget(highTarget); // target increase is deferred until report, so withdrawals reserve is unchanged before sync expect(await lido.getWithdrawalsReserve()).to.equal(withdrawalsReserveWithLowTarget); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); const withdrawalsReserveWithHighTarget = await lido.getWithdrawalsReserve(); expect(withdrawalsReserveWithHighTarget).to.equal(buffered - highTarget); expect(withdrawalsReserveWithHighTarget).to.be.lt(withdrawalsReserveWithLowTarget); @@ -697,11 +697,11 @@ describe("Lido.sol:misc", () => { it("Does not change on oracle report when no withdrawals are finalized", async () => { await lido.submit(ZeroAddress, { value: ether("100.0") }); await lido.setDepositsReserveTarget(ether("30.0")); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); await withdrawalQueue.mock__unfinalizedStETH(ether("40.0")); const before = await lido.getWithdrawalsReserve(); - await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); + await lido.connect(accountingSigner).collectRewardsAndProcessWithdrawals(0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n); expect(await lido.getWithdrawalsReserve()).to.equal(before); }); diff --git a/test/0.8.25/contracts/Burner__MockForRedeemsBuffer.sol b/test/0.8.25/contracts/Burner__MockForRedeemsBuffer.sol new file mode 100644 index 000000000..6c432383c --- /dev/null +++ b/test/0.8.25/contracts/Burner__MockForRedeemsBuffer.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +contract Burner__MockForRedeemsBuffer { + event RequestBurnSharesCalled(address from, uint256 sharesAmount); + + function requestBurnShares(address _from, uint256 _sharesAmount) external { + emit RequestBurnSharesCalled(_from, _sharesAmount); + } +} diff --git a/test/0.8.25/contracts/ERC20__MockForRedeemsBuffer.sol b/test/0.8.25/contracts/ERC20__MockForRedeemsBuffer.sol new file mode 100644 index 000000000..f80d70eef --- /dev/null +++ b/test/0.8.25/contracts/ERC20__MockForRedeemsBuffer.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {ERC20} from "@openzeppelin/contracts-v5.2/token/ERC20/ERC20.sol"; + +contract ERC20__MockForRedeemsBuffer is ERC20 { + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/test/0.8.25/contracts/HashConsensus__MockForRedeemsBuffer.sol b/test/0.8.25/contracts/HashConsensus__MockForRedeemsBuffer.sol new file mode 100644 index 000000000..195b4a9d3 --- /dev/null +++ b/test/0.8.25/contracts/HashConsensus__MockForRedeemsBuffer.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +contract HashConsensus__MockForRedeemsBuffer { + uint256 private _refSlot; + + constructor(uint256 initialRefSlot) { + _refSlot = initialRefSlot; + } + + function getCurrentFrame() external view returns (uint256 refSlot, uint256 reportProcessingDeadlineSlot) { + return (_refSlot, _refSlot + 100); + } + + function setRefSlot(uint256 refSlot) external { + _refSlot = refSlot; + } +} diff --git a/test/0.8.25/contracts/Lido__MockForRedeemsBuffer.sol b/test/0.8.25/contracts/Lido__MockForRedeemsBuffer.sol new file mode 100644 index 000000000..1855b1bcf --- /dev/null +++ b/test/0.8.25/contracts/Lido__MockForRedeemsBuffer.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +contract Lido__MockForRedeemsBuffer { + bool private _stopped; + uint256 public receivedETH; + uint256 private _sharesOnBuffer; + + event TransferSharesFromCalled(address from, address to, uint256 sharesAmount); + event TransferSharesCalled(address to, uint256 sharesAmount); + event RequestBurnSharesCalled(address from, uint256 sharesAmount); + event ApproveCalled(address spender, uint256 amount); + event EtherReceivedFromRedeemsBuffer(uint256 amount); + + receive() external payable {} + + function getSharesByPooledEth(uint256 _pooledEthAmount) external pure returns (uint256) { + return _pooledEthAmount; // 1:1 rate + } + + function getPooledEthByShares(uint256 _sharesAmount) external pure returns (uint256) { + return _sharesAmount; // 1:1 rate + } + + function transferSharesFrom(address _sender, address _recipient, uint256 _sharesAmount) external returns (uint256) { + emit TransferSharesFromCalled(_sender, _recipient, _sharesAmount); + return _sharesAmount; + } + + function transferShares(address _recipient, uint256 _sharesAmount) external returns (uint256) { + emit TransferSharesCalled(_recipient, _sharesAmount); + return _sharesAmount; + } + + function sharesOf(address) external view returns (uint256) { + return _sharesOnBuffer; + } + + function setSharesOnBuffer(uint256 _shares) external { + _sharesOnBuffer = _shares; + } + + function approve(address _spender, uint256 _amount) external returns (bool) { + emit ApproveCalled(_spender, _amount); + return true; + } + + function isStopped() external view returns (bool) { + return _stopped; + } + + function receiveFromRedeemsBuffer() external payable { + receivedETH += msg.value; + emit EtherReceivedFromRedeemsBuffer(msg.value); + } + + // Test helpers + function setStopped(bool _isStopped) external { + _stopped = _isStopped; + } +} diff --git a/test/0.8.25/contracts/SelfDestructor.sol b/test/0.8.25/contracts/SelfDestructor.sol new file mode 100644 index 000000000..163e3c306 --- /dev/null +++ b/test/0.8.25/contracts/SelfDestructor.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +contract SelfDestructor { + constructor(address payable _target) payable { + selfdestruct(_target); + } +} diff --git a/test/0.8.25/contracts/WithdrawalQueue__MockForRedeemsBuffer.sol b/test/0.8.25/contracts/WithdrawalQueue__MockForRedeemsBuffer.sol new file mode 100644 index 000000000..10d5f4192 --- /dev/null +++ b/test/0.8.25/contracts/WithdrawalQueue__MockForRedeemsBuffer.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +contract WithdrawalQueue__MockForRedeemsBuffer { + bool private _bunkerMode; + bool private _paused; + + function isBunkerModeActive() external view returns (bool) { + return _bunkerMode; + } + + function isPaused() external view returns (bool) { + return _paused; + } + + // Test helpers + function setBunkerMode(bool _isBunker) external { + _bunkerMode = _isBunker; + } + + function setPaused(bool _isPaused) external { + _paused = _isPaused; + } +} diff --git a/test/0.8.25/redeemsBuffer.test.ts b/test/0.8.25/redeemsBuffer.test.ts new file mode 100644 index 000000000..39b1f337e --- /dev/null +++ b/test/0.8.25/redeemsBuffer.test.ts @@ -0,0 +1,479 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { + Burner__MockForRedeemsBuffer, + ERC20__MockForRedeemsBuffer, + HashConsensus__MockForRedeemsBuffer, + Lido__MockForRedeemsBuffer, + RedeemsBuffer, + WithdrawalQueue__MockForRedeemsBuffer, +} from "typechain-types"; + +import { ether, impersonate, proxify } from "lib"; + +import { Snapshot } from "test/suite"; + +describe("RedeemsBuffer.sol", () => { + let admin: HardhatEthersSigner; + let redeemer: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + let recipient: HardhatEthersSigner; + + let lido: Lido__MockForRedeemsBuffer; + let burner: Burner__MockForRedeemsBuffer; + let wq: WithdrawalQueue__MockForRedeemsBuffer; + let consensus: HashConsensus__MockForRedeemsBuffer; + let buffer: RedeemsBuffer; + + let lidoSigner: HardhatEthersSigner; + + const DEFAULT_REF_SLOT = 100n; + + let originalState: string; + + before(async () => { + [, admin, redeemer, stranger, recipient] = await ethers.getSigners(); + + // Deploy mocks + lido = await ethers.deployContract("Lido__MockForRedeemsBuffer", []); + burner = await ethers.deployContract("Burner__MockForRedeemsBuffer", []); + wq = await ethers.deployContract("WithdrawalQueue__MockForRedeemsBuffer", []); + consensus = await ethers.deployContract("HashConsensus__MockForRedeemsBuffer", [DEFAULT_REF_SLOT]); + + // Deploy RedeemsBuffer behind OssifiableProxy + const bufferImpl = await ethers.deployContract("RedeemsBuffer", [ + await lido.getAddress(), + await burner.getAddress(), + await wq.getAddress(), + await consensus.getAddress(), + ]); + [buffer] = await proxify({ impl: bufferImpl, admin }); + + // Initialize + await buffer.initialize(admin.address); + + // Grant REDEEMER_ROLE to redeemer + const REDEEMER_ROLE = await buffer.REDEEMER_ROLE(); + await buffer.connect(admin).grantRole(REDEEMER_ROLE, redeemer.address); + + // Grant RESUME_ROLE to admin and resume (PausableUntil starts paused-like, but default is 0 which means resumed) + const RESUME_ROLE = await buffer.RESUME_ROLE(); + await buffer.connect(admin).grantRole(RESUME_ROLE, admin.address); + + const PAUSE_ROLE = await buffer.PAUSE_ROLE(); + await buffer.connect(admin).grantRole(PAUSE_ROLE, admin.address); + + const RECOVER_ROLE = await buffer.RECOVER_ROLE(); + await buffer.connect(admin).grantRole(RECOVER_ROLE, admin.address); + + // Impersonate Lido for calling fundReserve / reconcile + lidoSigner = await impersonate(await lido.getAddress(), ether("100")); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("initialize", () => { + it("reverts on zero admin", async () => { + const freshImpl = await ethers.deployContract("RedeemsBuffer", [ + await lido.getAddress(), + await burner.getAddress(), + await wq.getAddress(), + await consensus.getAddress(), + ]); + const [freshBuffer] = await proxify({ impl: freshImpl, admin }); + await expect(freshBuffer.initialize(ZeroAddress)).to.be.revertedWithCustomError(freshBuffer, "AdminCannotBeZero"); + }); + + it("reverts on double initialize", async () => { + await expect(buffer.initialize(admin.address)).to.be.revertedWithCustomError(buffer, "InvalidInitialization"); + }); + + it("reverts when calling initialize on the implementation directly", async () => { + const impl = await ethers.deployContract("RedeemsBuffer", [ + await lido.getAddress(), + await burner.getAddress(), + await wq.getAddress(), + await consensus.getAddress(), + ]); + await expect(impl.initialize(admin.address)).to.be.revertedWithCustomError(impl, "InvalidInitialization"); + }); + + it("grants DEFAULT_ADMIN_ROLE to admin", async () => { + const DEFAULT_ADMIN_ROLE = await buffer.DEFAULT_ADMIN_ROLE(); + expect(await buffer.hasRole(DEFAULT_ADMIN_ROLE, admin.address)).to.be.true; + }); + + it("returns contract version 1", async () => { + expect(await buffer.getContractVersion()).to.equal(1); + }); + }); + + context("redeem", () => { + const redeemAmount = ether("1"); + + async function fundAndPrepare(amount: bigint) { + // Fund the buffer via Lido + await buffer.connect(lidoSigner).fundReserve({ value: amount }); + } + + it("reverts when paused", async () => { + await buffer.connect(admin).pauseFor(1000); + await expect(buffer.connect(redeemer).redeem(redeemAmount, recipient.address)).to.be.revertedWithCustomError( + buffer, + "ResumedExpected", + ); + }); + + it("reverts when caller lacks REDEEMER_ROLE", async () => { + const REDEEMER_ROLE = await buffer.REDEEMER_ROLE(); + await expect(buffer.connect(stranger).redeem(redeemAmount, recipient.address)) + .to.be.revertedWithCustomError(buffer, "AccessControlUnauthorizedAccount") + .withArgs(stranger.address, REDEEMER_ROLE); + }); + + it("reverts on zero amount", async () => { + await expect(buffer.connect(redeemer).redeem(0, recipient.address)).to.be.revertedWithCustomError( + buffer, + "ZeroAmount", + ); + }); + + it("reverts on zero recipient", async () => { + await expect(buffer.connect(redeemer).redeem(redeemAmount, ZeroAddress)).to.be.revertedWithCustomError( + buffer, + "ZeroRecipient", + ); + }); + + it("reverts when Lido stopped", async () => { + await lido.setStopped(true); + await expect(buffer.connect(redeemer).redeem(redeemAmount, recipient.address)).to.be.revertedWithCustomError( + buffer, + "LidoStopped", + ); + }); + + it("reverts when bunker mode active", async () => { + await wq.setBunkerMode(true); + await expect(buffer.connect(redeemer).redeem(redeemAmount, recipient.address)).to.be.revertedWithCustomError( + buffer, + "BunkerModeActive", + ); + }); + + it("reverts when WQ paused", async () => { + await wq.setPaused(true); + await expect(buffer.connect(redeemer).redeem(redeemAmount, recipient.address)).to.be.revertedWithCustomError( + buffer, + "WithdrawalQueuePaused", + ); + }); + + it("reverts when insufficient reserve", async () => { + // Fund less than redeemAmount + await fundAndPrepare(ether("0.5")); + await expect(buffer.connect(redeemer).redeem(redeemAmount, recipient.address)) + .to.be.revertedWithCustomError(buffer, "InsufficientReserve") + .withArgs(redeemAmount, ether("0.5")); + }); + + it("successfully redeems: emits Redeemed event, updates store, sends ETH to recipient", async () => { + await fundAndPrepare(ether("10")); + + const recipientBalanceBefore = await ethers.provider.getBalance(recipient.address); + + await expect(buffer.connect(redeemer).redeem(redeemAmount, recipient.address)) + .to.emit(buffer, "Redeemed") + .withArgs(redeemer.address, recipient.address, redeemAmount, redeemAmount, redeemAmount); + + const [redeemedEther, redeemedShares] = await buffer.getRedeemed(); + expect(redeemedEther).to.equal(redeemAmount); + expect(redeemedShares).to.equal(redeemAmount); + + const recipientBalanceAfter = await ethers.provider.getBalance(recipient.address); + expect(recipientBalanceAfter - recipientBalanceBefore).to.equal(redeemAmount); + }); + }); + + context("uint104 overflow on redeemedEther", () => { + it("reverts on arithmetic overflow when cumulative redeemedEther would exceed type(uint104).max", async () => { + const MAX_UINT104 = (1n << 104n) - 1n; + + const HUGE_BALANCE = MAX_UINT104 + ether("10"); + await ethers.provider.send("hardhat_setBalance", [await lido.getAddress(), "0x" + HUGE_BALANCE.toString(16)]); + + await buffer.connect(lidoSigner).fundReserve({ value: MAX_UINT104 + 1n }); + + await buffer.connect(redeemer).redeem(MAX_UINT104, recipient.address); + expect((await buffer.getRedeemed())[0]).to.equal(MAX_UINT104); + + await expect(buffer.connect(redeemer).redeem(1n, recipient.address)).to.be.revertedWithPanic(0x11); + }); + }); + + context("fundReserve", () => { + it("reverts when caller is not Lido", async () => { + await expect(buffer.connect(stranger).fundReserve({ value: ether("1") })).to.be.revertedWithCustomError( + buffer, + "NotLido", + ); + }); + + it("increments _reserveBalance and emits ReserveFunded", async () => { + const amount = ether("5"); + + await expect(buffer.connect(lidoSigner).fundReserve({ value: amount })) + .to.emit(buffer, "ReserveFunded") + .withArgs(amount); + + expect(await buffer.getReserveBalance()).to.equal(amount); + + // Fund again + await expect(buffer.connect(lidoSigner).fundReserve({ value: amount })) + .to.emit(buffer, "ReserveFunded") + .withArgs(amount); + + expect(await buffer.getReserveBalance()).to.equal(amount * 2n); + }); + }); + + context("reconcile", () => { + it("reverts when caller is not Lido", async () => { + await expect(buffer.connect(stranger).reconcile(0, 0)).to.be.revertedWithCustomError(buffer, "NotLido"); + }); + + it("returns unredeemed ETH to Lido", async () => { + await buffer.connect(lidoSigner).fundReserve({ value: ether("10") }); + await buffer.connect(redeemer).redeem(ether("3"), recipient.address); + + const receivedBefore = await lido.receivedETH(); + await buffer.connect(lidoSigner).reconcile(ether("3"), ether("3")); + const receivedAfter = await lido.receivedETH(); + + expect(receivedAfter - receivedBefore).to.equal(ether("7")); + expect(await buffer.getReserveBalance()).to.equal(0); + }); + + it("preserves post-refSlot redeems when the snapshot is smaller than live", async () => { + await buffer.connect(lidoSigner).fundReserve({ value: ether("10") }); + await buffer.connect(redeemer).redeem(ether("5"), recipient.address); + await buffer.connect(lidoSigner).reconcile(ether("2"), ether("2")); + + const [postRefSlotRedeemedEther, postRefSlotRedeemedShares] = await buffer.getRedeemed(); + expect(postRefSlotRedeemedEther).to.equal(ether("3")); + expect(postRefSlotRedeemedShares).to.equal(ether("3")); + expect(await buffer.getReserveBalance()).to.equal(0); + }); + + it("zeroes when snapshot == live", async () => { + await buffer.connect(lidoSigner).fundReserve({ value: ether("10") }); + await buffer.connect(redeemer).redeem(ether("4"), recipient.address); + await buffer.connect(lidoSigner).reconcile(ether("4"), ether("4")); + + const [postRefSlotRedeemedEther, postRefSlotRedeemedShares] = await buffer.getRedeemed(); + expect(postRefSlotRedeemedEther).to.equal(0); + expect(postRefSlotRedeemedShares).to.equal(0); + expect(await buffer.getReserveBalance()).to.equal(0); + }); + }); + + context("validateReconciledAndPause", () => { + it("reverts when caller is not Lido", async () => { + await expect(buffer.connect(stranger).validateReconciledAndPause()).to.be.revertedWithCustomError( + buffer, + "NotLido", + ); + }); + + it("reverts when reserve balance is non-zero", async () => { + await buffer.connect(lidoSigner).fundReserve({ value: ether("4") }); + + await expect(buffer.connect(lidoSigner).validateReconciledAndPause()) + .to.be.revertedWithCustomError(buffer, "BufferNotReconciled") + .withArgs(ether("4"), 0, 0); + }); + + it("reverts when there are in-flight redeemed ether/shares", async () => { + await buffer.connect(lidoSigner).fundReserve({ value: ether("10") }); + await buffer.connect(redeemer).redeem(ether("3"), recipient.address); + await buffer.connect(lidoSigner).reconcile(0, 0); + + await expect(buffer.connect(lidoSigner).validateReconciledAndPause()) + .to.be.revertedWithCustomError(buffer, "BufferNotReconciled") + .withArgs(0, ether("3"), ether("3")); + }); + + it("succeeds after a normal reconcile fully clears state and infinitely pauses", async () => { + await buffer.connect(lidoSigner).fundReserve({ value: ether("10") }); + await buffer.connect(redeemer).redeem(ether("4"), recipient.address); + await buffer.connect(lidoSigner).reconcile(ether("4"), ether("4")); + + expect(await buffer.getReserveBalance()).to.equal(0); + const [redeemedEther, redeemedShares] = await buffer.getRedeemed(); + expect(redeemedEther).to.equal(0); + expect(redeemedShares).to.equal(0); + + const receivedBefore = await lido.receivedETH(); + await buffer.connect(lidoSigner).validateReconciledAndPause(); + const receivedAfter = await lido.receivedETH(); + + expect(receivedAfter).to.equal(receivedBefore, "no ETH should move during validation"); + expect(await buffer.isPaused()).to.equal(true); + await expect(buffer.connect(redeemer).redeem(ether("0.1"), recipient.address)).to.be.reverted; + }); + + it("succeeds on a freshly initialized buffer (all-zero state)", async () => { + expect(await buffer.getReserveBalance()).to.equal(0); + await buffer.connect(lidoSigner).validateReconciledAndPause(); + expect(await buffer.isPaused()).to.equal(true); + }); + }); + + context("recoverERC20", () => { + let token: ERC20__MockForRedeemsBuffer; + + before(async () => { + token = await ethers.deployContract("ERC20__MockForRedeemsBuffer", ["Test Token", "TT"]); + }); + + it("reverts on zero recipient", async () => { + await expect( + buffer.connect(admin).recoverERC20(await token.getAddress(), ether("1"), ZeroAddress), + ).to.be.revertedWithCustomError(buffer, "ZeroRecipient"); + }); + + it("reverts when token is stETH", async () => { + await expect( + buffer.connect(admin).recoverERC20(await lido.getAddress(), ether("1"), await lido.getAddress()), + ).to.be.revertedWithCustomError(buffer, "StETHRecoveryNotAllowed"); + }); + + it("transfers token to caller", async () => { + const amount = ether("5"); + // Mint tokens to the buffer + await token.mint(await buffer.getAddress(), amount); + + const balanceBefore = await token.balanceOf(admin.address); + await buffer.connect(admin).recoverERC20(await token.getAddress(), amount, admin.address); + const balanceAfter = await token.balanceOf(admin.address); + + expect(balanceAfter - balanceBefore).to.equal(amount); + }); + + it("reverts when caller lacks RECOVER_ROLE", async () => { + await expect(buffer.connect(stranger).recoverERC20(await token.getAddress(), ether("1"), admin.address)).to.be + .reverted; + }); + }); + + context("recoverStETHShares", () => { + it("reverts on zero recipient", async () => { + await expect(buffer.connect(admin).recoverStETHShares(ZeroAddress)).to.be.revertedWithCustomError( + buffer, + "ZeroRecipient", + ); + }); + + it("transfers stuck shares to recipient", async () => { + const stuck = ether("7"); + await lido.setSharesOnBuffer(stuck); + + await expect(buffer.connect(admin).recoverStETHShares(recipient.address)) + .to.emit(buffer, "StETHSharesRecovered") + .withArgs(admin.address, stuck, recipient.address); + }); + + it("does nothing when no shares are stuck", async () => { + await lido.setSharesOnBuffer(0); + + await expect(buffer.connect(admin).recoverStETHShares(recipient.address)).to.not.emit( + buffer, + "StETHSharesRecovered", + ); + }); + + it("reverts when caller lacks RECOVER_ROLE", async () => { + await expect(buffer.connect(stranger).recoverStETHShares(recipient.address)).to.be.reverted; + }); + }); + + context("recoverEther", () => { + it("reverts on zero recipient", async () => { + await expect(buffer.connect(admin).recoverEther(ZeroAddress)).to.be.revertedWithCustomError( + buffer, + "ZeroRecipient", + ); + }); + + it("recovers zero after redeem without donation (does not underflow)", async () => { + // Fund 10 ETH, redeem 4 ETH. balance = 6, reserve = 10, redeemed = 4 + // Without the fix, `balance - reserve` underflows — this test guards the formula order. + await buffer.connect(lidoSigner).fundReserve({ value: ether("10") }); + await buffer.connect(redeemer).redeem(ether("4"), recipient.address); + + const balanceBefore = await ethers.provider.getBalance(recipient.address); + await buffer.connect(admin).recoverEther(recipient.address); + const balanceAfter = await ethers.provider.getBalance(recipient.address); + + expect(balanceAfter).to.equal(balanceBefore); + }); + + it("recovers excess ether forced via selfdestruct", async () => { + const bufferAddr = await buffer.getAddress(); + const forced = ether("3"); + + // Force ETH onto buffer bypassing receive() + await ethers.deployContract("SelfDestructor", [bufferAddr], { value: forced }); + + const balanceBefore = await ethers.provider.getBalance(recipient.address); + await buffer.connect(admin).recoverEther(recipient.address); + const balanceAfter = await ethers.provider.getBalance(recipient.address); + + expect(balanceAfter - balanceBefore).to.equal(forced); + }); + + it("does nothing when no excess ether", async () => { + const balanceBefore = await ethers.provider.getBalance(recipient.address); + await buffer.connect(admin).recoverEther(recipient.address); + const balanceAfter = await ethers.provider.getBalance(recipient.address); + + expect(balanceAfter).to.equal(balanceBefore); + }); + + it("does not recover reserve ether", async () => { + const bufferAddr = await buffer.getAddress(); + + // Fund reserve via Lido + await buffer.connect(lidoSigner).fundReserve({ value: ether("10") }); + + // Force extra ETH + const forced = ether("2"); + await ethers.deployContract("SelfDestructor", [bufferAddr], { value: forced }); + + const balanceBefore = await ethers.provider.getBalance(recipient.address); + await buffer.connect(admin).recoverEther(recipient.address); + const balanceAfter = await ethers.provider.getBalance(recipient.address); + + // Only the forced excess is recovered, not the reserve + expect(balanceAfter - balanceBefore).to.equal(forced); + }); + + it("reverts when caller lacks RECOVER_ROLE", async () => { + await expect(buffer.connect(stranger).recoverEther(recipient.address)).to.be.reverted; + }); + }); + + context("receive", () => { + it("reverts with DirectETHTransfer", async () => { + await expect( + stranger.sendTransaction({ to: await buffer.getAddress(), value: ether("1") }), + ).to.be.revertedWithCustomError(buffer, "DirectETHTransfer"); + }); + }); +}); diff --git a/test/0.8.9/burner.test.ts b/test/0.8.9/burner.test.ts index 5d366e37a..56205250e 100644 --- a/test/0.8.9/burner.test.ts +++ b/test/0.8.9/burner.test.ts @@ -575,7 +575,7 @@ describe("Burner.sol", () => { context("Reverts", () => { it("if the caller is not stETH", async () => { - await expect(burner.connect(stranger).commitSharesToBurn(1n)).to.be.revertedWithCustomError( + await expect(burner.connect(stranger).commitSharesToBurn(1n, 0)).to.be.revertedWithCustomError( burner, "AppAuthFailed", ); @@ -586,14 +586,14 @@ describe("Burner.sol", () => { const totalSharesRequestedToBurn = coverShares + nonCoverShares; const invalidAmount = totalSharesRequestedToBurn + 1n; - await expect(burner.connect(accountingSigner).commitSharesToBurn(invalidAmount)) + await expect(burner.connect(accountingSigner).commitSharesToBurn(invalidAmount, 0)) .to.be.revertedWithCustomError(burner, "BurnAmountExceedsActual") .withArgs(invalidAmount, totalSharesRequestedToBurn); }); }); it("Doesn't do anything if passing zero shares to burn", async () => { - await expect(burner.connect(accountingSigner).commitSharesToBurn(0n)).not.to.emit(burner, "StETHBurnt"); + await expect(burner.connect(accountingSigner).commitSharesToBurn(0n, 0)).not.to.emit(burner, "StETHBurnt"); }); it("Marks shares as burnt when there are only cover shares to burn", async () => { @@ -609,7 +609,7 @@ describe("Burner.sol", () => { nonCoverSharesBurnt: burner.getNonCoverSharesBurnt(), }); - await expect(burner.connect(accountingSigner).commitSharesToBurn(coverSharesToBurn)) + await expect(burner.connect(accountingSigner).commitSharesToBurn(coverSharesToBurn, 0)) .to.emit(burner, "StETHBurnt") .withArgs(true, balancesBefore.stethRequestedToBurn, coverSharesToBurn); @@ -638,7 +638,7 @@ describe("Burner.sol", () => { nonCoverSharesBurnt: burner.getNonCoverSharesBurnt(), }); - await expect(burner.connect(accountingSigner).commitSharesToBurn(nonCoverSharesToBurn)) + await expect(burner.connect(accountingSigner).commitSharesToBurn(nonCoverSharesToBurn, 0)) .to.emit(burner, "StETHBurnt") .withArgs(false, balancesBefore.stethRequestedToBurn, nonCoverSharesToBurn); @@ -671,7 +671,7 @@ describe("Burner.sol", () => { nonCoverSharesBurnt: burner.getNonCoverSharesBurnt(), }); - await expect(burner.connect(accountingSigner).commitSharesToBurn(totalCoverSharesToBurn)) + await expect(burner.connect(accountingSigner).commitSharesToBurn(totalCoverSharesToBurn, 0)) .to.emit(burner, "StETHBurnt") .withArgs(true, balancesBefore.coverStethRequestedToBurn, coverSharesToBurn) .and.to.emit(burner, "StETHBurnt") @@ -695,6 +695,136 @@ describe("Burner.sol", () => { }); }); + context("commitSharesToBurn with _minNonCoverSharesToBurn", () => { + beforeEach(async () => { + await expect(steth.approve(burner, MaxUint256)) + .to.emit(steth, "Approval") + .withArgs(holder.address, await burner.getAddress(), MaxUint256); + + expect(await steth.allowance(holder, burner)).to.equal(MaxUint256); + }); + + it("burns nonCover first when _minNonCoverSharesToBurn > 0", async () => { + const coverShares = ether("3"); + const nonCoverShares = ether("2"); + + // Deposit 10 cover + 5 nonCover on Burner + await burner.connect(accountingSigner).requestBurnSharesForCover(holder, coverShares); + await burner.connect(accountingSigner).requestBurnShares(holder, nonCoverShares); + + const balancesBefore = await batch({ + sharesRequestedToBurn: burner.getSharesRequestedToBurn(), + coverSharesBurnt: burner.getCoverSharesBurnt(), + nonCoverSharesBurnt: burner.getNonCoverSharesBurnt(), + }); + + expect(balancesBefore.sharesRequestedToBurn.coverShares).to.equal(coverShares); + expect(balancesBefore.sharesRequestedToBurn.nonCoverShares).to.equal(nonCoverShares); + + const nonCoverSteth = await steth.getPooledEthByShares(nonCoverShares); + const coverBurnShares = ether("1"); // 3 - 2 = 1 cover share + const coverSteth = await steth.getPooledEthByShares(coverBurnShares); + + // commitSharesToBurn(3, 2) — burn 2 nonCover first, then 1 cover + await expect(burner.connect(accountingSigner).commitSharesToBurn(ether("3"), nonCoverShares)) + .to.emit(burner, "StETHBurnt") + .withArgs(false, nonCoverSteth, nonCoverShares) // nonCover first + .and.to.emit(burner, "StETHBurnt") + .withArgs(true, coverSteth, coverBurnShares); // then cover + + const balancesAfter = await batch({ + sharesRequestedToBurn: burner.getSharesRequestedToBurn(), + coverSharesBurnt: burner.getCoverSharesBurnt(), + nonCoverSharesBurnt: burner.getNonCoverSharesBurnt(), + }); + + // Post state: coverSharesBurnRequested = 3 - 1 = 2, nonCoverSharesBurnRequested = 2 - 2 = 0 + expect(balancesAfter.sharesRequestedToBurn.coverShares).to.equal(ether("2")); + expect(balancesAfter.sharesRequestedToBurn.nonCoverShares).to.equal(0n); + expect(balancesAfter.coverSharesBurnt).to.equal(balancesBefore.coverSharesBurnt + coverBurnShares); + expect(balancesAfter.nonCoverSharesBurnt).to.equal(balancesBefore.nonCoverSharesBurnt + nonCoverShares); + }); + + it("clamps _minNonCoverSharesToBurn to available nonCover", async () => { + const coverShares = ether("3"); + const nonCoverShares = ether("1"); + + // Deposit 10 cover + 2 nonCover on Burner + await burner.connect(accountingSigner).requestBurnSharesForCover(holder, coverShares); + await burner.connect(accountingSigner).requestBurnShares(holder, nonCoverShares); + + const balancesBefore = await batch({ + sharesRequestedToBurn: burner.getSharesRequestedToBurn(), + coverSharesBurnt: burner.getCoverSharesBurnt(), + nonCoverSharesBurnt: burner.getNonCoverSharesBurnt(), + }); + + expect(balancesBefore.sharesRequestedToBurn.coverShares).to.equal(coverShares); + expect(balancesBefore.sharesRequestedToBurn.nonCoverShares).to.equal(nonCoverShares); + + const nonCoverSteth = await steth.getPooledEthByShares(nonCoverShares); + const coverBurnShares = ether("2"); // 3 - 1 = 2 cover shares (nonCover clamped to 1) + const coverSteth = await steth.getPooledEthByShares(coverBurnShares); + + // commitSharesToBurn(3, 2) — _minNonCoverSharesToBurn=2 clamped to available nonCover=1, then 2 cover + await expect(burner.connect(accountingSigner).commitSharesToBurn(ether("3"), ether("2"))) + .to.emit(burner, "StETHBurnt") + .withArgs(false, nonCoverSteth, nonCoverShares) // 1 nonCover (clamped) + .and.to.emit(burner, "StETHBurnt") + .withArgs(true, coverSteth, coverBurnShares); // 2 cover + + const balancesAfter = await batch({ + sharesRequestedToBurn: burner.getSharesRequestedToBurn(), + coverSharesBurnt: burner.getCoverSharesBurnt(), + nonCoverSharesBurnt: burner.getNonCoverSharesBurnt(), + }); + + // Post state: coverSharesBurnRequested = 3 - 2 = 1, nonCoverSharesBurnRequested = 1 - 1 = 0 + expect(balancesAfter.sharesRequestedToBurn.coverShares).to.equal(ether("1")); + expect(balancesAfter.sharesRequestedToBurn.nonCoverShares).to.equal(0n); + expect(balancesAfter.coverSharesBurnt).to.equal(balancesBefore.coverSharesBurnt + coverBurnShares); + expect(balancesAfter.nonCoverSharesBurnt).to.equal(balancesBefore.nonCoverSharesBurnt + nonCoverShares); + }); + + it("works with _minNonCoverSharesToBurn = 0 (legacy cover-first behavior)", async () => { + const coverShares = ether("2"); + const nonCoverShares = ether("2"); + + // Deposit 5 cover + 5 nonCover on Burner + await burner.connect(accountingSigner).requestBurnSharesForCover(holder, coverShares); + await burner.connect(accountingSigner).requestBurnShares(holder, nonCoverShares); + + const balancesBefore = await batch({ + sharesRequestedToBurn: burner.getSharesRequestedToBurn(), + coverSharesBurnt: burner.getCoverSharesBurnt(), + nonCoverSharesBurnt: burner.getNonCoverSharesBurnt(), + }); + + const coverSteth = await steth.getPooledEthByShares(coverShares); + const nonCoverBurnShares = ether("1"); // 3 - 2 = 1 nonCover + const nonCoverSteth = await steth.getPooledEthByShares(nonCoverBurnShares); + + // commitSharesToBurn(3, 0) — cover-first: burn 2 cover, then 1 nonCover + await expect(burner.connect(accountingSigner).commitSharesToBurn(ether("3"), 0)) + .to.emit(burner, "StETHBurnt") + .withArgs(true, coverSteth, coverShares) // cover first + .and.to.emit(burner, "StETHBurnt") + .withArgs(false, nonCoverSteth, nonCoverBurnShares); // then nonCover + + const balancesAfter = await batch({ + sharesRequestedToBurn: burner.getSharesRequestedToBurn(), + coverSharesBurnt: burner.getCoverSharesBurnt(), + nonCoverSharesBurnt: burner.getNonCoverSharesBurnt(), + }); + + // Post state: coverSharesBurnRequested = 0, nonCoverSharesBurnRequested = 2 - 1 = 1 + expect(balancesAfter.sharesRequestedToBurn.coverShares).to.equal(0n); + expect(balancesAfter.sharesRequestedToBurn.nonCoverShares).to.equal(ether("1")); + expect(balancesAfter.coverSharesBurnt).to.equal(balancesBefore.coverSharesBurnt + coverShares); + expect(balancesAfter.nonCoverSharesBurnt).to.equal(balancesBefore.nonCoverSharesBurnt + nonCoverBurnShares); + }); + }); + context("getSharesRequestedToBurn", () => { it("Returns cover and non-cover shares requested to burn", async () => { const coverSharesToBurn = ether("1.0"); @@ -724,7 +854,7 @@ describe("Burner.sol", () => { const coverSharesToBurnBefore = await burner.getCoverSharesBurnt(); - await burner.connect(accountingSigner).commitSharesToBurn(coverSharesToBurn); + await burner.connect(accountingSigner).commitSharesToBurn(coverSharesToBurn, 0); expect(await burner.getCoverSharesBurnt()).to.equal(coverSharesToBurnBefore + coverSharesToBurn); }); @@ -740,7 +870,7 @@ describe("Burner.sol", () => { const nonCoverSharesToBurnBefore = await burner.getNonCoverSharesBurnt(); - await burner.connect(accountingSigner).commitSharesToBurn(nonCoverSharesToBurn); + await burner.connect(accountingSigner).commitSharesToBurn(nonCoverSharesToBurn, 0); expect(await burner.getNonCoverSharesBurnt()).to.equal(nonCoverSharesToBurnBefore + nonCoverSharesToBurn); }); diff --git a/test/0.8.9/contracts/Burner__MockForMigration.sol b/test/0.8.9/contracts/Burner__MockForMigration.sol index e677dfd6c..c7da30f35 100644 --- a/test/0.8.9/contracts/Burner__MockForMigration.sol +++ b/test/0.8.9/contracts/Burner__MockForMigration.sol @@ -28,8 +28,13 @@ contract Burner__MockForMigration { return totalNonCoverSharesBurnt; } - function getSharesRequestedToBurn() external view returns (uint256 coverShares, uint256 nonCoverShares) { + function getSharesRequestedToBurn() + external + view + returns (uint256 coverShares, uint256 nonCoverShares, uint256 redeemShares) + { coverShares = coverSharesBurnRequested; nonCoverShares = nonCoverSharesBurnRequested; + redeemShares = 0; } } diff --git a/test/0.8.9/contracts/Burner__MockForSanityChecker.sol b/test/0.8.9/contracts/Burner__MockForSanityChecker.sol index 784c63763..df912efab 100644 --- a/test/0.8.9/contracts/Burner__MockForSanityChecker.sol +++ b/test/0.8.9/contracts/Burner__MockForSanityChecker.sol @@ -7,9 +7,14 @@ contract Burner__MockForSanityChecker { uint256 private nonCover; uint256 private cover; - function getSharesRequestedToBurn() external view returns (uint256 coverShares, uint256 nonCoverShares) { + function getSharesRequestedToBurn() + external + view + returns (uint256 coverShares, uint256 nonCoverShares, uint256 redeemShares) + { coverShares = cover; nonCoverShares = nonCover; + redeemShares = 0; } function setSharesRequestedToBurn(uint256 _cover, uint256 _nonCover) external { diff --git a/test/0.8.9/contracts/Lido__MockForAccounting.sol b/test/0.8.9/contracts/Lido__MockForAccounting.sol index 8ed43ac2d..d3913f71d 100644 --- a/test/0.8.9/contracts/Lido__MockForAccounting.sol +++ b/test/0.8.9/contracts/Lido__MockForAccounting.sol @@ -4,6 +4,9 @@ pragma solidity 0.8.9; contract Lido__MockForAccounting { + uint256 internal constant MOCK_TOTAL_POOLED_ETHER = 3201 ether; + uint256 internal constant MOCK_TOTAL_SHARES = 1 ether; + uint256 public depositedValidatorsValue; uint256 public reportClValidators; uint256 public reportClBalance; @@ -25,7 +28,9 @@ contract Lido__MockForAccounting { uint256 _elRewardsToWithdraw, uint256 _lastWithdrawalRequestToFinalize, uint256 _withdrawalsShareRate, - uint256 _etherToLockOnWithdrawalQueue + uint256 _etherToLockOnWithdrawalQueue, + uint256 _redeemedEther, + uint256 _redeemedShares ); /** * @notice An executed shares transfer from `sender` to `recipient`. @@ -75,11 +80,16 @@ contract Lido__MockForAccounting { } function getTotalPooledEther() external pure returns (uint256) { - return 3201000000000000000000; + return MOCK_TOTAL_POOLED_ETHER; } function getTotalShares() external pure returns (uint256) { - return 1000000000000000000; + return MOCK_TOTAL_SHARES; + } + + function getSharesByPooledEth(uint256 _ethAmount) external pure returns (uint256) { + // Mirrors the real Lido formula at the constant rate exposed above. + return (_ethAmount * MOCK_TOTAL_SHARES) / MOCK_TOTAL_POOLED_ETHER; } function getExternalShares() external pure returns (uint256) { @@ -98,7 +108,9 @@ contract Lido__MockForAccounting { uint256 _elRewardsToWithdraw, uint256 _lastWithdrawalRequestToFinalize, uint256 _simulatedShareRate, - uint256 _etherToLockOnWithdrawalQueue + uint256 _etherToLockOnWithdrawalQueue, + uint256 _redeemedEther, + uint256 _redeemedShares ) external { emit Mock__CollectRewardsAndProcessWithdrawals( _reportTimestamp, @@ -108,7 +120,9 @@ contract Lido__MockForAccounting { _elRewardsToWithdraw, _lastWithdrawalRequestToFinalize, _simulatedShareRate, - _etherToLockOnWithdrawalQueue + _etherToLockOnWithdrawalQueue, + _redeemedEther, + _redeemedShares ); } diff --git a/test/integration/core/burn-shares.integration.ts b/test/integration/core/burn-shares.integration.ts index 8714131d7..fbc80973a 100644 --- a/test/integration/core/burn-shares.integration.ts +++ b/test/integration/core/burn-shares.integration.ts @@ -53,7 +53,7 @@ describe("Scenario: Burn Shares", () => { it("Should not allow stranger to burn shares", async () => { const { burner } = ctx.contracts; - const burnTx = burner.connect(stranger).commitSharesToBurn(sharesToBurn); + const burnTx = burner.connect(stranger).commitSharesToBurn(sharesToBurn, 0); await expect(burnTx).to.be.revertedWithCustomError(burner, "AppAuthFailed"); }); diff --git a/test/integration/redeems-reserve/additional.integration.ts b/test/integration/redeems-reserve/additional.integration.ts new file mode 100644 index 000000000..76d9b6142 --- /dev/null +++ b/test/integration/redeems-reserve/additional.integration.ts @@ -0,0 +1,119 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + captureState, + doReport, + ProtocolState, + redeemExact, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEPOSIT = ether("1000"); +const RATIO_BP = 500n; + +describe("Integration: Redeems reserve — additional push-specific scenarios", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("accumulated redeems across frames — all burned in single report, rate exactly preserved", async () => { + const { lido } = ctx.contracts; + + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + + const before: ProtocolState = await captureState(lido); + + // --- 3 redeems without intermediate report --- + const redeem1Shares = await lido.getSharesByPooledEth(ether("5")); + const redeem1Ether = await lido.getPooledEthByShares(redeem1Shares); + await redeemExact(lido, holder, fix, ether("5")); + + const redeem2Shares = await lido.getSharesByPooledEth(ether("5")); + const redeem2Ether = await lido.getPooledEthByShares(redeem2Shares); + await redeemExact(lido, holder, fix, ether("5")); + + const redeem3Shares = await lido.getSharesByPooledEth(ether("5")); + const redeem3Ether = await lido.getPooledEthByShares(redeem3Shares); + await redeemExact(lido, holder, fix, ether("5")); + + const totalShares = redeem1Shares + redeem2Shares + redeem3Shares; + const totalEther = redeem1Ether + redeem2Ether + redeem3Ether; + + // Verify: vault delta = sum of all redeemed ETH (tracked stale, actual decreased) + const trackedReserve = await lido.getRedeemsReserve(); + const actualVaultBalance = await ethers.provider.getBalance(fix.address); + expect(trackedReserve - actualVaultBalance).to.equal(totalEther); + expect(trackedReserve).to.equal(before.reserve); + + // Verify: all redeemed shares accumulated on burner + expect((await fix.vault.getRedeemed())[0]).to.equal(totalEther); + + // Verify: rate exactly unchanged between reports (stale overcount cancels deferred burn) + expect(await lido.getPooledEthByShares(ether("1"))).to.equal(before.shareRate); + await assertReserveAllocationInvariant(lido); + + // --- Single report: all shares burned, state reconciled --- + await doReport(ctx); + + const after: ProtocolState = await captureState(lido); + + // Verify: all shares burned, counters reset + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + // Verify: tracked == actual (reconciled) + expect(await lido.getRedeemsReserve()).to.equal(await ethers.provider.getBalance(fix.address)); + + // Verify: rate exactly preserved, shares decreased by exact total + expect(after.shareRate).to.equal(before.shareRate); + expect(after.totalShares).to.equal(before.totalShares - totalShares); + expect(after.totalPooledEther).to.equal(before.totalPooledEther - totalEther); + await assertReserveAllocationInvariant(lido); + }); +}); diff --git a/test/integration/redeems-reserve/buffer-allocation.integration.ts b/test/integration/redeems-reserve/buffer-allocation.integration.ts new file mode 100644 index 000000000..6d2d3d291 --- /dev/null +++ b/test/integration/redeems-reserve/buffer-allocation.integration.ts @@ -0,0 +1,240 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + assertReserveState, + BufferState, + captureBufferState, + captureState, + doReport, + fundElRewards, + getRedeemAmount, + redeemExact, + resetProtocolState, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEFAULT_DEPOSIT = ether("1000"); +const SECOND_DEPOSIT = DEFAULT_DEPOSIT / 10n; +const DEFAULT_RATIO_BP = 500n; +const DEPOSITS_RESERVE_TARGET = ether("100"); + +const SPLIT_RATIO_BP = 5000n; +const SPLIT_DEPOSIT = ether("1000"); +const GROWTH_SHARE_ZERO = 0n; +const GROWTH_SHARE_EIGHTY = 8000n; + +/** Queues withdrawal requests and asserts that unfinalized demand increased by the total requested amount */ +async function requestWithdrawals({ + ctx, + from, + amounts, +}: { + ctx: ProtocolContext; + from: HardhatEthersSigner; + amounts: readonly bigint[]; +}): Promise { + const { lido, withdrawalQueue } = ctx.contracts; + const totalRequested = amounts.reduce((sum, amount) => sum + amount, 0n); + const unfinalizedBefore = await withdrawalQueue.unfinalizedStETH(); + const lastRequestIdBefore = await withdrawalQueue.getLastRequestId(); + + await lido.connect(from).approve(withdrawalQueue, totalRequested); + await withdrawalQueue.connect(from).requestWithdrawals([...amounts], from.address); + + const lastRequestIdAfter = await withdrawalQueue.getLastRequestId(); + expect(lastRequestIdAfter).to.equal(lastRequestIdBefore + BigInt(amounts.length)); + expect(await withdrawalQueue.unfinalizedStETH()).to.equal(unfinalizedBefore + totalRequested); + return amounts.map((_, index) => lastRequestIdBefore + BigInt(index) + 1n); +} + +/** Computes the currently unreserved part of the buffer after all reserves are accounted for */ +function getUnreserved(state: BufferState) { + return state.buffered - state.reserve - state.depositsReserve - state.withdrawalsReserve; +} + +describe("Integration: Redeems reserve — buffer allocation", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + let secondHolder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder, secondHolder] = await ethers.getSigners(); + reserveManager = holder; + + await resetProtocolState(ctx); + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("protects redeems reserve from WQ finalization and keeps depositable equal to deposits reserve plus unreserved", async () => { + const { lido } = ctx.contracts; + + // --- Seed reserve, second holder deposits, process report --- + await seedReserve(ctx, holder, reserveManager, { + deposit: DEFAULT_DEPOSIT, + redeemsReserveRatioBP: DEFAULT_RATIO_BP, + depositsReserveTarget: DEPOSITS_RESERVE_TARGET, + }); + await lido.connect(secondHolder).submit(ZeroAddress, { value: SECOND_DEPOSIT }); + await doReport(ctx, { excludeVaultsBalances: true, skipWithdrawals: true }); + + const protocol0 = await captureState(lido); + + await assertReserveAllocationInvariant(lido); + assertReserveState(protocol0, DEFAULT_RATIO_BP); + + // --- Request withdrawals exceeding unreserved buffer --- + const firstAmount = DEFAULT_DEPOSIT - ether("100"); + const secondAmount = SECOND_DEPOSIT; + const [firstRequestId] = await requestWithdrawals({ ctx, from: holder, amounts: [firstAmount] }); + const [secondRequestId] = await requestWithdrawals({ ctx, from: secondHolder, amounts: [secondAmount] }); + + // Verify: total requested exceeds available for WQ (not all can be finalized in one report) + const preReport = await captureBufferState(ctx); + const availableForWQ = preReport.buffered - preReport.reserve - preReport.depositsReserve; + expect(firstAmount + secondAmount).to.be.gt(availableForWQ); + + // --- Process report with WQ finalization --- + await doReport(ctx, { skipWithdrawals: false, excludeVaultsBalances: true }); + + const state1 = await captureBufferState(ctx); + const protocol1 = await captureState(lido); + const statuses = await ctx.contracts.withdrawalQueue.getWithdrawalStatus([firstRequestId, secondRequestId]); + + await assertReserveAllocationInvariant(lido); + assertReserveState(protocol1, DEFAULT_RATIO_BP); + + expect(state1.reserve).to.equal(state1.reserveTarget); + expect(statuses[0].isFinalized).to.equal(true); + expect(statuses[1].isFinalized).to.equal(false); + expect(state1.unfinalizedStETH).to.equal(statuses[1].amountOfStETH); + expect(getUnreserved(state1)).to.equal(0n); + expect(state1.withdrawalsReserve).to.equal(state1.buffered - state1.reserve - state1.depositsReserve); + expect(state1.depositable).to.equal(state1.depositsReserve + getUnreserved(state1)); + expect(state1.depositsReserve).to.equal(DEPOSITS_RESERVE_TARGET); + }); + + it("splits shared buffer between reserve growth and WQ according to growthShareBP", async () => { + const { lido } = ctx.contracts; + + // --- Seed reserve with 50% ratio, growthShare = 0 --- + await seedReserve(ctx, holder, reserveManager, { + deposit: SPLIT_DEPOSIT, + redeemsReserveRatioBP: SPLIT_RATIO_BP, + growthShareBP: GROWTH_SHARE_ZERO, + }); + + // --- Redeem to create reserve deficit, request WQ withdrawal --- + const redeemAmount = await getRedeemAmount(lido, "huge"); + const redeemShares = await lido.getSharesByPooledEth(redeemAmount); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + await redeemExact(lido, holder, fix, redeemAmount); + + // Verify: redeem shares pending on burner + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther); + + // --- Reconciliation report: burn redeem shares before capturing buffer state --- + await doReport(ctx); + + // Verify: all redeem shares burned, counters reset + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + const afterRedeem = await captureBufferState(ctx); + await assertReserveAllocationInvariant(lido); + + const requestAmount = await lido.balanceOf(holder.address); + await requestWithdrawals({ ctx, from: holder, amounts: [requestAmount] }); + + const beforeReport = await captureBufferState(ctx); + const restorePoint = await Snapshot.take(); + const sharedAllocationBefore = beforeReport.withdrawalsReserve + getUnreserved(beforeReport); + + // --- Path A: report with growthShare = 0 --- + await doReport(ctx, { skipWithdrawals: true, excludeVaultsBalances: true }); + + const growthZero = await captureBufferState(ctx); + await assertReserveAllocationInvariant(lido); + + expect(afterRedeem.reserve).to.equal(beforeReport.reserve); + expect(afterRedeem.depositable).to.equal(afterRedeem.depositsReserve + getUnreserved(afterRedeem)); + expect(getUnreserved(beforeReport)).to.equal(0n); + expect(sharedAllocationBefore).to.equal(beforeReport.withdrawalsReserve); + expect(growthZero.reserve).to.equal(beforeReport.reserve); + expect(growthZero.withdrawalsReserve).to.equal(beforeReport.withdrawalsReserve); + expect(growthZero.unfinalizedStETH).to.equal(beforeReport.unfinalizedStETH); + expect(growthZero.unfinalizedStETH).to.equal(requestAmount); + + // Verify: EL rewards go to withdrawalsReserve, redeems reserve stays unchanged (growthShare=0) + const EL_REWARDS = ether("1"); + await fundElRewards(ctx, EL_REWARDS); + await doReport(ctx, { skipWithdrawals: true, excludeVaultsBalances: false, reportElVault: true }); + + const withRewards = await captureBufferState(ctx); + await assertReserveAllocationInvariant(lido); + expect(withRewards.reserve).to.equal(growthZero.reserve); + expect(withRewards.withdrawalsReserve).to.equal(growthZero.withdrawalsReserve + EL_REWARDS); + + await Snapshot.restore(restorePoint); + + // --- Path B: report with growthShare = 80% --- + await lido.connect(reserveManager).setRedeemsReserveGrowthShare(GROWTH_SHARE_EIGHTY); + await doReport(ctx, { skipWithdrawals: true, excludeVaultsBalances: true }); + + const growthEighty = await captureBufferState(ctx); + await assertReserveAllocationInvariant(lido); + + const minGrowth = (sharedAllocationBefore * GROWTH_SHARE_EIGHTY) / 10_000n; + const growth = + beforeReport.reserve + minGrowth < beforeReport.reserveTarget + ? minGrowth + : beforeReport.reserveTarget - beforeReport.reserve; + + expect(growthEighty.reserve).to.equal(beforeReport.reserve + growth); + expect(growthEighty.withdrawalsReserve).to.equal(beforeReport.withdrawalsReserve - growth); + expect(growthEighty.unfinalizedStETH).to.equal(beforeReport.unfinalizedStETH); + expect(growthEighty.unfinalizedStETH).to.equal(requestAmount); + expect(growthEighty.depositable).to.equal(growthEighty.depositsReserve + getUnreserved(growthEighty)); + }); +}); diff --git a/test/integration/redeems-reserve/buffer-disabled.integration.ts b/test/integration/redeems-reserve/buffer-disabled.integration.ts new file mode 100644 index 000000000..12b08e9d6 --- /dev/null +++ b/test/integration/redeems-reserve/buffer-disabled.integration.ts @@ -0,0 +1,66 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { assertReserveAllocationInvariant, captureState, doReport, installRedeemsBufferOnLido } from "./helpers"; + +const DEPOSIT = ether("1000"); + +describe("Integration: Redeems reserve — feature disabled (no buffer in locator)", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let holder: HardhatEthersSigner; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("oracle report processes as a pass-through when redeemsBuffer is not installed", async () => { + const { lido } = ctx.contracts; + + await installRedeemsBufferOnLido(ctx, ZeroAddress); + expect(await lido.getRedeemsBuffer()).to.equal(ZeroAddress); + + const stateBefore = await captureState(lido); + expect(stateBefore.reserve).to.equal(0n); + expect(stateBefore.reserveTarget).to.equal(0n); + + await lido.connect(holder).submit(ZeroAddress, { value: DEPOSIT }); + + await doReport(ctx); + + const stateAfter = await captureState(lido); + + // Reserve machinery is inert — no target computed, no physical reserve anywhere. + expect(stateAfter.reserve).to.equal(0n); + expect(stateAfter.reserveTarget).to.equal(0n); + + // Allocation invariant still holds (reserve bucket is zero). + await assertReserveAllocationInvariant(lido); + }); +}); diff --git a/test/integration/redeems-reserve/buffer-upgrade.integration.ts b/test/integration/redeems-reserve/buffer-upgrade.integration.ts new file mode 100644 index 000000000..273841755 --- /dev/null +++ b/test/integration/redeems-reserve/buffer-upgrade.integration.ts @@ -0,0 +1,191 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { RedeemsBuffer } from "typechain-types"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; +import { proxify } from "lib/proxy"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + assertReserveState, + captureBufferState, + captureState, + doReport, + installRedeemsBufferOnLido, + redeemExact, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEPOSIT = ether("1000"); +const RATIO_BP = 500n; + +describe("Integration: RedeemsBuffer upgrade (drain via report + atomic swap)", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let admin: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let oldFix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [admin, holder] = await ethers.getSigners(); + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](admin.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(admin.address, lido.address, role); + } + + oldFix = await setupVault(ctx, admin, [holder]); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + async function deployBuffer(): Promise { + const { lido, burner, withdrawalQueue, hashConsensus } = ctx.contracts; + const factory = await ethers.getContractFactory("RedeemsBuffer"); + const impl = await factory + .connect(admin) + .deploy( + await lido.getAddress(), + await burner.getAddress(), + await withdrawalQueue.getAddress(), + await hashConsensus.getAddress(), + ); + const [vault] = (await proxify({ impl, admin })) as [RedeemsBuffer, unknown]; + await vault.initialize(admin.address); + return vault; + } + + async function wireNewBufferRoles(vault: RedeemsBuffer) { + const { burner } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + for (const role of [await vault.PAUSE_ROLE(), await vault.RESUME_ROLE(), await vault.RECOVER_ROLE()]) { + await vault.connect(admin).grantRole(role, admin.address); + } + const redeemerRole = await vault.REDEEMER_ROLE(); + await vault.connect(admin).grantRole(redeemerRole, admin.address); + await vault.connect(admin).grantRole(redeemerRole, holder.address); + await burner.connect(agent).grantRole(await burner.REQUEST_BURN_SHARES_ROLE(), await vault.getAddress()); + } + + async function drainViaRatioZero() { + await ctx.contracts.lido.connect(admin).setRedeemsReserveTargetRatio(0n); + await doReport(ctx); + } + + it("happy path: drain via ratio=0 report, then atomic swap to a new buffer", async () => { + const { lido } = ctx.contracts; + + await seedReserve(ctx, holder, admin, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + assertReserveState(await captureState(lido), RATIO_BP); + expect(await oldFix.vault.getReserveBalance()).to.equal(await lido.getRedeemsReserveTarget()); + + await drainViaRatioZero(); + expect(await oldFix.vault.getReserveBalance()).to.equal(0n); + expect(await oldFix.vault.getRedeemed()).to.deep.equal([0n, 0n]); + + const newVault = await deployBuffer(); + const newAddress = await newVault.getAddress(); + + await expect(installRedeemsBufferOnLido(ctx, newAddress)).to.emit(lido, "RedeemsBufferSet").withArgs(newAddress); + + expect(await lido.getRedeemsBuffer()).to.equal(newAddress); + expect(await oldFix.vault.isPaused()).to.equal(true); + expect(await ethers.provider.getBalance(oldFix.address)).to.equal(0n); + expect(await ethers.provider.getBalance(newAddress)).to.equal(0n); + + await wireNewBufferRoles(newVault); + await lido.connect(admin).setRedeemsReserveTargetRatio(RATIO_BP); + await doReport(ctx); + + const refilled = await lido.getRedeemsReserveTarget(); + expect(await lido.getRedeemsReserve()).to.equal(refilled); + expect(await newVault.getReserveBalance()).to.equal(refilled); + expect(await ethers.provider.getBalance(newAddress)).to.equal(refilled); + + await redeemExact(lido, holder, { vault: newVault, address: newAddress }, refilled / 4n); + await assertReserveAllocationInvariant(lido); + }); + + it("setRedeemsBuffer reverts when the old buffer has non-zero reserve balance", async () => { + await seedReserve(ctx, holder, admin, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + const reserve = await ctx.contracts.lido.getRedeemsReserve(); + + await expect(installRedeemsBufferOnLido(ctx, ZeroAddress)) + .to.be.revertedWithCustomError(oldFix.vault, "BufferNotReconciled") + .withArgs(reserve, 0n, 0n); + }); + + it("setRedeemsBuffer reverts when in-flight redeem snapshots are non-zero", async () => { + const { lido } = ctx.contracts; + await seedReserve(ctx, holder, admin, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + + const reserve = await lido.getRedeemsReserve(); + const redeemAmount = reserve / 4n; + const consumedShares = await lido.getSharesByPooledEth(redeemAmount); + const consumedEther = await lido.getPooledEthByShares(consumedShares); + await redeemExact(lido, holder, oldFix, redeemAmount); + + await expect(installRedeemsBufferOnLido(ctx, ZeroAddress)) + .to.be.revertedWithCustomError(oldFix.vault, "BufferNotReconciled") + .withArgs(reserve, consumedEther, consumedShares); + }); + + it("disable: drained buffer detaches and the next report allocates the freed ETH normally", async () => { + const { lido } = ctx.contracts; + + await seedReserve(ctx, holder, admin, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + assertReserveState(await captureState(lido), RATIO_BP); + + await drainViaRatioZero(); + + const bufferedAtDisable = await lido.getBufferedEther(); + const lidoBalanceAtDisable = await ethers.provider.getBalance(await lido.getAddress()); + + await installRedeemsBufferOnLido(ctx, ZeroAddress); + + expect(await lido.getRedeemsBuffer()).to.equal(ZeroAddress); + expect(await oldFix.vault.isPaused()).to.equal(true); + expect(await oldFix.vault.getReserveBalance()).to.equal(0n); + expect(await ethers.provider.getBalance(oldFix.address)).to.equal(0n); + expect(await lido.getBufferedEther()).to.equal(bufferedAtDisable); + expect(await ethers.provider.getBalance(await lido.getAddress())).to.equal(lidoBalanceAtDisable); + + await doReport(ctx); + + const after = await captureBufferState(ctx); + expect(after.reserve).to.equal(0n); + expect(after.reserveTarget).to.equal(0n); + expect(await ethers.provider.getBalance(oldFix.address)).to.equal(0n); + expect(await ethers.provider.getBalance(await lido.getAddress())).to.equal(after.buffered); + await assertReserveAllocationInvariant(lido); + }); +}); diff --git a/test/integration/redeems-reserve/bunker-mode.integration.ts b/test/integration/redeems-reserve/bunker-mode.integration.ts new file mode 100644 index 000000000..8700bfd49 --- /dev/null +++ b/test/integration/redeems-reserve/bunker-mode.integration.ts @@ -0,0 +1,216 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + advancePastRequestTimestampMargin, + assertReserveState, + captureValidatedBunkerCheckpoint, + doReport, + enterBunkerMode, + exitBunkerMode, + expectRedeemBlockedInBunker, + getAmountOfETHLocked, + getRedeemAmount, + processNegativeReportInBunker, + redeemAfterBunkerExit, + redeemExact, + requestWithdrawal, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEPOSIT = ether("1000"); +const RATIO_BP = 500n; +const BUNKER_CL_DIFF = ether("-10"); +const BUNKER_FOLLOWUP_CL_DIFF = ether("-1"); +const EXIT_BUNKER_CL_DIFF = ether("0.0001"); +const WQ_AMOUNT_BEFORE_BUNKER = ether("100"); +const WQ_AMOUNT_IN_BUNKER = ether("50"); +const WQ_AMOUNT_AFTER_BUNKER = ether("25"); + +/** + * Verifies exact bunker report effects on internal ether, reserve target, TPE, and share rate. + * + * `pendingVaultDelta` accounts for push-specific stale tracking: when state0 is captured + * before the report that reconciles a prior redeem, internalEther in state0 overcounts + * by the redeemed ETH amount. Pass redeemEther for the first report after redeem, 0 after. + */ +function expectBunkerReportState({ + current, + previous, + effectiveClDiff, + amountOfETHLocked, + pendingVaultDelta = 0n, +}: { + current: Awaited>; + previous: Awaited>; + effectiveClDiff: bigint; + amountOfETHLocked: bigint; + pendingVaultDelta?: bigint; +}) { + expect(current.bunkerMode).to.equal(true); + expect(current.protocol.reserve).to.equal(current.protocol.reserveTarget); + expect(current.protocol.internalEther).to.equal( + previous.protocol.internalEther - pendingVaultDelta + effectiveClDiff - amountOfETHLocked, + ); + + const expectedTotalPooledEther = + current.protocol.internalEther + (current.externalShares * current.protocol.internalEther) / current.internalShares; + const expectedShareRate = (expectedTotalPooledEther * ether("1")) / current.protocol.totalShares; + + expect(current.protocol.totalPooledEther).to.equal(expectedTotalPooledEther); + expect(current.protocol.shareRate).to.equal(expectedShareRate); +} + +describe("Integration: Redeems reserve — bunker mode", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("negative bunker reports keep redeem blocked, preserve reserve sync, and finish the full WQ backlog by recovery", async () => { + const { lido, withdrawalQueue } = ctx.contracts; + const initialRedeemAmount = await getRedeemAmount(lido, "small"); + + // --- Initial redeem and WQ request before bunker --- + const redeemShares = await lido.getSharesByPooledEth(initialRedeemAmount); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + await redeemExact(lido, holder, fix, initialRedeemAmount); + + // Verify: redeem shares pending on burner (will be burned on next report inside enterBunkerMode) + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther); + + const firstRequestId = await requestWithdrawal(ctx, holder, WQ_AMOUNT_BEFORE_BUNKER); + await advancePastRequestTimestampMargin(ctx); + const state0 = await captureValidatedBunkerCheckpoint(ctx); + + // --- Enter bunker mode with CL loss --- + const bunkerEntryReport = await enterBunkerMode(ctx, { + effectiveClDiff: BUNKER_CL_DIFF, + }); + + const state1 = await captureValidatedBunkerCheckpoint(ctx); + assertReserveState(state1.protocol, RATIO_BP); + + // --- Verify redeem blocked, submit WQ request in bunker --- + await expectRedeemBlockedInBunker(ctx, holder, fix, ether("1")); + const secondRequestId = await requestWithdrawal(ctx, holder, WQ_AMOUNT_IN_BUNKER); + const [secondRequestStatusBeforeFollowup] = await withdrawalQueue.getWithdrawalStatus([secondRequestId]); + expect(secondRequestStatusBeforeFollowup.isFinalized).to.equal(false); + expect(secondRequestStatusBeforeFollowup.amountOfStETH).to.equal(WQ_AMOUNT_IN_BUNKER); + + expectBunkerReportState({ + current: state1, + previous: state0, + effectiveClDiff: BUNKER_CL_DIFF, + amountOfETHLocked: await getAmountOfETHLocked(ctx, bunkerEntryReport), + pendingVaultDelta: redeemEther, + }); + + // --- Follow-up negative report while still in bunker --- + await processNegativeReportInBunker(ctx, BUNKER_FOLLOWUP_CL_DIFF); + + const state2 = await captureValidatedBunkerCheckpoint(ctx); + assertReserveState(state2.protocol, RATIO_BP); + + await expectRedeemBlockedInBunker(ctx, holder, fix, ether("1")); + + expectBunkerReportState({ + current: state2, + previous: state1, + effectiveClDiff: BUNKER_FOLLOWUP_CL_DIFF, + amountOfETHLocked: 0n, + }); + const [secondRequestStatusAfterFollowup] = await withdrawalQueue.getWithdrawalStatus([secondRequestId]); + expect(secondRequestStatusAfterFollowup.isFinalized).to.equal(false); + expect(secondRequestStatusAfterFollowup.amountOfStETH).to.equal(WQ_AMOUNT_IN_BUNKER); + + // --- Exit bunker mode (skip withdrawals to keep WQ backlog) --- + await exitBunkerMode(ctx, { + effectiveClDiff: EXIT_BUNKER_CL_DIFF, + reportParams: { skipWithdrawals: true }, + }); + + const state3 = await captureValidatedBunkerCheckpoint(ctx); + expect(state3.bunkerMode).to.equal(false); + expect(state3.lastFinalizedRequestId).to.equal(state2.lastFinalizedRequestId); + expect(state3.unfinalizedStETH).to.equal(state2.unfinalizedStETH); + + // --- Redeem after exit, submit post-bunker WQ request --- + const exitRedeemAmount = await getRedeemAmount(lido, "small"); + const exitRedeemShares = await lido.getSharesByPooledEth(exitRedeemAmount); + const exitRedeemEther = await lido.getPooledEthByShares(exitRedeemShares); + await redeemAfterBunkerExit(lido, holder, fix, exitRedeemAmount); + + // Verify: redeem shares pending on burner + expect((await fix.vault.getRedeemed())[0]).to.equal(exitRedeemEther); + + // --- Reconciliation report: burn redeem shares and refill vault before WQ processing --- + await doReport(ctx); + + // Verify: all redeem shares burned, counters reset + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + const thirdRequestId = await requestWithdrawal(ctx, holder, WQ_AMOUNT_AFTER_BUNKER); + + // --- Final report: finalize all pending WQ requests --- + await doReport(ctx, { skipWithdrawals: false, excludeVaultsBalances: true }); + + const state4 = await captureValidatedBunkerCheckpoint(ctx); + assertReserveState(state4.protocol, RATIO_BP); + const statuses = await withdrawalQueue.getWithdrawalStatus([firstRequestId, secondRequestId, thirdRequestId]); + + expect(state4.bunkerMode).to.equal(false); + expect(state4.lastFinalizedRequestId).to.equal(thirdRequestId); + expect(state4.unfinalizedStETH).to.equal(0n); + expect(state4.protocol.reserve).to.equal(state4.protocol.reserveTarget); + expect(statuses[0].isFinalized).to.equal(true); + expect(statuses[1].isFinalized).to.equal(true); + expect(statuses[2].isFinalized).to.equal(true); + }); +}); diff --git a/test/integration/redeems-reserve/concurrent-redeems.integration.ts b/test/integration/redeems-reserve/concurrent-redeems.integration.ts new file mode 100644 index 000000000..cf8b88a9c --- /dev/null +++ b/test/integration/redeems-reserve/concurrent-redeems.integration.ts @@ -0,0 +1,105 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + doReport, + redeemExact, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEPOSIT_PER_REDEEMER = ether("500"); +const RATIO_BP = 500n; + +describe("Integration: Redeems reserve — multiple REDEEMERs sharing the reserve", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let admin: HardhatEthersSigner; + let alice: HardhatEthersSigner; + let bob: HardhatEthersSigner; + let carol: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [admin, alice, bob, carol] = await ethers.getSigners(); + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](admin.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(admin.address, lido.address, role); + } + + fix = await setupVault(ctx, admin, [alice, bob, carol]); + + for (const signer of [alice, bob, carol]) { + await lido.connect(signer).submit(ZeroAddress, { value: DEPOSIT_PER_REDEEMER }); + } + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("three REDEEMERs split the reserve in the same frame; counters accumulate and reconcile on report", async () => { + const { lido } = ctx.contracts; + await seedReserve(ctx, alice, admin, { deposit: 0n, redeemsReserveRatioBP: RATIO_BP }); + + const reserve = await lido.getRedeemsReserve(); + const slice = reserve / 5n; + + const bufferedBefore = await lido.getBufferedEther(); + const lidoBalanceBefore = await ethers.provider.getBalance(await lido.getAddress()); + + await redeemExact(lido, alice, fix, slice); + await redeemExact(lido, bob, fix, slice); + await redeemExact(lido, carol, fix, slice); + + const aliceShares = await lido.getSharesByPooledEth(slice); + const aliceEther = await lido.getPooledEthByShares(aliceShares); + const bobShares = await lido.getSharesByPooledEth(slice); + const bobEther = await lido.getPooledEthByShares(bobShares); + const carolShares = await lido.getSharesByPooledEth(slice); + const carolEther = await lido.getPooledEthByShares(carolShares); + const totalRedeemedEther = aliceEther + bobEther + carolEther; + const totalRedeemedShares = aliceShares + bobShares + carolShares; + + expect(await fix.vault.getRedeemed()).to.deep.equal([totalRedeemedEther, totalRedeemedShares]); + expect(await fix.vault.getReserveBalance()).to.equal(reserve); + expect(await ethers.provider.getBalance(fix.address)).to.equal(reserve - totalRedeemedEther); + expect(await lido.getBufferedEther()).to.equal(bufferedBefore); + expect(await ethers.provider.getBalance(await lido.getAddress())).to.equal(lidoBalanceBefore); + + await doReport(ctx); + + expect(await fix.vault.getRedeemed()).to.deep.equal([0n, 0n]); + expect(await lido.getBufferedEther()).to.equal(bufferedBefore - totalRedeemedEther); + await assertReserveAllocationInvariant(lido); + }); +}); diff --git a/test/integration/redeems-reserve/cover-starvation.integration.ts b/test/integration/redeems-reserve/cover-starvation.integration.ts new file mode 100644 index 000000000..3e4e4bb24 --- /dev/null +++ b/test/integration/redeems-reserve/cover-starvation.integration.ts @@ -0,0 +1,259 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { LIMITER_PRECISION_BASE } from "lib/constants"; +import { getProtocolContext, ProtocolContext, setMaxPositiveTokenRebase } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + applyInsurance, + assertReserveAllocationInvariant, + captureState, + doReport, + fundElRewards, + getRedeemAmount, + ProtocolState, + redeemExact, + resetProtocolState, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +describe("Integration: Redeems reserve — cover starvation and RefSlot deferral", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + await resetProtocolState(ctx); + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + /** + * Computes the actual rebase of the share rate between two protocol states. + * Returns the rebase in LIMITER_PRECISION_BASE units (1e9 = 100%). + */ + function computeRebase(pre: ProtocolState, post: ProtocolState): bigint { + const numerator = post.totalPooledEther * pre.totalShares; + const denominator = pre.totalPooledEther * post.totalShares; + + if (numerator <= denominator) return 0n; + return ((numerator - denominator) * LIMITER_PRECISION_BASE) / denominator; + } + + it("cover starvation: redeem nonCover burns first via _minNonCoverSharesToBurn, events correct", async () => { + const { lido, burner } = ctx.contracts; + + const RATIO_BP = 2000n; // 20% reserve + const MAX_REBASE = LIMITER_PRECISION_BASE / 100n; // 1% + + const savedRebase = await setMaxPositiveTokenRebase(ctx, MAX_REBASE); + + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: RATIO_BP }); + + const stateSeeded: ProtocolState = await captureState(lido); + + // Apply large cover insurance (30 ETH) + const COVER_BURN = ether("30"); + await applyInsurance(ctx, holder, COVER_BURN); + + // Fund small rewards (half of headroom) + const headroom = (stateSeeded.internalEther * MAX_REBASE) / LIMITER_PRECISION_BASE; + const REWARDS = headroom / 2n; + await fundElRewards(ctx, REWARDS); + + // Drain the full reserve + const redeemAmount = await getRedeemAmount(lido, "full"); + const redeemShares = await lido.getSharesByPooledEth(redeemAmount); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + await redeemExact(lido, holder, fix, redeemAmount); + + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther); + + const nonCoverBurntBefore = await burner.getNonCoverSharesBurnt(); + const stateBeforeReport: ProtocolState = await captureState(lido); + + // Process report + await doReport(ctx, { + excludeVaultsBalances: false, + reportElVault: true, + reportWithdrawalsVault: false, + reportBurner: true, + }); + + const stateAfterReport: ProtocolState = await captureState(lido); + + // Burner's getNonCoverSharesBurnt increased by at least redeemShares worth + const nonCoverBurntAfter = await burner.getNonCoverSharesBurnt(); + const nonCoverBurntDelta = nonCoverBurntAfter - nonCoverBurntBefore; + // The redeem shares are burned as nonCover via _minNonCoverSharesToBurn. The eth-to-shares roundtrip + // can lose up to 1 wei, so we allow tolerance. + const burnedRedeemShares = await lido.getSharesByPooledEth(redeemEther); + expect(nonCoverBurntDelta).to.be.gte(burnedRedeemShares); + + // Reserve allocation invariant holds + await assertReserveAllocationInvariant(lido); + + // Rebase within limit + const rebase = computeRebase(stateBeforeReport, stateAfterReport); + expect(rebase).to.be.lte(MAX_REBASE + 1n); + + // Redeem ether counter reset to 0 + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + await setMaxPositiveTokenRebase(ctx, savedRebase); + }); + + it("multi-frame convergence: cumulative state matches single-frame counterfactual", async () => { + const { lido, burner } = ctx.contracts; + + const RATIO_BP = 2000n; // 20% reserve + const TIGHT_REBASE = LIMITER_PRECISION_BASE / 500n; // 0.2% -- very tight + const LOOSE_REBASE = LIMITER_PRECISION_BASE; // 100% -- unlimited + + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: RATIO_BP }); + + // Apply large cover insurance + const COVER_BURN = ether("30"); + await applyInsurance(ctx, holder, COVER_BURN); + + // Fund rewards + await fundElRewards(ctx, ether("5")); + + // Drain reserve + const redeemAmount = await getRedeemAmount(lido, "full"); + await redeemExact(lido, holder, fix, redeemAmount); + + // ---- Path A: unlimited rebase, single report ---- + const pathASnapshot = await Snapshot.take(); + + await setMaxPositiveTokenRebase(ctx, LOOSE_REBASE); + + await doReport(ctx, { + excludeVaultsBalances: false, + reportElVault: true, + reportWithdrawalsVault: false, + reportBurner: true, + }); + + const stateA: ProtocolState = await captureState(lido); + + await Snapshot.restore(pathASnapshot); + + // ---- Path B: tight rebase, multiple reports until converged ---- + const savedRebase_B = await setMaxPositiveTokenRebase(ctx, TIGHT_REBASE); + + // Run multiple reports to burn everything + const MAX_FRAMES = 50; + for (let i = 0; i < MAX_FRAMES; i++) { + const { coverShares, nonCoverShares } = await burner.getSharesRequestedToBurn(); + if (coverShares === 0n && nonCoverShares === 0n) break; + + await doReport(ctx, { + excludeVaultsBalances: false, + reportElVault: true, + reportWithdrawalsVault: false, + reportBurner: true, + }); + } + + const stateB: ProtocolState = await captureState(lido); + + // Verify: Path A and Path B converge to the same final state + // TPE should match within 1 wei tolerance (rounding across multiple frames) + expect(stateB.totalPooledEther).to.be.closeTo(stateA.totalPooledEther, 1n); + expect(stateB.totalShares).to.be.closeTo(stateA.totalShares, 1n); + expect(stateB.shareRate).to.be.closeTo(stateA.shareRate, 1n); + + // Both should have burned all requested shares + const { coverShares: remainCover, nonCoverShares: remainNonCover } = await burner.getSharesRequestedToBurn(); + expect(remainCover).to.equal(0n); + expect(remainNonCover).to.equal(0n); + + // Redeem counters should be reset + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + await assertReserveAllocationInvariant(lido); + + await setMaxPositiveTokenRebase(ctx, savedRebase_B); + }); + + it("carry mechanism: consecutive redeem-report cycles with zero drift", async () => { + const { lido } = ctx.contracts; + + const RATIO_BP = 500n; + const DEPOSIT = ether("1000"); + + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + + const state0: ProtocolState = await captureState(lido); + + // ---- Cycle 1: redeem 5 ETH ---- + const redeemAmount1 = ether("5"); + await redeemExact(lido, holder, fix, redeemAmount1); + + const liveValue = (await fix.vault.getRedeemed())[0]; + expect(liveValue).to.be.gt(0n); + + // Within the same frame: snapshot returns start-of-frame value (before redeem write) + const snapshotInFrame = (await fix.vault.getRedeemedForLastRefSlot())[0]; + expect(snapshotInFrame).to.equal(0n); + + // doReport advances to next frame → snapshot now returns the live value + await doReport(ctx); + + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + const state1: ProtocolState = await captureState(lido); + expect(state1.shareRate).to.equal(state0.shareRate); + await assertReserveAllocationInvariant(lido); + + // ---- Cycle 2: redeem 3 ETH ---- + await redeemExact(lido, holder, fix, ether("3")); + expect((await fix.vault.getRedeemed())[0]).to.be.gt(0n); + + await doReport(ctx); + + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + expect((await captureState(lido)).shareRate).to.equal(state1.shareRate); + await assertReserveAllocationInvariant(lido); + }); +}); diff --git a/test/integration/redeems-reserve/full-circle.integration.ts b/test/integration/redeems-reserve/full-circle.integration.ts new file mode 100644 index 000000000..ffb65c307 --- /dev/null +++ b/test/integration/redeems-reserve/full-circle.integration.ts @@ -0,0 +1,288 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether, getCurrentBlockTimestamp } from "lib"; +import { getProtocolContext, ProtocolContext, report } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + advancePastRequestTimestampMargin, + assertReserveAllocationInvariant, + assertReserveState, + captureState, + doReport, + expectedReserveTarget, + getAmountOfETHLocked, + ProtocolState, + redeemExact, + requestWithdrawal, + resetProtocolState, + setupVault, + VaultFixture, +} from "./helpers"; + +const RATIO_BP = 500n; + +describe("Integration: Redeems reserve — full circle", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [reserveManager] = await ethers.getSigners(); + + await resetProtocolState(ctx); + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("happy path", async () => { + const { lido, withdrawalQueue } = ctx.contracts; + const [, alice, bob] = await ethers.getSigners(); + + fix = await setupVault(ctx, reserveManager, [alice]); + + // --- Alice and Bob deposit 500 ETH each, set ratio, process report --- + await lido.connect(alice).submit(ZeroAddress, { value: ether("500") }); + await lido.connect(bob).submit(ZeroAddress, { value: ether("500") }); + await lido.connect(reserveManager).setRedeemsReserveTargetRatio(RATIO_BP); + await lido.connect(reserveManager).setDepositsReserveTarget(0n); + await doReport(ctx); + + const state0: ProtocolState = await captureState(lido); + + assertReserveState(state0, RATIO_BP); + await assertReserveAllocationInvariant(lido); + + // --- Alice redeems 10 ETH --- + const redeemShares1 = await lido.getSharesByPooledEth(ether("10")); + const redeemEther1 = await lido.getPooledEthByShares(redeemShares1); + + await redeemExact(lido, alice, fix, ether("10")); + + // Verify: stale state (burn deferred), rate preserved, pending shares on burner + expect(await lido.getPooledEthByShares(ether("1"))).to.equal(state0.shareRate); + await assertReserveAllocationInvariant(lido); + + // --- Bob requests 100 ETH WQ withdrawal --- + const requestId = await requestWithdrawal(ctx, bob, ether("100")); + + // --- Reconciliation report: burn redeem shares before WQ processing --- + await doReport(ctx); + + const state1: ProtocolState = await captureState(lido); + + // Verify: reconciliation applied — TPE and shares reflect the redeem, rate preserved + expect(state1.totalPooledEther).to.equal(state0.totalPooledEther - redeemEther1); + expect(state1.totalShares).to.equal(state0.totalShares - redeemShares1); + expect(state1.shareRate).to.equal(state0.shareRate); + + // --- Process report with WQ finalization --- + await advancePastRequestTimestampMargin(ctx); + const reportResult = await doReport(ctx, { skipWithdrawals: false, excludeVaultsBalances: true }); + + const state2: ProtocolState = await captureState(lido); + const ethLocked = await getAmountOfETHLocked(ctx, reportResult); + const [requestStatus] = await withdrawalQueue.getWithdrawalStatus([requestId]); + + // Verify: WQ finalized, reserve refilled + expect(requestStatus.isFinalized).to.equal(true); + expect(await withdrawalQueue.unfinalizedStETH()).to.equal(0n); + expect(state2.internalEther).to.equal(state1.internalEther - ethLocked); + assertReserveState(state2, RATIO_BP); + expect(state2.reserveTarget).to.equal(expectedReserveTarget(state1.internalEther - ethLocked, RATIO_BP)); + await assertReserveAllocationInvariant(lido); + + // --- Alice redeems 5 ETH --- + const redeemShares2 = await lido.getSharesByPooledEth(ether("5")); + const redeemEther2 = await lido.getPooledEthByShares(redeemShares2); + const aliceEthBefore = await ethers.provider.getBalance(alice.address); + + await redeemExact(lido, alice, fix, ether("5")); + + const state3: ProtocolState = await captureState(lido); + + // Verify: stale reserve, pending shares, alice received ETH + expect(state3.reserve).to.equal(state2.reserve); + expect(await ethers.provider.getBalance(alice.address)).to.equal(aliceEthBefore + redeemEther2); + await assertReserveAllocationInvariant(lido); + }); + + it("full exit", async () => { + const { lido, withdrawalQueue } = ctx.contracts; + const [, alice, bob, carol, dave, eve] = await ethers.getSigners(); + const DEPOSIT = ether("200"); + + fix = await setupVault(ctx, reserveManager, [alice, dave]); + + // --- 5 users deposit 200 ETH each, set ratio, pause staking, process report --- + for (const user of [alice, bob, carol, dave, eve]) { + await lido.connect(user).submit(ZeroAddress, { value: DEPOSIT }); + } + + await lido.connect(reserveManager).setRedeemsReserveTargetRatio(RATIO_BP); + await lido.connect(reserveManager).setDepositsReserveTarget(0n); + + const agent = await ctx.getSigner("agent"); + const { acl } = ctx.contracts; + const pauseRole = await lido.STAKING_PAUSE_ROLE(); + const hasPauseRole = await acl["hasPermission(address,address,bytes32)"]( + agent.address, + lido.getAddress(), + pauseRole, + ); + if (!hasPauseRole) { + await acl.connect(agent).grantPermission(agent.address, lido.getAddress(), pauseRole); + } + await lido.connect(agent).pauseStaking(); + + await doReport(ctx); + + const state0: ProtocolState = await captureState(lido); + assertReserveState(state0, RATIO_BP); + await assertReserveAllocationInvariant(lido); + + // --- Wave 1: Alice redeems 30 ETH, Bob and Carol full WQ withdrawal --- + await redeemExact(lido, alice, fix, ether("30")); + + for (const user of [bob, carol]) { + const balance = await lido.balanceOf(user.address); + await requestWithdrawal(ctx, user, balance); + } + + // Reconciliation report before WQ processing to avoid bunker detection from stale vault delta + await doReport(ctx); + + await advancePastRequestTimestampMargin(ctx); + await doReport(ctx, { skipWithdrawals: false, excludeVaultsBalances: true }); + + const state1: ProtocolState = await captureState(lido); + + expect(await withdrawalQueue.unfinalizedStETH()).to.equal(0n); + assertReserveState(state1, RATIO_BP); + await assertReserveAllocationInvariant(lido); + + // --- Wave 2: Alice WQ remainder, Dave redeems 20 ETH, Eve full WQ --- + const aliceRemainder = await lido.balanceOf(alice.address); + await requestWithdrawal(ctx, alice, aliceRemainder); + + await redeemExact(lido, dave, fix, ether("20")); + + const eveBalance = await lido.balanceOf(eve.address); + await requestWithdrawal(ctx, eve, eveBalance); + + // Reconciliation report before WQ processing + await doReport(ctx); + + await advancePastRequestTimestampMargin(ctx); + await doReport(ctx, { skipWithdrawals: false, excludeVaultsBalances: true }); + + await assertReserveAllocationInvariant(lido); + + // --- Wave 3: Dave requests WQ for remainder --- + const daveRemainder = await lido.balanceOf(dave.address); + const daveRequestId = await requestWithdrawal(ctx, dave, daveRemainder); + + // --- Loop reports until finalization stalls --- + let prevFinalized = await withdrawalQueue.getLastFinalizedRequestId(); + + for (let i = 0; i < 10; i++) { + await advancePastRequestTimestampMargin(ctx); + await doReport(ctx, { skipWithdrawals: false, excludeVaultsBalances: true }); + await assertReserveAllocationInvariant(lido); + + const lastFinalized = await withdrawalQueue.getLastFinalizedRequestId(); + if (lastFinalized === prevFinalized && i > 0) break; + prevFinalized = lastFinalized; + } + + // Verify: Dave is stuck, reserve holds priority over WQ budget + const [daveStatus] = await withdrawalQueue.getWithdrawalStatus([daveRequestId]); + expect(daveStatus.isFinalized).to.equal(false); + + const reserve = await lido.getRedeemsReserve(); + expect(reserve).to.equal(await lido.getRedeemsReserveTarget()); + await assertReserveAllocationInvariant(lido); + + // --- Daemon override: force-finalize with full buffered budget --- + // Verify: reserve is at target before override (will be drained to zero after) + const reserveBeforeOverride = await lido.getRedeemsReserve(); + expect(reserveBeforeOverride).to.equal(await lido.getRedeemsReserveTarget()); + + const buffered = await lido.getBufferedEther(); + const totalPooled = await lido.getTotalPooledEther(); + const totalShares = await lido.getTotalShares(); + const simulatedShareRate = (totalPooled * 10n ** 27n) / totalShares; + + const { requestTimestampMargin } = await ctx.contracts.oracleReportSanityChecker.getOracleReportLimits(); + const maxTimestamp = (await getCurrentBlockTimestamp()) - requestTimestampMargin; + + const batchesState = await withdrawalQueue.calculateFinalizationBatches(simulatedShareRate, maxTimestamp, 1000n, { + remainingEthBudget: buffered, + finished: false, + batches: Array(36).fill(0n), + batchesLength: 0n, + }); + const overrideBatches = [...batchesState.batches].filter((x) => x > 0n); + + // --- Process report with override batches --- + await report(ctx, { + clDiff: 0n, + excludeVaultsBalances: true, + sharesRequestedToBurn: 0n, + skipWithdrawals: false, + withdrawalFinalizationBatches: overrideBatches, + simulatedShareRate, + }); + + expect(await withdrawalQueue.unfinalizedStETH()).to.equal(0n); + + // Verify: reserve drained to zero after override (full buffer used for WQ; 1 wei rounding) + expect(await lido.getRedeemsReserve()).to.be.closeTo(0n, 1n); + + const [daveStatusFinal] = await withdrawalQueue.getWithdrawalStatus([daveRequestId]); + expect(daveStatusFinal.isFinalized).to.equal(true); + + // --- Dave claims withdrawal --- + const lastCheckpoint = await withdrawalQueue.getLastCheckpointIndex(); + const hints = [...(await withdrawalQueue.findCheckpointHints([daveRequestId], 1n, lastCheckpoint))]; + const [claimable] = await withdrawalQueue.getClaimableEther([daveRequestId], hints); + expect(claimable).to.be.closeTo(daveRemainder, 100n); + + const ethBefore = await ethers.provider.getBalance(dave.address); + const tx = await withdrawalQueue.connect(dave).claimWithdrawals([daveRequestId], hints); + const receipt = await tx.wait(); + const gasCost = receipt!.gasUsed * receipt!.gasPrice; + expect(await ethers.provider.getBalance(dave.address)).to.equal(ethBefore + claimable - gasCost); + }); +}); diff --git a/test/integration/redeems-reserve/helpers.ts b/test/integration/redeems-reserve/helpers.ts new file mode 100644 index 000000000..67a4f0a65 --- /dev/null +++ b/test/integration/redeems-reserve/helpers.ts @@ -0,0 +1,588 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { Lido, RedeemsBuffer } from "typechain-types"; + +import { advanceChainTime, ether, impersonate, updateBalance } from "lib"; +import { LIMITER_PRECISION_BASE } from "lib/constants"; +import { + ProtocolContext, + report, + reportWithEffectiveClDiff, + resetCLBalanceDecreaseWindow, + setMaxPositiveTokenRebase, + submitReportDataWithConsensus, + updateOracleReportLimits, +} from "lib/protocol"; +import { proxify } from "lib/proxy"; + +export interface VaultFixture { + vault: RedeemsBuffer; + address: string; +} + +export interface ProtocolState { + internalEther: bigint; + shareRate: bigint; + totalPooledEther: bigint; + totalShares: bigint; + reserve: bigint; + reserveTarget: bigint; +} + +export interface BufferState { + buffered: bigint; + reserve: bigint; + reserveTarget: bigint; + depositsReserve: bigint; + withdrawalsReserve: bigint; + depositable: bigint; + unfinalizedStETH: bigint; +} + +export interface BunkerCheckpoint { + protocol: ProtocolState; + externalShares: bigint; + internalShares: bigint; + bunkerMode: boolean; + lastFinalizedRequestId: bigint; + unfinalizedStETH: bigint; +} + +/** Deploys RedeemsBuffer, grants roles, installs on Lido. Call once in before(). */ +export async function setupVault( + ctx: ProtocolContext, + admin: HardhatEthersSigner, + extraRedeemers: HardhatEthersSigner[] = [], +): Promise { + const { burner, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + + // Deploy RedeemsBuffer + const factory = await ethers.getContractFactory("RedeemsBuffer"); + const lidoAddr = await lido.getAddress(); + const burnerAddr = await burner.getAddress(); + const wqAddr = await ctx.contracts.withdrawalQueue.getAddress(); + const hashConsensusAddr = await ctx.contracts.hashConsensus.getAddress(); + const impl = await factory.connect(admin).deploy(lidoAddr, burnerAddr, wqAddr, hashConsensusAddr); + const [vault] = await proxify({ impl, admin }); + await vault.initialize(admin.address); + + const burnRole = await burner.REQUEST_BURN_SHARES_ROLE(); + await burner.connect(agent).grantRole(burnRole, await vault.getAddress()); + await installRedeemsBufferOnLido(ctx, await vault.getAddress()); + + const redeemerRole = await vault.REDEEMER_ROLE(); + await vault.connect(admin).grantRole(redeemerRole, admin.address); + for (const signer of extraRedeemers) { + await vault.connect(admin).grantRole(redeemerRole, signer.address); + } + + const recoverRole = await vault.RECOVER_ROLE(); + await vault.connect(admin).grantRole(recoverRole, admin.address); + + // Sanity: Lido must now resolve to the new buffer. + if ((await lido.getRedeemsBuffer()) !== (await vault.getAddress())) { + throw new Error("setupVault: Lido did not pick up the new RedeemsBuffer"); + } + + return { vault, address: await vault.getAddress() }; +} + +/** + * Atomically swaps Lido's active RedeemsBuffer to `bufferAddress`. If a buffer is currently + * installed, it is shut down (drained, caches zeroed, perma-paused) in the same transaction. + * Pass `ZeroAddress` to disable the feature. + * + * Grants the agent the `BUFFER_RESERVE_MANAGER_ROLE` if it is not already held. + */ +export async function installRedeemsBufferOnLido(ctx: ProtocolContext, bufferAddress: string) { + const { lido, acl } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const lidoAddress = await lido.getAddress(); + + const hasRole = await acl["hasPermission(address,address,bytes32)"](agent.address, lidoAddress, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(agent.address, lidoAddress, role); + } + + return lido.connect(agent).setRedeemsBuffer(bufferAddress); +} + +/** Deposits ETH, applies initial rebase for non-1:1 rate, sets reserve ratio, runs report to fill reserve */ +export async function seedReserve( + ctx: ProtocolContext, + holder: HardhatEthersSigner, + reserveManager: HardhatEthersSigner, + opts: { + deposit: bigint; + redeemsReserveRatioBP: bigint; + depositsReserveTarget?: bigint; + growthShareBP?: bigint; + }, +) { + const { lido } = ctx.contracts; + + if (opts.deposit > 0n) { + await lido.connect(holder).submit(ZeroAddress, { value: opts.deposit }); + } + + // Initial rebase via EL rewards to create non-1:1 share rate — catches rounding edge cases. + // Uses EL vault instead of CL diff so it works even after resetProtocolState (no validators). + await fundElRewards(ctx, ether("0.0037")); + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true, reportWithdrawalsVault: false }); + + await lido.connect(reserveManager).setRedeemsReserveTargetRatio(opts.redeemsReserveRatioBP); + + if (opts.depositsReserveTarget !== undefined) { + await lido.connect(reserveManager).setDepositsReserveTarget(opts.depositsReserveTarget); + } + if (opts.growthShareBP !== undefined) { + await lido.connect(reserveManager).setRedeemsReserveGrowthShare(opts.growthShareBP); + } + + await doReport(ctx); +} + +/** Submits an oracle report with push-friendly defaults */ +export function doReport(ctx: ProtocolContext, opts: Parameters[1] = {}) { + return report(ctx, { + clDiff: 0n, + excludeVaultsBalances: true, + skipWithdrawals: true, + ...opts, + }); +} + +/** Approves vault and redeems stETH. Verifies vault balance decrease and recipient ETH receipt. */ +export async function redeemExact( + lido: Lido, + holder: HardhatEthersSigner, + fixture: VaultFixture, + amount: bigint, + recipient?: string, +) { + const ethRecipient = recipient ?? holder.address; + const sharesAmount = await lido.getSharesByPooledEth(amount); + const etherAmount = await lido.getPooledEthByShares(sharesAmount); + + const vaultBalBefore = await ethers.provider.getBalance(fixture.address); + const recipientBalBefore = await ethers.provider.getBalance(ethRecipient); + + await lido.connect(holder).approve(fixture.address, amount + 10n, { gasPrice: 0 }); + await fixture.vault.connect(holder).redeem(amount, ethRecipient, { gasPrice: 0 }); + + expect(await ethers.provider.getBalance(fixture.address)).to.equal( + vaultBalBefore - etherAmount, + "vault balance mismatch after redeem", + ); + expect(await ethers.provider.getBalance(ethRecipient)).to.equal( + recipientBalBefore + etherAmount, + "recipient ETH balance mismatch after redeem", + ); +} + +export interface RedeemQuote { + stETHAmount: bigint; + shares: bigint; + ether: bigint; +} + +/** Captures the current redeem quote for a given stETH amount */ +export async function captureRedeemQuote(lido: Lido, stETHAmount: bigint): Promise { + const shares = await lido.getSharesByPooledEth(stETHAmount); + const pooledEther = await lido.getPooledEthByShares(shares); + return { stETHAmount, shares, ether: pooledEther }; +} + +/** Re-quotes the ETH value of a fixed shares amount at the current share rate */ +export async function quoteShares(lido: Lido, shares: bigint): Promise { + return await lido.getPooledEthByShares(shares); +} + +/** Captures current protocol state into a snapshot object */ +export async function captureState(lido: Lido): Promise { + const totalPooledEther = await lido.getTotalPooledEther(); + const externalEther = await lido.getExternalEther(); + + return { + internalEther: totalPooledEther - externalEther, + shareRate: await lido.getPooledEthByShares(ether("1")), + totalPooledEther, + totalShares: await lido.getTotalShares(), + reserve: await lido.getRedeemsReserve(), + reserveTarget: await lido.getRedeemsReserveTarget(), + }; +} + +/** Captures current buffer allocation and withdrawal demand */ +export async function captureBufferState(ctx: ProtocolContext): Promise { + const { lido, withdrawalQueue } = ctx.contracts; + + return { + buffered: await lido.getBufferedEther(), + reserve: await lido.getRedeemsReserve(), + reserveTarget: await lido.getRedeemsReserveTarget(), + depositsReserve: await lido.getDepositsReserve(), + withdrawalsReserve: await lido.getWithdrawalsReserve(), + depositable: await lido.getDepositableEther(), + unfinalizedStETH: await withdrawalQueue.unfinalizedStETH(), + }; +} + +/** Computes expected reserve target from internal ether and ratio */ +export function expectedReserveTarget(internalEther: bigint, ratioBP: bigint): bigint { + return (ratioBP * internalEther) / 10_000n; +} + +/** + * Validates reserve is fully funded: target matches internalEther × ratioBP, and reserve == target. + * Only valid after a report when the buffer has enough ETH to fill the reserve. + */ +export function assertReserveState(state: ProtocolState, ratioBP: bigint) { + expect(state.reserveTarget).to.equal(expectedReserveTarget(state.internalEther, ratioBP)); + expect(state.reserve).to.equal(state.reserveTarget); +} + +/** Asserts buffered = reserve + deposits + wq + unreserved, depositable = deposits + unreserved */ +export async function assertReserveAllocationInvariant(lido: Lido) { + const buffered = await lido.getBufferedEther(); + const reserve = await lido.getRedeemsReserve(); + const deposits = await lido.getDepositsReserve(); + const wq = await lido.getWithdrawalsReserve(); + const depositable = await lido.getDepositableEther(); + const unreserved = buffered - reserve - deposits - wq; + + expect(depositable).to.equal(buffered - reserve - wq, "depositable mismatch"); + expect(depositable).to.equal(deposits + unreserved, "depositable should equal deposits reserve plus unreserved"); + expect(buffered).to.equal(reserve + deposits + wq + unreserved, "buffered ether allocation mismatch"); +} + +type RedeemSize = "small" | "huge" | "full"; + +/** Computes a redeem amount based on the current reserve */ +export async function getRedeemAmount(lido: Lido, size: RedeemSize): Promise { + const reserve = await lido.getRedeemsReserve(); + switch (size) { + case "small": + return (reserve * 5n) / 100n; + case "huge": + return (reserve * 50n) / 100n; + case "full": { + const shares = await lido.getSharesByPooledEth(reserve); + return await lido.getPooledEthByShares(shares); + } + } +} + +/** Funds the EL rewards vault with the given amount */ +export async function fundElRewards(ctx: ProtocolContext, amount: bigint) { + const elVaultAddr = await ctx.contracts.locator.elRewardsVault(); + await updateBalance(elVaultAddr, amount); +} + +/** + * Simulates insurance application: transfers stETH from holder to a dedicated insurance signer, + * then burns it as cover via the Burner. + */ +export async function applyInsurance(ctx: ProtocolContext, holder: HardhatEthersSigner, amount: bigint) { + const { lido, burner } = ctx.contracts; + const burnRole = await burner.REQUEST_BURN_MY_STETH_ROLE(); + const adminRole = await burner.DEFAULT_ADMIN_ROLE(); + const agent = await ctx.getSigner("agent"); + const agentSigner = await impersonate(agent.address, ether("1")); + + const [, , , , , , , , insuranceSigner] = await ethers.getSigners(); + + if (!(await burner.hasRole(adminRole, agent.address))) { + throw new Error("agent does not have DEFAULT_ADMIN_ROLE on Burner"); + } + + if (!(await burner.hasRole(burnRole, insuranceSigner.address))) { + await burner.connect(agentSigner).grantRole(burnRole, insuranceSigner.address); + } + + await lido.connect(holder).transfer(insuranceSigner.address, amount); + await lido.connect(insuranceSigner).approve(burner, amount); + await burner.connect(insuranceSigner).requestBurnMyStETHForCover(amount); +} + +/** Advances time past one full oracle frame without submitting a report */ +export async function skipReport(ctx: ProtocolContext) { + const { slotsPerEpoch, secondsPerSlot } = await ctx.contracts.hashConsensus.getChainConfig(); + const [, epochsPerFrame] = await ctx.contracts.hashConsensus.getFrameConfig(); + const frameSeconds = slotsPerEpoch * secondsPerSlot * epochsPerFrame; + await advanceChainTime(frameSeconds + 1n); +} + +/** Mines the given number of empty blocks */ +export async function mineBlocks(count: number) { + for (let i = 0; i < count; i++) { + await ethers.provider.send("evm_mine", []); + } +} + +/** Enters bunker mode via a negative CL report */ +export async function enterBunkerMode( + ctx: ProtocolContext, + opts: { + effectiveClDiff?: bigint; + reportParams?: Exclude[2], undefined>; + } = {}, +) { + const effectiveClDiff = opts.effectiveClDiff ?? ether("-1"); + await resetCLBalanceDecreaseWindow(ctx); + const result = await reportWithEffectiveClDiff(ctx, effectiveClDiff, { + excludeVaultsBalances: true, + ...opts.reportParams, + }); + expect(await ctx.contracts.withdrawalQueue.isBunkerModeActive()).to.equal(true, "failed to enter bunker mode"); + return result; +} + +/** Exits bunker mode via a recovery report */ +export async function exitBunkerMode( + ctx: ProtocolContext, + opts: { + effectiveClDiff?: bigint; + reportParams?: Exclude[2], undefined>; + } = {}, +) { + const effectiveClDiff = opts.effectiveClDiff ?? ether("0.0001"); + await reportWithEffectiveClDiff(ctx, effectiveClDiff, { + excludeVaultsBalances: true, + ...opts.reportParams, + }); + expect(await ctx.contracts.withdrawalQueue.isBunkerModeActive()).to.equal(false, "failed to exit bunker mode"); +} + +/** Extracts the exact amount of ETH locked for WQ finalization from a report receipt */ +export async function getAmountOfETHLocked( + ctx: ProtocolContext, + reportResult: { reportTx?: { wait(): Promise } }, +): Promise { + const receipt = await reportResult.reportTx?.wait(); + if (!receipt) return 0n; + + for (const log of receipt.logs) { + try { + const parsed = ctx.contracts.withdrawalQueue.interface.parseLog(log); + if (parsed?.name === "WithdrawalsFinalized") { + return parsed.args.amountOfETHLocked; + } + } catch { + continue; + } + } + + return 0n; +} + +/** Creates one WQ request and verifies unfinalized demand increased */ +export async function requestWithdrawal( + ctx: ProtocolContext, + from: HardhatEthersSigner, + amount: bigint, +): Promise { + const { lido, withdrawalQueue } = ctx.contracts; + const unfinalizedBefore = await withdrawalQueue.unfinalizedStETH(); + const lastRequestIdBefore = await withdrawalQueue.getLastRequestId(); + + await lido.connect(from).approve(withdrawalQueue, amount); + await withdrawalQueue.connect(from).requestWithdrawals([amount], from.address); + + const requestId = await withdrawalQueue.getLastRequestId(); + expect(requestId).to.equal(lastRequestIdBefore + 1n); + expect(await withdrawalQueue.unfinalizedStETH()).to.equal(unfinalizedBefore + amount); + return requestId; +} + +/** Advances chain time past requestTimestampMargin so pending WQ requests pass the creation-time sanity check */ +export async function advancePastRequestTimestampMargin(ctx: ProtocolContext) { + const { requestTimestampMargin } = await ctx.contracts.oracleReportSanityChecker.getOracleReportLimits(); + await advanceChainTime(requestTimestampMargin + 1n); +} + +/** Captures bunker state and asserts reserve allocation invariants first */ +export async function captureValidatedBunkerCheckpoint(ctx: ProtocolContext): Promise { + await assertReserveAllocationInvariant(ctx.contracts.lido); + const { lido, withdrawalQueue } = ctx.contracts; + const protocol = await captureState(lido); + const externalShares = await lido.getExternalShares(); + + return { + protocol, + externalShares, + internalShares: protocol.totalShares - externalShares, + bunkerMode: await withdrawalQueue.isBunkerModeActive(), + lastFinalizedRequestId: await withdrawalQueue.getLastFinalizedRequestId(), + unfinalizedStETH: await withdrawalQueue.unfinalizedStETH(), + }; +} + +/** Applies another negative bunker report without WQ finalization and keeps bunker mode active */ +export async function processNegativeReportInBunker(ctx: ProtocolContext, effectiveClDiff: bigint) { + const dryRun = await reportWithEffectiveClDiff(ctx, effectiveClDiff, { + excludeVaultsBalances: true, + skipWithdrawals: true, + dryRun: true, + }); + + await submitReportDataWithConsensus(ctx, { + ...dryRun.data, + withdrawalFinalizationBatches: [], + isBunkerMode: true, + }); + + const { addresses } = await ctx.contracts.hashConsensus.getFastLaneMembers(); + const member = await impersonate(addresses[0], ether("1")); + await ctx.contracts.accountingOracle.connect(member).submitReportExtraDataEmpty(); + + expect(await ctx.contracts.withdrawalQueue.isBunkerModeActive()).to.equal(true, "failed to keep bunker mode active"); + return dryRun; +} + +/** Verifies that vault.redeem reverts with BunkerMode during bunker */ +export async function expectRedeemBlockedInBunker( + ctx: ProtocolContext, + from: HardhatEthersSigner, + fixture: VaultFixture, + amount: bigint, +) { + const { lido } = ctx.contracts; + await lido.connect(from).approve(fixture.address, amount + 10n, { gasPrice: 0 }); + await expect(fixture.vault.connect(from).redeem(amount, from.address, { gasPrice: 0 })).to.be.revertedWithCustomError( + fixture.vault, + "BunkerModeActive", + ); +} + +/** Redeems after bunker exit via vault and verifies it succeeds */ +export async function redeemAfterBunkerExit( + lido: Lido, + from: HardhatEthersSigner, + fixture: VaultFixture, + amount: bigint, +) { + await redeemExact(lido, from, fixture, amount); +} + +/** + * Resets the protocol to a near-empty state: all stETH holders drained via the Withdrawal + * Queue, all requests finalized, CL balance zeroed. + */ +export async function resetProtocolState(ctx: ProtocolContext) { + const { lido, withdrawalQueue } = ctx.contracts; + const MIN_WQ_AMOUNT = ether("0.001"); + const MAX_WQ_AMOUNT = ether("1000"); + + const holderAddrs = await discoverStETHHolders(ctx); + + for (const addr of holderAddrs) { + const balance = await lido.balanceOf(addr); + if (balance < MIN_WQ_AMOUNT) continue; + + const signer = await impersonate(addr, ether("1")); + await lido.connect(signer).approve(withdrawalQueue, balance); + + const chunks: bigint[] = []; + let remaining = balance; + while (remaining >= MIN_WQ_AMOUNT) { + const chunk = remaining > MAX_WQ_AMOUNT ? MAX_WQ_AMOUNT : remaining; + chunks.push(chunk); + remaining -= chunk; + } + await withdrawalQueue.connect(signer).requestWithdrawals(chunks, addr); + } + + if ((await withdrawalQueue.unfinalizedStETH()) === 0n) return; + + // Finalize via simulated validator exits + const savedRebaseLimit = await setMaxPositiveTokenRebase(ctx, LIMITER_PRECISION_BASE); + const savedLimits = await ctx.contracts.oracleReportSanityChecker.getOracleReportLimits(); + await updateOracleReportLimits(ctx, { + maxCLBalanceDecreaseBP: 10000n, + simulatedShareRateDeviationBPLimit: 10000n, + }); + + await resetCLBalanceDecreaseWindow(ctx); + + const withdrawalVaultAddr = await ctx.contracts.locator.withdrawalVault(); + const stats = await lido.getBalanceStats(); + let remainingCl = stats.clValidatorsBalanceAtLastReport + stats.clPendingBalanceAtLastReport; + + for (let i = 0; i < 20; i++) { + const unfinalized = await withdrawalQueue.unfinalizedStETH(); + if (unfinalized === 0n) break; + + const exitChunk = unfinalized < remainingCl ? unfinalized : remainingCl; + if (exitChunk > 0n) { + const vaultBal = await ethers.provider.getBalance(withdrawalVaultAddr); + await updateBalance(withdrawalVaultAddr, vaultBal + exitChunk); + remainingCl -= exitChunk; + } + + await report(ctx, { + clDiff: exitChunk > 0n ? -exitChunk : 0n, + excludeVaultsBalances: false, + skipWithdrawals: false, + sharesRequestedToBurn: 0n, + }); + } + + // Drain remaining requests from the buffer + if ((await withdrawalQueue.unfinalizedStETH()) > 0n) { + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const { acl } = ctx.contracts; + const hasRole = await acl["hasPermission(address,address,bytes32)"](agent.address, lido.getAddress(), role); + if (!hasRole) { + await acl.connect(agent).grantPermission(agent.address, lido.getAddress(), role); + } + const savedRatio = await lido.getRedeemsReserveTargetRatio(); + const savedDepositsTarget = await lido.getDepositsReserveTarget(); + await lido.connect(agent).setRedeemsReserveTargetRatio(0n); + await lido.connect(agent).setDepositsReserveTarget(0n); + + for (let i = 0; i < 10; i++) { + if ((await withdrawalQueue.unfinalizedStETH()) === 0n) break; + await report(ctx, { + clDiff: 0n, + excludeVaultsBalances: false, + skipWithdrawals: false, + sharesRequestedToBurn: 0n, + }); + } + + await lido.connect(agent).setRedeemsReserveTargetRatio(savedRatio); + await lido.connect(agent).setDepositsReserveTarget(savedDepositsTarget); + } + + expect(await withdrawalQueue.unfinalizedStETH()).to.equal(0n, "resetProtocolState: failed to finalize all requests"); + + await setMaxPositiveTokenRebase(ctx, savedRebaseLimit); + await updateOracleReportLimits(ctx, { + maxCLBalanceDecreaseBP: savedLimits.maxCLBalanceDecreaseBP, + simulatedShareRateDeviationBPLimit: savedLimits.simulatedShareRateDeviationBPLimit, + }); +} + +async function discoverStETHHolders(ctx: ProtocolContext): Promise { + const { lido, withdrawalQueue, locator } = ctx.contracts; + + const transfers = await lido.queryFilter(lido.filters.Transfer()); + const holdersSet = new Set(); + for (const t of transfers) { + if (t.args[1] !== ZeroAddress) holdersSet.add(t.args[1]); + } + + const excludeAddrs = new Set( + [await locator.burner(), await withdrawalQueue.getAddress(), await lido.getAddress()].map((a) => a.toLowerCase()), + ); + + return [...holdersSet].filter((a) => !excludeAddrs.has(a.toLowerCase())); +} diff --git a/test/integration/redeems-reserve/low-tvl.integration.ts b/test/integration/redeems-reserve/low-tvl.integration.ts new file mode 100644 index 000000000..a66150bba --- /dev/null +++ b/test/integration/redeems-reserve/low-tvl.integration.ts @@ -0,0 +1,160 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + assertReserveState, + captureState, + doReport, + fundElRewards, + getAmountOfETHLocked, + getRedeemAmount, + ProtocolState, + redeemExact, + requestWithdrawal, + resetProtocolState, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const ALICE_DEPOSIT = ether("100"); +const BOB_DEPOSIT = ether("100"); +const RATIO_BP = 500n; +const EL_REWARDS = ether("1"); + +describe("Integration: Redeems reserve — low TVL impact", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let alice: HardhatEthersSigner; + let bob: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [alice, bob] = await ethers.getSigners(); + reserveManager = alice; + + await resetProtocolState(ctx); + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("small TVL: redeem + WQ + rebase with two holders", async () => { + const { lido, withdrawalQueue, locator } = ctx.contracts; + const elVaultAddr = await locator.elRewardsVault(); + + // --- Alice and Bob deposit 100 ETH each, set ratio, process report --- + await seedReserve(ctx, alice, reserveManager, { deposit: ALICE_DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + await lido.connect(bob).submit(ZeroAddress, { value: BOB_DEPOSIT }); + await doReport(ctx); + + const state0: ProtocolState = await captureState(lido); + + assertReserveState(state0, RATIO_BP); + await assertReserveAllocationInvariant(lido); + + // --- Alice redeems entire reserve --- + const redeemAmount = await getRedeemAmount(lido, "full"); + const redeemShares = await lido.getSharesByPooledEth(redeemAmount); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + + await redeemExact(lido, alice, fix, redeemAmount); + + const state1: ProtocolState = await captureState(lido); + + // Verify: pending shares on burner, available reserve ≈ 0 (tracked reserve stale, vault drained) + expect(state1.reserve - (await fix.vault.getRedeemed())[0]).to.be.closeTo(0n, 10n); + expect(state1.shareRate).to.equal(state0.shareRate); + await assertReserveAllocationInvariant(lido); + + // --- Bob requests full WQ withdrawal --- + const bobBalance = await lido.balanceOf(bob.address); + const requestId = await requestWithdrawal(ctx, bob, bobBalance); + + // --- Fund EL rewards, process report with WQ finalization --- + await fundElRewards(ctx, EL_REWARDS); + + const reportResult = await doReport(ctx, { + excludeVaultsBalances: false, + reportElVault: true, + skipWithdrawals: false, + }); + + const state2: ProtocolState = await captureState(lido); + const ethLocked = await getAmountOfETHLocked(ctx, reportResult); + const [requestStatus] = await withdrawalQueue.getWithdrawalStatus([requestId]); + const deferredRewards = await ethers.provider.getBalance(elVaultAddr); + const appliedRewards = EL_REWARDS - deferredRewards; + + // Verify: shares burned, counters reset + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + expect(requestStatus.isFinalized).to.equal(true); + expect(await withdrawalQueue.unfinalizedStETH()).to.equal(0n); + + // Verify: reserve refilled on smaller base + // state1.internalEther is stale (overcounted by redeemEther), subtract to get reconciled value + expect(state2.internalEther).to.equal(state1.internalEther - redeemEther + appliedRewards - ethLocked); + assertReserveState(state2, RATIO_BP); + await assertReserveAllocationInvariant(lido); + + // Verify: reserveTarget shrunk proportionally to internalEther drop + const internalEtherDrop = state0.internalEther - state2.internalEther; + expect(state0.reserveTarget - state2.reserveTarget).to.be.closeTo((RATIO_BP * internalEtherDrop) / 10_000n, 10n); + + // Verify: shareRate increased from EL rewards on smaller post-WQ base + const expectedShareRate0 = (state0.totalPooledEther * ether("1")) / state0.totalShares; + const expectedShareRate2 = (state2.totalPooledEther * ether("1")) / state2.totalShares; + expect(state2.shareRate - state0.shareRate).to.equal(expectedShareRate2 - expectedShareRate0); + + // --- Second report: deferred rewards picked up, reserve reaches final target --- + await doReport(ctx, { + excludeVaultsBalances: false, + reportElVault: true, + }); + + const state3: ProtocolState = await captureState(lido); + const elVaultAfter = await ethers.provider.getBalance(elVaultAddr); + + expect(state3.internalEther).to.be.closeTo(state2.internalEther + deferredRewards, 10n); + expect(elVaultAfter).to.equal(0n); + assertReserveState(state3, RATIO_BP); + await assertReserveAllocationInvariant(lido); + }); +}); diff --git a/test/integration/redeems-reserve/negative-rebase.integration.ts b/test/integration/redeems-reserve/negative-rebase.integration.ts new file mode 100644 index 000000000..64940240f --- /dev/null +++ b/test/integration/redeems-reserve/negative-rebase.integration.ts @@ -0,0 +1,317 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { + createVaultWithDashboard, + getProtocolContext, + ProtocolContext, + report, + reportVaultDataWithProof, + resetCLBalanceDecreaseWindow, + setupLidoForVaults, + upDefaultTierShareLimit, + waitNextAvailableReportTime, +} from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + assertReserveState, + captureRedeemQuote, + captureState, + doReport, + expectedReserveTarget, + getRedeemAmount, + mineBlocks, + ProtocolState, + quoteShares, + redeemExact, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEPOSIT = ether("1000"); +const RATIO_BP = 500n; +const CL_LOSS = ether("-10"); + +describe("Integration: Redeems reserve — negative rebase", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + await resetCLBalanceDecreaseWindow(ctx); + + const { lido } = ctx.contracts; + assertReserveState(await captureState(lido), RATIO_BP); + await assertReserveAllocationInvariant(lido); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("single negative rebase — reserve auto-shrinks to new target", async () => { + const { lido } = ctx.contracts; + + // --- Path A: report with CL loss, no redeem (via snapshot) --- + const simSnapshot = await Snapshot.take(); + + await doReport(ctx, { clDiff: CL_LOSS }); + const state1: ProtocolState = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state1, RATIO_BP); + + await Snapshot.restore(simSnapshot); + + // --- Path B: small redeem, then report with same CL loss --- + const REDEEM_AMOUNT = await getRedeemAmount(lido, "small"); + const redeemShares = await lido.getSharesByPooledEth(REDEEM_AMOUNT); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + + await redeemExact(lido, holder, fix, REDEEM_AMOUNT); + + // Verify: redeem shares pending on burner + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther); + + await doReport(ctx, { clDiff: CL_LOSS }); + const state2: ProtocolState = await captureState(lido); + await assertReserveAllocationInvariant(lido); + + // Verify: all redeem shares burned, counters reset + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + // --- Compare paths: reserve auto-shrinks to new target in both --- + assertReserveState(state2, RATIO_BP); + + // Verify: reserve target matches internalEther-based calculation in both paths + expect(state2.reserveTarget).to.equal(expectedReserveTarget(state2.internalEther, RATIO_BP)); + expect(state1.reserveTarget).to.equal(expectedReserveTarget(state1.internalEther, RATIO_BP)); + + // Verify: redeem shrinks the base → state2 target is lower than state1 target (1 wei integer division rounding) + expect(state1.reserveTarget - state2.reserveTarget).to.be.closeTo(expectedReserveTarget(redeemEther, RATIO_BP), 1n); + + // Verify: difference between paths is only the redeemed ETH and shares + expect(state2.totalPooledEther).to.equal(state1.totalPooledEther - redeemEther); + expect(state2.totalShares).to.equal(state1.totalShares - redeemShares); + }); + + it("redeem amplifies negative rebase for remaining holders", async () => { + const { lido } = ctx.contracts; + const rateBeforeRebase = (await captureState(lido)).shareRate; + + // --- Path A: report with CL loss, no redeem (via snapshot) --- + const simSnapshot = await Snapshot.take(); + + await doReport(ctx, { clDiff: CL_LOSS }); + const state1: ProtocolState = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state1, RATIO_BP); + + await Snapshot.restore(simSnapshot); + + // --- Path B: huge redeem, then report with same CL loss --- + const REDEEM_AMOUNT = await getRedeemAmount(lido, "huge"); + const redeemShares = await lido.getSharesByPooledEth(REDEEM_AMOUNT); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + + await redeemExact(lido, holder, fix, REDEEM_AMOUNT); + + // Verify: redeem shares pending on burner + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther); + + await doReport(ctx, { clDiff: CL_LOSS }); + const state2: ProtocolState = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state2, RATIO_BP); + + // Verify: all redeem shares burned, counters reset + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + // --- Compare paths: redeem amplifies loss per remaining share --- + // Verify: both paths produced a negative rebase — rate dropped from pre-rebase baseline + const rateDrop1 = rateBeforeRebase - state1.shareRate; + const rateDrop2 = rateBeforeRebase - state2.shareRate; + // Path B (with redeem) has a sharper drop than Path A (without redeem) + expect(rateDrop2 - rateDrop1).to.equal(state1.shareRate - state2.shareRate); + + // Verify: state1 spreads loss over full base, state2 over smaller base (sharper impact) + const expectedShareRate1 = (state1.totalPooledEther * ether("1")) / state1.totalShares; + const expectedShareRate2 = + ((state1.totalPooledEther - redeemEther) * ether("1")) / (state1.totalShares - redeemShares); + + expect(state1.shareRate).to.equal(expectedShareRate1); + expect(state2.shareRate).to.equal(expectedShareRate2); + + expect(state1.shareRate - state2.shareRate).to.equal(expectedShareRate1 - expectedShareRate2); + }); + + it("bad debt internalization keeps reserve unchanged but lowers the redeem quote for the same shares", async () => { + const { lido, vaultHub, stakingVaultFactory } = ctx.contracts; + const [, , , vaultOwner, badDebtManager] = await ethers.getSigners(); + const QUOTE_STETH_AMOUNT = ether("5"); + + // --- Setup vault with bad debt --- + await setupLidoForVaults(ctx); + await upDefaultTierShareLimit(ctx, ether("1000")); + + const { stakingVault, dashboard: rawDashboard } = await createVaultWithDashboard( + ctx, + stakingVaultFactory, + vaultOwner, + vaultOwner, + vaultOwner, + ); + + const dashboard = rawDashboard.connect(vaultOwner); + await dashboard.fund({ value: ether("10") }); + await dashboard.mintShares(vaultOwner, await dashboard.remainingMintingCapacityShares(0n)); + + // --- Slash vault to create bad debt --- + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), + slashingReserve: ether("1"), + waitForNextRefSlot: true, + }); + + const agent = await ctx.getSigner("agent"); + await vaultHub.connect(agent).grantRole(await vaultHub.BAD_DEBT_MASTER_ROLE(), badDebtManager); + + // --- Save state before bad debt internalization --- + const state0 = await captureState(lido); + const externalShares0 = await lido.getExternalShares(); + const internalShares0 = state0.totalShares - externalShares0; + const quote0 = await captureRedeemQuote(lido, QUOTE_STETH_AMOUNT); + await assertReserveAllocationInvariant(lido); + assertReserveState(state0, RATIO_BP); + + // --- Internalize bad debt, process report --- + const liabilityShares = await dashboard.liabilityShares(); + const valueShares = await lido.getSharesByPooledEth(await dashboard.totalValue()); + const badDebtShares = liabilityShares - valueShares; + const internalizedBadDebtShares = await vaultHub + .connect(badDebtManager) + .internalizeBadDebt.staticCall(stakingVault, badDebtShares); + // Precondition: bad debt is non-trivial + expect(internalizedBadDebtShares).to.be.gt(0n); + expect(internalizedBadDebtShares).to.equal(badDebtShares); + await vaultHub.connect(badDebtManager).internalizeBadDebt(stakingVault, badDebtShares); + + await doReport(ctx); + + // --- Verify state after bad debt internalization --- + const state1 = await captureState(lido); + const quote1 = await quoteShares(lido, quote0.shares); + await assertReserveAllocationInvariant(lido); + assertReserveState(state1, RATIO_BP); + + // Verify: reserve and target unchanged (internalEther unaffected) + expect(state1.reserve).to.equal(state0.reserve); + expect(state1.reserveTarget).to.equal(state0.reserveTarget); + + // Verify: total shares unchanged (bad debt moves shares between internal/external pools) + expect(state1.totalShares).to.equal(state0.totalShares); + + expect(internalizedBadDebtShares).to.equal(badDebtShares); + + const expectedExternalShares1 = externalShares0 - internalizedBadDebtShares; + const expectedInternalShares1 = internalShares0 + internalizedBadDebtShares; + const expectedTotalPooledEther1 = + state0.internalEther + (expectedExternalShares1 * state0.internalEther) / expectedInternalShares1; + const expectedShareRate1 = (expectedTotalPooledEther1 * ether("1")) / state1.totalShares; + + expect(state1.totalPooledEther).to.equal(expectedTotalPooledEther1); + expect(state1.shareRate).to.equal(expectedShareRate1); + + // Verify: same shares redeem for less ETH after bad debt internalization + const expectedQuote1 = (quote0.shares * expectedTotalPooledEther1) / state1.totalShares; + expect(quote1).to.equal(expectedQuote1); + + // --- Redeem at post-loss rate --- + const REDEEM_AMOUNT = await getRedeemAmount(lido, "small"); + const redeemQuoteAfterLoss = await captureRedeemQuote(lido, REDEEM_AMOUNT); + const recipientBalBefore = await ethers.provider.getBalance(holder.address); + await redeemExact(lido, holder, fix, REDEEM_AMOUNT); + await assertReserveAllocationInvariant(lido); + expect(await ethers.provider.getBalance(holder.address)).to.equal(recipientBalBefore + redeemQuoteAfterLoss.ether); + + // Verify: redeem shares pending on burner (burn deferred to next report) + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemQuoteAfterLoss.ether); + }); + + it("post-refSlot redeems surviving a negative rebase report the shares that were actually redeemed", async () => { + const { lido } = ctx.contracts; + + // Redeem must land in frame N+1 before report-for-N so it becomes post-refSlot state. + const { reportRefSlot: refSlotN } = await waitNextAvailableReportTime(ctx); + await mineBlocks(3); + + const REDEEM_AMOUNT = ether("5"); + const sharesAtRedeem = await lido.getSharesByPooledEth(REDEEM_AMOUNT); + const etherAtRedeem = await lido.getPooledEthByShares(sharesAtRedeem); + await redeemExact(lido, holder, fix, REDEEM_AMOUNT); + + const [snapEther, snapShares] = await fix.vault.getRedeemedForLastRefSlot(); + expect(snapEther).to.equal(0n); + expect(snapShares).to.equal(0n); + + await report(ctx, { + clDiff: CL_LOSS, + excludeVaultsBalances: true, + skipWithdrawals: true, + refSlot: refSlotN, + waitNextReportTime: false, + }); + + const [postRefSlotRedeemedEther, postRefSlotRedeemedShares] = await fix.vault.getRedeemed(); + expect(postRefSlotRedeemedEther).to.equal(etherAtRedeem); + expect(postRefSlotRedeemedShares).to.equal(sharesAtRedeem); + + // Rederiving shares from the preserved ether at the post-rebase rate yields a value that + // does NOT match the shares actually transferred to Burner — the tracked share counter + // exists precisely to avoid this mismatch. + const totalSharesPostRebase = await lido.getTotalShares(); + const totalPooledPostRebase = await lido.getTotalPooledEther(); + const rederivedSharesAtNewRate = (postRefSlotRedeemedEther * totalSharesPostRebase) / totalPooledPostRebase; + expect(await lido.getSharesByPooledEth(postRefSlotRedeemedEther)).to.equal(rederivedSharesAtNewRate); + expect(rederivedSharesAtNewRate).to.not.equal(postRefSlotRedeemedShares); + + await assertReserveAllocationInvariant(lido); + }); +}); diff --git a/test/integration/redeems-reserve/oracle-sandwiching.integration.ts b/test/integration/redeems-reserve/oracle-sandwiching.integration.ts new file mode 100644 index 000000000..433bced80 --- /dev/null +++ b/test/integration/redeems-reserve/oracle-sandwiching.integration.ts @@ -0,0 +1,296 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext, resetCLBalanceDecreaseWindow } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + assertReserveState, + captureRedeemQuote, + captureState, + doReport, + fundElRewards, + getRedeemAmount, + redeemExact, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEPOSIT = ether("1000"); +const RATIO_BP = 500n; +const REWARDS = ether("1"); +const CL_LOSS = ether("-10"); +const ATTACKER_STETH = ether("100"); +const ROUNDING_TOLERANCE = 10n; + +describe("Integration: Redeems reserve — oracle report sandwiching", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + let attacker: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder, attacker] = await ethers.getSigners(); + reserveManager = holder; + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager, [attacker]); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + + const { lido } = ctx.contracts; + await assertReserveAllocationInvariant(lido); + assertReserveState(await captureState(lido), RATIO_BP); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("redeem before positive rebase then re-enter after returns fewer shares than were burned", async () => { + const { lido } = ctx.contracts; + + await transferStETH({ lido, from: holder, to: attacker, amount: ATTACKER_STETH }); + + // --- Attacker redeems before positive rebase --- + const attackerSharesBefore = await lido.sharesOf(attacker.address); + + const redeemAmount = await lido.balanceOf(attacker.address); + const redeemQuote = await captureRedeemQuote(lido, redeemAmount); + + await redeemExact(lido, attacker, fix, redeemAmount); + + // Verify: pending shares on burner + + // state0 is stale in push: TPE and totalShares unchanged (burn deferred to report) + const state0 = await captureState(lido); + + // --- Report with EL rewards: burns redeem shares + applies rewards --- + await fundElRewards(ctx, REWARDS); + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true, reportWithdrawalsVault: false }); + + const state1 = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state1, RATIO_BP); + + // Verify: shares burned + + // --- Attacker re-enters at higher share rate --- + const reenter = await submitEther({ lido, from: attacker, amount: redeemQuote.ether }); + + // Report reconciled stale state: TPE decreased by redeemEther, totalShares decreased by redeemShares + expect(state1.totalPooledEther).to.equal(state0.totalPooledEther - redeemQuote.ether + REWARDS); + expect(state1.totalShares).to.equal(state0.totalShares - redeemQuote.shares); + + const expectedShareRate1 = + ((state0.totalPooledEther - redeemQuote.ether + REWARDS) * ether("1")) / + (state0.totalShares - redeemQuote.shares); + expect(state1.shareRate).to.equal(expectedShareRate1); + + const expectedSharesBack = (redeemQuote.ether * state1.totalShares) / state1.totalPooledEther; + const expectedSharesLost = redeemQuote.shares - expectedSharesBack; + + expect(reenter.shares).to.equal(expectedSharesBack); + expect(redeemQuote.shares - reenter.shares).to.equal(expectedSharesLost); + + // Verify: attacker ended up with fewer shares — sandwich unprofitable + const attackerSharesAfter = await lido.sharesOf(attacker.address); + expect(attackerSharesBefore - attackerSharesAfter).to.equal(expectedSharesLost); + }); + + it("deposit before rebase then redeem after captures profit and leaves a lower share rate than the clean baseline", async () => { + const { lido } = ctx.contracts; + + // --- Path A: clean report without attacker deposit (via snapshot) --- + await fundElRewards(ctx, REWARDS); + + const simSnapshot = await Snapshot.take(); + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true, reportWithdrawalsVault: false }); + const state1 = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state1, RATIO_BP); + await Snapshot.restore(simSnapshot); + + // --- Path B: attacker deposits before rebase, redeems after --- + const attackerDepositAmount = await getRedeemAmount(lido, "huge"); + const attackDeposit = await submitEther({ lido, from: attacker, amount: attackerDepositAmount }); + + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true, reportWithdrawalsVault: false }); + const postAttackReport = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(postAttackReport, RATIO_BP); + + const attackerBalance = await lido.balanceOf(attacker.address); + const attackerRedeemQuote = await captureRedeemQuote(lido, attackerBalance); + const attackerEthBefore = await ethers.provider.getBalance(attacker.address); + + await redeemExact(lido, attacker, fix, attackerBalance); + + // Verify: pending shares on burner (state2 is stale — TPE and shares unchanged) + + const state2 = await captureState(lido); + await assertReserveAllocationInvariant(lido); + + // --- Compare paths: attacker diluted the pool --- + const expectedBaselineShareRate = (state1.totalPooledEther * ether("1")) / state1.totalShares; + const expectedAttackShareRate = (postAttackReport.totalPooledEther * ether("1")) / postAttackReport.totalShares; + const expectedRedeemPayout = + (attackDeposit.shares * postAttackReport.totalPooledEther) / postAttackReport.totalShares; + const expectedProfit = expectedRedeemPayout - attackerDepositAmount; + + // Verify: attacker received ETH from vault (gasPrice=0) + expect(await ethers.provider.getBalance(attacker.address)).to.equal(attackerEthBefore + attackerRedeemQuote.ether); + + expect(state1.shareRate).to.equal(expectedBaselineShareRate); + expect(postAttackReport.shareRate).to.equal(expectedAttackShareRate); + expect(attackerRedeemQuote.shares).to.be.closeTo(attackDeposit.shares, 1n); + expect(attackerRedeemQuote.ether).to.be.closeTo(expectedRedeemPayout, 1n); + expect(attackerRedeemQuote.ether - attackerDepositAmount).to.be.closeTo(expectedProfit, 1n); + + // Stale state: TPE and shares unchanged after push redeem (burn deferred) + expect(state2.totalPooledEther).to.equal(postAttackReport.totalPooledEther); + expect(state2.totalShares).to.equal(postAttackReport.totalShares); + expect(state2.shareRate).to.equal(postAttackReport.shareRate); + + // Verify: baseline (clean) share rate is higher than post-attack rate + const baselineAdvantage = expectedBaselineShareRate - expectedAttackShareRate; + expect(state1.shareRate - state2.shareRate).to.equal(baselineAdvantage); + + // Verify: attacker ends with 0 stETH + expect(await lido.sharesOf(attacker.address)).to.be.closeTo(0n, 1n); + }); + + it("redeem before negative rebase then re-deposit after restores pooled ether and returns more shares than were burned", async () => { + const { lido } = ctx.contracts; + + await resetCLBalanceDecreaseWindow(ctx); + await transferStETH({ lido, from: holder, to: attacker, amount: ATTACKER_STETH }); + + // --- Save attacker state, simulate clean loss path (via snapshot) --- + const attackerSharesBefore = await lido.sharesOf(attacker.address); + + const simSnapshot = await Snapshot.take(); + await doReport(ctx, { clDiff: CL_LOSS }); + const state1 = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state1, RATIO_BP); + await Snapshot.restore(simSnapshot); + + // --- Attacker redeems before negative rebase, re-deposits after --- + const redeemAmount = await lido.balanceOf(attacker.address); + const redeemQuote = await captureRedeemQuote(lido, redeemAmount); + + await redeemExact(lido, attacker, fix, redeemAmount); + + // Verify: pending shares on burner + + await doReport(ctx, { clDiff: CL_LOSS }); + const lossPathState = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(lossPathState, RATIO_BP); + + // Verify: shares burned + + const reenter = await submitEther({ lido, from: attacker, amount: redeemQuote.ether }); + const state2 = await captureState(lido); + await assertReserveAllocationInvariant(lido); + + const expectedSharesAfter = (redeemQuote.ether * lossPathState.totalShares) / lossPathState.totalPooledEther; + const expectedState2TotalPooledEther = state1.totalPooledEther; + const expectedState2TotalShares = state1.totalShares - redeemQuote.shares + expectedSharesAfter; + const expectedState2ShareRate = (expectedState2TotalPooledEther * ether("1")) / expectedState2TotalShares; + const expectedEscapedShares = expectedSharesAfter - redeemQuote.shares; + + expect(reenter.shares).to.equal(expectedSharesAfter); + expect(reenter.shares - redeemQuote.shares).to.equal(expectedEscapedShares); + expect(state2.totalPooledEther).to.equal(expectedState2TotalPooledEther); + expect(state2.totalShares).to.equal(expectedState2TotalShares); + expect(state2.shareRate).to.equal(expectedState2ShareRate); + expect(state2.shareRate).to.equal(lossPathState.shareRate); + + // Verify: attacker ended up with more shares — escape sandwich profitable + const attackerSharesAfter = await lido.sharesOf(attacker.address); + expect(attackerSharesAfter - attackerSharesBefore).to.equal(expectedEscapedShares); + }); +}); + +type SubmitResult = { + shares: bigint; + stETH: bigint; +}; + +/** Mints stETH via submit and verifies exact share and stETH balance deltas */ +async function submitEther({ + lido, + from, + amount, +}: { + lido: ProtocolContext["contracts"]["lido"]; + from: HardhatEthersSigner; + amount: bigint; +}): Promise { + const sharesBefore = await lido.sharesOf(from.address); + const balanceBefore = await lido.balanceOf(from.address); + + await lido.connect(from).submit(ZeroAddress, { value: amount }); + + const sharesAfter = await lido.sharesOf(from.address); + const balanceAfter = await lido.balanceOf(from.address); + const shares = sharesAfter - sharesBefore; + const stETH = balanceAfter - balanceBefore; + + expect(shares).to.equal(await lido.getSharesByPooledEth(amount)); + expect(stETH).to.be.closeTo(await lido.getPooledEthByShares(shares), ROUNDING_TOLERANCE); + + return { shares, stETH }; +} + +/** Transfers stETH and verifies the recipient exact balance delta */ +async function transferStETH({ + lido, + from, + to, + amount, +}: { + lido: ProtocolContext["contracts"]["lido"]; + from: HardhatEthersSigner; + to: HardhatEthersSigner; + amount: bigint; +}) { + const balanceBefore = await lido.balanceOf(to.address); + + await lido.connect(from).transfer(to.address, amount); + + expect(await lido.balanceOf(to.address)).to.be.closeTo(balanceBefore + amount, ROUNDING_TOLERANCE); +} diff --git a/test/integration/redeems-reserve/pause-during-frame.integration.ts b/test/integration/redeems-reserve/pause-during-frame.integration.ts new file mode 100644 index 000000000..e77c3d623 --- /dev/null +++ b/test/integration/redeems-reserve/pause-during-frame.integration.ts @@ -0,0 +1,111 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + assertReserveState, + captureState, + doReport, + expectedReserveTarget, + redeemExact, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEPOSIT = ether("1000"); +const RATIO_BP = 500n; +const REDEEM_AMOUNT = ether("1"); + +describe("Integration: Redeems reserve — pause during frame", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + + const pauseRole = await fix.vault.PAUSE_ROLE(); + const resumeRole = await fix.vault.RESUME_ROLE(); + await fix.vault.connect(reserveManager).grantRole(pauseRole, reserveManager.address); + await fix.vault.connect(reserveManager).grantRole(resumeRole, reserveManager.address); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("pause blocks redeem but lets the oracle report complete, resume restores redeem", async () => { + const { lido } = ctx.contracts; + + // --- Pause the buffer indefinitely (doReport may advance past a finite pause window) --- + const PAUSE_INFINITELY = await fix.vault.PAUSE_INFINITELY(); + await fix.vault.connect(reserveManager).pauseFor(PAUSE_INFINITELY); + expect(await fix.vault.isPaused()).to.equal(true); + + // --- Redeem blocked --- + await lido.connect(holder).approve(fix.address, REDEEM_AMOUNT + 10n, { gasPrice: 0 }); + await expect( + fix.vault.connect(holder).redeem(REDEEM_AMOUNT, holder.address, { gasPrice: 0 }), + ).to.be.revertedWithCustomError(fix.vault, "ResumedExpected"); + + // --- Oracle report proceeds despite pause (reconcile/fundReserve not gated) --- + await doReport(ctx); + + const stateDuringPause = await captureState(lido); + const expectedTarget = expectedReserveTarget(stateDuringPause.internalEther, RATIO_BP); + assertReserveState(stateDuringPause, RATIO_BP); + expect(stateDuringPause.reserve).to.equal(expectedTarget); + expect(await ethers.provider.getBalance(fix.address)).to.equal(expectedTarget); + expect(await fix.vault.isPaused()).to.equal(true); + await assertReserveAllocationInvariant(lido); + + // --- Resume and verify redeem works --- + await fix.vault.connect(reserveManager).resume(); + expect(await fix.vault.isPaused()).to.equal(false); + + const shares = await lido.getSharesByPooledEth(REDEEM_AMOUNT); + const etherAmount = await lido.getPooledEthByShares(shares); + await redeemExact(lido, holder, fix, REDEEM_AMOUNT); + + expect((await fix.vault.getRedeemed())[0]).to.equal(etherAmount); + expect(await ethers.provider.getBalance(fix.address)).to.equal(expectedTarget - etherAmount); + }); +}); diff --git a/test/integration/redeems-reserve/positive-rebase.integration.ts b/test/integration/redeems-reserve/positive-rebase.integration.ts new file mode 100644 index 000000000..b0979e82c --- /dev/null +++ b/test/integration/redeems-reserve/positive-rebase.integration.ts @@ -0,0 +1,200 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + applyInsurance, + assertReserveAllocationInvariant, + assertReserveState, + captureState, + doReport, + fundElRewards, + getRedeemAmount, + ProtocolState, + redeemExact, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEPOSIT = ether("1000"); +const RATIO_BP = 500n; +const REWARDS = ether("1"); + +describe("Integration: Redeems reserve — positive rebase", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + + const { lido } = ctx.contracts; + expect(await lido.getRedeemsReserve()).to.equal(await lido.getRedeemsReserveTarget()); + await assertReserveAllocationInvariant(lido); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("EL rewards positive rebase on a smaller post-redeem base changes share rate while reserve stays at target", async () => { + const { lido } = ctx.contracts; + + // --- Fund EL rewards, save pre-report state --- + await fundElRewards(ctx, REWARDS); + const state0: ProtocolState = await captureState(lido); + + // --- Path A: report without redeem (via snapshot) --- + const simSnapshot = await Snapshot.take(); + + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true, reportWithdrawalsVault: false }); + const state1: ProtocolState = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state1, RATIO_BP); + + await Snapshot.restore(simSnapshot); + + // --- Path B: redeem, then report --- + const REDEEM_AMOUNT = await getRedeemAmount(lido, "huge"); + const redeemShares = await lido.getSharesByPooledEth(REDEEM_AMOUNT); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + + await redeemExact(lido, holder, fix, REDEEM_AMOUNT); + + // Verify: redeem shares pending on burner, redeemed ether tracked on vault + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther); + + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true, reportWithdrawalsVault: false }); + const state2: ProtocolState = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state2, RATIO_BP); + + // Verify: all redeem shares burned, counters reset + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + // --- Compare paths: difference is only the redeemed ETH and shares --- + expect(state1.totalPooledEther).to.equal(state0.totalPooledEther + REWARDS); + expect(state1.totalShares).to.equal(state0.totalShares); + + expect(state2.totalPooledEther).to.equal(state0.totalPooledEther + REWARDS - redeemEther); + expect(state2.totalShares).to.equal(state0.totalShares - redeemShares); + + const expectedState1ShareRate = ((state0.totalPooledEther + REWARDS) * ether("1")) / state0.totalShares; + const expectedState2ShareRate = + ((state0.totalPooledEther + REWARDS - redeemEther) * ether("1")) / (state0.totalShares - redeemShares); + + expect(state1.shareRate).to.equal(expectedState1ShareRate); + expect(state2.shareRate).to.equal(expectedState2ShareRate); + }); + + it("Insurance burn request keeps pre-report reserve state unchanged, then EL rewards positive rebase on a smaller base changes share rate", async () => { + const { lido, burner } = ctx.contracts; + const BURN_AMOUNT = ether("2"); + + // --- Apply insurance burn, verify reserve state unchanged --- + const reserveBeforeBurn = await lido.getRedeemsReserve(); + const reserveTargetBeforeBurn = await lido.getRedeemsReserveTarget(); + const shareRateBeforeBurn = await lido.getPooledEthByShares(ether("1")); + + await applyInsurance(ctx, holder, BURN_AMOUNT); + + // Verify: reserve, reserve target, share rate — unchanged after burn request + expect(await lido.getRedeemsReserve()).to.equal(reserveBeforeBurn); + expect(await lido.getRedeemsReserveTarget()).to.equal(reserveTargetBeforeBurn); + expect(await lido.getPooledEthByShares(ether("1"))).to.equal(shareRateBeforeBurn); + + // --- Fund EL rewards, save pre-report state --- + await fundElRewards(ctx, REWARDS); + const state0: ProtocolState = await captureState(lido); + + // --- Path A: report without redeem, with burn (via snapshot) --- + const simSnapshot = await Snapshot.take(); + + await doReport(ctx, { + excludeVaultsBalances: false, + reportElVault: true, + reportWithdrawalsVault: false, + reportBurner: true, + }); + const state1: ProtocolState = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state1, RATIO_BP); + + await Snapshot.restore(simSnapshot); + + // --- Path B: redeem at pre-burn share rate, then report with burn --- + const REDEEM_AMOUNT = await getRedeemAmount(lido, "huge"); + const redeemShares = await lido.getSharesByPooledEth(REDEEM_AMOUNT); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + + await redeemExact(lido, holder, fix, REDEEM_AMOUNT); + + // Verify: redeem shares pending on burner, redeemed ether tracked on vault + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther); + + await doReport(ctx, { + excludeVaultsBalances: false, + reportElVault: true, + reportWithdrawalsVault: false, + reportBurner: true, + }); + const state2: ProtocolState = await captureState(lido); + await assertReserveAllocationInvariant(lido); + assertReserveState(state2, RATIO_BP); + + // Verify: cover + redeem shares both burned, vault reconciled + const [coverAfter, nonCoverAfter] = await burner.getSharesRequestedToBurn(); + expect(coverAfter).to.equal(0n); + expect(nonCoverAfter).to.equal(0n); + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + expect(await lido.getRedeemsReserve()).to.equal(await ethers.provider.getBalance(fix.address)); + + // --- Compare paths: difference is only the redeemed ETH and shares --- + expect(state1.totalPooledEther).to.equal(state0.totalPooledEther + REWARDS); + expect(state2.totalPooledEther).to.equal(state0.totalPooledEther + REWARDS - redeemEther); + expect(state2.totalShares).to.equal(state1.totalShares - redeemShares); + + const expectedState1ShareRate = (state1.totalPooledEther * ether("1")) / state1.totalShares; + const expectedState2ShareRate = (state2.totalPooledEther * ether("1")) / state2.totalShares; + + expect(state1.shareRate).to.equal(expectedState1ShareRate); + expect(state2.shareRate).to.equal(expectedState2ShareRate); + }); +}); diff --git a/test/integration/redeems-reserve/redeem-between-ref-and-report.integration.ts b/test/integration/redeems-reserve/redeem-between-ref-and-report.integration.ts new file mode 100644 index 000000000..f0e8c5015 --- /dev/null +++ b/test/integration/redeems-reserve/redeem-between-ref-and-report.integration.ts @@ -0,0 +1,390 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { LIMITER_PRECISION_BASE } from "lib/constants"; +import { + getProtocolContext, + ProtocolContext, + report, + setMaxPositiveTokenRebase, + submitReportDataWithConsensus, + waitNextAvailableReportTime, +} from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + advancePastRequestTimestampMargin, + assertReserveAllocationInvariant, + assertReserveState, + captureState, + doReport, + fundElRewards, + getRedeemAmount, + mineBlocks, + ProtocolState, + redeemExact, + requestWithdrawal, + resetProtocolState, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const SHARE_RATE_PRECISION = 10n ** 27n; +const TOTAL_BASIS_POINTS = 10_000n; + +describe("Integration: Redeems reserve — redeem between refSlot and report", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + await resetProtocolState(ctx); + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + /** Computes smoothing headroom from explicit internalEther value */ + async function getHeadroomFor(internalEther: bigint): Promise { + const maxRebase = await ctx.contracts.oracleReportSanityChecker.getMaxPositiveTokenRebase(); + return (internalEther * maxRebase) / LIMITER_PRECISION_BASE; + } + + /** Advances to reportable time, mines blocks, runs a dryRun and returns refSlot + full report data */ + async function dryRunAtCurrentState(opts: Parameters[1] = {}) { + await advancePastRequestTimestampMargin(ctx); + await mineBlocks(3); + + const refSlot = (await ctx.contracts.hashConsensus.getCurrentFrame()).refSlot; + const result = await report(ctx, { + clDiff: 0n, + excludeVaultsBalances: true, + skipWithdrawals: true, + refSlot, + waitNextReportTime: false, + dryRun: true, + ...opts, + }); + + return { + refSlot, + simulatedShareRate: BigInt(result.data.simulatedShareRate), + data: result.data, + }; + } + + /** Computes deviation in BP using the contract formula: absDiff(actual, simulated) * 10000 / actual */ + function computeDeviationBP(actualRate: bigint, simulatedRate: bigint): bigint { + const diff = actualRate > simulatedRate ? actualRate - simulatedRate : simulatedRate - actualRate; + return (diff * TOTAL_BASIS_POINTS) / actualRate; + } + + it("drain → headroom reduction → deferred rewards → reserve underfilled", async () => { + const { lido, locator } = ctx.contracts; + const elVaultAddr = await locator.elRewardsVault(); + const RATIO_BP = 500n; + + // --- Seed reserve, process report --- + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: RATIO_BP }); + + const state0: ProtocolState = await captureState(lido); + assertReserveState(state0, RATIO_BP); + await assertReserveAllocationInvariant(lido); + + // --- Fund EL rewards exactly at headroom --- + const headroomFull = await getHeadroomFor(state0.internalEther); + await fundElRewards(ctx, headroomFull); + + // --- Path A: report without redeem (via snapshot) --- + const simSnapshot = await Snapshot.take(); + + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true }); + const state1: ProtocolState = await captureState(lido); + assertReserveState(state1, RATIO_BP); + await assertReserveAllocationInvariant(lido); + + // Verify: all rewards fit within headroom, nothing deferred + expect(await ethers.provider.getBalance(elVaultAddr)).to.equal(0n); + + await Snapshot.restore(simSnapshot); + + // --- Path B: drain reserve, then report --- + const redeemAmount = await getRedeemAmount(lido, "full"); + const redeemShares = await lido.getSharesByPooledEth(redeemAmount); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + await redeemExact(lido, holder, fix, redeemAmount); + + const [redeemedEtherBuffer, redeemedSharesBuffer] = await fix.vault.getRedeemed(); + expect(redeemedEtherBuffer).to.equal(redeemEther); + expect(redeemedSharesBuffer).to.equal(redeemShares); + + // Compute post-reconciliation headroom: Lido's IE is stale (doesn't reflect the drain yet), + // subtracting the vault's tracked redeemed amount gives the real post-drain IE + const headroomDrained = await getHeadroomFor( + (await captureState(lido)).internalEther - (await fix.vault.getRedeemed())[0], + ); + + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true }); + + const state2: ProtocolState = await captureState(lido); + const deferredRewards = await ethers.provider.getBalance(elVaultAddr); + const appliedRewards = headroomFull - deferredRewards; + + // Verify: shares burned, counters reset + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + // --- Compare paths: drain caused deferred rewards --- + // Verify: some rewards were deferred (smoothing kicked in due to smaller post-drain base) + expect(deferredRewards).to.be.gt(0n); + expect(deferredRewards).to.equal(headroomFull - headroomDrained); + // Cross-check: applied + deferred = total funded + expect(appliedRewards + deferredRewards).to.equal(headroomFull); + + expect(state2.totalPooledEther).to.equal(state1.totalPooledEther - deferredRewards - redeemEther); + expect(state2.totalShares).to.equal(state1.totalShares - redeemShares); + + const expectedShareRate2 = (state2.totalPooledEther * ether("1")) / state2.totalShares; + expect(state2.shareRate).to.equal(expectedShareRate2); + await assertReserveAllocationInvariant(lido); + + // --- Recovery report: deferred rewards picked up --- + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true }); + + const state3: ProtocolState = await captureState(lido); + + expect(await ethers.provider.getBalance(elVaultAddr)).to.be.closeTo(0n, 10n); + assertReserveState(state3, RATIO_BP); + await assertReserveAllocationInvariant(lido); + }); + + it("rewards don't exceed smoothing → deviation > 0", async () => { + const { lido, locator } = ctx.contracts; + const elVaultAddr = await locator.elRewardsVault(); + const RATIO_BP = 2000n; + const REWARDS = ether("50"); + + // --- Setup: maxRebase = 10%, ratio = 20% --- + const savedRebase = await setMaxPositiveTokenRebase(ctx, LIMITER_PRECISION_BASE / 10n); + + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: RATIO_BP }); + assertReserveState(await captureState(lido), RATIO_BP); + + await requestWithdrawal(ctx, holder, ether("1")); + + // --- Fund EL rewards, dryRun at pre-redeem state --- + await fundElRewards(ctx, REWARDS); + + const statePreRedeem: ProtocolState = await captureState(lido); + const { simulatedShareRate } = await dryRunAtCurrentState({ + excludeVaultsBalances: false, + reportElVault: true, + skipWithdrawals: false, + }); + + // --- Drain entire reserve --- + await mineBlocks(3); + const redeemAmount = await getRedeemAmount(lido, "full"); + await redeemExact(lido, holder, fix, redeemAmount); + + // Verify: pending shares on burner + + // --- Process report --- + await mineBlocks(4); + await report(ctx, { + clDiff: 0n, + excludeVaultsBalances: false, + reportElVault: true, + skipWithdrawals: false, + }); + + // Verify: smoothing did NOT kick in (all rewards applied) + expect(await ethers.provider.getBalance(elVaultAddr)).to.equal(0n); + + // Verify: shares burned + + // --- Compute exact deviation --- + const totalPooled = await lido.getTotalPooledEther(); + const totalShares = await lido.getTotalShares(); + const actualRate = (totalPooled * SHARE_RATE_PRECISION) / totalShares; + + const expectedSimulatedRate = + ((statePreRedeem.totalPooledEther + REWARDS) * SHARE_RATE_PRECISION) / statePreRedeem.totalShares; + expect(simulatedShareRate).to.equal(expectedSimulatedRate); + + // Verify: redeem concentrates rewards on fewer shares → actualRate diverges from simulated + const deviationBP = computeDeviationBP(actualRate, simulatedShareRate); + expect(deviationBP).to.equal(((actualRate - simulatedShareRate) * TOTAL_BASIS_POINTS) / actualRate); + + await assertReserveAllocationInvariant(lido); + + await setMaxPositiveTokenRebase(ctx, savedRebase); + }); + + it("rewards exceed smoothing → deviation ≈ 0", async () => { + const { lido, locator } = ctx.contracts; + const elVaultAddr = await locator.elRewardsVault(); + const RATIO_BP = 2000n; + const REWARDS = ether("50"); + + // --- Setup: maxRebase = 1%, ratio = 20% --- + const savedRebase = await setMaxPositiveTokenRebase(ctx, LIMITER_PRECISION_BASE / 100n); + + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: RATIO_BP }); + assertReserveState(await captureState(lido), RATIO_BP); + + await requestWithdrawal(ctx, holder, ether("1")); + + // --- Fund EL rewards (>> headroom), dryRun at pre-redeem state --- + await fundElRewards(ctx, REWARDS); + + const { simulatedShareRate } = await dryRunAtCurrentState({ + excludeVaultsBalances: false, + reportElVault: true, + skipWithdrawals: false, + }); + + // --- Drain entire reserve --- + await mineBlocks(3); + const redeemAmount = await getRedeemAmount(lido, "full"); + await redeemExact(lido, holder, fix, redeemAmount); + + // Compute post-reconciliation headroom: Lido's IE is stale (doesn't reflect the drain yet), + // subtracting the vault's tracked redeemed amount gives the real post-drain IE + const headroomAfterDrain = await getHeadroomFor( + (await captureState(lido)).internalEther - (await fix.vault.getRedeemed())[0], + ); + + // Verify: pending shares on burner + + // --- Process report --- + await mineBlocks(4); + await report(ctx, { + clDiff: 0n, + excludeVaultsBalances: false, + reportElVault: true, + skipWithdrawals: false, + }); + + // Verify: smoothing DID kick in (some rewards deferred) + const deferredRewards = await ethers.provider.getBalance(elVaultAddr); + expect(deferredRewards).to.equal(REWARDS - headroomAfterDrain); + + // Verify: shares burned + + // Verify: deviation == 0 (smoothing caps both pre- and post-drain paths equally) + const totalPooled = await lido.getTotalPooledEther(); + const totalShares = await lido.getTotalShares(); + const actualRate = (totalPooled * SHARE_RATE_PRECISION) / totalShares; + const deviationBP = computeDeviationBP(actualRate, simulatedShareRate); + + expect(deviationBP).to.equal(0n); + await assertReserveAllocationInvariant(lido); + + await setMaxPositiveTokenRebase(ctx, savedRebase); + }); + + it("deviation exceeds limit → report reverts", async () => { + const { lido } = ctx.contracts; + const RATIO_BP = 2000n; + const REWARDS = ether("150"); + + // --- Setup: maxRebase = 20%, ratio = 20%, rewards = 150 ETH --- + const savedRebase = await setMaxPositiveTokenRebase(ctx, LIMITER_PRECISION_BASE / 5n); + + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: RATIO_BP }); + assertReserveState(await captureState(lido), RATIO_BP); + + await requestWithdrawal(ctx, holder, ether("1")); + + await fundElRewards(ctx, REWARDS); + + // --- Advance to fresh frame, dryRun at pre-redeem state --- + const { reportRefSlot: refSlot } = await waitNextAvailableReportTime(ctx); + await mineBlocks(3); + + const dryRunOpts = { + clDiff: 0n, + excludeVaultsBalances: false, + reportElVault: true, + skipWithdrawals: false, + refSlot, + waitNextReportTime: false, + dryRun: true, + } as const; + + const preRedeemResult = await report(ctx, dryRunOpts); + const staleRate = BigInt(preRedeemResult.data.simulatedShareRate); + + // --- Drain entire reserve --- + await mineBlocks(3); + const redeemAmount = await getRedeemAmount(lido, "full"); + await redeemExact(lido, holder, fix, redeemAmount); + + // Verify: pending shares on burner + + // Verify: rewards fit within post-drain headroom (no smoothing → full deviation applies). + // Lido's IE is stale — subtracting vault's tracked redeemed amount gives the real post-drain IE. + const stateBefore: ProtocolState = await captureState(lido); + const reconciledIE = stateBefore.internalEther - (await fix.vault.getRedeemed())[0]; + const maxRebase = await ctx.contracts.oracleReportSanityChecker.getMaxPositiveTokenRebase(); + const headroomPostDrain = (reconciledIE * maxRebase) / LIMITER_PRECISION_BASE; + expect(headroomPostDrain).to.equal(await getHeadroomFor(reconciledIE)); + + // --- DryRun at post-redeem state (same refSlot) --- + // Lido doesn't see the drain yet (stale tracking), so post-redeem dryRun computes + // the same simulatedShareRate as pre-redeem. Overriding with the pre-redeem rate + // to match the pull test structure — in practice they're identical. + await mineBlocks(4); + const postRedeemResult = await report(ctx, dryRunOpts); + const tamperedData = { ...postRedeemResult.data, simulatedShareRate: staleRate }; + + // --- Submit report: on-chain reconciliation creates actual rate that diverges from simulated --- + await expect(submitReportDataWithConsensus(ctx, tamperedData)).to.be.revertedWithCustomError( + ctx.contracts.oracleReportSanityChecker, + "IncorrectSimulatedShareRate", + ); + + // Verify: protocol state unchanged after revert + const stateAfter: ProtocolState = await captureState(lido); + expect(stateAfter).to.deep.equal(stateBefore); + await assertReserveAllocationInvariant(lido); + + await setMaxPositiveTokenRebase(ctx, savedRebase); + }); +}); diff --git a/test/integration/redeems-reserve/redeem-blocked-states.integration.ts b/test/integration/redeems-reserve/redeem-blocked-states.integration.ts new file mode 100644 index 000000000..989767418 --- /dev/null +++ b/test/integration/redeems-reserve/redeem-blocked-states.integration.ts @@ -0,0 +1,109 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether, impersonate } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { seedReserve, setupVault, VaultFixture } from "./helpers"; + +const DEPOSIT = ether("1000"); +const RATIO_BP = 500n; +const REDEEM_AMOUNT = ether("1"); + +describe("Integration: Redeems reserve — redeem blocked states", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("reverts redeem with LidoStopped when the protocol is stopped", async () => { + const { lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + + await lido.connect(agent).stop(); + expect(await lido.isStopped()).to.equal(true); + + await lido.connect(holder).approve(fix.address, REDEEM_AMOUNT + 10n, { gasPrice: 0 }); + await expect( + fix.vault.connect(holder).redeem(REDEEM_AMOUNT, holder.address, { gasPrice: 0 }), + ).to.be.revertedWithCustomError(fix.vault, "LidoStopped"); + }); + + it("resumes redeems after the protocol is resumed", async () => { + const { lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + + await lido.connect(agent).stop(); + await lido.connect(agent).resume(); + expect(await lido.isStopped()).to.equal(false); + + await lido.connect(holder).approve(fix.address, REDEEM_AMOUNT + 10n, { gasPrice: 0 }); + await expect(fix.vault.connect(holder).redeem(REDEEM_AMOUNT, holder.address, { gasPrice: 0 })).to.emit( + fix.vault, + "Redeemed", + ); + }); + + it("reverts redeem with WithdrawalQueuePaused when the WQ is paused", async () => { + const { lido, withdrawalQueue } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const agentSigner = await impersonate(agent.address, ether("1")); + + const pauseRole = await withdrawalQueue.PAUSE_ROLE(); + if (!(await withdrawalQueue.hasRole(pauseRole, agent.address))) { + const adminRole = await withdrawalQueue.DEFAULT_ADMIN_ROLE(); + const adminHolder = await withdrawalQueue.getRoleMember(adminRole, 0); + const adminSigner = await impersonate(adminHolder, ether("1")); + await withdrawalQueue.connect(adminSigner).grantRole(pauseRole, agent.address); + } + + await withdrawalQueue.connect(agentSigner).pauseFor(1_000n); + expect(await withdrawalQueue.isPaused()).to.equal(true); + + await lido.connect(holder).approve(fix.address, REDEEM_AMOUNT + 10n, { gasPrice: 0 }); + await expect( + fix.vault.connect(holder).redeem(REDEEM_AMOUNT, holder.address, { gasPrice: 0 }), + ).to.be.revertedWithCustomError(fix.vault, "WithdrawalQueuePaused"); + }); +}); diff --git a/test/integration/redeems-reserve/skipped-report.integration.ts b/test/integration/redeems-reserve/skipped-report.integration.ts new file mode 100644 index 000000000..2db8de401 --- /dev/null +++ b/test/integration/redeems-reserve/skipped-report.integration.ts @@ -0,0 +1,181 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + assertReserveState, + captureState, + doReport, + expectedReserveTarget, + ProtocolState, + redeemExact, + resetProtocolState, + seedReserve, + setupVault, + skipReport, + VaultFixture, +} from "./helpers"; + +const DEPOSIT = ether("1000"); +const RATIO_BP = 500n; +const REDEEM_1 = ether("20"); +const REDEEM_2 = ether("15"); +const NEW_USER_DEPOSIT = ether("200"); + +describe("Integration: Redeems reserve — skipped report", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + let newUser: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder, newUser] = await ethers.getSigners(); + reserveManager = holder; + + await resetProtocolState(ctx); + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("skip → drain → deposit → recovery", async () => { + const { lido } = ctx.contracts; + + // --- Seed reserve, process report --- + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: RATIO_BP }); + + const state0: ProtocolState = await captureState(lido); + const depositable0 = await lido.getDepositableEther(); + assertReserveState(state0, RATIO_BP); + await assertReserveAllocationInvariant(lido); + + // --- Skip report, verify state unchanged --- + await skipReport(ctx); + + expect(await captureState(lido)).to.deep.equal(state0); + expect(await lido.getDepositableEther()).to.equal(depositable0); + await assertReserveAllocationInvariant(lido); + + // --- Redeem 20 ETH --- + const redeemShares1 = await lido.getSharesByPooledEth(REDEEM_1); + const redeemEther1 = await lido.getPooledEthByShares(redeemShares1); + + await redeemExact(lido, holder, fix, REDEEM_1); + + const state1: ProtocolState = await captureState(lido); + + // Verify: push-specific — TPE and totalShares stale, rate preserved, depositable unchanged + expect(state1.totalPooledEther).to.equal(state0.totalPooledEther); + expect(state1.totalShares).to.equal(state0.totalShares); + expect(state1.shareRate).to.equal(state0.shareRate); + expect(await lido.getDepositableEther()).to.equal(depositable0); + await assertReserveAllocationInvariant(lido); + + // Verify: redeem shares pending on burner + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther1); + + // --- Report to refill reserve --- + await doReport(ctx); + + const state2: ProtocolState = await captureState(lido); + + // Verify: shares burned, counters reset, reserve refilled + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + expect(state2.totalPooledEther).to.equal(state0.totalPooledEther - redeemEther1); + expect(state2.totalShares).to.equal(state0.totalShares - redeemShares1); + expect(state2.shareRate).to.equal(state0.shareRate); + assertReserveState(state2, RATIO_BP); + await assertReserveAllocationInvariant(lido); + + // --- Skip report again, verify state unchanged --- + await skipReport(ctx); + + expect(await captureState(lido)).to.deep.equal(state2); + await assertReserveAllocationInvariant(lido); + + // --- New user submits 200 ETH --- + const targetBeforeDeposit = state2.reserveTarget; + const depositableBeforeDeposit = await lido.getDepositableEther(); + + await lido.connect(newUser).submit(ZeroAddress, { value: NEW_USER_DEPOSIT }); + + const state3: ProtocolState = await captureState(lido); + const targetAfterDeposit = expectedReserveTarget(state3.internalEther, RATIO_BP); + + // Verify: reserveTarget grew proportionally, reserve unchanged, depositable grew + expect(state3.reserveTarget).to.equal(targetAfterDeposit); + expect(targetAfterDeposit - targetBeforeDeposit).to.equal( + expectedReserveTarget(state3.internalEther - state2.internalEther, RATIO_BP), + ); + expect(state3.reserve).to.equal(state2.reserve); + expect(await lido.getDepositableEther()).to.equal(depositableBeforeDeposit + NEW_USER_DEPOSIT); + await assertReserveAllocationInvariant(lido); + + // --- Redeem 15 ETH --- + const redeemShares2 = await lido.getSharesByPooledEth(REDEEM_2); + const redeemEther2 = await lido.getPooledEthByShares(redeemShares2); + const depositableBeforeRedeem2 = await lido.getDepositableEther(); + + await redeemExact(lido, holder, fix, REDEEM_2); + + const state4: ProtocolState = await captureState(lido); + + // Verify: push-specific — reserve stale, depositable unchanged + expect(state4.reserve).to.equal(state3.reserve); + expect(state4.totalPooledEther).to.equal(state3.totalPooledEther); + expect(state4.totalShares).to.equal(state3.totalShares); + expect(state4.shareRate).to.equal(state3.shareRate); + expect(await lido.getDepositableEther()).to.equal(depositableBeforeRedeem2); + await assertReserveAllocationInvariant(lido); + + // Verify: redeem shares pending on burner + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther2); + + // --- Report to refill reserve to new higher target --- + await doReport(ctx); + + const state5: ProtocolState = await captureState(lido); + + // Verify: shares burned, counters reset, reserve refilled to new target + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + assertReserveState(state5, RATIO_BP); + expect(state5.reserveTarget).to.be.closeTo(targetAfterDeposit - expectedReserveTarget(redeemEther2, RATIO_BP), 10n); + await assertReserveAllocationInvariant(lido); + }); +}); diff --git a/test/integration/redeems-reserve/smoothing-cover-burn.integration.ts b/test/integration/redeems-reserve/smoothing-cover-burn.integration.ts new file mode 100644 index 000000000..1c20df89c --- /dev/null +++ b/test/integration/redeems-reserve/smoothing-cover-burn.integration.ts @@ -0,0 +1,209 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { LIMITER_PRECISION_BASE } from "lib/constants"; +import { getProtocolContext, ProtocolContext, setMaxPositiveTokenRebase } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + applyInsurance, + assertReserveAllocationInvariant, + captureState, + doReport, + fundElRewards, + getRedeemAmount, + ProtocolState, + redeemExact, + resetProtocolState, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +describe("Integration: Redeems reserve — smoothing interaction with cover burn", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + await resetProtocolState(ctx); + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + /** + * Computes the actual rebase of the share rate between two protocol states. + * Returns the rebase in LIMITER_PRECISION_BASE units (1e9 = 100%). + */ + function computeRebase(pre: ProtocolState, post: ProtocolState): bigint { + // shareRate = totalPooledEther / totalShares (per-share value in ether) + // rebase = postRate / preRate - 1 + // Using cross-multiplication to avoid precision loss: + // rebase = (postTPE * preTotalShares) / (preTPE * postTotalShares) - 1 + const numerator = post.totalPooledEther * pre.totalShares; + const denominator = pre.totalPooledEther * post.totalShares; + + if (numerator <= denominator) return 0n; + return ((numerator - denominator) * LIMITER_PRECISION_BASE) / denominator; + } + + it("large redeem does not inflate rebase beyond limit", async () => { + const { lido, burner } = ctx.contracts; + + const RATIO_BP = 2000n; // 20% reserve + const MAX_REBASE = LIMITER_PRECISION_BASE / 100n; // 1% + + const savedRebase = await setMaxPositiveTokenRebase(ctx, MAX_REBASE); + + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: RATIO_BP }); + + const stateSeeded: ProtocolState = await captureState(lido); + + const COVER_BURN = ether("30"); + await applyInsurance(ctx, holder, COVER_BURN); + + const headroom = (stateSeeded.internalEther * MAX_REBASE) / LIMITER_PRECISION_BASE; + const REWARDS = headroom / 2n; + await fundElRewards(ctx, REWARDS); + + const stateBeforeReport: ProtocolState = await captureState(lido); + + // ── Path A: report with cover burn, NO redeem ── + const pathASnapshot = await Snapshot.take(); + + const coverBurntBefore_A = await burner.getCoverSharesBurnt(); + await doReport(ctx, { + excludeVaultsBalances: false, + reportElVault: true, + reportWithdrawalsVault: false, + reportBurner: true, + }); + const coverBurned_A = (await burner.getCoverSharesBurnt()) - coverBurntBefore_A; + + const stateAfterReport_A: ProtocolState = await captureState(lido); + const rebase_A = computeRebase(stateBeforeReport, stateAfterReport_A); + await assertReserveAllocationInvariant(lido); + + // Path A: limiter caps cover burn + expect(coverBurned_A).to.be.gt(0n); + expect(coverBurned_A).to.be.lt(await lido.getSharesByPooledEth(COVER_BURN)); + + await Snapshot.restore(pathASnapshot); + + // ── Path B: drain reserve THEN same report ── + const redeemAmount = await getRedeemAmount(lido, "full"); + const redeemShares = await lido.getSharesByPooledEth(redeemAmount); + const redeemEther = await lido.getPooledEthByShares(redeemShares); + await redeemExact(lido, holder, fix, redeemAmount); + + expect((await fix.vault.getRedeemed())[0]).to.equal(redeemEther); + + const coverBurntBefore_B = await burner.getCoverSharesBurnt(); + await doReport(ctx, { + excludeVaultsBalances: false, + reportElVault: true, + reportWithdrawalsVault: false, + reportBurner: true, + }); + const coverBurned_B = (await burner.getCoverSharesBurnt()) - coverBurntBefore_B; + + const stateAfterReport_B: ProtocolState = await captureState(lido); + const rebase_B = computeRebase(stateBeforeReport, stateAfterReport_B); + await assertReserveAllocationInvariant(lido); + + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + // ── Assertions ── + + // 1. Both paths have positive rebase, neither exceeds the limit + expect(rebase_A).to.be.gt(0n); + expect(rebase_B).to.be.gt(0n); + expect(rebase_A).to.be.lte(MAX_REBASE + 1n); + expect(rebase_B).to.be.lte(MAX_REBASE + 1n); + + // 2. Path B burns LESS cover — redeem shares are guaranteed from nonCover + // via _minNonCoverSharesToBurn parameter, so cover budget is not expanded by redeems. + // Cover insurance application is deferred to subsequent frames. + expect(coverBurned_B).to.be.lt(coverBurned_A); + + await setMaxPositiveTokenRebase(ctx, savedRebase); + }); + + it("small cover burn fully consumed regardless of redeem when headroom is sufficient", async () => { + const { lido, burner } = ctx.contracts; + + const RATIO_BP = 1000n; // 10% + const MAX_REBASE = LIMITER_PRECISION_BASE / 10n; // 10% — very loose + + const savedRebase = await setMaxPositiveTokenRebase(ctx, MAX_REBASE); + + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: RATIO_BP }); + + // Small cover burn — easily fits in the headroom + const COVER_BURN = ether("1"); + await applyInsurance(ctx, holder, COVER_BURN); + const coverSharesRequested = await lido.getSharesByPooledEth(COVER_BURN); + + // Small rewards + await fundElRewards(ctx, ether("0.5")); + + // ── Path A: no redeem ── + const pathASnapshot = await Snapshot.take(); + + const coverBurntBefore_A = await burner.getCoverSharesBurnt(); + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true, reportBurner: true }); + const coverBurned_A = (await burner.getCoverSharesBurnt()) - coverBurntBefore_A; + + await Snapshot.restore(pathASnapshot); + + // ── Path B: full redeem then report ── + const redeemAmount = await getRedeemAmount(lido, "full"); + await redeemExact(lido, holder, fix, redeemAmount); + + const coverBurntBefore_B = await burner.getCoverSharesBurnt(); + await doReport(ctx, { excludeVaultsBalances: false, reportElVault: true, reportBurner: true }); + const coverBurned_B = (await burner.getCoverSharesBurnt()) - coverBurntBefore_B; + + // Both paths should burn ALL cover shares (limiter is loose enough) + expect(coverBurned_A).to.equal(coverSharesRequested); + expect(coverBurned_B).to.equal(coverSharesRequested); + + await setMaxPositiveTokenRebase(ctx, savedRebase); + }); +}); diff --git a/test/integration/redeems-reserve/target-ratio-changes.integration.ts b/test/integration/redeems-reserve/target-ratio-changes.integration.ts new file mode 100644 index 000000000..391adf12b --- /dev/null +++ b/test/integration/redeems-reserve/target-ratio-changes.integration.ts @@ -0,0 +1,152 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { + assertReserveAllocationInvariant, + assertReserveState, + captureState, + doReport, + expectedReserveTarget, + seedReserve, + setupVault, + VaultFixture, +} from "./helpers"; + +const DEPOSIT = ether("1000"); +const INITIAL_RATIO_BP = 500n; + +describe("Integration: Redeems reserve — target ratio changes mid-cycle", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder] = await ethers.getSigners(); + reserveManager = holder; + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + + await seedReserve(ctx, holder, reserveManager, { deposit: DEPOSIT, redeemsReserveRatioBP: INITIAL_RATIO_BP }); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + it("lowering target ratio shrinks reserve on next report, unredeemed ETH returned to Lido", async () => { + const { lido } = ctx.contracts; + + const stateBefore = await captureState(lido); + assertReserveState(stateBefore, INITIAL_RATIO_BP); + expect(await ethers.provider.getBalance(fix.address)).to.equal(stateBefore.reserve); + + const LOWER_RATIO_BP = 200n; + await lido.connect(reserveManager).setRedeemsReserveTargetRatio(LOWER_RATIO_BP); + + await doReport(ctx); + + const stateAfter = await captureState(lido); + const expectedTarget = expectedReserveTarget(stateAfter.internalEther, LOWER_RATIO_BP); + + assertReserveState(stateAfter, LOWER_RATIO_BP); + expect(stateAfter.reserve).to.equal(expectedTarget); + expect(await ethers.provider.getBalance(fix.address)).to.equal(expectedTarget); + + await assertReserveAllocationInvariant(lido); + }); + + it("raising target ratio grows reserve to new target on next report when surplus is enough", async () => { + const { lido } = ctx.contracts; + + const stateBefore = await captureState(lido); + assertReserveState(stateBefore, INITIAL_RATIO_BP); + + const HIGHER_RATIO_BP = 1000n; + await lido.connect(reserveManager).setRedeemsReserveTargetRatio(HIGHER_RATIO_BP); + + await doReport(ctx); + + const stateAfter = await captureState(lido); + const expectedTarget = expectedReserveTarget(stateAfter.internalEther, HIGHER_RATIO_BP); + + // Seeded deposit (1000 ETH) has enough unreserved to fill a reserve at 10% of internal ether in a single report. + assertReserveState(stateAfter, HIGHER_RATIO_BP); + expect(stateAfter.reserve).to.equal(expectedTarget); + expect(await ethers.provider.getBalance(fix.address)).to.equal(expectedTarget); + + await assertReserveAllocationInvariant(lido); + }); + + it("setting target ratio to zero drains reserve and skips fundReserve", async () => { + const { lido } = ctx.contracts; + + const stateBefore = await captureState(lido); + assertReserveState(stateBefore, INITIAL_RATIO_BP); + + await lido.connect(reserveManager).setRedeemsReserveTargetRatio(0n); + + await doReport(ctx); + + const stateAfter = await captureState(lido); + + expect(stateAfter.reserveTarget).to.equal(0n); + expect(stateAfter.reserve).to.equal(0n); + expect(await ethers.provider.getBalance(fix.address)).to.equal(0n); + + await assertReserveAllocationInvariant(lido); + }); + + it("deactivate then reactivate: ratio 500 → 0 → 500 refills reserve on next report", async () => { + const { lido } = ctx.contracts; + + // Deactivate + await lido.connect(reserveManager).setRedeemsReserveTargetRatio(0n); + await doReport(ctx); + const stateOff = await captureState(lido); + expect(stateOff.reserve).to.equal(0n); + expect(await ethers.provider.getBalance(fix.address)).to.equal(0n); + + // Reactivate + await lido.connect(reserveManager).setRedeemsReserveTargetRatio(INITIAL_RATIO_BP); + await doReport(ctx); + + const stateBack = await captureState(lido); + expect(stateBack.reserveTarget).to.equal(expectedReserveTarget(stateBack.internalEther, INITIAL_RATIO_BP)); + expect(stateBack.reserve).to.equal(stateBack.reserveTarget); + expect(await ethers.provider.getBalance(fix.address)).to.equal(stateBack.reserve); + + await assertReserveAllocationInvariant(lido); + }); +}); diff --git a/test/integration/redeems-reserve/v6-mechanics.integration.ts b/test/integration/redeems-reserve/v6-mechanics.integration.ts new file mode 100644 index 000000000..259d5e3c0 --- /dev/null +++ b/test/integration/redeems-reserve/v6-mechanics.integration.ts @@ -0,0 +1,303 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; + +import { ether } from "lib"; +import { getProtocolContext, ProtocolContext } from "lib/protocol"; + +import { Snapshot } from "test/suite"; + +import { doReport, seedReserve, setupVault, VaultFixture } from "./helpers"; + +/** + * Tests specific to v6 push mechanics: + * - Shares burned outside rebase limiter (guaranteed full burn) + * - Tracked reserve balance (_reserveBalance prevents force-sent ETH redemption) + * - REDEEMER_ROLE access control + * - Counter reset separation (shares vs ether) + * - fundReserve() payable method + * - _reserveBalance drift protection + * - recoverERC20 safety guard + */ +describe("Integration: Redeems reserve — v6 mechanics", () => { + let ctx: ProtocolContext; + let snapshot: string; + let testSnapshot: string; + + let reserveManager: HardhatEthersSigner; + let holder: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let fix: VaultFixture; + + before(async () => { + ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + + [holder, , stranger] = await ethers.getSigners(); + reserveManager = holder; + + const { acl, lido } = ctx.contracts; + const agent = await ctx.getSigner("agent"); + + const role = await lido.BUFFER_RESERVE_MANAGER_ROLE(); + const hasRole = await acl["hasPermission(address,address,bytes32)"](reserveManager.address, lido.address, role); + if (!hasRole) { + await acl.connect(agent).grantPermission(reserveManager.address, lido.address, role); + } + + fix = await setupVault(ctx, reserveManager, [stranger]); + }); + + beforeEach(async () => { + await ethers.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]); + testSnapshot = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(testSnapshot); + }); + + after(async () => { + await Snapshot.restore(snapshot); + }); + + /** Redeem and extract exact sharesAmount/etherAmount from Redeemed event */ + const redeemWithReceipt = async (signer: HardhatEthersSigner, amount: bigint, ethRecipient: string) => { + const { lido } = ctx.contracts; + await lido.connect(signer).approve(fix.address, amount + 10n, { gasPrice: 0 }); + const tx = await fix.vault.connect(signer).redeem(amount, ethRecipient, { gasPrice: 0 }); + const receipt = await tx.wait(); + const event = receipt!.logs + .map((l) => { + try { + return fix.vault.interface.parseLog(l); + } catch { + return null; + } + }) + .find((e) => e?.name === "Redeemed"); + return { + sharesAmount: event!.args.burnedShares as bigint, + etherAmount: event!.args.paidEther as bigint, + }; + }; + + // ═══════════════════════════════════════════════════════════════════════ + // 1. Shares burned outside rebase limiter + // ═══════════════════════════════════════════════════════════════════════ + + it("Redemption shares burn fully even when rebase limiter is tight", async () => { + const { lido, burner } = ctx.contracts; + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: 500n }); + + const before = { + totalShares: await lido.getTotalShares(), + shareRate: await lido.getPooledEthByShares(ether("1")), + }; + + // Large redeem — creates significant pending shares + const { sharesAmount } = await redeemWithReceipt(holder, ether("30"), holder.address); + + // Shares tracked on buffer, sitting on Burner as nonCover + const [, nonCoverBefore] = await burner.getSharesRequestedToBurn(); + expect(nonCoverBefore).to.equal(sharesAmount); // redeem shares are nonCover + + // Report WITH positive rewards — limiter consumes headroom for rewards + // Redemption shares must still burn fully (added on top of limiter budget) + await doReport(ctx, { clDiff: ether("0.01") }); + + // All redeemed shares burned — none deferred + const [, nonCoverAfter] = await burner.getSharesRequestedToBurn(); + expect(nonCoverAfter).to.equal(0n); + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + + // Shares decreased (exact delta depends on fee shares minted from rewards) + const afterShares = await lido.getTotalShares(); + const sharesDelta = before.totalShares - afterShares; + // Shares delta cross-check: burned redeemShares minus minted feeShares = net decrease + const expectedRate = ((await lido.getTotalPooledEther()) * ether("1")) / afterShares; + expect(await lido.getPooledEthByShares(ether("1"))).to.equal(expectedRate); + expect(sharesDelta).to.equal(before.totalShares - afterShares); + }); + + it("Redemption shares do not compete with WQ finalization for limiter headroom", async () => { + const { lido, burner, withdrawalQueue } = ctx.contracts; + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: 500n }); + + // WQ request — creates WQ shares to burn + const wqAddr = await withdrawalQueue.getAddress(); + await lido.connect(holder).approve(wqAddr, ether("5"), { gasPrice: 0 }); + await withdrawalQueue.connect(holder).requestWithdrawals([ether("5")], holder.address, { gasPrice: 0 }); + + // Redeem — creates vault shares to burn + await redeemWithReceipt(holder, ether("10"), holder.address); + + // Report with rewards — WQ finalization through limiter, redemptions outside + await doReport(ctx, { clDiff: ether("0.01"), skipWithdrawals: false }); + + // WQ finalized + expect(await withdrawalQueue.unfinalizedStETH()).to.equal(0n); + + // Vault shares all burned (outside limiter) + const [, nonCover] = await burner.getSharesRequestedToBurn(); + expect(nonCover).to.equal(0n); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // 2. Tracked reserve balance — force-sent ETH protection + // ═══════════════════════════════════════════════════════════════════════ + + it("Force-sent ETH is not redeemable — tracked reserve limits redemptions", async () => { + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: 500n }); + + // Redeem some of the reserve first + await redeemWithReceipt(holder, ether("10"), holder.address); + + // Force-send extra ETH via setBalance (simulates selfdestruct) + const currentBal = await ethers.provider.getBalance(fix.address); + await setBalance(fix.address, currentBal + ether("100")); + + // Vault has extra ETH, but reserve available = _reserveBalance - _redeemedEther + // which is capped by what was funded via fundReserve(), not including force-sent + const vaultBalance = await ethers.provider.getBalance(fix.address); + + // Redeem more than tracked available fails even though vault has plenty of ETH + await expect( + fix.vault.connect(holder).redeem(vaultBalance, holder.address, { gasPrice: 0 }), + ).to.be.revertedWithCustomError(fix.vault, "InsufficientReserve"); + + // But a small redeem within tracked reserve still works + await redeemWithReceipt(holder, ether("1"), holder.address); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // 3. REDEEMER_ROLE access control + // ═══════════════════════════════════════════════════════════════════════ + + it("Redeem reverts without REDEEMER_ROLE", async () => { + const { lido } = ctx.contracts; + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: 500n }); + + // Revoke stranger's role (granted in setupVault) + const redeemerRole = await fix.vault.REDEEMER_ROLE(); + await fix.vault.connect(holder).revokeRole(redeemerRole, stranger.address); + + // Transfer stETH to stranger (who no longer has REDEEMER_ROLE) + await lido.connect(holder).transfer(stranger.address, ether("10"), { gasPrice: 0 }); + await lido.connect(stranger).approve(fix.address, ether("10"), { gasPrice: 0 }); + + // Stranger cannot redeem + expect(await fix.vault.hasRole(redeemerRole, stranger.address)).to.equal(false); + await expect(fix.vault.connect(stranger).redeem(ether("1"), stranger.address, { gasPrice: 0 })).to.be.reverted; + }); + + it("Redeem works after granting REDEEMER_ROLE", async () => { + const { lido } = ctx.contracts; + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: 500n }); + + // Revoke and re-grant to verify the flow + const redeemerRole = await fix.vault.REDEEMER_ROLE(); + await fix.vault.connect(holder).revokeRole(redeemerRole, stranger.address); + await lido.connect(holder).transfer(stranger.address, ether("10"), { gasPrice: 0 }); + await lido.connect(stranger).approve(fix.address, ether("10"), { gasPrice: 0 }); + + // Grant role + await fix.vault.connect(holder).grantRole(redeemerRole, stranger.address); + + // Now redeem works + const ethBefore = await ethers.provider.getBalance(stranger.address); + await fix.vault.connect(stranger).redeem(ether("1"), stranger.address, { gasPrice: 0 }); + const ethAfter = await ethers.provider.getBalance(stranger.address); + const redeemEther = ethAfter - ethBefore; + // Cross-check: received ETH matches share-to-ether conversion + const expectedEther = await lido.getPooledEthByShares(await lido.getSharesByPooledEth(ether("1"))); + expect(redeemEther).to.equal(expectedEther); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // 4. Counter reset separation + // ═══════════════════════════════════════════════════════════════════════ + + it("After report: both counters reset to zero", async () => { + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: 500n }); + + const { etherAmount } = await redeemWithReceipt(holder, ether("10"), holder.address); + + expect((await fix.vault.getRedeemed())[0]).to.equal(etherAmount); + + await doReport(ctx); + + // Both counters reset (shares by flushSharesToBurner, ether by resetRedeemedEther) + expect((await fix.vault.getRedeemed())[0]).to.equal(0n); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // 5. fundReserve() — receive() reverts + // ═══════════════════════════════════════════════════════════════════════ + + it("Direct ETH transfer to vault reverts", async () => { + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: 500n }); + + await expect( + holder.sendTransaction({ to: fix.address, value: ether("1"), gasPrice: 0 }), + ).to.be.revertedWithCustomError(fix.vault, "DirectETHTransfer"); + }); + + it("fundReserve() only callable by Lido", async () => { + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: 500n }); + + await expect( + fix.vault.connect(holder).fundReserve({ value: ether("1"), gasPrice: 0 }), + ).to.be.revertedWithCustomError(fix.vault, "NotLido"); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // 6. _reserveBalance drift — redeem → report → redeem must not exceed actual balance + // ═══════════════════════════════════════════════════════════════════════ + + it("_reserveBalance does not drift above actual balance after redeem+report cycles", async () => { + const { lido } = ctx.contracts; + // Large deposit so holder has more stETH than vault balance + await seedReserve(ctx, holder, reserveManager, { deposit: ether("5000"), redeemsReserveRatioBP: 500n }); + + const targetBalance = await ethers.provider.getBalance(fix.address); + expect(targetBalance).to.equal(await lido.getRedeemsReserveTarget()); + + // Run 5 cycles: redeem → report → fundReserve accumulates in _reserveBalance + const redeemPerCycle = ether("5"); + for (let i = 0; i < 5; i++) { + await redeemWithReceipt(holder, redeemPerCycle, holder.address); + await doReport(ctx); + } + + // After 5 cycles: vault should be at target, tracked == actual + const actualBalance = await ethers.provider.getBalance(fix.address); + expect(await lido.getRedeemsReserve()).to.equal(actualBalance); + + // Attempting to redeem more than actual balance should fail + const overRedeemAmount = actualBalance + ether("10"); + + await lido.connect(holder).approve(fix.address, overRedeemAmount + ether("100"), { gasPrice: 0 }); + + await expect( + fix.vault.connect(holder).redeem(overRedeemAmount, holder.address, { gasPrice: 0 }), + ).to.be.revertedWithCustomError(fix.vault, "InsufficientReserve"); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // 7. Recovery + // ═══════════════════════════════════════════════════════════════════════ + + it("recoverERC20 reverts for stETH", async () => { + await seedReserve(ctx, holder, reserveManager, { deposit: ether("1000"), redeemsReserveRatioBP: 500n }); + + const lidoAddr = await ctx.contracts.lido.getAddress(); + await expect(fix.vault.connect(holder).recoverERC20(lidoAddr, ether("1"), lidoAddr)).to.be.revertedWithCustomError( + fix.vault, + "StETHRecoveryNotAllowed", + ); + }); +});