diff --git a/.env.example b/.env.example index 28369e584..5dcda8f6b 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,7 @@ SEPOLIA_RPC_URL= # RPC URL for Hardhat Network forking, required for running tests on mainnet fork with tracing (Infura, Alchemy, etc.) # https://hardhat.org/hardhat-network/docs/guides/forking-other-networks#forking-other-networks -HARDHAT_FORKING_URL= +HARDHAT_FORKING_URL=https://eth.drpc.org # Scratch deployment via hardhat variables DEPLOYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 diff --git a/.github/workflows/tests-integration-mainnet.yml b/.github/workflows/tests-integration-mainnet.yml index 742776c25..dcee343ea 100644 --- a/.github/workflows/tests-integration-mainnet.yml +++ b/.github/workflows/tests-integration-mainnet.yml @@ -1,4 +1,5 @@ name: Integration Tests + #on: [push] # #jobs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4babc6ce..5ca9857d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -328,7 +328,7 @@ mainnet environment, allowing you to run integration tests with trace logging. > [!NOTE] > Ensure that `HARDHAT_FORKING_URL` is set to Ethereum Mainnet RPC and `MAINNET_*` environment variables are set in the -> `.env` file (refer to `.env.example` for guidance). +> `.env` file (refer to `.env.example` for guidance). Otherwise, the tests will run against the Scratch deployment. ```bash # Run all integration tests diff --git a/contracts/0.4.24/Lido.sol b/contracts/0.4.24/Lido.sol index b217aad2e..2194052c4 100644 --- a/contracts/0.4.24/Lido.sol +++ b/contracts/0.4.24/Lido.sol @@ -196,7 +196,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { * @param _eip712StETH eip712 helper contract for StETH */ function initialize(address _lidoLocator, address _eip712StETH) public payable onlyInit { - _bootstrapInitialHolder(); + _bootstrapInitialHolder(); // stone in the elevator LIDO_LOCATOR_POSITION.setStorageAddress(_lidoLocator); emit LidoLocatorSet(_lidoLocator); @@ -947,6 +947,21 @@ contract Lido is Versioned, StETHPermit, AragonApp { return internalEther.add(_getExternalEther(internalEther)); } + /// @dev the numerator (in ether) of the share rate for StETH conversion between shares and ether and vice versa. + /// using the numerator and denominator different from totalShares and totalPooledEther allows to: + /// - avoid double precision loss on additional division on external ether calculations + /// - optimize gas cost of conversions between shares and ether + function _getShareRateNumerator() internal view returns (uint256) { + return _getInternalEther(); + } + + /// @dev the denominator (in shares) of the share rate for StETH conversion between shares and ether and vice versa. + function _getShareRateDenominator() internal view returns (uint256) { + uint256 externalShares = EXTERNAL_SHARES_POSITION.getStorageUint256(); + uint256 internalShares = _getTotalShares() - externalShares; // never 0 because of the stone in the elevator + return internalShares; + } + /// @notice Calculate the maximum amount of external shares that can be minted while maintaining /// maximum allowed external ratio limits /// @return Maximum amount of external shares that can be minted diff --git a/contracts/0.4.24/StETH.sol b/contracts/0.4.24/StETH.sol index 32e384605..3c5b6c610 100644 --- a/contracts/0.4.24/StETH.sol +++ b/contracts/0.4.24/StETH.sol @@ -303,8 +303,8 @@ contract StETH is IERC20, Pausable { */ function getSharesByPooledEth(uint256 _ethAmount) public view returns (uint256) { return _ethAmount - .mul(_getTotalShares()) - .div(_getTotalPooledEther()); + .mul(_getShareRateDenominator()) // denominator in shares + .div(_getShareRateNumerator()); // numerator in ether } /** @@ -312,8 +312,8 @@ contract StETH is IERC20, Pausable { */ function getPooledEthByShares(uint256 _sharesAmount) public view returns (uint256) { return _sharesAmount - .mul(_getTotalPooledEther()) - .div(_getTotalShares()); + .mul(_getShareRateNumerator()) // numerator in ether + .div(_getShareRateDenominator()); // denominator in shares } /** @@ -322,14 +322,14 @@ contract StETH is IERC20, Pausable { * for `shareRate >= 0.5`, `getSharesByPooledEth(getPooledEthBySharesRoundUp(1))` will be 1. */ function getPooledEthBySharesRoundUp(uint256 _sharesAmount) public view returns (uint256 etherAmount) { - uint256 totalEther = _getTotalPooledEther(); - uint256 totalShares = _getTotalShares(); + uint256 numeratorInEther = _getShareRateNumerator(); + uint256 denominatorInShares = _getShareRateDenominator(); etherAmount = _sharesAmount - .mul(totalEther) - .div(totalShares); + .mul(numeratorInEther) + .div(denominatorInShares); - if (etherAmount.mul(totalShares) != _sharesAmount.mul(totalEther)) { + if (_sharesAmount.mul(numeratorInEther) != etherAmount.mul(denominatorInShares)) { ++etherAmount; } } @@ -389,6 +389,22 @@ contract StETH is IERC20, Pausable { */ function _getTotalPooledEther() internal view returns (uint256); + /** + * @return the numerator of the protocol's share rate (in ether). + * @dev used to convert shares to tokens and vice versa. + */ + function _getShareRateNumerator() internal view returns (uint256) { + return _getTotalPooledEther(); + } + + /** + * @return the denominator of the protocol's share rate (in shares). + * @dev used to convert shares to tokens and vice versa. + */ + function _getShareRateDenominator() internal view returns (uint256) { + return _getTotalShares(); + } + /** * @notice Moves `_amount` tokens from `_sender` to `_recipient`. * Emits a `Transfer` event. diff --git a/contracts/0.8.25/utils/PausableUntilWithRoles.sol b/contracts/0.8.25/utils/PausableUntilWithRoles.sol new file mode 100644 index 000000000..e8c2d831b --- /dev/null +++ b/contracts/0.8.25/utils/PausableUntilWithRoles.sol @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +pragma solidity 0.8.25; + +import {PausableUntil} from "contracts/common/utils/PausableUntil.sol"; +import {AccessControlEnumerableUpgradeable} from "contracts/openzeppelin/5.0.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; + +/** + * @title PausableUntilWithRoles + * @notice a `PausableUntil` implementation using OpenZeppelin's `AccessControlEnumerableUpgradeable` + * @dev the inheriting contract must use `whenNotPaused` modifier from `PausableUntil` to block some functions on pause + */ +abstract contract PausableUntilWithRoles is PausableUntil, AccessControlEnumerableUpgradeable { + /// @notice role that allows to pause the contract + bytes32 public constant PAUSE_ROLE = keccak256("PausableUntilWithRoles.PauseRole"); + /// @notice role that allows to resume the contract + bytes32 public constant RESUME_ROLE = keccak256("PausableUntilWithRoles.ResumeRole"); + + /** + * @notice Resume the contract + * @dev Reverts if contracts is not paused + * @dev Reverts if sender has no `RESUME_ROLE` + */ + function resume() external onlyRole(RESUME_ROLE) { + _resume(); + } + + /** + * @notice Pause the contract for a specified period + * @param _duration pause duration in seconds (use `PAUSE_INFINITELY` for unlimited) + * @dev Reverts if contract is already paused + * @dev Reverts if sender has no `PAUSE_ROLE` + * @dev Reverts if zero duration is passed + */ + function pauseFor(uint256 _duration) external onlyRole(PAUSE_ROLE) { + _pauseFor(_duration); + } + + /** + * @notice Pause the contract until a specified timestamp + * @param _pauseUntilInclusive the last second to pause until inclusive + * @dev Reverts if the timestamp is in the past + * @dev Reverts if sender has no `PAUSE_ROLE` + * @dev Reverts if contract is already paused + */ + function pauseUntil(uint256 _pauseUntilInclusive) external onlyRole(PAUSE_ROLE) { + _pauseUntil(_pauseUntilInclusive); + } +} diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index 36c869f81..86363f787 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -13,90 +13,85 @@ import {Dashboard} from "./Dashboard.sol"; * * The delegation hierarchy is as follows: * - DEFAULT_ADMIN_ROLE is the underlying owner of StakingVault; - * - OPERATOR_ROLE is the node operator of StakingVault; and itself is the role admin, - * and the DEFAULT_ADMIN_ROLE cannot assign OPERATOR_ROLE; - * - CLAIM_OPERATOR_DUE_ROLE is the role that can claim operator due; is assigned by OPERATOR_ROLE; + * - NODE_OPERATOR_MANAGER_ROLE is the node operator manager of StakingVault; and itself is the role admin, + * and the DEFAULT_ADMIN_ROLE cannot assign NODE_OPERATOR_MANAGER_ROLE; + * - NODE_OPERATOR_FEE_CLAIMER_ROLE is the role that can claim node operator fee; is assigned by NODE_OPERATOR_MANAGER_ROLE; * - * Additionally, the following roles are assigned by the owner (DEFAULT_ADMIN_ROLE): - * - CURATOR_ROLE is the curator of StakingVault empowered by the owner; - * performs the daily operations of the StakingVault on behalf of the owner; - * - STAKER_ROLE funds and withdraws from the StakingVault; - * - TOKEN_MASTER_ROLE mints and burns shares of stETH backed by the StakingVault; + * Additionally, the following roles are assigned by DEFAULT_ADMIN_ROLE: + * - CURATOR_ROLE is the curator of StakingVault and perfoms some operations on behalf of DEFAULT_ADMIN_ROLE; + * - FUND_WITHDRAW_ROLE funds and withdraws from the StakingVault; + * - MINT_BURN_ROLE mints and burns shares of stETH backed by the StakingVault; * - * Operator and Curator have their respective fees and dues. - * The fee is calculated as a percentage (in basis points) of the StakingVault rewards. - * The due is the amount of ether that is owed to the Curator or Operator based on the fee. + * The curator and node operator have their respective fees. + * The feeBP is the percentage (in basis points) of the StakingVault rewards. + * The unclaimed fee is the amount of ether that is owed to the curator or node operator based on the feeBP. */ contract Delegation is Dashboard { /** - * @notice Maximum fee value; equals to 100%. + * @notice Maximum combined feeBP value; equals to 100%. */ - uint256 private constant MAX_FEE = TOTAL_BASIS_POINTS; + uint256 private constant MAX_FEE_BP = TOTAL_BASIS_POINTS; /** - * @notice Curator: + * @notice Curator role: * - sets curator fee; - * - votes operator fee; + * - claims curator fee; * - votes on vote lifetime; - * - votes on ownership transfer; - * - claims curator due. + * - votes on node operator fee; + * - votes on ownership transfer. */ bytes32 public constant CURATOR_ROLE = keccak256("Vault.Delegation.CuratorRole"); /** - * @notice Staker: - * - funds vault; - * - withdraws from vault. + * @notice Mint/burn role: + * - mints shares of stETH; + * - burns shares of stETH. */ - bytes32 public constant STAKER_ROLE = keccak256("Vault.Delegation.StakerRole"); + bytes32 public constant MINT_BURN_ROLE = keccak256("Vault.Delegation.MintBurnRole"); /** - * @notice Token master: - * - mints shares; - * - burns shares. + * @notice Fund/withdraw role: + * - funds StakingVault; + * - withdraws from StakingVault. */ - bytes32 public constant TOKEN_MASTER_ROLE = keccak256("Vault.Delegation.TokenMasterRole"); + bytes32 public constant FUND_WITHDRAW_ROLE = keccak256("Vault.Delegation.FundWithdrawRole"); /** - * @notice Node operator: + * @notice Node operator manager role: * - votes on vote lifetime; - * - votes on operator fee; + * - votes on node operator fee; * - votes on ownership transfer; - * - is the role admin for CLAIM_OPERATOR_DUE_ROLE. + * - assigns NODE_OPERATOR_FEE_CLAIMER_ROLE. */ - bytes32 public constant OPERATOR_ROLE = keccak256("Vault.Delegation.OperatorRole"); + bytes32 public constant NODE_OPERATOR_MANAGER_ROLE = keccak256("Vault.Delegation.NodeOperatorManagerRole"); /** - * @notice Claim operator due: - * - claims operator due. + * @notice Node operator fee claimer role: + * - claims node operator fee. */ - bytes32 public constant CLAIM_OPERATOR_DUE_ROLE = keccak256("Vault.Delegation.ClaimOperatorDueRole"); + bytes32 public constant NODE_OPERATOR_FEE_CLAIMER_ROLE = keccak256("Vault.Delegation.NodeOperatorFeeClaimerRole"); /** - * @notice Curator fee in basis points; combined with operator fee cannot exceed 100%. - * The term "fee" is used to represent the percentage (in basis points) of curator's share of the rewards. - * The term "due" is used to represent the actual amount of fees in ether. - * The curator due in ether is returned by `curatorDue()`. + * @notice Curator fee in basis points; combined with node operator fee cannot exceed 100%. + * The curator's unclaimed fee in ether is returned by `curatorUnclaimedFee()`. */ - uint256 public curatorFee; + uint256 public curatorFeeBP; /** - * @notice The last report for which curator due was claimed. Updated on each claim. + * @notice The last report for which curator fee was claimed. Updated on each claim. */ - IStakingVault.Report public curatorDueClaimedReport; + IStakingVault.Report public curatorFeeClaimedReport; /** - * @notice Operator fee in basis points; combined with curator fee cannot exceed 100%. - * The term "fee" is used to represent the percentage (in basis points) of operator's share of the rewards. - * The term "due" is used to represent the actual amount of fees in ether. - * The operator due in ether is returned by `operatorDue()`. + * @notice Node operator fee in basis points; combined with curator fee cannot exceed 100%, or 10,000 basis points. + * The node operator's unclaimed fee in ether is returned by `nodeOperatorUnclaimedFee()`. */ - uint256 public operatorFee; + uint256 public nodeOperatorFeeBP; /** - * @notice The last report for which operator due was claimed. Updated on each claim. + * @notice The last report for which node operator fee was claimed. Updated on each claim. */ - IStakingVault.Report public operatorDueClaimedReport; + IStakingVault.Report public nodeOperatorFeeClaimedReport; /** * @notice Tracks committee votes @@ -114,7 +109,8 @@ contract Delegation is Dashboard { uint256 public voteLifetime; /** - * @notice Initializes the contract with the weth address. + * @notice Constructs the contract. + * @dev Stores token addresses in the bytecode to reduce gas costs. * @param _weth Address of the weth token contract. * @param _lidoLocator Address of the Lido locator contract. */ @@ -124,13 +120,11 @@ contract Delegation is Dashboard { * @notice Initializes the contract: * - sets the address of StakingVault; * - sets up the roles; - * - sets the vote lifetime to 7 days (can be changed later by CURATOR_ROLE and OPERATOR_ROLE). + * - sets the vote lifetime to 7 days (can be changed later by CURATOR_ROLE and NODE_OPERATOR_MANAGER_ROLE). * @param _stakingVault The address of StakingVault. - * @dev The msg.sender here is VaultFactory. It is given the OPERATOR_ROLE - * to be able to set initial operatorFee in VaultFactory, because only OPERATOR_ROLE - * is the admin role for itself. The rest of the roles are also temporarily given to - * VaultFactory to be able to set initial config in VaultFactory. - * All the roles are revoked from VaultFactory at the end of the initialization. + * @dev The msg.sender here is VaultFactory. The VaultFactory is temporarily granted + * DEFAULT_ADMIN_ROLE AND NODE_OPERATOR_MANAGER_ROLE to be able to set initial fees and roles in VaultFactory. + * All the roles are revoked from VaultFactory by the end of the initialization. */ function initialize(address _stakingVault) external override { _initialize(_stakingVault); @@ -138,51 +132,51 @@ contract Delegation is Dashboard { // the next line implies that the msg.sender is an operator // however, the msg.sender is the VaultFactory, and the role will be revoked // at the end of the initialization - _grantRole(OPERATOR_ROLE, msg.sender); - _setRoleAdmin(OPERATOR_ROLE, OPERATOR_ROLE); - _setRoleAdmin(CLAIM_OPERATOR_DUE_ROLE, OPERATOR_ROLE); + _grantRole(NODE_OPERATOR_MANAGER_ROLE, msg.sender); + _setRoleAdmin(NODE_OPERATOR_MANAGER_ROLE, NODE_OPERATOR_MANAGER_ROLE); + _setRoleAdmin(NODE_OPERATOR_FEE_CLAIMER_ROLE, NODE_OPERATOR_MANAGER_ROLE); voteLifetime = 7 days; } /** - * @notice Returns the accumulated curator due in ether, - * calculated as: CD = (SVR * CF) / TBP + * @notice Returns the accumulated unclaimed curator fee in ether, + * calculated as: U = (R * F) / T * where: - * - CD is the curator due; - * - SVR is the StakingVault rewards accrued since the last curator due claim; - * - CF is the curator fee in basis points; - * - TBP is the total basis points (100%). - * @return uint256: the amount of due ether. - */ - function curatorDue() public view returns (uint256) { - return _calculateDue(curatorFee, curatorDueClaimedReport); + * - U is the curator unclaimed fee; + * - R is the StakingVault rewards accrued since the last curator fee claim; + * - F is `curatorFeeBP`; + * - T is the total basis points, 10,000. + * @return uint256: the amount of unclaimed fee in ether. + */ + function curatorUnclaimedFee() public view returns (uint256) { + return _calculateFee(curatorFeeBP, curatorFeeClaimedReport); } /** - * @notice Returns the accumulated operator due in ether, - * calculated as: OD = (SVR * OF) / TBP + * @notice Returns the accumulated unclaimed node operator fee in ether, + * calculated as: U = (R * F) / T * where: - * - OD is the operator due; - * - SVR is the StakingVault rewards accrued since the last operator due claim; - * - OF is the operator fee in basis points; - * - TBP is the total basis points (100%). - * @return uint256: the amount of due ether. - */ - function operatorDue() public view returns (uint256) { - return _calculateDue(operatorFee, operatorDueClaimedReport); + * - U is the node operator unclaimed fee; + * - R is the StakingVault rewards accrued since the last node operator fee claim; + * - F is `nodeOperatorFeeBP`; + * - T is the total basis points, 10,000. + * @return uint256: the amount of unclaimed fee in ether. + */ + function nodeOperatorUnclaimedFee() public view returns (uint256) { + return _calculateFee(nodeOperatorFeeBP, nodeOperatorFeeClaimedReport); } /** * @notice Returns the unreserved amount of ether, * i.e. the amount of ether that is not locked in the StakingVault - * and not reserved for curator due and operator due. + * and not reserved for curator and node operator fees. * This amount does not account for the current balance of the StakingVault and * can return a value greater than the actual balance of the StakingVault. * @return uint256: the amount of unreserved ether. */ function unreserved() public view returns (uint256) { - uint256 reserved = stakingVault.locked() + curatorDue() + operatorDue(); + uint256 reserved = stakingVault.locked() + curatorUnclaimedFee() + nodeOperatorUnclaimedFee(); uint256 valuation = stakingVault.valuation(); return reserved > valuation ? 0 : valuation - reserved; @@ -191,33 +185,33 @@ contract Delegation is Dashboard { /** * @notice Returns the committee that can: * - change the vote lifetime; - * - set the operator fee; + * - set the node operator fee; * - transfer the ownership of the StakingVault. * @return committee is an array of roles that form the voting committee. */ function votingCommittee() public pure returns (bytes32[] memory committee) { committee = new bytes32[](2); committee[0] = CURATOR_ROLE; - committee[1] = OPERATOR_ROLE; + committee[1] = NODE_OPERATOR_MANAGER_ROLE; } /** * @notice Funds the StakingVault with ether. */ - function fund() external payable override onlyRole(STAKER_ROLE) { + function fund() external payable override onlyRole(FUND_WITHDRAW_ROLE) { _fund(msg.value); } /** * @notice Withdraws ether from the StakingVault. * Cannot withdraw more than the unreserved amount: which is the amount of ether - * that is not locked in the StakingVault and not reserved for curator due and operator due. + * that is not locked in the StakingVault and not reserved for curator and node operator fees. * Does not include a check for the balance of the StakingVault, this check is present * on the StakingVault itself. * @param _recipient The address to which the ether will be sent. * @param _ether The amount of ether to withdraw. */ - function withdraw(address _recipient, uint256 _ether) external override onlyRole(STAKER_ROLE) { + function withdraw(address _recipient, uint256 _ether) external override onlyRole(FUND_WITHDRAW_ROLE) { if (_recipient == address(0)) revert ZeroArgument("_recipient"); if (_ether == 0) revert ZeroArgument("_ether"); uint256 withdrawable = unreserved(); @@ -236,7 +230,7 @@ contract Delegation is Dashboard { function mintShares( address _recipient, uint256 _amountOfShares - ) external payable override onlyRole(TOKEN_MASTER_ROLE) fundAndProceed { + ) external payable override onlyRole(MINT_BURN_ROLE) fundAndProceed { _mintSharesTo(_recipient, _amountOfShares); } @@ -247,7 +241,7 @@ contract Delegation is Dashboard { * NB: Delegation contract must have ERC-20 approved allowance to burn sender's shares. * @param _amountOfShares The amount of shares to burn. */ - function burnShares(uint256 _amountOfShares) external override onlyRole(TOKEN_MASTER_ROLE) { + function burnShares(uint256 _amountOfShares) external override onlyRole(MINT_BURN_ROLE) { _burnSharesFrom(msg.sender, _amountOfShares); } @@ -275,56 +269,56 @@ contract Delegation is Dashboard { /** * @notice Sets the curator fee. * The curator fee is the percentage (in basis points) of curator's share of the StakingVault rewards. - * The curator fee combined with the operator fee cannot exceed 100%. - * The curator due must be claimed before the curator fee can be changed to avoid - * @param _newCuratorFee The new curator fee in basis points. + * The curator and node operator fees combined cannot exceed 100%, or 10,000 basis points. + * The function will revert if the curator fee is unclaimed. + * @param _newCuratorFeeBP The new curator fee in basis points. */ - function setCuratorFee(uint256 _newCuratorFee) external onlyRole(CURATOR_ROLE) { - if (_newCuratorFee + operatorFee > MAX_FEE) revert CombinedFeesExceed100Percent(); - if (curatorDue() > 0) revert CuratorDueUnclaimed(); - uint256 oldCuratorFee = curatorFee; - curatorFee = _newCuratorFee; + function setCuratorFeeBP(uint256 _newCuratorFeeBP) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_newCuratorFeeBP + nodeOperatorFeeBP > MAX_FEE_BP) revert CombinedFeesExceed100Percent(); + if (curatorUnclaimedFee() > 0) revert CuratorFeeUnclaimed(); + uint256 oldCuratorFeeBP = curatorFeeBP; + curatorFeeBP = _newCuratorFeeBP; - emit CuratorFeeSet(msg.sender, oldCuratorFee, _newCuratorFee); + emit CuratorFeeBPSet(msg.sender, oldCuratorFeeBP, _newCuratorFeeBP); } /** - * @notice Sets the operator fee. - * The operator fee is the percentage (in basis points) of operator's share of the StakingVault rewards. - * The operator fee combined with the curator fee cannot exceed 100%. - * Note that the function reverts if the operator due is not claimed and all the votes must be recasted to execute it again, - * which is why the deciding voter must make sure that the operator due is claimed before calling this function. - * @param _newOperatorFee The new operator fee in basis points. + * @notice Sets the node operator fee. + * The node operator fee is the percentage (in basis points) of node operator's share of the StakingVault rewards. + * The node operator fee combined with the curator fee cannot exceed 100%. + * Note that the function reverts if the node operator fee is unclaimed and all the votes must be recasted to execute it again, + * which is why the deciding voter must make sure that `nodeOperatorUnclaimedFee()` is 0 before calling this function. + * @param _newNodeOperatorFeeBP The new node operator fee in basis points. */ - function setOperatorFee(uint256 _newOperatorFee) external onlyIfVotedBy(votingCommittee()) { - if (_newOperatorFee + curatorFee > MAX_FEE) revert CombinedFeesExceed100Percent(); - if (operatorDue() > 0) revert OperatorDueUnclaimed(); - uint256 oldOperatorFee = operatorFee; - operatorFee = _newOperatorFee; + function setNodeOperatorFeeBP(uint256 _newNodeOperatorFeeBP) external onlyIfVotedBy(votingCommittee()) { + if (_newNodeOperatorFeeBP + curatorFeeBP > MAX_FEE_BP) revert CombinedFeesExceed100Percent(); + if (nodeOperatorUnclaimedFee() > 0) revert NodeOperatorFeeUnclaimed(); + uint256 oldNodeOperatorFeeBP = nodeOperatorFeeBP; + nodeOperatorFeeBP = _newNodeOperatorFeeBP; - emit OperatorFeeSet(msg.sender, oldOperatorFee, _newOperatorFee); + emit NodeOperatorFeeBPSet(msg.sender, oldNodeOperatorFeeBP, _newNodeOperatorFeeBP); } /** - * @notice Claims the curator due. - * @param _recipient The address to which the curator due will be sent. + * @notice Claims the curator fee. + * @param _recipient The address to which the curator fee will be sent. */ - function claimCuratorDue(address _recipient) external onlyRole(CURATOR_ROLE) { - uint256 due = curatorDue(); - curatorDueClaimedReport = stakingVault.latestReport(); - _claimDue(_recipient, due); + function claimCuratorFee(address _recipient) external onlyRole(CURATOR_ROLE) { + uint256 fee = curatorUnclaimedFee(); + curatorFeeClaimedReport = stakingVault.latestReport(); + _claimFee(_recipient, fee); } /** - * @notice Claims the operator due. - * Note that the authorized role is CLAIM_OPERATOR_DUE_ROLE, not OPERATOR_ROLE, - * although OPERATOR_ROLE is the admin role for CLAIM_OPERATOR_DUE_ROLE. - * @param _recipient The address to which the operator due will be sent. + * @notice Claims the node operator fee. + * Note that the authorized role is NODE_OPERATOR_FEE_CLAIMER_ROLE, not NODE_OPERATOR_MANAGER_ROLE, + * although NODE_OPERATOR_MANAGER_ROLE is the admin role for NODE_OPERATOR_FEE_CLAIMER_ROLE. + * @param _recipient The address to which the node operator fee will be sent. */ - function claimOperatorDue(address _recipient) external onlyRole(CLAIM_OPERATOR_DUE_ROLE) { - uint256 due = operatorDue(); - operatorDueClaimedReport = stakingVault.latestReport(); - _claimDue(_recipient, due); + function claimNodeOperatorFee(address _recipient) external onlyRole(NODE_OPERATOR_FEE_CLAIMER_ROLE) { + uint256 fee = nodeOperatorUnclaimedFee(); + nodeOperatorFeeClaimedReport = stakingVault.latestReport(); + _claimFee(_recipient, fee); } /** @@ -423,13 +417,13 @@ contract Delegation is Dashboard { } /** - * @dev Calculates the curator/operatordue amount based on the fee and the last claimed report. - * @param _fee The fee in basis points. + * @dev Calculates the curator/node operator fee amount based on the fee and the last claimed report. + * @param _feeBP The fee in basis points. * @param _lastClaimedReport The last claimed report. - * @return The accrued due amount. + * @return The accrued fee amount. */ - function _calculateDue( - uint256 _fee, + function _calculateFee( + uint256 _feeBP, IStakingVault.Report memory _lastClaimedReport ) internal view returns (uint256) { IStakingVault.Report memory latestReport = stakingVault.latestReport(); @@ -437,19 +431,19 @@ contract Delegation is Dashboard { int128 rewardsAccrued = int128(latestReport.valuation - _lastClaimedReport.valuation) - (latestReport.inOutDelta - _lastClaimedReport.inOutDelta); - return rewardsAccrued > 0 ? (uint256(uint128(rewardsAccrued)) * _fee) / TOTAL_BASIS_POINTS : 0; + return rewardsAccrued > 0 ? (uint256(uint128(rewardsAccrued)) * _feeBP) / TOTAL_BASIS_POINTS : 0; } /** - * @dev Claims the curator/operator due amount. - * @param _recipient The address to which the due will be sent. - * @param _due The accrued due amount. + * @dev Claims the curator/node operator fee amount. + * @param _recipient The address to which the fee will be sent. + * @param _fee The accrued fee amount. */ - function _claimDue(address _recipient, uint256 _due) internal { + function _claimFee(address _recipient, uint256 _fee) internal { if (_recipient == address(0)) revert ZeroArgument("_recipient"); - if (_due == 0) revert NoDueToClaim(); + if (_fee == 0) revert ZeroArgument("_fee"); - _withdraw(_recipient, _due); + _withdraw(_recipient, _fee); } /** @@ -461,17 +455,17 @@ contract Delegation is Dashboard { /** * @dev Emitted when the curator fee is set. - * @param oldCuratorFee The old curator fee. - * @param newCuratorFee The new curator fee. + * @param oldCuratorFeeBP The old curator fee. + * @param newCuratorFeeBP The new curator fee. */ - event CuratorFeeSet(address indexed sender, uint256 oldCuratorFee, uint256 newCuratorFee); + event CuratorFeeBPSet(address indexed sender, uint256 oldCuratorFeeBP, uint256 newCuratorFeeBP); /** - * @dev Emitted when the operator fee is set. - * @param oldOperatorFee The old operator fee. - * @param newOperatorFee The new operator fee. + * @dev Emitted when the node operator fee is set. + * @param oldNodeOperatorFeeBP The old node operator fee. + * @param newNodeOperatorFeeBP The new node operator fee. */ - event OperatorFeeSet(address indexed sender, uint256 oldOperatorFee, uint256 newOperatorFee); + event NodeOperatorFeeBPSet(address indexed sender, uint256 oldNodeOperatorFeeBP, uint256 newNodeOperatorFeeBP); /** * @dev Emitted when a committee member votes. @@ -488,17 +482,17 @@ contract Delegation is Dashboard { error NotACommitteeMember(); /** - * @dev Error emitted when the curator due is unclaimed. + * @dev Error emitted when the curator fee is unclaimed. */ - error CuratorDueUnclaimed(); + error CuratorFeeUnclaimed(); /** - * @dev Error emitted when the operator due is unclaimed. + * @dev Error emitted when the node operator fee is unclaimed. */ - error OperatorDueUnclaimed(); + error NodeOperatorFeeUnclaimed(); /** - * @dev Error emitted when the combined fees exceed 100%. + * @dev Error emitted when the combined feeBPs exceed 100%. */ error CombinedFeesExceed100Percent(); @@ -506,9 +500,4 @@ contract Delegation is Dashboard { * @dev Error emitted when the requested amount exceeds the unreserved amount. */ error RequestedAmountExceedsUnreserved(); - - /** - * @dev Error emitted when there is no due to claim. - */ - error NoDueToClaim(); } diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index bc6e585d9..79b6179ac 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -59,13 +59,13 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic * @custom:report Latest report containing valuation and inOutDelta * @custom:locked Amount of ether locked on StakingVault by VaultHub and cannot be withdrawn by owner * @custom:inOutDelta Net difference between ether funded and withdrawn from StakingVault - * @custom:operator Address of the node operator + * @custom:nodeOperator Address of the node operator */ struct ERC7201Storage { Report report; uint128 locked; int128 inOutDelta; - address operator; + address nodeOperator; } /** @@ -115,14 +115,14 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic } /** - * @notice Initializes `StakingVault` with an owner, operator, and optional parameters + * @notice Initializes `StakingVault` with an owner, node operator, and optional parameters * @param _owner Address that will own the vault - * @param _operator Address of the node operator + * @param _nodeOperator Address of the node operator * @param - Additional initialization parameters */ - function initialize(address _owner, address _operator, bytes calldata /* _params */ ) external onlyBeacon initializer { + function initialize(address _owner, address _nodeOperator, bytes calldata /* _params */ ) external onlyBeacon initializer { __Ownable_init(_owner); - _getStorage().operator = _operator; + _getStorage().nodeOperator = _nodeOperator; } /** @@ -242,8 +242,8 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic * Node operator address is set in the initialization and can never be changed. * @return Address of the node operator */ - function operator() external view returns (address) { - return _getStorage().operator; + function nodeOperator() external view returns (address) { + return _getStorage().nodeOperator; } /** @@ -316,7 +316,7 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic ) external { if (_numberOfDeposits == 0) revert ZeroArgument("_numberOfDeposits"); if (!isBalanced()) revert Unbalanced(); - if (msg.sender != _getStorage().operator) revert NotAuthorized("depositToBeaconChain", msg.sender); + if (msg.sender != _getStorage().nodeOperator) revert NotAuthorized("depositToBeaconChain", msg.sender); _makeBeaconChainDeposits32ETH(_numberOfDeposits, bytes.concat(withdrawalCredentials()), _pubkeys, _signatures); emit DepositedToBeaconChain(msg.sender, _numberOfDeposits, _numberOfDeposits * 32 ether); @@ -325,7 +325,7 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic /** * @notice Requests validator exit from the beacon chain * @param _pubkeys Concatenated validator public keys - * @dev Signals the operator to eject the specified validators from the beacon chain + * @dev Signals the node operator to eject the specified validators from the beacon chain */ function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { emit ValidatorsExitRequest(msg.sender, _pubkeys); @@ -422,7 +422,7 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic /** * @notice Emitted when a validator exit request is made - * @dev Signals `operator` to exit the validator + * @dev Signals `nodeOperator` to exit the validator * @param sender Address that requested the validator exit * @param pubkey Public key of the validator requested to exit */ diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index 2edf21e73..a32e841c9 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -12,31 +12,31 @@ pragma solidity 0.8.25; interface IDelegation { struct InitialState { address curator; - address staker; - address tokenMaster; - address operator; - address claimOperatorDueRole; - uint256 curatorFee; - uint256 operatorFee; + address minterBurner; + address funderWithdrawer; + address nodeOperatorManager; + address nodeOperatorFeeClaimer; + uint256 curatorFeeBP; + uint256 nodeOperatorFeeBP; } function DEFAULT_ADMIN_ROLE() external view returns (bytes32); function CURATOR_ROLE() external view returns (bytes32); - function STAKER_ROLE() external view returns (bytes32); + function FUND_WITHDRAW_ROLE() external view returns (bytes32); - function TOKEN_MASTER_ROLE() external view returns (bytes32); + function MINT_BURN_ROLE() external view returns (bytes32); - function OPERATOR_ROLE() external view returns (bytes32); + function NODE_OPERATOR_MANAGER_ROLE() external view returns (bytes32); - function CLAIM_OPERATOR_DUE_ROLE() external view returns (bytes32); + function NODE_OPERATOR_FEE_CLAIMER_ROLE() external view returns (bytes32); function initialize(address _stakingVault) external; - function setCuratorFee(uint256 _newCuratorFee) external; + function setCuratorFeeBP(uint256 _newCuratorFeeBP) external; - function setOperatorFee(uint256 _newOperatorFee) external; + function setNodeOperatorFeeBP(uint256 _newNodeOperatorFee) external; function grantRole(bytes32 role, address account) external; @@ -74,28 +74,28 @@ contract VaultFactory is UpgradeableBeacon { delegation = IDelegation(Clones.clone(delegationImpl)); // initialize StakingVault - vault.initialize(address(delegation), _delegationInitialState.operator, _stakingVaultInitializerExtraParams); + vault.initialize(address(delegation), _delegationInitialState.nodeOperatorManager, _stakingVaultInitializerExtraParams); // initialize Delegation delegation.initialize(address(vault)); // grant roles to owner, manager, operator delegation.grantRole(delegation.DEFAULT_ADMIN_ROLE(), msg.sender); delegation.grantRole(delegation.CURATOR_ROLE(), _delegationInitialState.curator); - delegation.grantRole(delegation.STAKER_ROLE(), _delegationInitialState.staker); - delegation.grantRole(delegation.TOKEN_MASTER_ROLE(), _delegationInitialState.tokenMaster); - delegation.grantRole(delegation.OPERATOR_ROLE(), _delegationInitialState.operator); - delegation.grantRole(delegation.CLAIM_OPERATOR_DUE_ROLE(), _delegationInitialState.claimOperatorDueRole); + delegation.grantRole(delegation.FUND_WITHDRAW_ROLE(), _delegationInitialState.funderWithdrawer); + delegation.grantRole(delegation.MINT_BURN_ROLE(), _delegationInitialState.minterBurner); + delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationInitialState.nodeOperatorManager); + delegation.grantRole(delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE(), _delegationInitialState.nodeOperatorFeeClaimer); // grant temporary roles to factory delegation.grantRole(delegation.CURATOR_ROLE(), address(this)); - delegation.grantRole(delegation.OPERATOR_ROLE(), address(this)); + delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), address(this)); // set fees - delegation.setCuratorFee(_delegationInitialState.curatorFee); - delegation.setOperatorFee(_delegationInitialState.operatorFee); + delegation.setCuratorFeeBP(_delegationInitialState.curatorFeeBP); + delegation.setNodeOperatorFeeBP(_delegationInitialState.nodeOperatorFeeBP); // revoke temporary roles from factory delegation.revokeRole(delegation.CURATOR_ROLE(), address(this)); - delegation.revokeRole(delegation.OPERATOR_ROLE(), address(this)); + delegation.revokeRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), address(this)); delegation.revokeRole(delegation.DEFAULT_ADMIN_ROLE(), address(this)); emit VaultCreated(address(delegation), address(vault)); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index b8e6af96d..8ce4527fd 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -5,13 +5,14 @@ pragma solidity 0.8.25; import {IBeacon} from "@openzeppelin/contracts-v5.0.2/proxy/beacon/IBeacon.sol"; -import {AccessControlEnumerableUpgradeable} from "contracts/openzeppelin/5.0.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; import {OwnableUpgradeable} from "contracts/openzeppelin/5.0.2/upgradeable/access/OwnableUpgradeable.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; import {ILido as IStETH} from "../interfaces/ILido.sol"; import {IBeaconProxy} from "./interfaces/IBeaconProxy.sol"; +import {PausableUntilWithRoles} from "../utils/PausableUntilWithRoles.sol"; + import {Math256} from "contracts/common/lib/Math256.sol"; /// @notice VaultHub is a contract that manages vaults connected to the Lido protocol @@ -19,7 +20,7 @@ import {Math256} from "contracts/common/lib/Math256.sol"; /// It also allows to force rebalance of the vaults /// Also, it passes the report from the accounting oracle to the vaults and charges fees /// @author folkyatina -abstract contract VaultHub is AccessControlEnumerableUpgradeable { +abstract contract VaultHub is PausableUntilWithRoles { /// @custom:storage-location erc7201:VaultHub struct VaultHubStorage { /// @notice vault sockets with vaults connected to the hub @@ -217,7 +218,7 @@ abstract contract VaultHub is AccessControlEnumerableUpgradeable { /// @param _vault vault address /// @dev msg.sender should be vault's owner /// @dev vault's `mintedShares` should be zero - function voluntaryDisconnect(address _vault) external { + function voluntaryDisconnect(address _vault) external whenResumed { if (_vault == address(0)) revert ZeroArgument("_vault"); _vaultAuth(_vault, "disconnect"); @@ -229,7 +230,7 @@ abstract contract VaultHub is AccessControlEnumerableUpgradeable { /// @param _recipient address of the receiver /// @param _amountOfShares amount of stETH shares to mint /// @dev msg.sender should be vault's owner - function mintSharesBackedByVault(address _vault, address _recipient, uint256 _amountOfShares) external { + function mintSharesBackedByVault(address _vault, address _recipient, uint256 _amountOfShares) external whenResumed { if (_vault == address(0)) revert ZeroArgument("_vault"); if (_recipient == address(0)) revert ZeroArgument("_recipient"); if (_amountOfShares == 0) revert ZeroArgument("_amountOfShares"); @@ -268,7 +269,7 @@ abstract contract VaultHub is AccessControlEnumerableUpgradeable { /// @param _amountOfShares amount of shares to burn /// @dev msg.sender should be vault's owner /// @dev VaultHub must have all the stETH on its balance - function burnSharesBackedByVault(address _vault, uint256 _amountOfShares) public { + function burnSharesBackedByVault(address _vault, uint256 _amountOfShares) public whenResumed { if (_vault == address(0)) revert ZeroArgument("_vault"); if (_amountOfShares == 0) revert ZeroArgument("_amountOfShares"); _vaultAuth(_vault, "burn"); @@ -334,7 +335,7 @@ abstract contract VaultHub is AccessControlEnumerableUpgradeable { /// @notice rebalances the vault by writing off the amount of ether equal /// to `msg.value` from the vault's minted stETH /// @dev msg.sender should be vault's contract - function rebalance() external payable { + function rebalance() external payable whenResumed { if (msg.value == 0) revert ZeroArgument("msg.value"); VaultSocket storage socket = _connectedSocket(msg.sender); diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 54d597073..51ebe61c5 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -23,7 +23,7 @@ interface IStakingVault { function initialize(address _owner, address _operator, bytes calldata _params) external; function getInitializedVersion() external view returns (uint64); function vaultHub() external view returns (address); - function operator() external view returns (address); + function nodeOperator() external view returns (address); function locked() external view returns (uint256); function valuation() external view returns (uint256); function isBalanced() external view returns (bool); diff --git a/contracts/common/lib/UnstructuredStorage.sol b/contracts/common/lib/UnstructuredStorage.sol new file mode 100644 index 000000000..04d9cbb6f --- /dev/null +++ b/contracts/common/lib/UnstructuredStorage.sol @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 Lido , Aragon +// SPDX-License-Identifier: MIT + +// solhint-disable-next-line lido/fixed-compiler-version +pragma solidity ^0.8.9; + +library UnstructuredStorage { + function getStorageBool(bytes32 position) internal view returns (bool data) { + assembly { data := sload(position) } + } + + function getStorageAddress(bytes32 position) internal view returns (address data) { + assembly { data := sload(position) } + } + + function getStorageBytes32(bytes32 position) internal view returns (bytes32 data) { + assembly { data := sload(position) } + } + + function getStorageUint256(bytes32 position) internal view returns (uint256 data) { + assembly { data := sload(position) } + } + + function setStorageBool(bytes32 position, bool data) internal { + assembly { sstore(position, data) } + } + + function setStorageAddress(bytes32 position, address data) internal { + assembly { sstore(position, data) } + } + + function setStorageBytes32(bytes32 position, bytes32 data) internal { + assembly { sstore(position, data) } + } + + function setStorageUint256(bytes32 position, uint256 data) internal { + assembly { sstore(position, data) } + } +} diff --git a/contracts/common/utils/PausableUntil.sol b/contracts/common/utils/PausableUntil.sol new file mode 100644 index 000000000..4ef0988a7 --- /dev/null +++ b/contracts/common/utils/PausableUntil.sol @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 +// solhint-disable-next-line lido/fixed-compiler-version +pragma solidity ^0.8.9; + +import {UnstructuredStorage} from "contracts/common/lib/UnstructuredStorage.sol"; + +/** + * @title PausableUntil + * @notice allows to pause the contract for a specific duration or indefinitely + */ +abstract contract PausableUntil { + using UnstructuredStorage for bytes32; + + /// Contract resume/pause control storage slot + bytes32 internal constant RESUME_SINCE_TIMESTAMP_POSITION = keccak256("lido.PausableUntil.resumeSinceTimestamp"); + /// Special value for the infinite pause + uint256 public constant PAUSE_INFINITELY = type(uint256).max; + + /// @notice Emitted when paused by the `pauseFor` or `pauseUntil` call + event Paused(uint256 duration); + /// @notice Emitted when resumed by the `resume` call + event Resumed(); + + error ZeroPauseDuration(); + error PausedExpected(); + error ResumedExpected(); + error PauseUntilMustBeInFuture(); + + /// @notice Reverts if paused + modifier whenResumed() { + _checkResumed(); + _; + } + + /// @notice Returns whether the contract is paused + function isPaused() public view returns (bool) { + return block.timestamp < RESUME_SINCE_TIMESTAMP_POSITION.getStorageUint256(); + } + + /// @notice Returns one of: + /// - PAUSE_INFINITELY if paused infinitely returns + /// - the timestamp when the contract get resumed if paused for specific duration + /// - some timestamp in past if not paused + function getResumeSinceTimestamp() external view returns (uint256) { + return RESUME_SINCE_TIMESTAMP_POSITION.getStorageUint256(); + } + + function _checkPaused() internal view { + if (!isPaused()) { + revert PausedExpected(); + } + } + + function _checkResumed() internal view { + if (isPaused()) { + revert ResumedExpected(); + } + } + + function _resume() internal { + _checkPaused(); + RESUME_SINCE_TIMESTAMP_POSITION.setStorageUint256(block.timestamp); + emit Resumed(); + } + + function _pauseFor(uint256 _duration) internal { + _checkResumed(); + if (_duration == 0) revert ZeroPauseDuration(); + + uint256 resumeSince; + if (_duration == PAUSE_INFINITELY) { + resumeSince = PAUSE_INFINITELY; + } else { + resumeSince = block.timestamp + _duration; + } + _setPausedState(resumeSince); + } + + function _pauseUntil(uint256 _pauseUntilInclusive) internal { + _checkResumed(); + if (_pauseUntilInclusive < block.timestamp) revert PauseUntilMustBeInFuture(); + + uint256 resumeSince; + if (_pauseUntilInclusive != PAUSE_INFINITELY) { + resumeSince = _pauseUntilInclusive + 1; + } else { + resumeSince = PAUSE_INFINITELY; + } + _setPausedState(resumeSince); + } + + function _setPausedState(uint256 _resumeSince) internal { + RESUME_SINCE_TIMESTAMP_POSITION.setStorageUint256(_resumeSince); + if (_resumeSince == PAUSE_INFINITELY) { + emit Paused(PAUSE_INFINITELY); + } else { + emit Paused(_resumeSince - block.timestamp); + } + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index a8a1af019..460b268f5 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; +import * as process from "node:process"; import "@nomicfoundation/hardhat-chai-matchers"; import "@nomicfoundation/hardhat-toolbox"; @@ -13,46 +12,20 @@ import "hardhat-watcher"; import "hardhat-ignore-warnings"; import "hardhat-contract-sizer"; import "hardhat-gas-reporter"; -import { globSync } from "glob"; -import { TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS } from "hardhat/builtin-tasks/task-names"; -import { HardhatUserConfig, subtask } from "hardhat/config"; +import { HardhatUserConfig } from "hardhat/config"; import { mochaRootHooks } from "test/hooks"; import "./tasks"; -const RPC_URL: string = process.env.RPC_URL || ""; -const ACCOUNTS_PATH = "./accounts.json"; - -const HARDHAT_FORKING_URL = process.env.HARDHAT_FORKING_URL || ""; - -const INTEGRATION_WITH_SCRATCH_DEPLOY = process.env.INTEGRATION_WITH_SCRATCH_DEPLOY || "off"; - -/* Determines the forking configuration for Hardhat */ -function getHardhatForkingConfig() { - if (INTEGRATION_WITH_SCRATCH_DEPLOY === "on" || !HARDHAT_FORKING_URL) { - return undefined; - } - return { url: HARDHAT_FORKING_URL }; -} +import { getHardhatForkingConfig, loadAccounts } from "./hardhat.helpers"; -function loadAccounts(networkName: string) { - // TODO: this plaintext accounts.json private keys management is a subject - // of rework to a solution with the keys stored encrypted - if (!existsSync(ACCOUNTS_PATH)) { - return []; - } - const content = JSON.parse(readFileSync(ACCOUNTS_PATH, "utf-8")); - if (!content.eth) { - return []; - } - return content.eth[networkName] || []; -} +const RPC_URL: string = process.env.RPC_URL || ""; const config: HardhatUserConfig = { defaultNetwork: "hardhat", gasReporter: { - enabled: true, + enabled: process.env.SKIP_GAS_REPORT ? false : true, }, networks: { "hardhat": { @@ -73,18 +46,6 @@ const config: HardhatUserConfig = { "local": { url: process.env.LOCAL_RPC_URL || RPC_URL, }, - "holesky-vaults-devnet-0": { - url: process.env.LOCAL_RPC_URL || RPC_URL, - timeout: 20 * 60 * 1000, // 20 minutes - }, - "mekong-vaults-devnet-1": { - url: process.env.LOCAL_RPC_URL || RPC_URL, - timeout: 20 * 60 * 1000, // 20 minutes - }, - "mainnet-fork": { - url: process.env.MAINNET_RPC_URL || RPC_URL, - timeout: 20 * 60 * 1000, // 20 minutes - }, "holesky": { url: process.env.HOLESKY_RPC_URL || RPC_URL, chainId: 17000, @@ -99,28 +60,13 @@ const config: HardhatUserConfig = { url: process.env.SEPOLIA_RPC_URL || RPC_URL, chainId: 11155111, }, - "mekong": { - url: process.env.MEKONG_RPC_URL || RPC_URL, - chainId: 7078815900, - accounts: loadAccounts("mekong"), + "mainnet-fork": { + url: process.env.MAINNET_RPC_URL || RPC_URL, + timeout: 20 * 60 * 1000, // 20 minutes }, }, etherscan: { - apiKey: { - default: process.env.ETHERSCAN_API_KEY || "", - holesky: process.env.ETHERSCAN_API_KEY || "", - mekong: process.env.BLOCKSCOUT_API_KEY || "", - }, - customChains: [ - { - network: "mekong", - chainId: 7078815900, - urls: { - apiURL: "https://explorer.mekong.ethpandaops.io/api", - browserURL: "https://explorer.mekong.ethpandaops.io", - }, - }, - ], + apiKey: process.env.ETHERSCAN_API_KEY || "", }, solidity: { compilers: [ @@ -198,7 +144,10 @@ const config: HardhatUserConfig = { }, watcher: { test: { - tasks: [{ command: "test", params: { testFiles: ["{path}"] } }], + tasks: [ + { command: "compile", params: { quiet: true } }, + { command: "test", params: { noCompile: true, testFiles: ["{path}"] } }, + ], files: ["./test/**/*"], clearOnStart: true, start: "echo Running tests...", @@ -225,22 +174,10 @@ const config: HardhatUserConfig = { contractSizer: { alphaSort: false, disambiguatePaths: false, - runOnCompile: true, + runOnCompile: process.env.SKIP_CONTRACT_SIZE ? false : true, strict: true, except: ["template", "mocks", "@aragon", "openzeppelin", "test"], }, }; -// a workaround for having an additional source directory for compilation -// see, https://github.com/NomicFoundation/hardhat/issues/776#issuecomment-1713584386 -subtask(TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS).setAction(async (_, hre, runSuper) => { - const paths = await runSuper(); - - const otherDirectoryGlob = path.join(hre.config.paths.root, "test", "**", "*.sol"); - // Don't need to compile test, helper and script files that are not part of the contracts for Hardhat. - const otherPaths = globSync(otherDirectoryGlob).filter((x) => !/\.([ths]\.sol)$/.test(x)); - - return [...paths, ...otherPaths]; -}); - export default config; diff --git a/hardhat.helpers.ts b/hardhat.helpers.ts new file mode 100644 index 000000000..47f6533b8 --- /dev/null +++ b/hardhat.helpers.ts @@ -0,0 +1,32 @@ +import { existsSync, readFileSync } from "node:fs"; + +/* Determines the forking configuration for Hardhat */ +export function getHardhatForkingConfig() { + const forkingUrl = process.env.HARDHAT_FORKING_URL || ""; + + if (!forkingUrl) { + // Scratch deploy, need to disable CSM + process.env.INTEGRATION_ON_SCRATCH = "on"; + process.env.INTEGRATION_WITH_CSM = "off"; + return undefined; + } + + return { url: forkingUrl }; +} + +// TODO: this plaintext accounts.json private keys management is a subject +// of rework to a solution with the keys stored encrypted +export function loadAccounts(networkName: string) { + const accountsPath = "./accounts.json"; + + if (!existsSync(accountsPath)) { + return []; + } + + const content = JSON.parse(readFileSync(accountsPath, "utf-8")); + if (!content.eth) { + return []; + } + + return content.eth[networkName] || []; +} diff --git a/lib/proxy.ts b/lib/proxy.ts index 582a8312a..c86dacdc7 100644 --- a/lib/proxy.ts +++ b/lib/proxy.ts @@ -54,13 +54,13 @@ export async function createVaultProxy( ): Promise { // Define the parameters for the struct const initializationParams: DelegationInitializationParamsStruct = { - curatorFee: 100n, - operatorFee: 200n, + curatorFeeBP: 100n, + nodeOperatorFeeBP: 200n, curator: await _owner.getAddress(), - staker: await _owner.getAddress(), - tokenMaster: await _owner.getAddress(), - operator: await _operator.getAddress(), - claimOperatorDueRole: await _owner.getAddress(), + funderWithdrawer: await _owner.getAddress(), + minterBurner: await _owner.getAddress(), + nodeOperatorManager: await _operator.getAddress(), + nodeOperatorFeeClaimer: await _owner.getAddress(), }; const tx = await vaultFactory.connect(_owner).createVault(initializationParams, "0x"); diff --git a/package.json b/package.json index a8711c17c..8f65a95cd 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "engines": { "node": ">=22" }, - "packageManager": "yarn@4.5.3", + "packageManager": "yarn@4.6.0", "scripts": { "compile": "hardhat compile", "cleanup": "hardhat clean", @@ -22,7 +22,7 @@ "test:sequential": "hardhat test test/**/*.test.ts", "test:trace": "hardhat test test/**/*.test.ts --trace --disabletracer", "test:fulltrace": "hardhat test test/**/*.test.ts --fulltrace --disabletracer", - "test:watch": "hardhat watch test", + "test:watch": "SKIP_GAS_REPORT=true SKIP_CONTRACT_SIZE=true hardhat watch test", "test:integration": "hardhat test test/integration/**/*.ts", "test:integration:trace": "hardhat test test/integration/**/*.ts --trace --disabletracer", "test:integration:fulltrace": "hardhat test test/integration/**/*.ts --fulltrace --disabletracer", @@ -31,6 +31,7 @@ "test:integration:scratch:fulltrace": "INTEGRATION_WITH_CSM=off INTEGRATION_WITH_SCRATCH_DEPLOY=on hardhat test test/integration/**/*.ts --fulltrace --disabletracer", "test:integration:fork:local": "hardhat test test/integration/**/*.ts --network local", "test:integration:fork:mainnet": "hardhat test test/integration/**/*.ts --network mainnet-fork", + "test:integration:fork:mainnet:custom": "hardhat test --network mainnet-fork", "typecheck": "tsc --noEmit", "prepare": "husky", "abis:extract": "hardhat abis:extract", @@ -90,7 +91,7 @@ "lint-staged": "15.2.10", "prettier": "3.4.1", "prettier-plugin-solidity": "1.4.1", - "solhint": "5.0.3", + "solhint": "5.0.4", "solhint-plugin-lido": "0.0.4", "solidity-coverage": "0.8.14", "ts-node": "10.9.2", diff --git a/tasks/index.ts b/tasks/index.ts index ada28baca..04b17d7c9 100644 --- a/tasks/index.ts +++ b/tasks/index.ts @@ -1,2 +1,3 @@ export * from "./verify-contracts"; export * from "./extract-abis"; +export * from "./solidity-get-source"; diff --git a/tasks/solidity-get-source.ts b/tasks/solidity-get-source.ts new file mode 100644 index 000000000..522a4af59 --- /dev/null +++ b/tasks/solidity-get-source.ts @@ -0,0 +1,18 @@ +import path from "node:path"; + +import { globSync } from "glob"; +import { TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS } from "hardhat/builtin-tasks/task-names"; +import { subtask } from "hardhat/config"; + +// a workaround for having an additional source directory for compilation +// see, https://github.com/NomicFoundation/hardhat/issues/776#issuecomment-1713584386 + +subtask(TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS).setAction(async (_, hre, runSuper) => { + const paths = await runSuper(); + + const otherDirectoryGlob = path.join(hre.config.paths.root, "test", "**", "*.sol"); + // Don't need to compile test, helper and script files that are not part of the contracts for Hardhat. + const otherPaths = globSync(otherDirectoryGlob).filter((x) => !/\.([ths]\.sol)$/.test(x)); + + return [...paths, ...otherPaths]; +}); diff --git a/test/0.4.24/lido/lido.externalShares.test.ts b/test/0.4.24/lido/lido.externalShares.test.ts index fc217f97a..735e4bdd5 100644 --- a/test/0.4.24/lido/lido.externalShares.test.ts +++ b/test/0.4.24/lido/lido.externalShares.test.ts @@ -46,12 +46,12 @@ describe("Lido.sol:externalShares", () => { accountingSigner = await impersonate(await locator.accounting(), ether("1")); // Add some ether to the protocol - await lido.connect(whale).submit(ZeroAddress, { value: 1000n }); + await lido.connect(whale).submit(ZeroAddress, { value: ether("1000") }); // Burn some shares to make share rate fractional const burner = await impersonate(await locator.burner(), ether("1")); - await lido.connect(whale).transfer(burner, 500n); - await lido.connect(burner).burnShares(500n); + await lido.connect(whale).transfer(burner, ether("500")); + await lido.connect(burner).burnShares(ether("500")); }); beforeEach(async () => (originalState = await Snapshot.take())); @@ -199,16 +199,16 @@ describe("Lido.sol:externalShares", () => { // Increase the external ether limit to 10% await lido.setMaxExternalRatioBP(maxExternalRatioBP); - const amountToMint = await lido.getMaxMintableExternalShares(); - const etherToMint = await lido.getPooledEthByShares(amountToMint); + const sharesToMint = 1n; + const etherToMint = await lido.getPooledEthByShares(sharesToMint); - await expect(lido.connect(accountingSigner).mintExternalShares(whale, amountToMint)) + await expect(lido.connect(accountingSigner).mintExternalShares(whale, sharesToMint)) .to.emit(lido, "Transfer") .withArgs(ZeroAddress, whale, etherToMint) .to.emit(lido, "TransferShares") - .withArgs(ZeroAddress, whale, amountToMint) + .withArgs(ZeroAddress, whale, sharesToMint) .to.emit(lido, "ExternalSharesMinted") - .withArgs(whale, amountToMint, etherToMint); + .withArgs(whale, sharesToMint, etherToMint); // Verify external balance was increased const externalEther = await lido.getExternalEther(); @@ -280,11 +280,11 @@ describe("Lido.sol:externalShares", () => { // Burn partial amount await lido.connect(accountingSigner).burnExternalShares(150n); - expect(await lido.getExternalEther()).to.equal(150n); + expect(await lido.getExternalShares()).to.equal(150n); // Burn remaining await lido.connect(accountingSigner).burnExternalShares(150n); - expect(await lido.getExternalEther()).to.equal(0n); + expect(await lido.getExternalShares()).to.equal(0n); }); }); diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 140bca169..c1b09e278 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -27,7 +27,7 @@ import { Snapshot } from "test/suite"; describe("Dashboard", () => { let factoryOwner: HardhatEthersSigner; let vaultOwner: HardhatEthersSigner; - let operator: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; let stranger: HardhatEthersSigner; let steth: StETHPermit__HarnessForDashboard; @@ -50,7 +50,7 @@ describe("Dashboard", () => { const BP_BASE = 10_000n; before(async () => { - [factoryOwner, vaultOwner, operator, stranger] = await ethers.getSigners(); + [factoryOwner, vaultOwner, nodeOperator, stranger] = await ethers.getSigners(); steth = await ethers.deployContract("StETHPermit__HarnessForDashboard"); await steth.mock__setTotalShares(ether("1000000")); @@ -76,7 +76,7 @@ describe("Dashboard", () => { expect(await factory.implementation()).to.equal(vaultImpl); expect(await factory.dashboardImpl()).to.equal(dashboardImpl); - const createVaultTx = await factory.connect(vaultOwner).createVault(operator); + const createVaultTx = await factory.connect(vaultOwner).createVault(nodeOperator); const createVaultReceipt = await createVaultTx.wait(); if (!createVaultReceipt) throw new Error("Vault creation receipt not found"); @@ -145,8 +145,7 @@ describe("Dashboard", () => { it("post-initialization state is correct", async () => { // vault state expect(await vault.owner()).to.equal(dashboard); - expect(await vault.operator()).to.equal(operator); - // dashboard state + expect(await vault.nodeOperator()).to.equal(nodeOperator); expect(await dashboard.isInitialized()).to.equal(true); // dashboard contracts expect(await dashboard.stakingVault()).to.equal(vault); diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 4ee5d63b4..7b3750ebc 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -27,10 +27,10 @@ const MAX_FEE = BP_BASE; describe("Delegation.sol", () => { let vaultOwner: HardhatEthersSigner; let curator: HardhatEthersSigner; - let staker: HardhatEthersSigner; - let tokenMaster: HardhatEthersSigner; - let operator: HardhatEthersSigner; - let claimOperatorDueRole: HardhatEthersSigner; + let funderWithdrawer: HardhatEthersSigner; + let minterBurner: HardhatEthersSigner; + let nodeOperatorManager: HardhatEthersSigner; + let nodeOperatorFeeClaimer: HardhatEthersSigner; let stranger: HardhatEthersSigner; let factoryOwner: HardhatEthersSigner; let hubSigner: HardhatEthersSigner; @@ -52,8 +52,17 @@ describe("Delegation.sol", () => { let originalState: string; before(async () => { - [vaultOwner, curator, staker, tokenMaster, operator, claimOperatorDueRole, stranger, factoryOwner, rewarder] = - await ethers.getSigners(); + [ + vaultOwner, + curator, + funderWithdrawer, + minterBurner, + nodeOperatorManager, + nodeOperatorFeeClaimer, + stranger, + factoryOwner, + rewarder, + ] = await ethers.getSigners(); steth = await ethers.deployContract("StETH__MockForDelegation"); weth = await ethers.deployContract("WETH9__MockForVault"); @@ -78,12 +87,18 @@ describe("Delegation.sol", () => { expect(await factory.implementation()).to.equal(vaultImpl); expect(await factory.delegationImpl()).to.equal(delegationImpl); - const vaultCreationTx = await factory - .connect(vaultOwner) - .createVault( - { curator, staker, tokenMaster, operator, claimOperatorDueRole, curatorFee: 0n, operatorFee: 0n }, - "0x", - ); + const vaultCreationTx = await factory.connect(vaultOwner).createVault( + { + curator, + funderWithdrawer, + minterBurner, + nodeOperatorManager, + nodeOperatorFeeClaimer, + curatorFeeBP: 0n, + nodeOperatorFeeBP: 0n, + }, + "0x", + ); const vaultCreationReceipt = await vaultCreationTx.wait(); if (!vaultCreationReceipt) throw new Error("Vault creation receipt not found"); @@ -157,7 +172,7 @@ describe("Delegation.sol", () => { context("initialized state", () => { it("initializes the contract correctly", async () => { expect(await vault.owner()).to.equal(delegation); - expect(await vault.operator()).to.equal(operator); + expect(await vault.nodeOperator()).to.equal(nodeOperatorManager); expect(await delegation.stakingVault()).to.equal(vault); expect(await delegation.vaultHub()).to.equal(hub); @@ -166,21 +181,22 @@ describe("Delegation.sol", () => { expect(await delegation.getRoleMemberCount(await delegation.DEFAULT_ADMIN_ROLE())).to.equal(1); expect(await delegation.hasRole(await delegation.CURATOR_ROLE(), curator)).to.be.true; expect(await delegation.getRoleMemberCount(await delegation.CURATOR_ROLE())).to.equal(1); - expect(await delegation.hasRole(await delegation.STAKER_ROLE(), staker)).to.be.true; - expect(await delegation.getRoleMemberCount(await delegation.STAKER_ROLE())).to.equal(1); - expect(await delegation.hasRole(await delegation.TOKEN_MASTER_ROLE(), tokenMaster)).to.be.true; - expect(await delegation.getRoleMemberCount(await delegation.TOKEN_MASTER_ROLE())).to.equal(1); - expect(await delegation.hasRole(await delegation.OPERATOR_ROLE(), operator)).to.be.true; - expect(await delegation.getRoleMemberCount(await delegation.OPERATOR_ROLE())).to.equal(1); - expect(await delegation.hasRole(await delegation.CLAIM_OPERATOR_DUE_ROLE(), claimOperatorDueRole)).to.be.true; - expect(await delegation.getRoleMemberCount(await delegation.CLAIM_OPERATOR_DUE_ROLE())).to.equal(1); - - expect(await delegation.curatorFee()).to.equal(0n); - expect(await delegation.operatorFee()).to.equal(0n); - expect(await delegation.curatorDue()).to.equal(0n); - expect(await delegation.operatorDue()).to.equal(0n); - expect(await delegation.curatorDueClaimedReport()).to.deep.equal([0n, 0n]); - expect(await delegation.operatorDueClaimedReport()).to.deep.equal([0n, 0n]); + expect(await delegation.hasRole(await delegation.FUND_WITHDRAW_ROLE(), funderWithdrawer)).to.be.true; + expect(await delegation.getRoleMemberCount(await delegation.FUND_WITHDRAW_ROLE())).to.equal(1); + expect(await delegation.hasRole(await delegation.MINT_BURN_ROLE(), minterBurner)).to.be.true; + expect(await delegation.getRoleMemberCount(await delegation.MINT_BURN_ROLE())).to.equal(1); + expect(await delegation.hasRole(await delegation.NODE_OPERATOR_MANAGER_ROLE(), nodeOperatorManager)).to.be.true; + expect(await delegation.getRoleMemberCount(await delegation.NODE_OPERATOR_MANAGER_ROLE())).to.equal(1); + expect(await delegation.hasRole(await delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE(), nodeOperatorFeeClaimer)).to.be + .true; + expect(await delegation.getRoleMemberCount(await delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE())).to.equal(1); + + expect(await delegation.curatorFeeBP()).to.equal(0n); + expect(await delegation.nodeOperatorFeeBP()).to.equal(0n); + expect(await delegation.curatorUnclaimedFee()).to.equal(0n); + expect(await delegation.nodeOperatorUnclaimedFee()).to.equal(0n); + expect(await delegation.curatorFeeClaimedReport()).to.deep.equal([0n, 0n]); + expect(await delegation.nodeOperatorFeeClaimedReport()).to.deep.equal([0n, 0n]); }); }); @@ -188,7 +204,7 @@ describe("Delegation.sol", () => { it("returns the correct roles", async () => { expect(await delegation.votingCommittee()).to.deep.equal([ await delegation.CURATOR_ROLE(), - await delegation.OPERATOR_ROLE(), + await delegation.NODE_OPERATOR_MANAGER_ROLE(), ]); }); }); @@ -212,55 +228,54 @@ describe("Delegation.sol", () => { .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData); voteTimestamp = await getNextBlockTimestamp(); - await expect(delegation.connect(operator).setVoteLifetime(newVoteLifetime)) + await expect(delegation.connect(nodeOperatorManager).setVoteLifetime(newVoteLifetime)) .to.emit(delegation, "RoleMemberVoted") - .withArgs(operator, await delegation.OPERATOR_ROLE(), voteTimestamp, msgData) + .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), voteTimestamp, msgData) .and.to.emit(delegation, "VoteLifetimeSet") - .withArgs(operator, oldVoteLifetime, newVoteLifetime); + .withArgs(nodeOperatorManager, oldVoteLifetime, newVoteLifetime); expect(await delegation.voteLifetime()).to.equal(newVoteLifetime); }); }); - context("claimCuratorDue", () => { + context("claimCuratorFee", () => { it("reverts if the caller is not a member of the curator due claim role", async () => { - await expect(delegation.connect(stranger).claimCuratorDue(stranger)) + await expect(delegation.connect(stranger).claimCuratorFee(stranger)) .to.be.revertedWithCustomError(delegation, "AccessControlUnauthorizedAccount") .withArgs(stranger, await delegation.CURATOR_ROLE()); }); it("reverts if the recipient is the zero address", async () => { - await expect(delegation.connect(curator).claimCuratorDue(ethers.ZeroAddress)) + await expect(delegation.connect(curator).claimCuratorFee(ethers.ZeroAddress)) .to.be.revertedWithCustomError(delegation, "ZeroArgument") .withArgs("_recipient"); }); it("reverts if the due is zero", async () => { - expect(await delegation.curatorDue()).to.equal(0n); - await expect(delegation.connect(curator).claimCuratorDue(stranger)).to.be.revertedWithCustomError( - delegation, - "NoDueToClaim", - ); + expect(await delegation.curatorUnclaimedFee()).to.equal(0n); + await expect(delegation.connect(curator).claimCuratorFee(stranger)) + .to.be.revertedWithCustomError(delegation, "ZeroArgument") + .withArgs("_fee"); }); it("claims the due", async () => { const curatorFee = 10_00n; // 10% - await delegation.connect(curator).setCuratorFee(curatorFee); - expect(await delegation.curatorFee()).to.equal(curatorFee); + await delegation.connect(vaultOwner).setCuratorFeeBP(curatorFee); + expect(await delegation.curatorFeeBP()).to.equal(curatorFee); const rewards = ether("1"); await vault.connect(hubSigner).report(rewards, 0n, 0n); const expectedDue = (rewards * curatorFee) / BP_BASE; - expect(await delegation.curatorDue()).to.equal(expectedDue); - expect(await delegation.curatorDue()).to.be.greaterThan(await ethers.provider.getBalance(vault)); + expect(await delegation.curatorUnclaimedFee()).to.equal(expectedDue); + expect(await delegation.curatorUnclaimedFee()).to.be.greaterThan(await ethers.provider.getBalance(vault)); expect(await ethers.provider.getBalance(vault)).to.equal(0n); await rewarder.sendTransaction({ to: vault, value: rewards }); expect(await ethers.provider.getBalance(vault)).to.equal(rewards); expect(await ethers.provider.getBalance(recipient)).to.equal(0n); - await expect(delegation.connect(curator).claimCuratorDue(recipient)) + await expect(delegation.connect(curator).claimCuratorFee(recipient)) .to.emit(vault, "Withdrawn") .withArgs(delegation, recipient, expectedDue); expect(await ethers.provider.getBalance(recipient)).to.equal(expectedDue); @@ -268,47 +283,46 @@ describe("Delegation.sol", () => { }); }); - context("claimOperatorDue", () => { + context("claimNodeOperatorFee", () => { it("reverts if the caller does not have the operator due claim role", async () => { - await expect(delegation.connect(stranger).claimOperatorDue(stranger)).to.be.revertedWithCustomError( + await expect(delegation.connect(stranger).claimNodeOperatorFee(stranger)).to.be.revertedWithCustomError( delegation, "AccessControlUnauthorizedAccount", ); }); it("reverts if the recipient is the zero address", async () => { - await expect(delegation.connect(claimOperatorDueRole).claimOperatorDue(ethers.ZeroAddress)) + await expect(delegation.connect(nodeOperatorFeeClaimer).claimNodeOperatorFee(ethers.ZeroAddress)) .to.be.revertedWithCustomError(delegation, "ZeroArgument") .withArgs("_recipient"); }); it("reverts if the due is zero", async () => { - expect(await delegation.operatorDue()).to.equal(0n); - await expect(delegation.connect(claimOperatorDueRole).claimOperatorDue(recipient)).to.be.revertedWithCustomError( - delegation, - "NoDueToClaim", - ); + expect(await delegation.nodeOperatorUnclaimedFee()).to.equal(0n); + await expect(delegation.connect(nodeOperatorFeeClaimer).claimNodeOperatorFee(recipient)) + .to.be.revertedWithCustomError(delegation, "ZeroArgument") + .withArgs("_fee"); }); it("claims the due", async () => { const operatorFee = 10_00n; // 10% - await delegation.connect(operator).setOperatorFee(operatorFee); - await delegation.connect(curator).setOperatorFee(operatorFee); - expect(await delegation.operatorFee()).to.equal(operatorFee); + await delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(operatorFee); + await delegation.connect(curator).setNodeOperatorFeeBP(operatorFee); + expect(await delegation.nodeOperatorFeeBP()).to.equal(operatorFee); const rewards = ether("1"); await vault.connect(hubSigner).report(rewards, 0n, 0n); const expectedDue = (rewards * operatorFee) / BP_BASE; - expect(await delegation.operatorDue()).to.equal(expectedDue); - expect(await delegation.operatorDue()).to.be.greaterThan(await ethers.provider.getBalance(vault)); + expect(await delegation.nodeOperatorUnclaimedFee()).to.equal(expectedDue); + expect(await delegation.nodeOperatorUnclaimedFee()).to.be.greaterThan(await ethers.provider.getBalance(vault)); expect(await ethers.provider.getBalance(vault)).to.equal(0n); await rewarder.sendTransaction({ to: vault, value: rewards }); expect(await ethers.provider.getBalance(vault)).to.equal(rewards); expect(await ethers.provider.getBalance(recipient)).to.equal(0n); - await expect(delegation.connect(claimOperatorDueRole).claimOperatorDue(recipient)) + await expect(delegation.connect(nodeOperatorFeeClaimer).claimNodeOperatorFee(recipient)) .to.emit(vault, "Withdrawn") .withArgs(delegation, recipient, expectedDue); expect(await ethers.provider.getBalance(recipient)).to.equal(expectedDue); @@ -345,7 +359,7 @@ describe("Delegation.sol", () => { expect(await vault.inOutDelta()).to.equal(0n); expect(await vault.valuation()).to.equal(0n); - await expect(delegation.connect(staker).fund({ value: amount })) + await expect(delegation.connect(funderWithdrawer).fund({ value: amount })) .to.emit(vault, "Funded") .withArgs(delegation, amount); @@ -364,14 +378,13 @@ describe("Delegation.sol", () => { }); it("reverts if the recipient is the zero address", async () => { - await expect(delegation.connect(staker).withdraw(ethers.ZeroAddress, ether("1"))).to.be.revertedWithCustomError( - delegation, - "ZeroArgument", - ); + await expect( + delegation.connect(funderWithdrawer).withdraw(ethers.ZeroAddress, ether("1")), + ).to.be.revertedWithCustomError(delegation, "ZeroArgument"); }); it("reverts if the amount is zero", async () => { - await expect(delegation.connect(staker).withdraw(recipient, 0n)).to.be.revertedWithCustomError( + await expect(delegation.connect(funderWithdrawer).withdraw(recipient, 0n)).to.be.revertedWithCustomError( delegation, "ZeroArgument", ); @@ -379,10 +392,9 @@ describe("Delegation.sol", () => { it("reverts if the amount is greater than the unreserved amount", async () => { const unreserved = await delegation.unreserved(); - await expect(delegation.connect(staker).withdraw(recipient, unreserved + 1n)).to.be.revertedWithCustomError( - delegation, - "RequestedAmountExceedsUnreserved", - ); + await expect( + delegation.connect(funderWithdrawer).withdraw(recipient, unreserved + 1n), + ).to.be.revertedWithCustomError(delegation, "RequestedAmountExceedsUnreserved"); }); it("withdraws the amount", async () => { @@ -396,7 +408,7 @@ describe("Delegation.sol", () => { expect(await ethers.provider.getBalance(vault)).to.equal(amount); expect(await ethers.provider.getBalance(recipient)).to.equal(0n); - await expect(delegation.connect(staker).withdraw(recipient, amount)) + await expect(delegation.connect(funderWithdrawer).withdraw(recipient, amount)) .to.emit(vault, "Withdrawn") .withArgs(delegation, recipient, amount); expect(await ethers.provider.getBalance(vault)).to.equal(0n); @@ -414,7 +426,7 @@ describe("Delegation.sol", () => { it("rebalances the vault by transferring ether", async () => { const amount = ether("1"); - await delegation.connect(staker).fund({ value: amount }); + await delegation.connect(funderWithdrawer).fund({ value: amount }); await expect(delegation.connect(curator).rebalanceVault(amount)) .to.emit(hub, "Mock__Rebalanced") @@ -441,7 +453,7 @@ describe("Delegation.sol", () => { it("mints the tokens", async () => { const amount = 100n; - await expect(delegation.connect(tokenMaster).mintShares(recipient, amount)) + await expect(delegation.connect(minterBurner).mintShares(recipient, amount)) .to.emit(steth, "Transfer") .withArgs(ethers.ZeroAddress, recipient, amount); }); @@ -457,25 +469,45 @@ describe("Delegation.sol", () => { it("burns the tokens", async () => { const amount = 100n; - await delegation.connect(tokenMaster).mintShares(tokenMaster, amount); + await delegation.connect(minterBurner).mintShares(minterBurner, amount); - await expect(delegation.connect(tokenMaster).burnShares(amount)) + await expect(delegation.connect(minterBurner).burnShares(amount)) .to.emit(steth, "Transfer") - .withArgs(tokenMaster, hub, amount) + .withArgs(minterBurner, hub, amount) .and.to.emit(steth, "Transfer") .withArgs(hub, ethers.ZeroAddress, amount); }); }); - context("setCuratorFee", () => { + context("setCuratorFeeBP", () => { it("reverts if caller is not curator", async () => { - await expect(delegation.connect(stranger).setCuratorFee(1000n)) + await expect(delegation.connect(stranger).setCuratorFeeBP(1000n)) .to.be.revertedWithCustomError(delegation, "AccessControlUnauthorizedAccount") - .withArgs(stranger, await delegation.CURATOR_ROLE()); + .withArgs(stranger, await delegation.DEFAULT_ADMIN_ROLE()); + }); + + it("reverts if curator fee is not zero", async () => { + // set the curator fee to 5% + const newCuratorFee = 500n; + await delegation.connect(vaultOwner).setCuratorFeeBP(newCuratorFee); + expect(await delegation.curatorFeeBP()).to.equal(newCuratorFee); + + // bring rewards + const totalRewards = ether("1"); + const inOutDelta = 0n; + const locked = 0n; + await vault.connect(hubSigner).report(totalRewards, inOutDelta, locked); + expect(await delegation.curatorUnclaimedFee()).to.equal((totalRewards * newCuratorFee) / BP_BASE); + + // attempt to change the performance fee to 6% + await expect(delegation.connect(vaultOwner).setCuratorFeeBP(600n)).to.be.revertedWithCustomError( + delegation, + "CuratorFeeUnclaimed", + ); }); it("reverts if new fee is greater than max fee", async () => { - await expect(delegation.connect(curator).setCuratorFee(MAX_FEE + 1n)).to.be.revertedWithCustomError( + await expect(delegation.connect(vaultOwner).setCuratorFeeBP(MAX_FEE + 1n)).to.be.revertedWithCustomError( delegation, "CombinedFeesExceed100Percent", ); @@ -483,66 +515,65 @@ describe("Delegation.sol", () => { it("sets the curator fee", async () => { const newCuratorFee = 1000n; - await delegation.connect(curator).setCuratorFee(newCuratorFee); - expect(await delegation.curatorFee()).to.equal(newCuratorFee); + await delegation.connect(vaultOwner).setCuratorFeeBP(newCuratorFee); + expect(await delegation.curatorFeeBP()).to.equal(newCuratorFee); }); }); context("setOperatorFee", () => { it("reverts if new fee is greater than max fee", async () => { const invalidFee = MAX_FEE + 1n; - await delegation.connect(curator).setOperatorFee(invalidFee); + await delegation.connect(curator).setNodeOperatorFeeBP(invalidFee); - await expect(delegation.connect(operator).setOperatorFee(invalidFee)).to.be.revertedWithCustomError( - delegation, - "CombinedFeesExceed100Percent", - ); + await expect( + delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(invalidFee), + ).to.be.revertedWithCustomError(delegation, "CombinedFeesExceed100Percent"); }); it("reverts if performance due is not zero", async () => { // set the performance fee to 5% const newOperatorFee = 500n; - await delegation.connect(curator).setOperatorFee(newOperatorFee); - await delegation.connect(operator).setOperatorFee(newOperatorFee); - expect(await delegation.operatorFee()).to.equal(newOperatorFee); + await delegation.connect(curator).setNodeOperatorFeeBP(newOperatorFee); + await delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee); + expect(await delegation.nodeOperatorFeeBP()).to.equal(newOperatorFee); // bring rewards const totalRewards = ether("1"); const inOutDelta = 0n; const locked = 0n; await vault.connect(hubSigner).report(totalRewards, inOutDelta, locked); - expect(await delegation.operatorDue()).to.equal((totalRewards * newOperatorFee) / BP_BASE); + expect(await delegation.nodeOperatorUnclaimedFee()).to.equal((totalRewards * newOperatorFee) / BP_BASE); // attempt to change the performance fee to 6% - await delegation.connect(curator).setOperatorFee(600n); - await expect(delegation.connect(operator).setOperatorFee(600n)).to.be.revertedWithCustomError( + await delegation.connect(curator).setNodeOperatorFeeBP(600n); + await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(600n)).to.be.revertedWithCustomError( delegation, - "OperatorDueUnclaimed", + "NodeOperatorFeeUnclaimed", ); }); it("requires both curator and operator to set the operator fee and emits the RoleMemberVoted event", async () => { - const previousOperatorFee = await delegation.operatorFee(); + const previousOperatorFee = await delegation.nodeOperatorFeeBP(); const newOperatorFee = 1000n; let voteTimestamp = await getNextBlockTimestamp(); - const msgData = delegation.interface.encodeFunctionData("setOperatorFee", [newOperatorFee]); + const msgData = delegation.interface.encodeFunctionData("setNodeOperatorFeeBP", [newOperatorFee]); - await expect(delegation.connect(curator).setOperatorFee(newOperatorFee)) + await expect(delegation.connect(curator).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberVoted") .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData); // fee is unchanged - expect(await delegation.operatorFee()).to.equal(previousOperatorFee); + expect(await delegation.nodeOperatorFeeBP()).to.equal(previousOperatorFee); // check vote expect(await delegation.votings(keccak256(msgData), await delegation.CURATOR_ROLE())).to.equal(voteTimestamp); voteTimestamp = await getNextBlockTimestamp(); - await expect(delegation.connect(operator).setOperatorFee(newOperatorFee)) + await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberVoted") - .withArgs(operator, await delegation.OPERATOR_ROLE(), voteTimestamp, msgData) - .and.to.emit(delegation, "OperatorFeeSet") - .withArgs(operator, previousOperatorFee, newOperatorFee); + .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), voteTimestamp, msgData) + .and.to.emit(delegation, "NodeOperatorFeeBPSet") + .withArgs(nodeOperatorManager, previousOperatorFee, newOperatorFee); - expect(await delegation.operatorFee()).to.equal(newOperatorFee); + expect(await delegation.nodeOperatorFeeBP()).to.equal(newOperatorFee); // resets the votes for (const role of await delegation.votingCommittee()) { @@ -552,23 +583,23 @@ describe("Delegation.sol", () => { it("reverts if the caller is not a member of the operator fee committee", async () => { const newOperatorFee = 1000n; - await expect(delegation.connect(stranger).setOperatorFee(newOperatorFee)).to.be.revertedWithCustomError( + await expect(delegation.connect(stranger).setNodeOperatorFeeBP(newOperatorFee)).to.be.revertedWithCustomError( delegation, "NotACommitteeMember", ); }); it("doesn't execute if an earlier vote has expired", async () => { - const previousOperatorFee = await delegation.operatorFee(); + const previousOperatorFee = await delegation.nodeOperatorFeeBP(); const newOperatorFee = 1000n; - const msgData = delegation.interface.encodeFunctionData("setOperatorFee", [newOperatorFee]); + const msgData = delegation.interface.encodeFunctionData("setNodeOperatorFeeBP", [newOperatorFee]); const callId = keccak256(msgData); let voteTimestamp = await getNextBlockTimestamp(); - await expect(delegation.connect(curator).setOperatorFee(newOperatorFee)) + await expect(delegation.connect(curator).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberVoted") .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData); // fee is unchanged - expect(await delegation.operatorFee()).to.equal(previousOperatorFee); + expect(await delegation.nodeOperatorFeeBP()).to.equal(previousOperatorFee); // check vote expect(await delegation.votings(callId, await delegation.CURATOR_ROLE())).to.equal(voteTimestamp); @@ -576,24 +607,26 @@ describe("Delegation.sol", () => { await advanceChainTime(days(7n) + 1n); const expectedVoteTimestamp = await getNextBlockTimestamp(); expect(expectedVoteTimestamp).to.be.greaterThan(voteTimestamp + days(7n)); - await expect(delegation.connect(operator).setOperatorFee(newOperatorFee)) + await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberVoted") - .withArgs(operator, await delegation.OPERATOR_ROLE(), expectedVoteTimestamp, msgData); + .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), expectedVoteTimestamp, msgData); // fee is still unchanged - expect(await delegation.operatorFee()).to.equal(previousOperatorFee); + expect(await delegation.nodeOperatorFeeBP()).to.equal(previousOperatorFee); // check vote - expect(await delegation.votings(callId, await delegation.OPERATOR_ROLE())).to.equal(expectedVoteTimestamp); + expect(await delegation.votings(callId, await delegation.NODE_OPERATOR_MANAGER_ROLE())).to.equal( + expectedVoteTimestamp, + ); // curator has to vote again voteTimestamp = await getNextBlockTimestamp(); - await expect(delegation.connect(curator).setOperatorFee(newOperatorFee)) + await expect(delegation.connect(curator).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberVoted") .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData) - .and.to.emit(delegation, "OperatorFeeSet") + .and.to.emit(delegation, "NodeOperatorFeeBPSet") .withArgs(curator, previousOperatorFee, newOperatorFee); // fee is now changed - expect(await delegation.operatorFee()).to.equal(newOperatorFee); + expect(await delegation.nodeOperatorFeeBP()).to.equal(newOperatorFee); }); }); @@ -616,9 +649,9 @@ describe("Delegation.sol", () => { expect(await vault.owner()).to.equal(delegation); voteTimestamp = await getNextBlockTimestamp(); - await expect(delegation.connect(operator).transferStVaultOwnership(newOwner)) + await expect(delegation.connect(nodeOperatorManager).transferStVaultOwnership(newOwner)) .to.emit(delegation, "RoleMemberVoted") - .withArgs(operator, await delegation.OPERATOR_ROLE(), voteTimestamp, msgData); + .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), voteTimestamp, msgData); // owner changed expect(await vault.owner()).to.equal(newOwner); }); diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.test.ts b/test/0.8.25/vaults/staking-vault/staking-vault.test.ts index eb4b27468..b08d97b6c 100644 --- a/test/0.8.25/vaults/staking-vault/staking-vault.test.ts +++ b/test/0.8.25/vaults/staking-vault/staking-vault.test.ts @@ -120,8 +120,7 @@ describe("StakingVault", () => { expect(await stakingVault.DEPOSIT_CONTRACT()).to.equal(depositContractAddress); expect(await stakingVault.getBeacon()).to.equal(vaultFactoryAddress); expect(await stakingVault.owner()).to.equal(await vaultOwner.getAddress()); - expect(await stakingVault.operator()).to.equal(operator); - + expect(await stakingVault.nodeOperator()).to.equal(operator); expect(await stakingVault.locked()).to.equal(0n); expect(await stakingVault.unlocked()).to.equal(0n); expect(await stakingVault.inOutDelta()).to.equal(0n); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts new file mode 100644 index 000000000..feb145fa0 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts @@ -0,0 +1,187 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; + +import { StETH__HarnessForVaultHub, VaultHub } from "typechain-types"; + +import { ether, MAX_UINT256 } from "lib"; + +import { deployLidoLocator } from "test/deploy"; +import { Snapshot } from "test/suite"; + +describe("VaultHub.sol:pausableUntil", () => { + let deployer: HardhatEthersSigner; + let user: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let vaultHub: VaultHub; + let steth: StETH__HarnessForVaultHub; + + let originalState: string; + + before(async () => { + [deployer, user, stranger] = await ethers.getSigners(); + + const locator = await deployLidoLocator(); + steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1.0") }); + + const vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth]); + const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); + + const accounting = await ethers.getContractAt("Accounting", proxy); + await accounting.initialize(deployer); + + vaultHub = await ethers.getContractAt("Accounting", proxy, user); + await accounting.grantRole(await vaultHub.PAUSE_ROLE(), user); + await accounting.grantRole(await vaultHub.RESUME_ROLE(), user); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("Constants", () => { + it("Returns the PAUSE_INFINITELY variable", async () => { + expect(await vaultHub.PAUSE_INFINITELY()).to.equal(MAX_UINT256); + }); + }); + + context("initialState", () => { + it("isPaused returns false", async () => { + expect(await vaultHub.isPaused()).to.equal(false); + }); + + it("getResumeSinceTimestamp returns 0", async () => { + expect(await vaultHub.getResumeSinceTimestamp()).to.equal(0); + }); + }); + + context("pauseFor", () => { + it("reverts if no PAUSE_ROLE", async () => { + await expect(vaultHub.connect(stranger).pauseFor(1000n)) + .to.be.revertedWithCustomError(vaultHub, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await vaultHub.PAUSE_ROLE()); + }); + + it("reverts if zero pause duration", async () => { + await expect(vaultHub.pauseFor(0n)).to.be.revertedWithCustomError(vaultHub, "ZeroPauseDuration"); + }); + + it("reverts if paused", async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused"); + + await expect(vaultHub.pauseFor(1000n)).to.be.revertedWithCustomError(vaultHub, "ResumedExpected"); + }); + + it("emits Paused event and change state", async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused").withArgs(1000n); + + expect(await vaultHub.isPaused()).to.equal(true); + expect(await vaultHub.getResumeSinceTimestamp()).to.equal((await time.latest()) + 1000); + }); + + it("works for MAX_UINT256 duration", async () => { + await expect(vaultHub.pauseFor(MAX_UINT256)).to.emit(vaultHub, "Paused").withArgs(MAX_UINT256); + + expect(await vaultHub.isPaused()).to.equal(true); + expect(await vaultHub.getResumeSinceTimestamp()).to.equal(MAX_UINT256); + }); + }); + + context("pauseUntil", () => { + it("reverts if no PAUSE_ROLE", async () => { + await expect(vaultHub.connect(stranger).pauseUntil(1000n)) + .to.be.revertedWithCustomError(vaultHub, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await vaultHub.PAUSE_ROLE()); + }); + + it("reverts if timestamp is in the past", async () => { + await expect(vaultHub.pauseUntil(0)).to.be.revertedWithCustomError(vaultHub, "PauseUntilMustBeInFuture"); + }); + + it("emits Paused event and change state", async () => { + const timestamp = await time.latest(); + + await expect(vaultHub.pauseUntil(timestamp + 1000)).to.emit(vaultHub, "Paused"); + // .withArgs(timestamp + 1000 - await time.latest()); // how to use last block timestamp in assertions + + expect(await vaultHub.isPaused()).to.equal(true); + expect(await vaultHub.getResumeSinceTimestamp()).to.greaterThanOrEqual((await time.latest()) + 1000); + }); + + it("works for MAX_UINT256 timestamp", async () => { + await expect(vaultHub.pauseUntil(MAX_UINT256)).to.emit(vaultHub, "Paused").withArgs(MAX_UINT256); + + expect(await vaultHub.isPaused()).to.equal(true); + expect(await vaultHub.getResumeSinceTimestamp()).to.equal(MAX_UINT256); + }); + }); + + context("resume", () => { + it("reverts if no RESUME_ROLE", async () => { + await expect(vaultHub.connect(stranger).resume()) + .to.be.revertedWithCustomError(vaultHub, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await vaultHub.RESUME_ROLE()); + }); + + it("reverts if not paused", async () => { + await expect(vaultHub.resume()).to.be.revertedWithCustomError(vaultHub, "PausedExpected"); + }); + + it("reverts if already resumed", async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused"); + await expect(vaultHub.resume()).to.emit(vaultHub, "Resumed"); + + await expect(vaultHub.resume()).to.be.revertedWithCustomError(vaultHub, "PausedExpected"); + }); + + it("emits Resumed event and change state", async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused"); + + await expect(vaultHub.resume()).to.emit(vaultHub, "Resumed"); + + expect(await vaultHub.isPaused()).to.equal(false); + expect(await vaultHub.getResumeSinceTimestamp()).to.equal(await time.latest()); + }); + }); + + context("isPaused", () => { + beforeEach(async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused"); + expect(await vaultHub.isPaused()).to.equal(true); + }); + + it("reverts voluntaryDisconnect() if paused", async () => { + await expect(vaultHub.voluntaryDisconnect(user)).to.be.revertedWithCustomError(vaultHub, "ResumedExpected"); + }); + + it("reverts mintSharesBackedByVault() if paused", async () => { + await expect(vaultHub.mintSharesBackedByVault(stranger, user, 1000n)).to.be.revertedWithCustomError( + vaultHub, + "ResumedExpected", + ); + }); + + it("reverts burnSharesBackedByVault() if paused", async () => { + await expect(vaultHub.burnSharesBackedByVault(stranger, 1000n)).to.be.revertedWithCustomError( + vaultHub, + "ResumedExpected", + ); + }); + + it("reverts rebalance() if paused", async () => { + await expect(vaultHub.rebalance()).to.be.revertedWithCustomError(vaultHub, "ResumedExpected"); + }); + + it("reverts transferAndBurnSharesBackedByVault() if paused", async () => { + await steth.connect(user).approve(vaultHub, 1000n); + + await expect(vaultHub.transferAndBurnSharesBackedByVault(stranger, 1000n)).to.be.revertedWithCustomError( + vaultHub, + "ResumedExpected", + ); + }); + }); +}); diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 897dfac6d..f98f9bcfd 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -43,10 +43,10 @@ describe("Scenario: Staking Vaults Happy Path", () => { let ethHolder: HardhatEthersSigner; let owner: HardhatEthersSigner; - let operator: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; let curator: HardhatEthersSigner; - let staker: HardhatEthersSigner; - let tokenMaster: HardhatEthersSigner; + let funderWithdrawer: HardhatEthersSigner; + let minterBurner: HardhatEthersSigner; let depositContract: string; @@ -70,7 +70,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { before(async () => { ctx = await getProtocolContext(); - [ethHolder, owner, operator, curator, staker, tokenMaster] = await ethers.getSigners(); + [ethHolder, owner, nodeOperator, curator, funderWithdrawer, minterBurner] = await ethers.getSigners(); const { depositSecurityModule } = ctx.contracts; depositContract = await depositSecurityModule.DEPOSIT_CONTRACT(); @@ -160,13 +160,13 @@ describe("Scenario: Staking Vaults Happy Path", () => { // Owner can create a vault with operator as a node operator const deployTx = await stakingVaultFactory.connect(owner).createVault( { - operatorFee: VAULT_OWNER_FEE, - curatorFee: VAULT_NODE_OPERATOR_FEE, + nodeOperatorFeeBP: VAULT_OWNER_FEE, + curatorFeeBP: VAULT_NODE_OPERATOR_FEE, curator: curator, - operator: operator, - staker: staker, - tokenMaster: tokenMaster, - claimOperatorDueRole: operator, + nodeOperatorManager: nodeOperator, + funderWithdrawer: funderWithdrawer, + minterBurner: minterBurner, + nodeOperatorFeeClaimer: nodeOperator, }, "0x", ); @@ -185,28 +185,28 @@ describe("Scenario: Staking Vaults Happy Path", () => { expect(await delegation.getRoleMemberCount(await delegation.CURATOR_ROLE())).to.be.equal(1n); expect(await delegation.hasRole(await delegation.CURATOR_ROLE(), curator)).to.be.true; - expect(await delegation.getRoleMemberCount(await delegation.OPERATOR_ROLE())).to.be.equal(1n); - expect(await delegation.hasRole(await delegation.OPERATOR_ROLE(), operator)).to.be.true; + expect(await delegation.getRoleMemberCount(await delegation.NODE_OPERATOR_MANAGER_ROLE())).to.be.equal(1n); + expect(await delegation.hasRole(await delegation.NODE_OPERATOR_MANAGER_ROLE(), nodeOperator)).to.be.true; - expect(await delegation.getRoleMemberCount(await delegation.CLAIM_OPERATOR_DUE_ROLE())).to.be.equal(1n); - expect(await delegation.hasRole(await delegation.CLAIM_OPERATOR_DUE_ROLE(), operator)).to.be.true; - expect(await delegation.getRoleAdmin(await delegation.CLAIM_OPERATOR_DUE_ROLE())).to.be.equal( - await delegation.OPERATOR_ROLE(), + expect(await delegation.getRoleMemberCount(await delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE())).to.be.equal(1n); + expect(await delegation.hasRole(await delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE(), nodeOperator)).to.be.true; + expect(await delegation.getRoleAdmin(await delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE())).to.be.equal( + await delegation.NODE_OPERATOR_MANAGER_ROLE(), ); - expect(await delegation.getRoleMemberCount(await delegation.TOKEN_MASTER_ROLE())).to.be.equal(1n); - expect(await delegation.hasRole(await delegation.TOKEN_MASTER_ROLE(), tokenMaster)).to.be.true; + expect(await delegation.getRoleMemberCount(await delegation.MINT_BURN_ROLE())).to.be.equal(1n); + expect(await delegation.hasRole(await delegation.MINT_BURN_ROLE(), minterBurner)).to.be.true; - expect(await delegation.getRoleMemberCount(await delegation.STAKER_ROLE())).to.be.equal(1n); - expect(await delegation.hasRole(await delegation.STAKER_ROLE(), staker)).to.be.true; + expect(await delegation.getRoleMemberCount(await delegation.FUND_WITHDRAW_ROLE())).to.be.equal(1n); + expect(await delegation.hasRole(await delegation.FUND_WITHDRAW_ROLE(), funderWithdrawer)).to.be.true; }); it("Should allow Owner to assign Staker and Token Master roles", async () => { - await delegation.connect(owner).grantRole(await delegation.STAKER_ROLE(), staker); - await delegation.connect(owner).grantRole(await delegation.TOKEN_MASTER_ROLE(), tokenMaster); + await delegation.connect(owner).grantRole(await delegation.FUND_WITHDRAW_ROLE(), funderWithdrawer); + await delegation.connect(owner).grantRole(await delegation.MINT_BURN_ROLE(), minterBurner); - expect(await delegation.hasRole(await delegation.STAKER_ROLE(), staker)).to.be.true; - expect(await delegation.hasRole(await delegation.TOKEN_MASTER_ROLE(), tokenMaster)).to.be.true; + expect(await delegation.hasRole(await delegation.FUND_WITHDRAW_ROLE(), funderWithdrawer)).to.be.true; + expect(await delegation.hasRole(await delegation.MINT_BURN_ROLE(), minterBurner)).to.be.true; }); it("Should allow Lido to recognize vaults and connect them to accounting", async () => { @@ -231,7 +231,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); it("Should allow Staker to fund vault via delegation contract", async () => { - const depositTx = await delegation.connect(staker).fund({ value: VAULT_DEPOSIT }); + const depositTx = await delegation.connect(funderWithdrawer).fund({ value: VAULT_DEPOSIT }); await trace("delegation.fund", depositTx); const vaultBalance = await ethers.provider.getBalance(stakingVault); @@ -245,7 +245,9 @@ describe("Scenario: Staking Vaults Happy Path", () => { pubKeysBatch = ethers.randomBytes(Number(keysToAdd * PUBKEY_LENGTH)); signaturesBatch = ethers.randomBytes(Number(keysToAdd * SIGNATURE_LENGTH)); - const topUpTx = await stakingVault.connect(operator).depositToBeaconChain(keysToAdd, pubKeysBatch, signaturesBatch); + const topUpTx = await stakingVault + .connect(nodeOperator) + .depositToBeaconChain(keysToAdd, pubKeysBatch, signaturesBatch); await trace("stakingVault.depositToBeaconChain", topUpTx); @@ -272,12 +274,14 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); // Validate minting with the cap - const mintOverLimitTx = delegation.connect(tokenMaster).mintShares(tokenMaster, stakingVaultMaxMintingShares + 1n); + const mintOverLimitTx = delegation + .connect(minterBurner) + .mintShares(minterBurner, stakingVaultMaxMintingShares + 1n); await expect(mintOverLimitTx) .to.be.revertedWithCustomError(accounting, "InsufficientValuationToMint") .withArgs(stakingVault, stakingVault.valuation()); - const mintTx = await delegation.connect(tokenMaster).mintShares(tokenMaster, stakingVaultMaxMintingShares); + const mintTx = await delegation.connect(minterBurner).mintShares(minterBurner, stakingVaultMaxMintingShares); const mintTxReceipt = await trace("delegation.mint", mintTx); const mintEvents = ctx.getEvents(mintTxReceipt, "MintedSharesOnVault"); @@ -324,25 +328,25 @@ describe("Scenario: Staking Vaults Happy Path", () => { expect(vaultReportedEvent[0].args?.inOutDelta).to.equal(VAULT_DEPOSIT); // TODO: add assertions or locked values and rewards - expect(await delegation.curatorDue()).to.be.gt(0n); - expect(await delegation.operatorDue()).to.be.gt(0n); + expect(await delegation.curatorUnclaimedFee()).to.be.gt(0n); + expect(await delegation.nodeOperatorUnclaimedFee()).to.be.gt(0n); }); it("Should allow Operator to claim performance fees", async () => { - const performanceFee = await delegation.operatorDue(); + const performanceFee = await delegation.nodeOperatorUnclaimedFee(); log.debug("Staking Vault stats", { "Staking Vault performance fee": ethers.formatEther(performanceFee), }); - const operatorBalanceBefore = await ethers.provider.getBalance(operator); + const operatorBalanceBefore = await ethers.provider.getBalance(nodeOperator); - const claimPerformanceFeesTx = await delegation.connect(operator).claimOperatorDue(operator); + const claimPerformanceFeesTx = await delegation.connect(nodeOperator).claimNodeOperatorFee(nodeOperator); const claimPerformanceFeesTxReceipt = await trace( - "delegation.claimOperatorDue", + "delegation.claimNodeOperatorFee", claimPerformanceFeesTx, ); - const operatorBalanceAfter = await ethers.provider.getBalance(operator); + const operatorBalanceAfter = await ethers.provider.getBalance(nodeOperator); const gasFee = claimPerformanceFeesTxReceipt.gasPrice * claimPerformanceFeesTxReceipt.cumulativeGasUsed; log.debug("Operator's StETH balance", { @@ -375,7 +379,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); it("Should allow Manager to claim manager rewards in ETH after rebase with exited validator", async () => { - const feesToClaim = await delegation.curatorDue(); + const feesToClaim = await delegation.curatorUnclaimedFee(); log.debug("Staking Vault stats after operator exit", { "Staking Vault management fee": ethers.formatEther(feesToClaim), @@ -384,8 +388,8 @@ describe("Scenario: Staking Vaults Happy Path", () => { const managerBalanceBefore = await ethers.provider.getBalance(curator); - const claimEthTx = await delegation.connect(curator).claimCuratorDue(curator); - const { gasUsed, gasPrice } = await trace("delegation.claimCuratorDue", claimEthTx); + const claimEthTx = await delegation.connect(curator).claimCuratorFee(curator); + const { gasUsed, gasPrice } = await trace("delegation.claimCuratorFee", claimEthTx); const managerBalanceAfter = await ethers.provider.getBalance(curator); const vaultBalance = await ethers.provider.getBalance(stakingVaultAddress); @@ -406,11 +410,11 @@ describe("Scenario: Staking Vaults Happy Path", () => { // Token master can approve the vault to burn the shares const approveVaultTx = await lido - .connect(tokenMaster) + .connect(minterBurner) .approve(delegation, await lido.getPooledEthByShares(stakingVaultMaxMintingShares)); await trace("lido.approve", approveVaultTx); - const burnTx = await delegation.connect(tokenMaster).burnShares(stakingVaultMaxMintingShares); + const burnTx = await delegation.connect(minterBurner).burnShares(stakingVaultMaxMintingShares); await trace("delegation.burn", burnTx); const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); diff --git a/yarn.lock b/yarn.lock index c910ac91b..a8657fefc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8095,7 +8095,7 @@ __metadata: openzeppelin-solidity: "npm:2.0.0" prettier: "npm:3.4.1" prettier-plugin-solidity: "npm:1.4.1" - solhint: "npm:5.0.3" + solhint: "npm:5.0.4" solhint-plugin-lido: "npm:0.0.4" solidity-coverage: "npm:0.8.14" ts-node: "npm:10.9.2" @@ -10638,11 +10638,11 @@ __metadata: languageName: node linkType: hard -"solhint@npm:5.0.3": - version: 5.0.3 - resolution: "solhint@npm:5.0.3" +"solhint@npm:5.0.4": + version: 5.0.4 + resolution: "solhint@npm:5.0.4" dependencies: - "@solidity-parser/parser": "npm:^0.18.0" + "@solidity-parser/parser": "npm:^0.19.0" ajv: "npm:^6.12.6" antlr4: "npm:^4.13.1-patch-1" ast-parents: "npm:^0.0.1" @@ -10666,7 +10666,7 @@ __metadata: optional: true bin: solhint: solhint.js - checksum: 10c0/262e86a8932d7d4d6ebae2a9d7317749e5068092e7cdf4caf07ac39fc72bd2c94f3907daaedcad37592ec001b57caed6dc5ed7c3fd6cd18b6443182f38c1715e + checksum: 10c0/70058b23c8746762fc88d48b571c4571719913ca7f3c582a55c123ad9ba38976a2338782025fbb9643bb75bfad18bf3dce1b71e500df6d99589e9814fbcce1d7 languageName: node linkType: hard