diff --git a/contracts/0.8.25/utils/AccessControlConfirmable.sol b/contracts/0.8.25/utils/AccessControlConfirmable.sol new file mode 100644 index 000000000..328aede81 --- /dev/null +++ b/contracts/0.8.25/utils/AccessControlConfirmable.sol @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +pragma solidity 0.8.25; + +import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.2/access/extensions/AccessControlEnumerable.sol"; + +/** + * @title AccessControlConfirmable + * @author Lido + * @notice An extension of AccessControlEnumerable that allows exectuing functions by mutual confirmation. + * @dev This contract extends AccessControlEnumerable and adds a confirmation mechanism in the form of a modifier. + */ +abstract contract AccessControlConfirmable is AccessControlEnumerable { + /** + * @notice Tracks confirmations + * - callId: unique identifier for the call, derived as `keccak256(msg.data)` + * - role: role that confirmed the action + * - timestamp: timestamp of the confirmation. + */ + mapping(bytes callData => mapping(bytes32 role => uint256 expiryTimestamp)) public confirmations; + + /** + * @notice Confirmation lifetime in seconds; after this period, the confirmation expires and no longer counts. + */ + uint256 public confirmLifetime; + + /** + * @dev Restricts execution of the function unless confirmed by all specified roles. + * Confirmation, in this context, is a call to the same function with the same arguments. + * + * The confirmation process works as follows: + * 1. When a role member calls the function: + * - Their confirmation is counted immediately + * - If not enough confirmations exist, their confirmation is recorded + * - If they're not a member of any of the specified roles, the call reverts + * + * 2. Confirmation counting: + * - Counts the current caller's confirmations if they're a member of any of the specified roles + * - Counts existing confirmations that are not expired, i.e. lifetime is not exceeded + * + * 3. Execution: + * - If all members of the specified roles have confirmed, executes the function + * - On successful execution, clears all confirmations for this call + * - If not enough confirmations, stores the current confirmations + * - Thus, if the caller has all the roles, the function is executed immediately + * + * 4. Gas Optimization: + * - Confirmations are stored in a deferred manner using a memory array + * - Confirmation storage writes only occur if the function cannot be executed immediately + * - This prevents unnecessary storage writes when all confirmations are present, + * because the confirmations are cleared anyway after the function is executed, + * - i.e. this optimization is beneficial for the deciding caller and + * saves 1 storage write for each role the deciding caller has + * + * @param _roles Array of role identifiers that must confirm the call in order to execute it + * + * @notice Confirmations past their lifetime are not counted and must be recast + * @notice Only members of the specified roles can submit confirmations + * @notice The order of confirmations does not matter + * + */ + modifier onlyMutuallyConfirmed(bytes32[] memory _roles) { + if (_roles.length == 0) revert ZeroConfirmingRoles(); + if (confirmLifetime == 0) revert ConfirmLifetimeNotSet(); + + uint256 numberOfRoles = _roles.length; + uint256 numberOfConfirms = 0; + bool[] memory deferredConfirms = new bool[](numberOfRoles); + bool isRoleMember = false; + + for (uint256 i = 0; i < numberOfRoles; ++i) { + bytes32 role = _roles[i]; + + if (super.hasRole(role, msg.sender)) { + isRoleMember = true; + numberOfConfirms++; + deferredConfirms[i] = true; + + emit RoleMemberConfirmed(msg.sender, role, block.timestamp, msg.data); + } else if (confirmations[msg.data][role] >= block.timestamp) { + numberOfConfirms++; + } + } + + if (!isRoleMember) revert SenderNotMember(); + + if (numberOfConfirms == numberOfRoles) { + for (uint256 i = 0; i < numberOfRoles; ++i) { + bytes32 role = _roles[i]; + delete confirmations[msg.data][role]; + } + _; + } else { + for (uint256 i = 0; i < numberOfRoles; ++i) { + if (deferredConfirms[i]) { + bytes32 role = _roles[i]; + confirmations[msg.data][role] = block.timestamp + confirmLifetime; + } + } + } + } + + /** + * @notice Sets the confirmation lifetime. + * Confirmation lifetime is a period during which the confirmation is counted. Once the period is over, + * the confirmation is considered expired, no longer counts and must be recasted for the confirmation to go through. + * @param _newConfirmLifetime The new confirmation lifetime in seconds. + */ + function _setConfirmLifetime(uint256 _newConfirmLifetime) internal { + if (_newConfirmLifetime == 0) revert ConfirmLifetimeCannotBeZero(); + + uint256 oldConfirmLifetime = confirmLifetime; + confirmLifetime = _newConfirmLifetime; + + emit ConfirmLifetimeSet(msg.sender, oldConfirmLifetime, _newConfirmLifetime); + } + + /** + * @dev Emitted when the confirmation lifetime is set. + * @param oldConfirmLifetime The old confirmation lifetime. + * @param newConfirmLifetime The new confirmation lifetime. + */ + event ConfirmLifetimeSet(address indexed sender, uint256 oldConfirmLifetime, uint256 newConfirmLifetime); + + /** + * @dev Emitted when a role member confirms. + * @param member The address of the confirming member. + * @param role The role of the confirming member. + * @param timestamp The timestamp of the confirmation. + * @param data The msg.data of the confirmation (selector + arguments). + */ + event RoleMemberConfirmed(address indexed member, bytes32 indexed role, uint256 timestamp, bytes data); + + /** + * @dev Thrown when attempting to set confirmation lifetime to zero. + */ + error ConfirmLifetimeCannotBeZero(); + + /** + * @dev Thrown when attempting to confirm when the confirmation lifetime is not set. + */ + error ConfirmLifetimeNotSet(); + + /** + * @dev Thrown when a caller without a required role attempts to confirm. + */ + error SenderNotMember(); + + /** + * @dev Thrown when the roles array is empty. + */ + error ZeroConfirmingRoles(); +} diff --git a/contracts/0.8.25/utils/AccessControlVoteable.sol b/contracts/0.8.25/utils/AccessControlVoteable.sol deleted file mode 100644 index b078dea5b..000000000 --- a/contracts/0.8.25/utils/AccessControlVoteable.sol +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Lido -// SPDX-License-Identifier: GPL-3.0 - -// See contracts/COMPILERS.md -pragma solidity 0.8.25; - -import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.2/access/extensions/AccessControlEnumerable.sol"; - -abstract contract AccessControlVoteable is AccessControlEnumerable { - /** - * @notice Tracks committee votes - * - callId: unique identifier for the call, derived as `keccak256(msg.data)` - * - role: role that voted - * - voteTimestamp: timestamp of the vote. - * The term "voting" refers to the entire voting process through which vote-restricted actions are performed. - * The term "vote" refers to a single individual vote cast by a committee member. - */ - mapping(bytes32 callId => mapping(bytes32 role => uint256 voteTimestamp)) public votings; - - /** - * @notice Vote lifetime in seconds; after this period, the vote expires and no longer counts. - */ - uint256 public voteLifetime; - - /** - * @dev Modifier that implements a mechanism for multi-role committee approval. - * Each unique function call (identified by msg.data: selector + arguments) requires - * approval from all committee role members within a specified time window. - * - * The voting process works as follows: - * 1. When a committee member calls the function: - * - Their vote is counted immediately - * - If not enough votes exist, their vote is recorded - * - If they're not a committee member, the call reverts - * - * 2. Vote counting: - * - Counts the current caller's votes if they're a committee member - * - Counts existing votes that are within the voting period - * - All votes must occur within the same voting period window - * - * 3. Execution: - * - If all committee members have voted within the period, executes the function - * - On successful execution, clears all voting state for this call - * - If not enough votes, stores the current votes - * - Thus, if the caller has all the roles, the function is executed immediately - * - * 4. Gas Optimization: - * - Votes are stored in a deferred manner using a memory array - * - Vote storage writes only occur if the function cannot be executed immediately - * - This prevents unnecessary storage writes when all votes are present, - * because the votes are cleared anyway after the function is executed, - * - i.e. this optimization is beneficial for the deciding caller and - * saves 1 storage write for each role the deciding caller has - * - * @param _committee Array of role identifiers that form the voting committee - * - * @notice Votes expire after the voting period and must be recast - * @notice All committee members must vote within the same voting period - * @notice Only committee members can initiate votes - * - * @custom:security-note Each unique function call (including parameters) requires its own set of votes - */ - modifier onlyIfVotedBy(bytes32[] memory _committee) { - if (voteLifetime == 0) revert VoteLifetimeNotSet(); - - bytes32 callId = keccak256(msg.data); - uint256 committeeSize = _committee.length; - uint256 votingStart = block.timestamp - voteLifetime; - uint256 voteTally = 0; - bool[] memory deferredVotes = new bool[](committeeSize); - bool isCommitteeMember = false; - - for (uint256 i = 0; i < committeeSize; ++i) { - bytes32 role = _committee[i]; - - if (super.hasRole(role, msg.sender)) { - isCommitteeMember = true; - voteTally++; - deferredVotes[i] = true; - - emit RoleMemberVoted(msg.sender, role, block.timestamp, msg.data); - } else if (votings[callId][role] >= votingStart) { - voteTally++; - } - } - - if (!isCommitteeMember) revert NotACommitteeMember(); - - if (voteTally == committeeSize) { - for (uint256 i = 0; i < committeeSize; ++i) { - bytes32 role = _committee[i]; - delete votings[callId][role]; - } - _; - } else { - for (uint256 i = 0; i < committeeSize; ++i) { - if (deferredVotes[i]) { - bytes32 role = _committee[i]; - votings[callId][role] = block.timestamp; - } - } - } - } - - /** - * @notice Sets the vote lifetime. - * Vote lifetime is a period during which the vote is counted. Once the period is over, - * the vote is considered expired, no longer counts and must be recasted for the voting to go through. - * @param _newVoteLifetime The new vote lifetime in seconds. - */ - function _setVoteLifetime(uint256 _newVoteLifetime) internal { - if (_newVoteLifetime == 0) revert VoteLifetimeCannotBeZero(); - - uint256 oldVoteLifetime = voteLifetime; - voteLifetime = _newVoteLifetime; - - emit VoteLifetimeSet(msg.sender, oldVoteLifetime, _newVoteLifetime); - } - - /** - * @dev Emitted when the vote lifetime is set. - * @param oldVoteLifetime The old vote lifetime. - * @param newVoteLifetime The new vote lifetime. - */ - event VoteLifetimeSet(address indexed sender, uint256 oldVoteLifetime, uint256 newVoteLifetime); - - /** - * @dev Emitted when a committee member votes. - * @param member The address of the voting member. - * @param role The role of the voting member. - * @param timestamp The timestamp of the vote. - * @param data The msg.data of the vote. - */ - event RoleMemberVoted(address indexed member, bytes32 indexed role, uint256 timestamp, bytes data); - - /** - * @dev Thrown when attempting to set vote lifetime to zero. - */ - error VoteLifetimeCannotBeZero(); - - /** - * @dev Thrown when attempting to vote when the vote lifetime is zero. - */ - error VoteLifetimeNotSet(); - - /** - * @dev Thrown when a caller without a required role attempts to vote. - */ - error NotACommitteeMember(); -} diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index a00923153..245423cda 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -36,14 +36,6 @@ interface IWstETH is IERC20, IERC20Permit { * including funding, withdrawing, minting, burning, and rebalancing operations. */ contract Dashboard is Permissions { - /** - * @notice Struct containing an account and a role for granting/revoking roles. - */ - struct RoleAssignment { - address account; - bytes32 role; - } - /** * @notice Total basis points for fee calculations; equals to 100%. */ @@ -97,18 +89,18 @@ contract Dashboard is Permissions { /** * @notice Initializes the contract with the default admin role */ - function initialize(address _defaultAdmin) external virtual { + function initialize(address _defaultAdmin, uint256 _confirmLifetime) external virtual { // reduces gas cost for `mintWsteth` // invariant: dashboard does not hold stETH on its balance STETH.approve(address(WSTETH), type(uint256).max); - _initialize(_defaultAdmin); + _initialize(_defaultAdmin, _confirmLifetime); } // ==================== View Functions ==================== - function votingCommittee() external pure returns (bytes32[] memory) { - return _votingCommittee(); + function confirmingRoles() external pure returns (bytes32[] memory) { + return _confirmingRoles(); } /** @@ -214,7 +206,7 @@ contract Dashboard is Permissions { /** * @notice Disconnects the staking vault from the vault hub. */ - function voluntaryDisconnect() external payable fundAndProceed { + function voluntaryDisconnect() external payable fundable { uint256 shares = vaultHub.vaultSocket(address(stakingVault())).sharesMinted; if (shares > 0) { @@ -275,7 +267,7 @@ contract Dashboard is Permissions { * @param _recipient Address of the recipient * @param _amountOfShares Amount of stETH shares to mint */ - function mintShares(address _recipient, uint256 _amountOfShares) external payable fundAndProceed { + function mintShares(address _recipient, uint256 _amountOfShares) external payable fundable { _mintShares(_recipient, _amountOfShares); } @@ -285,7 +277,7 @@ contract Dashboard is Permissions { * @param _recipient Address of the recipient * @param _amountOfStETH Amount of stETH to mint */ - function mintStETH(address _recipient, uint256 _amountOfStETH) external payable virtual fundAndProceed { + function mintStETH(address _recipient, uint256 _amountOfStETH) external payable virtual fundable { _mintShares(_recipient, STETH.getSharesByPooledEth(_amountOfStETH)); } @@ -294,7 +286,7 @@ contract Dashboard is Permissions { * @param _recipient Address of the recipient * @param _amountOfWstETH Amount of tokens to mint */ - function mintWstETH(address _recipient, uint256 _amountOfWstETH) external payable fundAndProceed { + function mintWstETH(address _recipient, uint256 _amountOfWstETH) external payable fundable { _mintShares(address(this), _amountOfWstETH); uint256 mintedStETH = STETH.getPooledEthBySharesRoundUp(_amountOfWstETH); @@ -331,38 +323,7 @@ contract Dashboard is Permissions { _burnWstETH(_amountOfWstETH); } - /** - * @dev Modifier to check if the permit is successful, and if not, check if the allowance is sufficient - */ - modifier safePermit( - address token, - address owner, - address spender, - PermitInput calldata permitInput - ) { - // Try permit() before allowance check to advance nonce if possible - try - IERC20Permit(token).permit( - owner, - spender, - permitInput.value, - permitInput.deadline, - permitInput.v, - permitInput.r, - permitInput.s - ) - { - _; - return; - } catch { - // Permit potentially got frontran. Continue anyways if allowance is sufficient. - if (IERC20(token).allowance(owner, spender) >= permitInput.value) { - _; - return; - } - } - revert InvalidPermit(token); - } + // TODO: move down /** * @notice Burns stETH tokens (in shares) backed by the vault from the sender using permit (with value in stETH). @@ -407,7 +368,7 @@ contract Dashboard is Permissions { * @notice Rebalances the vault by transferring ether * @param _ether Amount of ether to rebalance */ - function rebalanceVault(uint256 _ether) external payable fundAndProceed { + function rebalanceVault(uint256 _ether) external payable fundable { _rebalanceVault(_ether); } @@ -462,46 +423,51 @@ contract Dashboard is Permissions { _resumeBeaconChainDeposits(); } - // ==================== Role Management Functions ==================== - - /** - * @notice Mass-grants multiple roles to multiple accounts. - * @param _assignments An array of role assignments. - * @dev Performs the role admin checks internally. - */ - function grantRoles(RoleAssignment[] memory _assignments) external { - if (_assignments.length == 0) revert ZeroArgument("_assignments"); - - for (uint256 i = 0; i < _assignments.length; i++) { - grantRole(_assignments[i].role, _assignments[i].account); - } - } - - /** - * @notice Mass-revokes multiple roles from multiple accounts. - * @param _assignments An array of role assignments. - * @dev Performs the role admin checks internally. - */ - function revokeRoles(RoleAssignment[] memory _assignments) external { - if (_assignments.length == 0) revert ZeroArgument("_assignments"); - - for (uint256 i = 0; i < _assignments.length; i++) { - revokeRole(_assignments[i].role, _assignments[i].account); - } - } - // ==================== Internal Functions ==================== /** * @dev Modifier to fund the staking vault if msg.value > 0 */ - modifier fundAndProceed() { + modifier fundable() { if (msg.value > 0) { _fund(msg.value); } _; } + /** + * @dev Modifier to check if the permit is successful, and if not, check if the allowance is sufficient + */ + modifier safePermit( + address token, + address owner, + address spender, + PermitInput calldata permitInput + ) { + // Try permit() before allowance check to advance nonce if possible + try + IERC20Permit(token).permit( + owner, + spender, + permitInput.value, + permitInput.deadline, + permitInput.v, + permitInput.r, + permitInput.s + ) + { + _; + return; + } catch { + // Permit potentially got frontran. Continue anyways if allowance is sufficient. + if (IERC20(token).allowance(owner, spender) >= permitInput.value) { + _; + return; + } + } + revert InvalidPermit(token); + } + /** /** diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index a725eaec3..feafb9cf9 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -36,28 +36,28 @@ contract Delegation is Dashboard { * @notice Curator role: * - sets curator fee; * - claims curator fee; - * - votes on vote lifetime; - * - votes on node operator fee; - * - votes on ownership transfer; + * - confirms confirm lifetime; + * - confirms node operator fee; + * - confirms ownership transfer; * - pauses deposits to beacon chain; * - resumes deposits to beacon chain. */ - bytes32 public constant CURATOR_ROLE = keccak256("Vault.Delegation.CuratorRole"); + bytes32 public constant CURATOR_ROLE = keccak256("vaults.Delegation.CuratorRole"); /** * @notice Node operator manager role: - * - votes on vote lifetime; - * - votes on node operator fee; - * - votes on ownership transfer; + * - confirms confirm lifetime; + * - confirms node operator fee; + * - confirms ownership transfer; * - assigns NODE_OPERATOR_FEE_CLAIMER_ROLE. */ - bytes32 public constant NODE_OPERATOR_MANAGER_ROLE = keccak256("Vault.Delegation.NodeOperatorManagerRole"); + bytes32 public constant NODE_OPERATOR_MANAGER_ROLE = keccak256("vaults.Delegation.NodeOperatorManagerRole"); /** * @notice Node operator fee claimer role: * - claims node operator fee. */ - bytes32 public constant NODE_OPERATOR_FEE_CLAIMER_ROLE = keccak256("Vault.Delegation.NodeOperatorFeeClaimerRole"); + bytes32 public constant NODE_OPERATOR_FEE_CLAIMER_ROLE = keccak256("vaults.Delegation.NodeOperatorFeeClaimerRole"); /** * @notice Curator fee in basis points; combined with node operator fee cannot exceed 100%. @@ -92,18 +92,18 @@ contract Delegation is Dashboard { /** * @notice Initializes the contract: * - sets up the roles; - * - sets the vote lifetime to 7 days (can be changed later by CURATOR_ROLE and NODE_OPERATOR_MANAGER_ROLE). + * - sets the confirm lifetime to 7 days (can be changed later by CURATOR_ROLE and NODE_OPERATOR_MANAGER_ROLE). * @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 _defaultAdmin) external override { - _initialize(_defaultAdmin); + function initialize(address _defaultAdmin, uint256 _confirmLifetime) external override { + _initialize(_defaultAdmin, _confirmLifetime); // 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(NODE_OPERATOR_MANAGER_ROLE, msg.sender); + _grantRole(NODE_OPERATOR_MANAGER_ROLE, _defaultAdmin); _setRoleAdmin(NODE_OPERATOR_MANAGER_ROLE, NODE_OPERATOR_MANAGER_ROLE); _setRoleAdmin(NODE_OPERATOR_FEE_CLAIMER_ROLE, NODE_OPERATOR_MANAGER_ROLE); } @@ -152,13 +152,13 @@ contract Delegation is Dashboard { } /** - * @notice Sets the vote lifetime. - * Vote lifetime is a period during which the vote is counted. Once the period is over, - * the vote is considered expired, no longer counts and must be recasted for the voting to go through. - * @param _newVoteLifetime The new vote lifetime in seconds. + * @notice Sets the confirm lifetime. + * Confirm lifetime is a period during which the confirm is counted. Once the period is over, + * the confirm is considered expired, no longer counts and must be recasted. + * @param _newConfirmLifetime The new confirm lifetime in seconds. */ - function setVoteLifetime(uint256 _newVoteLifetime) external onlyIfVotedBy(_votingCommittee()) { - _setVoteLifetime(_newVoteLifetime); + function setConfirmLifetime(uint256 _newConfirmLifetime) external onlyMutuallyConfirmed(_confirmingRoles()) { + _setConfirmLifetime(_newConfirmLifetime); } /** @@ -181,11 +181,11 @@ contract Delegation is Dashboard { * @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. + * Note that the function reverts if the node operator fee is unclaimed and all the confirms must be recasted to execute it again, + * which is why the deciding confirm must make sure that `nodeOperatorUnclaimedFee()` is 0 before calling this function. * @param _newNodeOperatorFeeBP The new node operator fee in basis points. */ - function setNodeOperatorFeeBP(uint256 _newNodeOperatorFeeBP) external onlyIfVotedBy(_votingCommittee()) { + function setNodeOperatorFeeBP(uint256 _newNodeOperatorFeeBP) external onlyMutuallyConfirmed(_confirmingRoles()) { if (_newNodeOperatorFeeBP + curatorFeeBP > MAX_FEE_BP) revert CombinedFeesExceed100Percent(); if (nodeOperatorUnclaimedFee() > 0) revert NodeOperatorFeeUnclaimed(); uint256 oldNodeOperatorFeeBP = nodeOperatorFeeBP; @@ -254,20 +254,21 @@ contract Delegation is Dashboard { if (_recipient == address(0)) revert ZeroArgument("_recipient"); if (_fee == 0) revert ZeroArgument("_fee"); - super._unsafeWithdraw(_recipient, _fee); + stakingVault().withdraw(_recipient, _fee); } /** - * @notice Returns the committee that can: - * - change the vote lifetime; + * @notice Returns the roles that can: + * - change the confirm lifetime; + * - set the curator fee; * - set the node operator fee; * - transfer the ownership of the StakingVault. - * @return committee is an array of roles that form the voting committee. + * @return roles is an array of roles that form the confirming roles. */ - function _votingCommittee() internal pure override returns (bytes32[] memory committee) { - committee = new bytes32[](2); - committee[0] = CURATOR_ROLE; - committee[1] = NODE_OPERATOR_MANAGER_ROLE; + function _confirmingRoles() internal pure override returns (bytes32[] memory roles) { + roles = new bytes32[](2); + roles[0] = CURATOR_ROLE; + roles[1] = NODE_OPERATOR_MANAGER_ROLE; } /** diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index d2c7b31ea..419e63428 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -5,7 +5,7 @@ pragma solidity 0.8.25; import {Clones} from "@openzeppelin/contracts-v5.2/proxy/Clones.sol"; -import {AccessControlVoteable} from "contracts/0.8.25/utils/AccessControlVoteable.sol"; +import {AccessControlMutuallyConfirmable} from "contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol"; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; @@ -16,53 +16,60 @@ import {VaultHub} from "./VaultHub.sol"; * @author Lido * @notice Provides granular permissions for StakingVault operations. */ -abstract contract Permissions is AccessControlVoteable { +abstract contract Permissions is AccessControlMutuallyConfirmable { + /** + * @notice Struct containing an account and a role for granting/revoking roles. + */ + struct RoleAssignment { + address account; + bytes32 role; + } + /** * @notice Permission for funding the StakingVault. */ - bytes32 public constant FUND_ROLE = keccak256("StakingVault.Permissions.Fund"); + bytes32 public constant FUND_ROLE = keccak256("vaults.Permissions.Fund"); /** * @notice Permission for withdrawing funds from the StakingVault. */ - bytes32 public constant WITHDRAW_ROLE = keccak256("StakingVault.Permissions.Withdraw"); + bytes32 public constant WITHDRAW_ROLE = keccak256("vaults.Permissions.Withdraw"); /** * @notice Permission for minting stETH shares backed by the StakingVault. */ - bytes32 public constant MINT_ROLE = keccak256("StakingVault.Permissions.Mint"); + bytes32 public constant MINT_ROLE = keccak256("vaults.Permissions.Mint"); /** * @notice Permission for burning stETH shares backed by the StakingVault. */ - bytes32 public constant BURN_ROLE = keccak256("StakingVault.Permissions.Burn"); + bytes32 public constant BURN_ROLE = keccak256("vaults.Permissions.Burn"); /** * @notice Permission for rebalancing the StakingVault. */ - bytes32 public constant REBALANCE_ROLE = keccak256("StakingVault.Permissions.Rebalance"); + bytes32 public constant REBALANCE_ROLE = keccak256("vaults.Permissions.Rebalance"); /** * @notice Permission for pausing beacon chain deposits on the StakingVault. */ - bytes32 public constant PAUSE_BEACON_CHAIN_DEPOSITS_ROLE = - keccak256("StakingVault.Permissions.PauseBeaconChainDeposits"); + bytes32 public constant PAUSE_BEACON_CHAIN_DEPOSITS_ROLE = keccak256("vaults.Permissions.PauseBeaconChainDeposits"); /** * @notice Permission for resuming beacon chain deposits on the StakingVault. */ bytes32 public constant RESUME_BEACON_CHAIN_DEPOSITS_ROLE = - keccak256("StakingVault.Permissions.ResumeBeaconChainDeposits"); + keccak256("vaults.Permissions.ResumeBeaconChainDeposits"); /** * @notice Permission for requesting validator exit from the StakingVault. */ - bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("StakingVault.Permissions.RequestValidatorExit"); + bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("vaults.Permissions.RequestValidatorExit"); /** * @notice Permission for voluntary disconnecting the StakingVault. */ - bytes32 public constant VOLUNTARY_DISCONNECT_ROLE = keccak256("StakingVault.Permissions.VoluntaryDisconnect"); + bytes32 public constant VOLUNTARY_DISCONNECT_ROLE = keccak256("vaults.Permissions.VoluntaryDisconnect"); /** * @notice Address of the implementation contract @@ -84,7 +91,7 @@ abstract contract Permissions is AccessControlVoteable { _SELF = address(this); } - function _initialize(address _defaultAdmin) internal { + function _initialize(address _defaultAdmin, uint256 _confirmLifetime) internal { if (initialized) revert AlreadyInitialized(); if (address(this) == _SELF) revert NonProxyCallsForbidden(); if (_defaultAdmin == address(0)) revert ZeroArgument("_defaultAdmin"); @@ -93,21 +100,44 @@ abstract contract Permissions is AccessControlVoteable { vaultHub = VaultHub(stakingVault().vaultHub()); _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); - _setVoteLifetime(7 days); + _setConfirmLifetime(_confirmLifetime); - emit Initialized(); + emit Initialized(_defaultAdmin); } function stakingVault() public view returns (IStakingVault) { - bytes memory args = Clones.fetchCloneArgs(address(this)); - address addr; - assembly { - addr := mload(add(args, 32)) + return IStakingVault(_loadStakingVaultAddress()); + } + + // ==================== Role Management Functions ==================== + + /** + * @notice Mass-grants multiple roles to multiple accounts. + * @param _assignments An array of role assignments. + * @dev Performs the role admin checks internally. + */ + function grantRoles(RoleAssignment[] memory _assignments) external { + if (_assignments.length == 0) revert ZeroArgument("_assignments"); + + for (uint256 i = 0; i < _assignments.length; i++) { + grantRole(_assignments[i].role, _assignments[i].account); } - return IStakingVault(addr); } - function _votingCommittee() internal pure virtual returns (bytes32[] memory) { + /** + * @notice Mass-revokes multiple roles from multiple accounts. + * @param _assignments An array of role assignments. + * @dev Performs the role admin checks internally. + */ + function revokeRoles(RoleAssignment[] memory _assignments) external { + if (_assignments.length == 0) revert ZeroArgument("_assignments"); + + for (uint256 i = 0; i < _assignments.length; i++) { + revokeRole(_assignments[i].role, _assignments[i].account); + } + } + + function _confirmingRoles() internal pure virtual returns (bytes32[] memory) { bytes32[] memory roles = new bytes32[](1); roles[0] = DEFAULT_ADMIN_ROLE; return roles; @@ -118,7 +148,7 @@ abstract contract Permissions is AccessControlVoteable { } function _withdraw(address _recipient, uint256 _ether) internal virtual onlyRole(WITHDRAW_ROLE) { - _unsafeWithdraw(_recipient, _ether); + stakingVault().withdraw(_recipient, _ether); } function _mintShares(address _recipient, uint256 _shares) internal onlyRole(MINT_ROLE) { @@ -149,18 +179,21 @@ abstract contract Permissions is AccessControlVoteable { vaultHub.voluntaryDisconnect(address(stakingVault())); } - function _transferStakingVaultOwnership(address _newOwner) internal onlyIfVotedBy(_votingCommittee()) { + function _transferStakingVaultOwnership(address _newOwner) internal onlyMutuallyConfirmed(_confirmingRoles()) { OwnableUpgradeable(address(stakingVault())).transferOwnership(_newOwner); } - function _unsafeWithdraw(address _recipient, uint256 _ether) internal { - stakingVault().withdraw(_recipient, _ether); + function _loadStakingVaultAddress() internal view returns (address addr) { + bytes memory args = Clones.fetchCloneArgs(address(this)); + assembly { + addr := mload(add(args, 32)) + } } /** * @notice Emitted when the contract is initialized */ - event Initialized(); + event Initialized(address _defaultAdmin); /** * @notice Error when direct calls to the implementation are forbidden diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index b971e51f4..65b0c2bbb 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -26,6 +26,7 @@ struct DelegationConfig { address nodeOperatorFeeClaimer; uint16 curatorFeeBP; uint16 nodeOperatorFeeBP; + uint256 confirmLifetime; } contract VaultFactory { @@ -66,7 +67,7 @@ contract VaultFactory { ); // initialize Delegation - delegation.initialize(address(this)); + delegation.initialize(address(this), _delegationConfig.confirmLifetime); // setup roles delegation.grantRole(delegation.DEFAULT_ADMIN_ROLE(), _delegationConfig.defaultAdmin); diff --git a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol index 2404ca20d..caacda986 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol @@ -28,7 +28,7 @@ contract VaultFactory__MockForDashboard is UpgradeableBeacon { bytes memory immutableArgs = abi.encode(vault); dashboard = Dashboard(payable(Clones.cloneWithImmutableArgs(dashboardImpl, immutableArgs))); - dashboard.initialize(address(this)); + dashboard.initialize(address(this), 7 days); dashboard.grantRole(dashboard.DEFAULT_ADMIN_ROLE(), msg.sender); dashboard.grantRole(dashboard.FUND_ROLE(), msg.sender); dashboard.grantRole(dashboard.WITHDRAW_ROLE(), msg.sender); diff --git a/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol b/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol new file mode 100644 index 000000000..d73cbb826 --- /dev/null +++ b/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {Permissions} from "contracts/0.8.25/vaults/Permissions.sol"; + +contract Permissions__Harness is Permissions { + function initialize(address _defaultAdmin, uint256 _confirmLifetime) external { + _initialize(_defaultAdmin, _confirmLifetime); + } + + function confirmingRoles() external pure returns (bytes32[] memory) { + return _confirmingRoles(); + } + + function fund(uint256 _ether) external { + _fund(_ether); + } + + function withdraw(address _recipient, uint256 _ether) external { + _withdraw(_recipient, _ether); + } + + function mintShares(address _recipient, uint256 _shares) external { + _mintShares(_recipient, _shares); + } + + function burnShares(uint256 _shares) external { + _burnShares(_shares); + } + + function rebalanceVault(uint256 _ether) external { + _rebalanceVault(_ether); + } + + function pauseBeaconChainDeposits() external { + _pauseBeaconChainDeposits(); + } + + function resumeBeaconChainDeposits() external { + _resumeBeaconChainDeposits(); + } + + function requestValidatorExit(bytes calldata _pubkey) external { + _requestValidatorExit(_pubkey); + } + + function transferStakingVaultOwnership(address _newOwner) external { + _transferStakingVaultOwnership(_newOwner); + } + + function setConfirmLifetime(uint256 _newConfirmLifetime) external { + _setConfirmLifetime(_newConfirmLifetime); + } +} diff --git a/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol b/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol new file mode 100644 index 000000000..ba372a73c --- /dev/null +++ b/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {BeaconProxy} from "@openzeppelin/contracts-v5.2/proxy/beacon/BeaconProxy.sol"; +import {Clones} from "@openzeppelin/contracts-v5.2/proxy/Clones.sol"; + +import {Permissions__Harness} from "./Permissions__Harness.sol"; +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; + +struct PermissionsConfig { + address defaultAdmin; + address nodeOperator; + uint256 confirmLifetime; + address funder; + address withdrawer; + address minter; + address burner; + address rebalancer; + address depositPauser; + address depositResumer; + address exitRequester; + address disconnecter; +} + +contract VaultFactory__MockPermissions { + address public immutable BEACON; + address public immutable PERMISSIONS_IMPL; + + /// @param _beacon The address of the beacon contract + /// @param _permissionsImpl The address of the Permissions implementation + constructor(address _beacon, address _permissionsImpl) { + if (_beacon == address(0)) revert ZeroArgument("_beacon"); + if (_permissionsImpl == address(0)) revert ZeroArgument("_permissionsImpl"); + + BEACON = _beacon; + PERMISSIONS_IMPL = _permissionsImpl; + } + + /// @notice Creates a new StakingVault and Permissions contracts + /// @param _permissionsConfig The params of permissions initialization + /// @param _stakingVaultInitializerExtraParams The params of vault initialization + function createVaultWithPermissions( + PermissionsConfig calldata _permissionsConfig, + bytes calldata _stakingVaultInitializerExtraParams + ) external returns (IStakingVault vault, Permissions__Harness permissions) { + // create StakingVault + vault = IStakingVault(address(new BeaconProxy(BEACON, ""))); + + // create Permissions + bytes memory immutableArgs = abi.encode(vault); + permissions = Permissions__Harness(payable(Clones.cloneWithImmutableArgs(PERMISSIONS_IMPL, immutableArgs))); + + // initialize StakingVault + vault.initialize(address(permissions), _permissionsConfig.nodeOperator, _stakingVaultInitializerExtraParams); + + // initialize Permissions + permissions.initialize(address(this), _permissionsConfig.confirmLifetime); + + // setup roles + permissions.grantRole(permissions.DEFAULT_ADMIN_ROLE(), _permissionsConfig.defaultAdmin); + permissions.grantRole(permissions.FUND_ROLE(), _permissionsConfig.funder); + permissions.grantRole(permissions.WITHDRAW_ROLE(), _permissionsConfig.withdrawer); + permissions.grantRole(permissions.MINT_ROLE(), _permissionsConfig.minter); + permissions.grantRole(permissions.BURN_ROLE(), _permissionsConfig.burner); + permissions.grantRole(permissions.REBALANCE_ROLE(), _permissionsConfig.rebalancer); + permissions.grantRole(permissions.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), _permissionsConfig.depositPauser); + permissions.grantRole(permissions.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _permissionsConfig.depositResumer); + permissions.grantRole(permissions.REQUEST_VALIDATOR_EXIT_ROLE(), _permissionsConfig.exitRequester); + permissions.grantRole(permissions.VOLUNTARY_DISCONNECT_ROLE(), _permissionsConfig.disconnecter); + + permissions.revokeRole(permissions.DEFAULT_ADMIN_ROLE(), address(this)); + + emit VaultCreated(address(permissions), address(vault)); + emit PermissionsCreated(_permissionsConfig.defaultAdmin, address(permissions)); + } + + /** + * @notice Event emitted on a Vault creation + * @param owner The address of the Vault owner + * @param vault The address of the created Vault + */ + event VaultCreated(address indexed owner, address indexed vault); + + /** + * @notice Event emitted on a Permissions creation + * @param admin The address of the Permissions admin + * @param permissions The address of the created Permissions + */ + event PermissionsCreated(address indexed admin, address indexed permissions); + + /** + * @notice Error thrown for when a given value cannot be zero + * @param argument Name of the argument + */ + error ZeroArgument(string argument); +} diff --git a/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol b/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol new file mode 100644 index 000000000..f68a3f5a3 --- /dev/null +++ b/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +contract VaultHub__MockPermissions { + function hello() external pure returns (string memory) { + return "hello"; + } +} diff --git a/test/0.8.25/vaults/permissions/permissions.test.ts b/test/0.8.25/vaults/permissions/permissions.test.ts new file mode 100644 index 000000000..868ff179e --- /dev/null +++ b/test/0.8.25/vaults/permissions/permissions.test.ts @@ -0,0 +1,138 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { + DepositContract__MockForStakingVault, + Permissions__Harness, + Permissions__Harness__factory, + StakingVault, + StakingVault__factory, + UpgradeableBeacon, + VaultFactory__MockPermissions, + VaultHub__MockPermissions, +} from "typechain-types"; +import { PermissionsConfigStruct } from "typechain-types/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions"; + +import { days, findEvents } from "lib"; + +describe("Permissions", () => { + let deployer: HardhatEthersSigner; + let defaultAdmin: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; + let funder: HardhatEthersSigner; + let withdrawer: HardhatEthersSigner; + let minter: HardhatEthersSigner; + let burner: HardhatEthersSigner; + let rebalancer: HardhatEthersSigner; + let depositPauser: HardhatEthersSigner; + let depositResumer: HardhatEthersSigner; + let exitRequester: HardhatEthersSigner; + let disconnecter: HardhatEthersSigner; + + let depositContract: DepositContract__MockForStakingVault; + let permissionsImpl: Permissions__Harness; + let stakingVaultImpl: StakingVault; + let vaultHub: VaultHub__MockPermissions; + let beacon: UpgradeableBeacon; + let vaultFactory: VaultFactory__MockPermissions; + let stakingVault: StakingVault; + let permissions: Permissions__Harness; + + before(async () => { + [ + deployer, + defaultAdmin, + nodeOperator, + funder, + withdrawer, + minter, + burner, + rebalancer, + depositPauser, + depositResumer, + exitRequester, + disconnecter, + ] = await ethers.getSigners(); + + // 1. Deploy DepositContract + depositContract = await ethers.deployContract("DepositContract__MockForStakingVault"); + + // 2. Deploy VaultHub + vaultHub = await ethers.deployContract("VaultHub__MockPermissions"); + + // 3. Deploy StakingVault implementation + stakingVaultImpl = await ethers.deployContract("StakingVault", [vaultHub, depositContract]); + expect(await stakingVaultImpl.vaultHub()).to.equal(vaultHub); + expect(await stakingVaultImpl.depositContract()).to.equal(depositContract); + + // 4. Deploy Beacon and use StakingVault implementation as initial implementation + beacon = await ethers.deployContract("UpgradeableBeacon", [stakingVaultImpl, deployer]); + + // 5. Deploy Permissions implementation + permissionsImpl = await ethers.deployContract("Permissions__Harness"); + + // 6. Deploy VaultFactory and use Beacon and Permissions implementations + vaultFactory = await ethers.deployContract("VaultFactory__MockPermissions", [beacon, permissionsImpl]); + + // 7. Create StakingVault and Permissions proxies using VaultFactory + const vaultCreationTx = await vaultFactory.connect(deployer).createVaultWithPermissions( + { + defaultAdmin, + nodeOperator, + confirmLifetime: days(7n), + funder, + withdrawer, + minter, + burner, + rebalancer, + depositPauser, + depositResumer, + exitRequester, + disconnecter, + } as PermissionsConfigStruct, + "0x", + ); + const vaultCreationReceipt = await vaultCreationTx.wait(); + if (!vaultCreationReceipt) throw new Error("Vault creation failed"); + + // 8. Get StakingVault's proxy address from the event and wrap it in StakingVault interface + const vaultCreatedEvents = findEvents(vaultCreationReceipt, "VaultCreated"); + if (vaultCreatedEvents.length != 1) throw new Error("There should be exactly one VaultCreated event"); + const vaultCreatedEvent = vaultCreatedEvents[0]; + + stakingVault = StakingVault__factory.connect(vaultCreatedEvent.args.vault, defaultAdmin); + + // 9. Get Permissions' proxy address from the event and wrap it in Permissions interface + const permissionsCreatedEvents = findEvents(vaultCreationReceipt, "PermissionsCreated"); + if (permissionsCreatedEvents.length != 1) throw new Error("There should be exactly one PermissionsCreated event"); + const permissionsCreatedEvent = permissionsCreatedEvents[0]; + + permissions = Permissions__Harness__factory.connect(permissionsCreatedEvent.args.permissions, defaultAdmin); + + // 10. Check that StakingVault is initialized properly + expect(await stakingVault.owner()).to.equal(permissions); + expect(await stakingVault.nodeOperator()).to.equal(nodeOperator); + + // 11. Check events + expect(vaultCreatedEvent.args.owner).to.equal(permissions); + expect(permissionsCreatedEvent.args.admin).to.equal(defaultAdmin); + }); + + context("initial permissions", () => { + it("should have the correct roles", async () => { + await checkSoleMember(defaultAdmin, await permissions.DEFAULT_ADMIN_ROLE()); + await checkSoleMember(funder, await permissions.FUND_ROLE()); + await checkSoleMember(withdrawer, await permissions.WITHDRAW_ROLE()); + await checkSoleMember(minter, await permissions.MINT_ROLE()); + await checkSoleMember(burner, await permissions.BURN_ROLE()); + await checkSoleMember(rebalancer, await permissions.REBALANCE_ROLE()); + }); + }); + + async function checkSoleMember(account: HardhatEthersSigner, role: string) { + expect(await permissions.getRoleMemberCount(role)).to.equal(1); + expect(await permissions.getRoleMember(role, 0)).to.equal(account); + } +});