From 234cdd0122fdb44826b95ea09d517ffa04e6ab51 Mon Sep 17 00:00:00 2001 From: skhomuti Date: Tue, 30 Jun 2026 14:30:57 +0500 Subject: [PATCH 1/3] feat: add erc20 lock boost provider --- src/ERC20LockBoostProvider.sol | 258 ++++ src/ERC20LockVault.sol | 45 + src/LidoGovernanceLockVault.sol | 74 ++ src/LidoGovernanceLockVaultFactory.sol | 59 + src/interfaces/IAragonVotingLockVault.sol | 22 + src/interfaces/IERC20LockBoostProvider.sol | 107 ++ src/interfaces/IERC20LockVault.sol | 30 + src/interfaces/IERC20LockVaultFactory.sol | 22 + src/interfaces/ILidoAragonVoting.sol | 20 + .../ILidoGovernanceLockVaultFactory.sol | 23 + src/interfaces/ISnapshotDelegation.sol | 16 + .../ISnapshotDelegationLockVault.sol | 19 + test/unit/ERC20LockBoostProvider.t.sol | 1149 +++++++++++++++++ 13 files changed, 1844 insertions(+) create mode 100644 src/ERC20LockBoostProvider.sol create mode 100644 src/ERC20LockVault.sol create mode 100644 src/LidoGovernanceLockVault.sol create mode 100644 src/LidoGovernanceLockVaultFactory.sol create mode 100644 src/interfaces/IAragonVotingLockVault.sol create mode 100644 src/interfaces/IERC20LockBoostProvider.sol create mode 100644 src/interfaces/IERC20LockVault.sol create mode 100644 src/interfaces/IERC20LockVaultFactory.sol create mode 100644 src/interfaces/ILidoAragonVoting.sol create mode 100644 src/interfaces/ILidoGovernanceLockVaultFactory.sol create mode 100644 src/interfaces/ISnapshotDelegation.sol create mode 100644 src/interfaces/ISnapshotDelegationLockVault.sol create mode 100644 test/unit/ERC20LockBoostProvider.t.sol diff --git a/src/ERC20LockBoostProvider.sol b/src/ERC20LockBoostProvider.sol new file mode 100644 index 000000000..eacda64ac --- /dev/null +++ b/src/ERC20LockBoostProvider.sol @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import { NodeOperator } from "./interfaces/IBaseModule.sol"; +import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; +import { IERC20LockBoostProvider } from "./interfaces/IERC20LockBoostProvider.sol"; +import { IERC20LockVault } from "./interfaces/IERC20LockVault.sol"; +import { IERC20LockVaultFactory } from "./interfaces/IERC20LockVaultFactory.sol"; +import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; +import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; +import { MAX_BP } from "./lib/Constants.sol"; + +/// @notice Stores operator-level ERC20 locks and exposes node operator boost for scoring. +contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgradeable, IERC20LockBoostProvider { + using SafeCast for uint256; + using SafeERC20 for IERC20; + + struct ERC20LockBoostProviderStorage { + mapping(uint256 nodeOperatorId => LockInfo) locks; + uint256 lockPeriod; + LockBoostStep[] lockBoostSteps; + } + + ICuratedModule public immutable MODULE; + IMetaRegistry public immutable META_REGISTRY; + address public immutable TOKEN; + address public immutable VAULT_FACTORY; + uint256 public immutable MIN_LOCK_PERIOD; + uint256 public immutable MAX_LOCK_PERIOD; + + bytes32 public constant SET_LOCK_PERIOD_ROLE = keccak256("SET_LOCK_PERIOD_ROLE"); + + uint256 internal constant MAX_LOCK_PROVIDER_MULTIPLIER_BP = 2 * MAX_BP; + + // keccak256(abi.encode(uint256(keccak256("ERC20LockBoostProvider")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC20_LOCK_BOOST_PROVIDER_STORAGE_LOCATION = + 0x0d048d8a76e474169bd4c83d3eb46f84ff5b8d069b5f5174bf8047b6bd66fb00; + + constructor(address module, address token, address vaultFactory, uint256 minLockPeriod, uint256 maxLockPeriod) { + if (module == address(0) || token == address(0) || vaultFactory == address(0)) revert ZeroAddress(); + if (minLockPeriod == 0 || minLockPeriod > maxLockPeriod) revert InvalidLockPeriod(); + if (maxLockPeriod > type(uint128).max) revert InvalidLockPeriod(); + + ICuratedModule curatedModule = ICuratedModule(module); + IMetaRegistry metaRegistry = curatedModule.META_REGISTRY(); + + MODULE = curatedModule; + META_REGISTRY = metaRegistry; + TOKEN = token; + VAULT_FACTORY = vaultFactory; + MIN_LOCK_PERIOD = minLockPeriod; + MAX_LOCK_PERIOD = maxLockPeriod; + + _disableInitializers(); + } + + /// @inheritdoc IERC20LockBoostProvider + function initialize(address admin, uint256 lockPeriod) external initializer { + if (admin == address(0)) revert ZeroAdminAddress(); + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _setLockPeriod(lockPeriod); + } + + /// @inheritdoc IERC20LockBoostProvider + function getInitializedVersion() external view returns (uint64) { + return _getInitializedVersion(); + } + + /// @inheritdoc IERC20LockBoostProvider + function setLockPeriod(uint256 lockPeriod) external onlyRole(SET_LOCK_PERIOD_ROLE) { + if (_storage().lockPeriod == lockPeriod) revert SameLockPeriod(); + _setLockPeriod(lockPeriod); + } + + /// @inheritdoc IERC20LockBoostProvider + function setLockBoostSteps(LockBoostStep[] calldata steps) external onlyRole(DEFAULT_ADMIN_ROLE) { + _checkLockBoostSteps(steps); + + ERC20LockBoostProviderStorage storage $ = _storage(); + delete $.lockBoostSteps; + uint256 stepsCount = steps.length; + for (uint256 i; i < stepsCount; ++i) { + $.lockBoostSteps.push( + LockBoostStep({ minAmount: steps[i].minAmount, multiplierBP: steps[i].multiplierBP }) + ); + } + + emit LockBoostStepsSet(steps); + META_REGISTRY.notifyWeightBoostProviderConfigChanged(); + } + + /// @inheritdoc IERC20LockBoostProvider + function getLockBoostSteps() external view returns (LockBoostStep[] memory steps) { + steps = _storage().lockBoostSteps; + } + + /// @inheritdoc IERC20LockBoostProvider + function lock(uint256 nodeOperatorId, uint256 amount) external { + _onlyNodeOperatorOwner(nodeOperatorId); + _lockTokens(nodeOperatorId, amount); + } + + /// @inheritdoc IERC20LockBoostProvider + function withdraw(uint256 nodeOperatorId, uint256 amount, address receiver) external { + _onlyNodeOperatorOwner(nodeOperatorId); + + LockInfo storage lockInfo = _storage().locks[nodeOperatorId]; + if (lockInfo.amount == 0) revert NoTokensLocked(); + if (block.timestamp < lockInfo.lockUntil && !_isEarlyWithdrawalAllowed(nodeOperatorId)) { + revert LockPeriodNotEnded(); + } + + _withdraw(nodeOperatorId, amount, receiver); + } + + /// @inheritdoc IWeightBoostProvider + function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { + multiplierBP = _getMultiplierBP(_storage().locks[nodeOperatorId].amount); + } + + /// @inheritdoc IERC20LockBoostProvider + function getNodeOperatorLock(uint256 nodeOperatorId) external view returns (LockInfo memory lockInfo) { + lockInfo = _storage().locks[nodeOperatorId]; + } + + /// @inheritdoc IERC20LockBoostProvider + function getVault(uint256 nodeOperatorId) external view returns (address vault) { + vault = _storage().locks[nodeOperatorId].vault; + } + + /// @inheritdoc IERC20LockBoostProvider + function getLockPeriod() external view returns (uint256) { + return _storage().lockPeriod; + } + + function _lockTokens(uint256 nodeOperatorId, uint256 amount) internal { + if (amount == 0) revert InvalidAmount(); + + ERC20LockBoostProviderStorage storage $ = _storage(); + LockInfo storage lockInfo = $.locks[nodeOperatorId]; + + address vault = lockInfo.vault; + if (vault == address(0)) { + vault = IERC20LockVaultFactory(VAULT_FACTORY).createVault({ + nodeOperatorId: nodeOperatorId, + token: TOKEN, + provider: address(this), + module: address(MODULE) + }); + if (vault == address(0)) revert ZeroAddress(); + lockInfo.vault = vault; + emit VaultCreated(nodeOperatorId, vault, TOKEN); + } + + uint256 oldAmount = lockInfo.amount; + uint256 newAmount = oldAmount + amount; + uint256 lockUntil = block.timestamp + $.lockPeriod; + + lockInfo.amount = newAmount.toUint128(); + lockInfo.lockUntil = lockUntil.toUint128(); + + IERC20(TOKEN).safeTransferFrom(msg.sender, vault, amount); + + emit TokensLocked(nodeOperatorId, amount, lockUntil); + _syncWeightAfterLockChange(nodeOperatorId, oldAmount, newAmount); + } + + function _withdraw(uint256 nodeOperatorId, uint256 amount, address receiver) internal { + if (amount == 0) revert InvalidAmount(); + + LockInfo storage lockInfo = _storage().locks[nodeOperatorId]; + uint256 oldAmount = lockInfo.amount; + if (oldAmount == 0) revert NoTokensLocked(); + if (amount > oldAmount) revert InvalidAmount(); + + uint256 newAmount = oldAmount - amount; + lockInfo.amount = newAmount.toUint128(); + if (newAmount == 0) { + lockInfo.lockUntil = 0; + } + + IERC20LockVault(lockInfo.vault).transferTokens(receiver, amount); + + emit TokensWithdrawn(nodeOperatorId, receiver, amount, newAmount); + _syncWeightAfterLockChange(nodeOperatorId, oldAmount, newAmount); + } + + function _syncWeightAfterLockChange(uint256 nodeOperatorId, uint256 oldAmount, uint256 newAmount) internal { + uint16 oldMultiplierBP = _getMultiplierBP(oldAmount); + uint16 newMultiplierBP = _getMultiplierBP(newAmount); + if (oldMultiplierBP != newMultiplierBP) META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + } + + function _getMultiplierBP(uint256 amount) internal view returns (uint16 multiplierBP) { + ERC20LockBoostProviderStorage storage $ = _storage(); + multiplierBP = uint16(MAX_BP); + uint256 stepsCount = $.lockBoostSteps.length; + for (uint256 i; i < stepsCount; ++i) { + LockBoostStep storage step = $.lockBoostSteps[i]; + if (amount < step.minAmount) return multiplierBP; + multiplierBP = step.multiplierBP; + } + } + + function _onlyNodeOperatorOwner(uint256 nodeOperatorId) internal view { + address owner = MODULE.getNodeOperatorOwner(nodeOperatorId); + if (owner == address(0)) revert NodeOperatorDoesNotExist(); + if (owner != msg.sender) revert SenderIsNotNodeOperatorOwner(); + } + + function _isEarlyWithdrawalAllowed(uint256 nodeOperatorId) internal view returns (bool) { + if (META_REGISTRY.getNodeOperatorGroupId(nodeOperatorId) != 0) return false; + + NodeOperator memory no = MODULE.getNodeOperator(nodeOperatorId); + return no.totalDepositedKeys == no.totalWithdrawnKeys && no.depositableValidatorsCount == 0; + } + + function _checkLockBoostSteps(LockBoostStep[] calldata steps) internal pure { + uint256 stepsCount = steps.length; + if (stepsCount == 0) revert InvalidLockBoostSteps(); + + uint256 previousMinAmount = steps[0].minAmount; + uint256 previousMultiplierBP = steps[0].multiplierBP; + if (steps[0].multiplierBP > MAX_LOCK_PROVIDER_MULTIPLIER_BP) revert InvalidLockBoostSteps(); + + for (uint256 i = 1; i < stepsCount; ++i) { + LockBoostStep calldata step = steps[i]; + if (step.minAmount <= previousMinAmount) revert InvalidLockBoostSteps(); + if (step.multiplierBP < previousMultiplierBP) revert InvalidLockBoostSteps(); + if (step.multiplierBP > MAX_LOCK_PROVIDER_MULTIPLIER_BP) revert InvalidLockBoostSteps(); + + previousMinAmount = step.minAmount; + previousMultiplierBP = step.multiplierBP; + } + } + + function _setLockPeriod(uint256 lockPeriod) internal { + if (lockPeriod < MIN_LOCK_PERIOD || lockPeriod > MAX_LOCK_PERIOD) revert InvalidLockPeriod(); + + _storage().lockPeriod = lockPeriod; + emit LockPeriodSet(lockPeriod); + } + + function _storage() internal pure returns (ERC20LockBoostProviderStorage storage $) { + assembly ("memory-safe") { + $.slot := ERC20_LOCK_BOOST_PROVIDER_STORAGE_LOCATION + } + } +} diff --git a/src/ERC20LockVault.sol b/src/ERC20LockVault.sol new file mode 100644 index 000000000..fd8aa41f9 --- /dev/null +++ b/src/ERC20LockVault.sol @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IBaseModule } from "./interfaces/IBaseModule.sol"; +import { IERC20LockVault } from "./interfaces/IERC20LockVault.sol"; + +/// @notice Minimal per-operator vault holding locked ERC20 tokens. +contract ERC20LockVault is IERC20LockVault { + using SafeERC20 for IERC20; + + uint256 public immutable NODE_OPERATOR_ID; + address public immutable TOKEN; + address public immutable PROVIDER; + IBaseModule public immutable MODULE; + + constructor(uint256 nodeOperatorId, address token, address provider, address module) { + if (token == address(0) || provider == address(0) || module == address(0)) revert ZeroAddress(); + + NODE_OPERATOR_ID = nodeOperatorId; + TOKEN = token; + PROVIDER = provider; + MODULE = IBaseModule(module); + } + + /// @inheritdoc IERC20LockVault + function transferTokens(address receiver, uint256 amount) external { + _checkProvider(); + if (receiver == address(0)) revert ZeroAddress(); + + IERC20(TOKEN).safeTransfer(receiver, amount); + } + + function _checkNodeOperatorOwner() internal view { + if (msg.sender != MODULE.getNodeOperatorOwner(NODE_OPERATOR_ID)) revert SenderIsNotNodeOperatorOwner(); + } + + function _checkProvider() internal view { + if (msg.sender != PROVIDER) revert SenderIsNotProvider(); + } +} diff --git a/src/LidoGovernanceLockVault.sol b/src/LidoGovernanceLockVault.sol new file mode 100644 index 000000000..41f17285e --- /dev/null +++ b/src/LidoGovernanceLockVault.sol @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { ERC20LockVault } from "./ERC20LockVault.sol"; +import { IAragonVotingLockVault } from "./interfaces/IAragonVotingLockVault.sol"; +import { ILidoAragonVoting } from "./interfaces/ILidoAragonVoting.sol"; +import { IERC20LockVault } from "./interfaces/IERC20LockVault.sol"; +import { ILidoGovernanceLockVaultFactory } from "./interfaces/ILidoGovernanceLockVaultFactory.sol"; +import { ISnapshotDelegation } from "./interfaces/ISnapshotDelegation.sol"; +import { ISnapshotDelegationLockVault } from "./interfaces/ISnapshotDelegationLockVault.sol"; + +/// @notice ERC20 lock vault with Lido Aragon Voting and Snapshot delegation capabilities. +contract LidoGovernanceLockVault is ERC20LockVault, IAragonVotingLockVault, ISnapshotDelegationLockVault { + address public immutable VOTING_CONTRACT; + ILidoGovernanceLockVaultFactory public immutable GOVERNANCE_CONFIG; + + constructor( + uint256 nodeOperatorId, + address token, + address provider, + address module, + address votingContract, + address governanceConfig + ) ERC20LockVault(nodeOperatorId, token, provider, module) { + if (votingContract == address(0) || governanceConfig == address(0)) { + revert IERC20LockVault.ZeroAddress(); + } + + VOTING_CONTRACT = votingContract; + GOVERNANCE_CONFIG = ILidoGovernanceLockVaultFactory(governanceConfig); + } + + /// @inheritdoc IAragonVotingLockVault + function assignVotingDelegate(address votingDelegate) external { + _checkNodeOperatorOwner(); + + ILidoAragonVoting(VOTING_CONTRACT).assignDelegate(votingDelegate); + } + + /// @inheritdoc IAragonVotingLockVault + function unassignVotingDelegate() external { + _checkNodeOperatorOwner(); + + ILidoAragonVoting(VOTING_CONTRACT).unassignDelegate(); + } + + /// @inheritdoc ISnapshotDelegationLockVault + function assignSnapshotDelegate(bytes32 snapshotSpaceId, address snapshotDelegate) external { + _checkNodeOperatorOwner(); + + ISnapshotDelegation(snapshotDelegation()).setDelegate(snapshotSpaceId, snapshotDelegate); + } + + /// @inheritdoc ISnapshotDelegationLockVault + function unassignSnapshotDelegate(bytes32 snapshotSpaceId) external { + _checkNodeOperatorOwner(); + + ISnapshotDelegation(snapshotDelegation()).clearDelegate(snapshotSpaceId); + } + + /// @inheritdoc IAragonVotingLockVault + function vote(uint256 voteId, bool support) external { + _checkNodeOperatorOwner(); + + ILidoAragonVoting(VOTING_CONTRACT).vote(voteId, support, false); + } + + /// @inheritdoc ISnapshotDelegationLockVault + function snapshotDelegation() public view returns (address) { + return GOVERNANCE_CONFIG.snapshotDelegation(); + } +} diff --git a/src/LidoGovernanceLockVaultFactory.sol b/src/LidoGovernanceLockVaultFactory.sol new file mode 100644 index 000000000..f61b34c61 --- /dev/null +++ b/src/LidoGovernanceLockVaultFactory.sol @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { AccessControlEnumerable } from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; + +import { ILidoGovernanceLockVaultFactory } from "./interfaces/ILidoGovernanceLockVaultFactory.sol"; +import { LidoGovernanceLockVault } from "./LidoGovernanceLockVault.sol"; + +/// @notice Factory creating ERC20 lock vaults with Lido governance capabilities. +contract LidoGovernanceLockVaultFactory is ILidoGovernanceLockVaultFactory, AccessControlEnumerable { + address public immutable VOTING_CONTRACT; + address public snapshotDelegation; + + constructor(address admin, address votingContract, address snapshotDelegation_) { + if (admin == address(0) || votingContract == address(0) || snapshotDelegation_ == address(0)) { + revert ZeroAddress(); + } + + VOTING_CONTRACT = votingContract; + snapshotDelegation = snapshotDelegation_; + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + /// @notice Set Snapshot delegation registry used by all created vaults. + /// @param snapshotDelegation_ New Snapshot delegation registry. + function setSnapshotDelegation(address snapshotDelegation_) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (snapshotDelegation_ == address(0)) revert ZeroAddress(); + if (snapshotDelegation == snapshotDelegation_) revert SameSnapshotDelegation(); + + snapshotDelegation = snapshotDelegation_; + emit SnapshotDelegationSet(snapshotDelegation_); + } + + /// @notice Create a vault for the provided Node Operator. + /// @param nodeOperatorId Node Operator ID. + /// @param token ERC20 token locked in the vault. + /// @param provider ERC20 lock boost provider allowed to move locked tokens. + /// @param module Module used by optional vault capabilities to resolve the current owner. + /// @return vault Created vault address. + function createVault( + uint256 nodeOperatorId, + address token, + address provider, + address module + ) external returns (address vault) { + vault = address( + new LidoGovernanceLockVault({ + nodeOperatorId: nodeOperatorId, + token: token, + provider: provider, + module: module, + votingContract: VOTING_CONTRACT, + governanceConfig: address(this) + }) + ); + } +} diff --git a/src/interfaces/IAragonVotingLockVault.sol b/src/interfaces/IAragonVotingLockVault.sol new file mode 100644 index 000000000..76fc0cb25 --- /dev/null +++ b/src/interfaces/IAragonVotingLockVault.sol @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +/// @notice Optional Aragon Voting capability for an ERC20 lock vault. +interface IAragonVotingLockVault { + /// @notice Lido Aragon Voting contract used by this vault. + function VOTING_CONTRACT() external view returns (address); + + /// @notice Assign an Aragon Voting delegate from the vault address. + /// @param votingDelegate Address to assign as delegate. + function assignVotingDelegate(address votingDelegate) external; + + /// @notice Remove the current Aragon Voting delegate from the vault address. + function unassignVotingDelegate() external; + + /// @notice Cast a direct Aragon vote from the vault address. + /// @param voteId Vote ID. + /// @param support Whether to support the vote. + function vote(uint256 voteId, bool support) external; +} diff --git a/src/interfaces/IERC20LockBoostProvider.sol b/src/interfaces/IERC20LockBoostProvider.sol new file mode 100644 index 000000000..69c7dd21f --- /dev/null +++ b/src/interfaces/IERC20LockBoostProvider.sol @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; + +/// @notice Operator-level ERC20 lock registry and weight boost provider. +interface IERC20LockBoostProvider is IWeightBoostProvider { + struct LockBoostStep { + uint128 minAmount; + uint16 multiplierBP; + } + + struct LockInfo { + address vault; + uint128 amount; + uint128 lockUntil; + } + + event TokensLocked(uint256 indexed nodeOperatorId, uint256 amount, uint256 lockUntil); + event TokensWithdrawn( + uint256 indexed nodeOperatorId, + address indexed receiver, + uint256 amount, + uint256 remainingAmount + ); + event VaultCreated(uint256 indexed nodeOperatorId, address indexed vault, address indexed token); + event LockPeriodSet(uint256 lockPeriod); + event LockBoostStepsSet(LockBoostStep[] steps); + + error ZeroAddress(); + error ZeroAdminAddress(); + error InvalidAmount(); + error InvalidLockPeriod(); + error InvalidLockBoostSteps(); + error SameLockPeriod(); + error NodeOperatorDoesNotExist(); + error SenderIsNotNodeOperatorOwner(); + error NoTokensLocked(); + error LockPeriodNotEnded(); + + /// @notice ERC20 token address. + function TOKEN() external view returns (address); + + /// @notice Factory creating per-operator ERC20 lock vaults. + function VAULT_FACTORY() external view returns (address); + + /// @notice Minimum lock period allowed by the registry. + function MIN_LOCK_PERIOD() external view returns (uint256); + + /// @notice Maximum lock period allowed by the registry. + function MAX_LOCK_PERIOD() external view returns (uint256); + + /// @notice Role allowed to set the default lock period. + function SET_LOCK_PERIOD_ROLE() external view returns (bytes32); + + /// @notice Initialize the provider. + /// @param admin Address to receive DEFAULT_ADMIN_ROLE. + /// @param lockPeriod Initial token lock period. + function initialize(address admin, uint256 lockPeriod) external; + + /// @notice Returns the initialized version of the contract. + function getInitializedVersion() external view returns (uint64); + + /// @notice Returns the current lock period. + function getLockPeriod() external view returns (uint256); + + /// @notice Set the default lock period applied to new locks and top-ups. + /// @param lockPeriod New lock period. + function setLockPeriod(uint256 lockPeriod) external; + + /// @notice Set token lock weight multiplier steps. + /// @param steps Ordered lock amount thresholds with full multiplier in basis points. + /// @dev Each step applies from minAmount inclusive until the next step minAmount. + /// 10_000 means no scaling, values below 10_000 penalize the weight. + /// Existing weights are not refreshed by this call; + /// a full deposit info update is requested via MetaRegistry. + /// Disable the boost provider in MetaRegistry to turn off the configured boosts. + function setLockBoostSteps(LockBoostStep[] calldata steps) external; + + /// @notice Returns token lock boost steps. + /// @return steps Stored lock boost steps. + function getLockBoostSteps() external view returns (LockBoostStep[] memory steps); + + /// @notice Lock tokens for a Node Operator or add tokens to an existing lock. + /// @param nodeOperatorId Node Operator ID. + /// @param amount Token amount to lock. + /// @dev Each call resets the lock period. + function lock(uint256 nodeOperatorId, uint256 amount) external; + + /// @notice Withdraw tokens after the lock period or when early withdrawal is allowed. + /// @param nodeOperatorId Node Operator ID. + /// @param amount Token amount to withdraw. + /// @param receiver Address to receive tokens. + function withdraw(uint256 nodeOperatorId, uint256 amount, address receiver) external; + + /// @notice Get Node Operator lock info. + /// @param nodeOperatorId Node Operator ID. + /// @return lockInfo Stored lock info. + function getNodeOperatorLock(uint256 nodeOperatorId) external view returns (LockInfo memory lockInfo); + + /// @notice Get Node Operator vault address. + /// @param nodeOperatorId Node Operator ID. + /// @return vault Stored vault address or zero if no vault has been created yet. + function getVault(uint256 nodeOperatorId) external view returns (address vault); +} diff --git a/src/interfaces/IERC20LockVault.sol b/src/interfaces/IERC20LockVault.sol new file mode 100644 index 000000000..b6c037ffd --- /dev/null +++ b/src/interfaces/IERC20LockVault.sol @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { IBaseModule } from "./IBaseModule.sol"; + +/// @notice Per-operator ERC20 vault used by the ERC20 lock boost provider. +interface IERC20LockVault { + error ZeroAddress(); + error SenderIsNotProvider(); + error SenderIsNotNodeOperatorOwner(); + + /// @notice Node Operator ID this vault belongs to. + function NODE_OPERATOR_ID() external view returns (uint256); + + /// @notice ERC20 token address. + function TOKEN() external view returns (address); + + /// @notice ERC20 lock boost provider allowed to move tokens. + function PROVIDER() external view returns (address); + + /// @notice Module used to resolve the current Node Operator owner. + function MODULE() external view returns (IBaseModule); + + /// @notice Transfer tokens to the receiver. + /// @param receiver Address to receive tokens. + /// @param amount Token amount to transfer. + function transferTokens(address receiver, uint256 amount) external; +} diff --git a/src/interfaces/IERC20LockVaultFactory.sol b/src/interfaces/IERC20LockVaultFactory.sol new file mode 100644 index 000000000..f8b953553 --- /dev/null +++ b/src/interfaces/IERC20LockVaultFactory.sol @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +/// @notice Factory creating per-operator ERC20 lock vaults. +interface IERC20LockVaultFactory { + error ZeroAddress(); + + /// @notice Create a vault for the provided Node Operator. + /// @param nodeOperatorId Node Operator ID. + /// @param token ERC20 token locked in the vault. + /// @param provider ERC20 lock boost provider allowed to move locked tokens. + /// @param module Module used by optional vault capabilities to resolve the current owner. + /// @return vault Created vault address. + function createVault( + uint256 nodeOperatorId, + address token, + address provider, + address module + ) external returns (address vault); +} diff --git a/src/interfaces/ILidoAragonVoting.sol b/src/interfaces/ILidoAragonVoting.sol new file mode 100644 index 000000000..c71ff609b --- /dev/null +++ b/src/interfaces/ILidoAragonVoting.sol @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +/// @notice Lido Aragon Voting interface used by token lock vaults. +interface ILidoAragonVoting { + /// @notice Assign delegate allowed to vote on behalf of msg.sender. + /// @param delegate Address of the delegate. + function assignDelegate(address delegate) external; + + /// @notice Remove msg.sender's current voting delegate. + function unassignDelegate() external; + + /// @notice Vote directly from msg.sender's voting power. + /// @param voteId Vote ID. + /// @param support Whether to support the vote. + /// @param executesIfDecided Deprecated flag kept for ABI compatibility. + function vote(uint256 voteId, bool support, bool executesIfDecided) external; +} diff --git a/src/interfaces/ILidoGovernanceLockVaultFactory.sol b/src/interfaces/ILidoGovernanceLockVaultFactory.sol new file mode 100644 index 000000000..557c0f5db --- /dev/null +++ b/src/interfaces/ILidoGovernanceLockVaultFactory.sol @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { IERC20LockVaultFactory } from "./IERC20LockVaultFactory.sol"; + +/// @notice Factory and runtime governance config for Lido governance lock vaults. +interface ILidoGovernanceLockVaultFactory is IERC20LockVaultFactory { + event SnapshotDelegationSet(address snapshotDelegation); + + error SameSnapshotDelegation(); + + /// @notice Lido Aragon Voting contract used by created vaults. + function VOTING_CONTRACT() external view returns (address); + + /// @notice Snapshot delegation registry used by created vaults. + function snapshotDelegation() external view returns (address); + + /// @notice Set Snapshot delegation registry used by all created vaults. + /// @param snapshotDelegation New Snapshot delegation registry. + function setSnapshotDelegation(address snapshotDelegation) external; +} diff --git a/src/interfaces/ISnapshotDelegation.sol b/src/interfaces/ISnapshotDelegation.sol new file mode 100644 index 000000000..85fa6c3db --- /dev/null +++ b/src/interfaces/ISnapshotDelegation.sol @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +/// @notice Snapshot delegation registry interface used by token lock vaults. +interface ISnapshotDelegation { + /// @notice Set msg.sender's Snapshot delegate for a space. + /// @param id Snapshot space ID. Zero ID means all spaces. + /// @param delegate Address to assign as delegate. + function setDelegate(bytes32 id, address delegate) external; + + /// @notice Clear msg.sender's Snapshot delegate for a space. + /// @param id Snapshot space ID. Zero ID means all spaces. + function clearDelegate(bytes32 id) external; +} diff --git a/src/interfaces/ISnapshotDelegationLockVault.sol b/src/interfaces/ISnapshotDelegationLockVault.sol new file mode 100644 index 000000000..2f4caaf59 --- /dev/null +++ b/src/interfaces/ISnapshotDelegationLockVault.sol @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +/// @notice Optional Snapshot delegation capability for an ERC20 lock vault. +interface ISnapshotDelegationLockVault { + /// @notice Snapshot delegation registry used by this vault. + function snapshotDelegation() external view returns (address); + + /// @notice Assign a Snapshot delegate from the vault address for a space. + /// @param snapshotSpaceId Snapshot space ID. Zero ID means all spaces. + /// @param snapshotDelegate Address to assign as delegate. + function assignSnapshotDelegate(bytes32 snapshotSpaceId, address snapshotDelegate) external; + + /// @notice Remove the current Snapshot delegate from the vault address for a space. + /// @param snapshotSpaceId Snapshot space ID. Zero ID means all spaces. + function unassignSnapshotDelegate(bytes32 snapshotSpaceId) external; +} diff --git a/test/unit/ERC20LockBoostProvider.t.sol b/test/unit/ERC20LockBoostProvider.t.sol new file mode 100644 index 000000000..e9ddcec61 --- /dev/null +++ b/test/unit/ERC20LockBoostProvider.t.sol @@ -0,0 +1,1149 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { Test } from "forge-std/Test.sol"; + +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import { ERC20LockBoostProvider } from "src/ERC20LockBoostProvider.sol"; +import { ERC20LockVault } from "src/ERC20LockVault.sol"; +import { LidoGovernanceLockVault } from "src/LidoGovernanceLockVault.sol"; +import { LidoGovernanceLockVaultFactory } from "src/LidoGovernanceLockVaultFactory.sol"; +import { IAragonVotingLockVault } from "src/interfaces/IAragonVotingLockVault.sol"; +import { IERC20LockBoostProvider } from "src/interfaces/IERC20LockBoostProvider.sol"; +import { IERC20LockVault } from "src/interfaces/IERC20LockVault.sol"; +import { IERC20LockVaultFactory } from "src/interfaces/IERC20LockVaultFactory.sol"; +import { ILidoGovernanceLockVaultFactory } from "src/interfaces/ILidoGovernanceLockVaultFactory.sol"; +import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; +import { ISnapshotDelegationLockVault } from "src/interfaces/ISnapshotDelegationLockVault.sol"; +import { MetaRegistry } from "src/MetaRegistry.sol"; +import { NodeOperator, NodeOperatorManagementProperties } from "src/interfaces/IBaseModule.sol"; + +import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; +import { StakingRouterMock } from "../helpers/mocks/StakingRouterMock.sol"; +import { ERC20Testable } from "../helpers/ERCTestable.sol"; +import { Utilities } from "../helpers/Utilities.sol"; +import { Fixtures } from "../helpers/Fixtures.sol"; + +contract LidoAragonVotingMock { + mapping(address voter => address delegate) public delegateOf; + address public lastVoter; + uint256 public lastVoteId; + bool public lastSupport; + bool public lastExecutesIfDecided; + + function assignDelegate(address delegate) external { + delegateOf[msg.sender] = delegate; + } + + function unassignDelegate() external { + delegateOf[msg.sender] = address(0); + } + + function vote(uint256 voteId, bool support, bool executesIfDecided) external { + lastVoter = msg.sender; + lastVoteId = voteId; + lastSupport = support; + lastExecutesIfDecided = executesIfDecided; + } +} + +contract SnapshotDelegationMock { + mapping(address delegator => mapping(bytes32 id => address delegate)) public delegateOf; + address public lastDelegator; + bytes32 public lastId; + address public lastDelegate; + + function setDelegate(bytes32 id, address delegate) external { + delegateOf[msg.sender][id] = delegate; + lastDelegator = msg.sender; + lastId = id; + lastDelegate = delegate; + } + + function clearDelegate(bytes32 id) external { + delete delegateOf[msg.sender][id]; + lastDelegator = msg.sender; + lastId = id; + lastDelegate = address(0); + } +} + +contract ERC20LockVaultFactoryZeroMock is IERC20LockVaultFactory { + function createVault(uint256, address, address, address) external pure returns (address) { + return address(0); + } +} + +contract ERC20LockBoostProviderForTest is ERC20LockBoostProvider { + constructor( + address module, + address token, + address vaultFactory, + uint256 minLockPeriod, + uint256 maxLockPeriod + ) ERC20LockBoostProvider(module, token, vaultFactory, minLockPeriod, maxLockPeriod) {} + + function mock_withdraw(uint256 nodeOperatorId, uint256 amount, address receiver) external { + _withdraw(nodeOperatorId, amount, receiver); + } +} + +contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { + CuratedMock public module; + StakingRouterMock public stakingRouter; + MetaRegistry public registry; + ERC20Testable public token; + LidoAragonVotingMock public voting; + SnapshotDelegationMock public snapshotDelegation; + LidoGovernanceLockVaultFactory public vaultFactory; + ERC20LockBoostProvider public provider; + + address public admin; + address public groupManager; + address public bondCurveWeightManager; + address public lockPeriodManager; + address public nodeOperatorOwner; + address public receiver; + address public votingDelegate; + address public snapshotDelegate; + address public stranger; + + uint256 internal constant NODE_OPERATOR_ID = 0; + uint256 internal constant GROUP_ID = 1; + uint256 internal constant BASE_WEIGHT = 10000; + uint16 internal constant MAX_BP = 10000; + uint256 internal constant MIN_LOCK_PERIOD = 1 days; + uint256 internal constant MAX_LOCK_PERIOD = 365 days; + uint256 internal constant LOCK_PERIOD = 30 days; + uint128 internal constant STEP_1_AMOUNT = 100 ether; + uint128 internal constant STEP_2_AMOUNT = 200 ether; + uint16 internal constant STEP_1_MULTIPLIER_BP = 11000; + uint16 internal constant STEP_2_MULTIPLIER_BP = 11500; + bytes32 internal constant SNAPSHOT_ALL_SPACES = bytes32(0); + bytes32 internal constant SNAPSHOT_SPACE_ID = bytes32("lido-snapshot-space"); + + function setUp() public virtual { + admin = nextAddress("ADMIN"); + groupManager = nextAddress("GROUP_MANAGER"); + bondCurveWeightManager = nextAddress("BOND_CURVE_WEIGHT_MANAGER"); + lockPeriodManager = nextAddress("LOCK_PERIOD_MANAGER"); + nodeOperatorOwner = nextAddress("NODE_OPERATOR_OWNER"); + receiver = nextAddress("RECEIVER"); + votingDelegate = nextAddress("VOTING_DELEGATE"); + snapshotDelegate = nextAddress("SNAPSHOT_DELEGATE"); + stranger = nextAddress("STRANGER"); + + module = new CuratedMock(); + module.mock_setNodeOperatorsCount(1); + module.mock_setNodeOperatorManagementProperties( + NodeOperatorManagementProperties({ + managerAddress: nodeOperatorOwner, + rewardAddress: nodeOperatorOwner, + extendedManagerPermissions: true + }) + ); + stakingRouter = StakingRouterMock(module.LIDO_LOCATOR().stakingRouter()); + address[] memory modules = new address[](1); + modules[0] = address(module); + stakingRouter.setModules(modules); + + registry = new MetaRegistry(address(module), address(0)); + module.mock_setMetaRegistry(address(registry)); + _enableInitializers(address(registry)); + registry.initialize(admin); + + vm.startPrank(admin); + registry.grantRole(registry.MANAGE_OPERATOR_GROUPS_ROLE(), groupManager); + registry.grantRole(registry.SET_BOND_CURVE_WEIGHT_ROLE(), bondCurveWeightManager); + vm.stopPrank(); + + token = new ERC20Testable(); + voting = new LidoAragonVotingMock(); + snapshotDelegation = new SnapshotDelegationMock(); + vaultFactory = new LidoGovernanceLockVaultFactory(admin, address(voting), address(snapshotDelegation)); + provider = new ERC20LockBoostProvider( + address(module), + address(token), + address(vaultFactory), + MIN_LOCK_PERIOD, + MAX_LOCK_PERIOD + ); + _enableInitializers(address(provider)); + provider.initialize(admin, LOCK_PERIOD); + + vm.startPrank(admin); + provider.grantRole(provider.SET_LOCK_PERIOD_ROLE(), lockPeriodManager); + registry.addWeightBoostProvider(provider, IMetaRegistry.WeightBoostProviderMode.GroupMax); + vm.stopPrank(); + + vm.prank(bondCurveWeightManager); + registry.setBondCurveWeight(0, BASE_WEIGHT); + + vm.prank(groupManager); + registry.createOrUpdateOperatorGroup( + 0, + IMetaRegistry.OperatorGroup({ + name: "Alpha", + subNodeOperators: _subOperatorsArr1(uint64(NODE_OPERATOR_ID), 10000), + externalOperators: _extOperatorsArr0() + }) + ); + } + + function _setDefaultSteps() internal { + vm.prank(admin); + provider.setLockBoostSteps(_steps(STEP_1_AMOUNT, STEP_1_MULTIPLIER_BP, STEP_2_AMOUNT, STEP_2_MULTIPLIER_BP)); + } + + function _mintAndApprove(uint256 amount) internal { + token.mint(nodeOperatorOwner, amount); + vm.prank(nodeOperatorOwner); + token.approve(address(provider), amount); + } + + function _lock(uint256 amount) internal { + _mintAndApprove(amount); + vm.prank(nodeOperatorOwner); + provider.lock(NODE_OPERATOR_ID, amount); + } + + function _weight(uint256 multiplierBP) internal pure returns (uint256) { + return (BASE_WEIGHT * multiplierBP) / MAX_BP; + } + + function _subOperatorsArr1( + uint64 nodeOperatorId, + uint16 share + ) internal pure returns (IMetaRegistry.SubNodeOperator[] memory ops) { + ops = new IMetaRegistry.SubNodeOperator[](1); + ops[0] = IMetaRegistry.SubNodeOperator({ nodeOperatorId: nodeOperatorId, share: share }); + } + + function _subOperatorsArr2( + IMetaRegistry.SubNodeOperator memory op0, + IMetaRegistry.SubNodeOperator memory op1 + ) internal pure returns (IMetaRegistry.SubNodeOperator[] memory ops) { + ops = new IMetaRegistry.SubNodeOperator[](2); + ops[0] = op0; + ops[1] = op1; + } + + function _extOperatorsArr0() internal pure returns (IMetaRegistry.ExternalOperator[] memory ops) {} + + function _subOperatorsArr0() internal pure returns (IMetaRegistry.SubNodeOperator[] memory ops) {} + + function _steps( + uint128 amount0, + uint16 multiplier0, + uint128 amount1, + uint16 multiplier1 + ) internal pure returns (IERC20LockBoostProvider.LockBoostStep[] memory steps) { + steps = new IERC20LockBoostProvider.LockBoostStep[](2); + steps[0] = IERC20LockBoostProvider.LockBoostStep({ minAmount: amount0, multiplierBP: multiplier0 }); + steps[1] = IERC20LockBoostProvider.LockBoostStep({ minAmount: amount1, multiplierBP: multiplier1 }); + } + + function _steps1( + uint128 amount, + uint16 multiplier + ) internal pure returns (IERC20LockBoostProvider.LockBoostStep[] memory steps) { + steps = new IERC20LockBoostProvider.LockBoostStep[](1); + steps[0] = IERC20LockBoostProvider.LockBoostStep({ minAmount: amount, multiplierBP: multiplier }); + } + + function _steps0() internal pure returns (IERC20LockBoostProvider.LockBoostStep[] memory steps) {} +} + +contract ERC20LockBoostProviderConstructorTest is ERC20LockBoostProviderBaseTest { + function test_constructor_SetsImmutables() public view { + assertEq(address(provider.MODULE()), address(module)); + assertEq(address(provider.META_REGISTRY()), address(registry)); + assertEq(provider.TOKEN(), address(token)); + assertEq(provider.VAULT_FACTORY(), address(vaultFactory)); + assertEq(provider.MIN_LOCK_PERIOD(), MIN_LOCK_PERIOD); + assertEq(provider.MAX_LOCK_PERIOD(), MAX_LOCK_PERIOD); + } + + function test_constructor_RevertWhen_ZeroAddresses() public { + vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); + new ERC20LockBoostProvider(address(0), address(token), address(vaultFactory), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD); + + vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); + new ERC20LockBoostProvider( + address(module), + address(0), + address(vaultFactory), + MIN_LOCK_PERIOD, + MAX_LOCK_PERIOD + ); + + vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); + new ERC20LockBoostProvider(address(module), address(token), address(0), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD); + } + + function test_constructor_RevertWhen_InvalidLockPeriodBounds() public { + vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); + new ERC20LockBoostProvider(address(module), address(token), address(vaultFactory), 0, MAX_LOCK_PERIOD); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); + new ERC20LockBoostProvider( + address(module), + address(token), + address(vaultFactory), + MAX_LOCK_PERIOD, + MIN_LOCK_PERIOD + ); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); + new ERC20LockBoostProvider( + address(module), + address(token), + address(vaultFactory), + MIN_LOCK_PERIOD, + uint256(type(uint128).max) + 1 + ); + } +} + +contract ERC20LockBoostProviderInitializeTest is ERC20LockBoostProviderBaseTest { + function test_initialize_SetsAdminAndLockPeriod() public view { + assertEq(provider.getInitializedVersion(), 1); + assertEq(provider.getLockPeriod(), LOCK_PERIOD); + assertTrue(provider.hasRole(provider.DEFAULT_ADMIN_ROLE(), admin)); + } + + function test_initialize_RevertWhen_ZeroAdmin() public { + ERC20LockBoostProvider p = new ERC20LockBoostProvider( + address(module), + address(token), + address(vaultFactory), + MIN_LOCK_PERIOD, + MAX_LOCK_PERIOD + ); + _enableInitializers(address(p)); + + vm.expectRevert(IERC20LockBoostProvider.ZeroAdminAddress.selector); + p.initialize(address(0), LOCK_PERIOD); + } + + function test_initialize_RevertWhen_InvalidLockPeriod() public { + ERC20LockBoostProvider p = new ERC20LockBoostProvider( + address(module), + address(token), + address(vaultFactory), + MIN_LOCK_PERIOD, + MAX_LOCK_PERIOD + ); + _enableInitializers(address(p)); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); + p.initialize(admin, MIN_LOCK_PERIOD - 1); + } + + function test_initialize_RevertWhen_DoubleCall() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + provider.initialize(admin, LOCK_PERIOD); + } +} + +contract ERC20LockBoostProviderAdminTest is ERC20LockBoostProviderBaseTest { + function test_setLockPeriod() public { + uint256 newLockPeriod = LOCK_PERIOD + 1 days; + + vm.expectEmit(address(provider)); + emit IERC20LockBoostProvider.LockPeriodSet(newLockPeriod); + vm.prank(lockPeriodManager); + provider.setLockPeriod(newLockPeriod); + + assertEq(provider.getLockPeriod(), newLockPeriod); + } + + function test_setLockPeriod_RevertWhen_NoRole() public { + expectRoleRevert(stranger, provider.SET_LOCK_PERIOD_ROLE()); + vm.prank(stranger); + provider.setLockPeriod(LOCK_PERIOD + 1 days); + } + + function test_setLockPeriod_RevertWhen_SamePeriod() public { + vm.prank(lockPeriodManager); + vm.expectRevert(IERC20LockBoostProvider.SameLockPeriod.selector); + provider.setLockPeriod(LOCK_PERIOD); + } + + function test_setLockPeriod_RevertWhen_InvalidPeriod() public { + vm.prank(lockPeriodManager); + vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); + provider.setLockPeriod(MIN_LOCK_PERIOD - 1); + } + + function test_setLockBoostSteps() public { + IERC20LockBoostProvider.LockBoostStep[] memory steps = _steps( + STEP_1_AMOUNT, + STEP_1_MULTIPLIER_BP, + STEP_2_AMOUNT, + STEP_2_MULTIPLIER_BP + ); + + vm.expectEmit(address(provider)); + emit IERC20LockBoostProvider.LockBoostStepsSet(steps); + vm.expectCall( + address(registry), + abi.encodeWithSelector(IMetaRegistry.notifyWeightBoostProviderConfigChanged.selector) + ); + vm.prank(admin); + provider.setLockBoostSteps(steps); + + IERC20LockBoostProvider.LockBoostStep[] memory stored = provider.getLockBoostSteps(); + assertEq(stored.length, 2); + assertEq(stored[0].minAmount, STEP_1_AMOUNT); + assertEq(stored[0].multiplierBP, STEP_1_MULTIPLIER_BP); + assertEq(stored[1].minAmount, STEP_2_AMOUNT); + assertEq(stored[1].multiplierBP, STEP_2_MULTIPLIER_BP); + } + + function test_setLockBoostSteps_RevertWhen_EmptySteps() public { + _setDefaultSteps(); + + vm.prank(admin); + vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); + provider.setLockBoostSteps(_steps0()); + } + + function test_setLockBoostSteps_DoesNotRefreshExistingWeights() public { + _setDefaultSteps(); + _lock(STEP_2_AMOUNT); + assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), STEP_2_MULTIPLIER_BP); + assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), _weight(STEP_2_MULTIPLIER_BP)); + + vm.prank(admin); + provider.setLockBoostSteps(_steps1(STEP_2_AMOUNT + 1, 12000)); + + assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), MAX_BP); + assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), _weight(STEP_2_MULTIPLIER_BP)); + + registry.refreshOperatorWeight(NODE_OPERATOR_ID); + assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), BASE_WEIGHT); + } + + function test_setLockBoostSteps_AllowsBelowBaselineMultiplier() public { + vm.prank(admin); + provider.setLockBoostSteps(_steps1(STEP_1_AMOUNT, 9000)); + + _lock(STEP_1_AMOUNT); + + assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), 9000); + assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), _weight(9000)); + } + + function test_setLockBoostSteps_RevertWhen_NoRole() public { + expectRoleRevert(stranger, provider.DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + provider.setLockBoostSteps(_steps1(STEP_1_AMOUNT, STEP_1_MULTIPLIER_BP)); + } + + function test_setLockBoostSteps_RevertWhen_InvalidSteps() public { + vm.startPrank(admin); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); + provider.setLockBoostSteps(_steps1(STEP_1_AMOUNT, uint16(20001))); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); + provider.setLockBoostSteps(_steps(STEP_1_AMOUNT, STEP_1_MULTIPLIER_BP, STEP_1_AMOUNT, STEP_2_MULTIPLIER_BP)); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); + provider.setLockBoostSteps(_steps(STEP_1_AMOUNT, STEP_2_MULTIPLIER_BP, STEP_2_AMOUNT, STEP_1_MULTIPLIER_BP)); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); + provider.setLockBoostSteps(_steps(STEP_1_AMOUNT, STEP_1_MULTIPLIER_BP, STEP_2_AMOUNT, uint16(20001))); + + vm.stopPrank(); + } +} + +contract ERC20LockBoostProviderLockTest is ERC20LockBoostProviderBaseTest { + function test_lock_CreatesVaultAndLocksTokens() public { + _setDefaultSteps(); + uint256 amount = STEP_1_AMOUNT; + uint256 nowTs = 1000; + vm.warp(nowTs); + + _mintAndApprove(amount); + vm.expectEmit(address(provider)); + emit IERC20LockBoostProvider.TokensLocked(NODE_OPERATOR_ID, amount, nowTs + LOCK_PERIOD); + vm.prank(nodeOperatorOwner); + provider.lock(NODE_OPERATOR_ID, amount); + + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + assertTrue(lockInfo.vault != address(0)); + assertEq(provider.getVault(NODE_OPERATOR_ID), lockInfo.vault); + assertEq(LidoGovernanceLockVault(lockInfo.vault).VOTING_CONTRACT(), address(voting)); + assertEq(LidoGovernanceLockVault(lockInfo.vault).snapshotDelegation(), address(snapshotDelegation)); + assertEq(lockInfo.amount, amount); + assertEq(lockInfo.lockUntil, nowTs + LOCK_PERIOD); + assertEq(token.balanceOf(lockInfo.vault), amount); + assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), STEP_1_MULTIPLIER_BP); + assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), _weight(STEP_1_MULTIPLIER_BP)); + } + + function test_lock_ResetsLockUntil() public { + _setDefaultSteps(); + vm.warp(1000); + _lock(1 ether); + + vm.warp(2000); + _lock(1 ether); + + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + assertEq(lockInfo.amount, 2 ether); + assertEq(lockInfo.lockUntil, 2000 + LOCK_PERIOD); + } + + function test_lock_DoesNotRefreshRegistryWhenBoostUnchanged() public { + _setDefaultSteps(); + _lock(STEP_1_AMOUNT); + + expectNoCall( + address(registry), + abi.encodeWithSelector(IMetaRegistry.notifyWeightBoostChanged.selector, NODE_OPERATOR_ID) + ); + _lock(1 ether); + + assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), STEP_1_MULTIPLIER_BP); + } + + function test_lock_RefreshesRegistryWhenBoostIncreases() public { + _setDefaultSteps(); + _lock(STEP_1_AMOUNT); + + vm.expectCall( + address(registry), + abi.encodeWithSelector(IMetaRegistry.notifyWeightBoostChanged.selector, NODE_OPERATOR_ID) + ); + _lock(STEP_2_AMOUNT - STEP_1_AMOUNT); + + assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), STEP_2_MULTIPLIER_BP); + assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), _weight(STEP_2_MULTIPLIER_BP)); + } + + function test_lock_GroupMaxBoostAppliesToWholeGroup() public { + module.mock_setNodeOperatorsCount(2); + _setDefaultSteps(); + _lock(STEP_1_AMOUNT); + + IMetaRegistry.SubNodeOperator memory op0 = IMetaRegistry.SubNodeOperator({ + nodeOperatorId: uint64(NODE_OPERATOR_ID), + share: 5000 + }); + IMetaRegistry.SubNodeOperator memory op1 = IMetaRegistry.SubNodeOperator({ nodeOperatorId: 1, share: 5000 }); + + vm.prank(groupManager); + registry.createOrUpdateOperatorGroup( + GROUP_ID, + IMetaRegistry.OperatorGroup({ + name: "Alpha", + subNodeOperators: _subOperatorsArr2(op0, op1), + externalOperators: _extOperatorsArr0() + }) + ); + + assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), STEP_1_MULTIPLIER_BP); + assertEq(provider.getWeightBoostMultiplierBP(1), MAX_BP); + assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), _weight(STEP_1_MULTIPLIER_BP) / 2); + assertEq(registry.getNodeOperatorWeight(1), _weight(STEP_1_MULTIPLIER_BP) / 2); + } + + function test_lock_RevertWhen_InvalidAmount() public { + vm.prank(nodeOperatorOwner); + vm.expectRevert(IERC20LockBoostProvider.InvalidAmount.selector); + provider.lock(NODE_OPERATOR_ID, 0); + } + + function test_lock_RevertWhen_NotNodeOperatorOwner() public { + vm.prank(stranger); + vm.expectRevert(IERC20LockBoostProvider.SenderIsNotNodeOperatorOwner.selector); + provider.lock(NODE_OPERATOR_ID, 1 ether); + } + + function test_lock_RevertWhen_NodeOperatorDoesNotExist() public { + vm.prank(nodeOperatorOwner); + vm.expectRevert(IERC20LockBoostProvider.NodeOperatorDoesNotExist.selector); + provider.lock(NODE_OPERATOR_ID + 1, 1 ether); + } + + function test_lock_RevertWhen_FactoryReturnsZeroVault() public { + ERC20LockBoostProvider p = new ERC20LockBoostProvider( + address(module), + address(token), + address(new ERC20LockVaultFactoryZeroMock()), + MIN_LOCK_PERIOD, + MAX_LOCK_PERIOD + ); + _enableInitializers(address(p)); + p.initialize(admin, LOCK_PERIOD); + + token.mint(nodeOperatorOwner, 1 ether); + vm.prank(nodeOperatorOwner); + token.approve(address(p), 1 ether); + + vm.prank(nodeOperatorOwner); + vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); + p.lock(NODE_OPERATOR_ID, 1 ether); + } +} + +contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { + function test_withdraw_AfterLockPeriod() public { + _setDefaultSteps(); + _lock(STEP_2_AMOUNT); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + + vm.warp(lockInfo.lockUntil); + vm.expectEmit(address(provider)); + emit IERC20LockBoostProvider.TokensWithdrawn(NODE_OPERATOR_ID, receiver, 60 ether, 140 ether); + vm.prank(nodeOperatorOwner); + provider.withdraw(NODE_OPERATOR_ID, 60 ether, receiver); + + lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + assertEq(lockInfo.amount, 140 ether); + assertEq(token.balanceOf(receiver), 60 ether); + assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), STEP_1_MULTIPLIER_BP); + assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), _weight(STEP_1_MULTIPLIER_BP)); + } + + function test_withdraw_ClearsLockUntilWhenFullyWithdrawn() public { + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + + vm.warp(lockInfo.lockUntil); + vm.prank(nodeOperatorOwner); + provider.withdraw(NODE_OPERATOR_ID, 10 ether, receiver); + + lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + assertEq(lockInfo.amount, 0); + assertEq(lockInfo.lockUntil, 0); + } + + function test_withdraw_DoesNotRefreshRegistryWhenBoostUnchanged() public { + _setDefaultSteps(); + _lock(STEP_1_AMOUNT + 10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + + vm.warp(lockInfo.lockUntil); + expectNoCall( + address(registry), + abi.encodeWithSelector(IMetaRegistry.notifyWeightBoostChanged.selector, NODE_OPERATOR_ID) + ); + vm.prank(nodeOperatorOwner); + provider.withdraw(NODE_OPERATOR_ID, 1 ether, receiver); + + assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), STEP_1_MULTIPLIER_BP); + } + + function test_withdraw_RevertWhen_LockPeriodNotEnded() public { + _lock(10 ether); + + vm.prank(nodeOperatorOwner); + vm.expectRevert(IERC20LockBoostProvider.LockPeriodNotEnded.selector); + provider.withdraw(NODE_OPERATOR_ID, 1 ether, receiver); + } + + function test_withdraw_AllowsEarlyWithdrawalWhenOperatorRemovedAndInactive() public { + _lock(10 ether); + + vm.prank(groupManager); + registry.createOrUpdateOperatorGroup( + GROUP_ID, + IMetaRegistry.OperatorGroup({ + name: "", + subNodeOperators: _subOperatorsArr0(), + externalOperators: _extOperatorsArr0() + }) + ); + + vm.prank(nodeOperatorOwner); + provider.withdraw(NODE_OPERATOR_ID, 10 ether, receiver); + + assertEq(token.balanceOf(receiver), 10 ether); + } + + function test_withdraw_RevertWhen_EarlyWithdrawalOutsideGroupButDepositable() public { + NodeOperator memory no; + no.depositableValidatorsCount = 1; + module.mock_setNodeOperator(no); + + _lock(10 ether); + vm.prank(groupManager); + registry.createOrUpdateOperatorGroup( + GROUP_ID, + IMetaRegistry.OperatorGroup({ + name: "", + subNodeOperators: _subOperatorsArr0(), + externalOperators: _extOperatorsArr0() + }) + ); + + vm.prank(nodeOperatorOwner); + vm.expectRevert(IERC20LockBoostProvider.LockPeriodNotEnded.selector); + provider.withdraw(NODE_OPERATOR_ID, 10 ether, receiver); + } + + function test_withdraw_RevertWhen_InvalidInputs() public { + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + vm.warp(lockInfo.lockUntil); + + vm.startPrank(nodeOperatorOwner); + + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + provider.withdraw(NODE_OPERATOR_ID, 1 ether, address(0)); + + vm.expectRevert(IERC20LockBoostProvider.InvalidAmount.selector); + provider.withdraw(NODE_OPERATOR_ID, 0, receiver); + + vm.expectRevert(IERC20LockBoostProvider.InvalidAmount.selector); + provider.withdraw(NODE_OPERATOR_ID, 11 ether, receiver); + + vm.stopPrank(); + } + + function test_withdraw_RevertWhen_NoTokensLocked() public { + vm.prank(nodeOperatorOwner); + vm.expectRevert(IERC20LockBoostProvider.NoTokensLocked.selector); + provider.withdraw(NODE_OPERATOR_ID, 1 ether, receiver); + } + + function test_withdraw_InternalGuardRevertWhen_NoTokensLocked() public { + ERC20LockBoostProviderForTest p = new ERC20LockBoostProviderForTest( + address(module), + address(token), + address(vaultFactory), + MIN_LOCK_PERIOD, + MAX_LOCK_PERIOD + ); + + vm.expectRevert(IERC20LockBoostProvider.NoTokensLocked.selector); + p.mock_withdraw(NODE_OPERATOR_ID, 1 ether, receiver); + } +} + +contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { + function test_assignAndUnassignVotingDelegate() public { + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); + + vm.prank(nodeOperatorOwner); + vault.assignVotingDelegate(votingDelegate); + + assertEq(voting.delegateOf(lockInfo.vault), votingDelegate); + + vm.prank(nodeOperatorOwner); + vault.unassignVotingDelegate(); + + assertEq(voting.delegateOf(lockInfo.vault), address(0)); + } + + function test_assignAndUnassignSnapshotDelegate() public { + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + ISnapshotDelegationLockVault vault = ISnapshotDelegationLockVault(lockInfo.vault); + + vm.prank(nodeOperatorOwner); + vault.assignSnapshotDelegate(SNAPSHOT_SPACE_ID, snapshotDelegate); + + assertEq(snapshotDelegation.delegateOf(lockInfo.vault, SNAPSHOT_SPACE_ID), snapshotDelegate); + assertEq(snapshotDelegation.lastDelegator(), lockInfo.vault); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_SPACE_ID); + + vm.prank(nodeOperatorOwner); + vault.unassignSnapshotDelegate(SNAPSHOT_SPACE_ID); + + assertEq(snapshotDelegation.delegateOf(lockInfo.vault, SNAPSHOT_SPACE_ID), address(0)); + assertEq(snapshotDelegation.lastDelegator(), lockInfo.vault); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_SPACE_ID); + } + + function test_assignVotingDelegate_ReassignsAndCanAssignAfterUnassign() public { + address newVotingDelegate = nextAddress("NEW_VOTING_DELEGATE"); + + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); + + vm.startPrank(nodeOperatorOwner); + vault.assignVotingDelegate(votingDelegate); + vault.assignVotingDelegate(newVotingDelegate); + + assertEq(voting.delegateOf(lockInfo.vault), newVotingDelegate); + + vault.unassignVotingDelegate(); + vault.assignVotingDelegate(votingDelegate); + vm.stopPrank(); + + assertEq(voting.delegateOf(lockInfo.vault), votingDelegate); + } + + function test_vote() public { + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); + + vm.prank(nodeOperatorOwner); + vault.vote(42, true); + + assertEq(voting.lastVoter(), lockInfo.vault); + assertEq(voting.lastVoteId(), 42); + assertTrue(voting.lastSupport()); + assertFalse(voting.lastExecutesIfDecided()); + } + + function test_assignVotingDelegate_AllowsZeroDelegate() public { + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); + + vm.prank(nodeOperatorOwner); + vault.assignVotingDelegate(address(0)); + + assertEq(voting.delegateOf(lockInfo.vault), address(0)); + } + + function test_assignVotingDelegate_AllowsSameDelegate() public { + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); + + vm.startPrank(nodeOperatorOwner); + vault.assignVotingDelegate(votingDelegate); + vault.assignVotingDelegate(votingDelegate); + vm.stopPrank(); + + assertEq(voting.delegateOf(lockInfo.vault), votingDelegate); + } + + function test_assignDelegates_AllowsEmptyVault() public { + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + vm.warp(lockInfo.lockUntil); + + vm.prank(nodeOperatorOwner); + provider.withdraw(NODE_OPERATOR_ID, 10 ether, receiver); + + vm.startPrank(nodeOperatorOwner); + IAragonVotingLockVault(lockInfo.vault).assignVotingDelegate(votingDelegate); + ISnapshotDelegationLockVault(lockInfo.vault).assignSnapshotDelegate(SNAPSHOT_ALL_SPACES, snapshotDelegate); + vm.stopPrank(); + + assertEq(voting.delegateOf(lockInfo.vault), votingDelegate); + assertEq(snapshotDelegation.delegateOf(lockInfo.vault, SNAPSHOT_ALL_SPACES), snapshotDelegate); + } + + function test_getVault_ReturnsZeroWhenNoVaultCreated() public view { + assertEq(provider.getVault(NODE_OPERATOR_ID), address(0)); + } +} + +contract LidoGovernanceLockVaultFactoryTest is Test, Utilities { + CuratedMock public module; + ERC20Testable public token; + LidoAragonVotingMock public voting; + SnapshotDelegationMock public snapshotDelegation; + LidoGovernanceLockVaultFactory public vaultFactory; + + address public admin; + address public provider; + address public stranger; + + uint256 internal constant NODE_OPERATOR_ID = 0; + + function setUp() public { + admin = nextAddress("ADMIN"); + provider = nextAddress("PROVIDER"); + stranger = nextAddress("STRANGER"); + + module = new CuratedMock(); + token = new ERC20Testable(); + voting = new LidoAragonVotingMock(); + snapshotDelegation = new SnapshotDelegationMock(); + vaultFactory = new LidoGovernanceLockVaultFactory(admin, address(voting), address(snapshotDelegation)); + } + + function test_constructor_SetsImmutables() public view { + assertEq(vaultFactory.VOTING_CONTRACT(), address(voting)); + assertEq(vaultFactory.snapshotDelegation(), address(snapshotDelegation)); + assertTrue(vaultFactory.hasRole(vaultFactory.DEFAULT_ADMIN_ROLE(), admin)); + } + + function test_constructor_RevertWhen_ZeroAddresses() public { + vm.expectRevert(IERC20LockVaultFactory.ZeroAddress.selector); + new LidoGovernanceLockVaultFactory(address(0), address(voting), address(snapshotDelegation)); + + vm.expectRevert(IERC20LockVaultFactory.ZeroAddress.selector); + new LidoGovernanceLockVaultFactory(admin, address(0), address(snapshotDelegation)); + + vm.expectRevert(IERC20LockVaultFactory.ZeroAddress.selector); + new LidoGovernanceLockVaultFactory(admin, address(voting), address(0)); + } + + function test_setSnapshotDelegation() public { + SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); + + vm.expectEmit(address(vaultFactory)); + emit ILidoGovernanceLockVaultFactory.SnapshotDelegationSet(address(newSnapshotDelegation)); + vm.prank(admin); + vaultFactory.setSnapshotDelegation(address(newSnapshotDelegation)); + + assertEq(vaultFactory.snapshotDelegation(), address(newSnapshotDelegation)); + } + + function test_setSnapshotDelegation_UpdatesCreatedVaults() public { + address vaultAddress = vaultFactory.createVault(NODE_OPERATOR_ID, address(token), provider, address(module)); + LidoGovernanceLockVault vault = LidoGovernanceLockVault(vaultAddress); + SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); + + vm.prank(admin); + vaultFactory.setSnapshotDelegation(address(newSnapshotDelegation)); + + assertEq(vault.snapshotDelegation(), address(newSnapshotDelegation)); + } + + function test_setSnapshotDelegation_RevertWhen_InvalidInputOrNoRole() public { + SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); + + vm.prank(admin); + vm.expectRevert(IERC20LockVaultFactory.ZeroAddress.selector); + vaultFactory.setSnapshotDelegation(address(0)); + + vm.prank(admin); + vm.expectRevert(ILidoGovernanceLockVaultFactory.SameSnapshotDelegation.selector); + vaultFactory.setSnapshotDelegation(address(snapshotDelegation)); + + expectRoleRevert(stranger, vaultFactory.DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + vaultFactory.setSnapshotDelegation(address(newSnapshotDelegation)); + } + + function test_createVault() public { + address vaultAddress = vaultFactory.createVault(NODE_OPERATOR_ID, address(token), provider, address(module)); + LidoGovernanceLockVault vault = LidoGovernanceLockVault(vaultAddress); + + assertEq(vault.NODE_OPERATOR_ID(), NODE_OPERATOR_ID); + assertEq(vault.TOKEN(), address(token)); + assertEq(vault.PROVIDER(), provider); + assertEq(address(vault.MODULE()), address(module)); + assertEq(vault.VOTING_CONTRACT(), address(voting)); + assertEq(vault.snapshotDelegation(), address(snapshotDelegation)); + } +} + +contract ERC20LockVaultTest is Test, Utilities { + ERC20Testable public token; + ERC20LockVault public vault; + + address public provider; + address public module; + address public receiver; + address public stranger; + + uint256 internal constant NODE_OPERATOR_ID = 13; + + function setUp() public { + provider = nextAddress("PROVIDER"); + module = nextAddress("MODULE"); + receiver = nextAddress("RECEIVER"); + stranger = nextAddress("STRANGER"); + + token = new ERC20Testable(); + vault = new ERC20LockVault(NODE_OPERATOR_ID, address(token), provider, module); + } + + function test_constructor_SetsImmutables() public view { + assertEq(vault.NODE_OPERATOR_ID(), NODE_OPERATOR_ID); + assertEq(vault.TOKEN(), address(token)); + assertEq(vault.PROVIDER(), provider); + assertEq(address(vault.MODULE()), module); + } + + function test_constructor_RevertWhen_ZeroAddresses() public { + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + new ERC20LockVault(NODE_OPERATOR_ID, address(0), provider, module); + + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + new ERC20LockVault(NODE_OPERATOR_ID, address(token), address(0), module); + + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + new ERC20LockVault(NODE_OPERATOR_ID, address(token), provider, address(0)); + } + + function test_transferTokens() public { + token.mint(address(vault), 1 ether); + + vm.prank(provider); + vault.transferTokens(receiver, 1 ether); + + assertEq(token.balanceOf(receiver), 1 ether); + } + + function test_transferTokens_RevertWhen_InvalidCallerOrReceiver() public { + token.mint(address(vault), 1 ether); + + vm.prank(stranger); + vm.expectRevert(IERC20LockVault.SenderIsNotProvider.selector); + vault.transferTokens(receiver, 1 ether); + + vm.prank(provider); + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + vault.transferTokens(address(0), 1 ether); + } +} + +contract LidoGovernanceLockVaultTest is Test, Utilities { + CuratedMock public module; + ERC20Testable public token; + LidoAragonVotingMock public voting; + SnapshotDelegationMock public snapshotDelegation; + LidoGovernanceLockVaultFactory public vaultFactory; + LidoGovernanceLockVault public vault; + + address public admin; + address public provider; + address public nodeOperatorOwner; + address public votingDelegate; + address public snapshotDelegate; + address public stranger; + + uint256 internal constant NODE_OPERATOR_ID = 0; + bytes32 internal constant SNAPSHOT_SPACE_ID = bytes32("lido-snapshot-space"); + + function setUp() public { + admin = nextAddress("ADMIN"); + provider = nextAddress("PROVIDER"); + nodeOperatorOwner = nextAddress("NODE_OPERATOR_OWNER"); + votingDelegate = nextAddress("VOTING_DELEGATE"); + snapshotDelegate = nextAddress("SNAPSHOT_DELEGATE"); + stranger = nextAddress("STRANGER"); + + module = new CuratedMock(); + module.mock_setNodeOperatorManagementProperties( + NodeOperatorManagementProperties({ + managerAddress: nodeOperatorOwner, + rewardAddress: nodeOperatorOwner, + extendedManagerPermissions: true + }) + ); + token = new ERC20Testable(); + voting = new LidoAragonVotingMock(); + snapshotDelegation = new SnapshotDelegationMock(); + vaultFactory = new LidoGovernanceLockVaultFactory(admin, address(voting), address(snapshotDelegation)); + vault = new LidoGovernanceLockVault( + NODE_OPERATOR_ID, + address(token), + provider, + address(module), + address(voting), + address(vaultFactory) + ); + } + + function test_constructor_SetsImmutables() public view { + assertEq(vault.NODE_OPERATOR_ID(), NODE_OPERATOR_ID); + assertEq(vault.TOKEN(), address(token)); + assertEq(vault.PROVIDER(), provider); + assertEq(address(vault.MODULE()), address(module)); + assertEq(vault.VOTING_CONTRACT(), address(voting)); + assertEq(address(vault.GOVERNANCE_CONFIG()), address(vaultFactory)); + assertEq(vault.snapshotDelegation(), address(snapshotDelegation)); + } + + function test_constructor_RevertWhen_ZeroGovernanceAddresses() public { + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + new LidoGovernanceLockVault( + NODE_OPERATOR_ID, + address(token), + provider, + address(module), + address(0), + address(vaultFactory) + ); + + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + new LidoGovernanceLockVault( + NODE_OPERATOR_ID, + address(token), + provider, + address(module), + address(voting), + address(0) + ); + } + + function test_assignAndUnassignVotingDelegate() public { + vm.prank(nodeOperatorOwner); + vault.assignVotingDelegate(votingDelegate); + + assertEq(voting.delegateOf(address(vault)), votingDelegate); + + vm.prank(nodeOperatorOwner); + vault.unassignVotingDelegate(); + + assertEq(voting.delegateOf(address(vault)), address(0)); + } + + function test_assignAndUnassignSnapshotDelegate() public { + vm.prank(nodeOperatorOwner); + vault.assignSnapshotDelegate(SNAPSHOT_SPACE_ID, snapshotDelegate); + + assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_SPACE_ID), snapshotDelegate); + assertEq(snapshotDelegation.lastDelegator(), address(vault)); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_SPACE_ID); + + vm.prank(nodeOperatorOwner); + vault.unassignSnapshotDelegate(SNAPSHOT_SPACE_ID); + + assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_SPACE_ID), address(0)); + assertEq(snapshotDelegation.lastDelegator(), address(vault)); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_SPACE_ID); + } + + function test_assignSnapshotDelegate_UsesCurrentSnapshotDelegation() public { + SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); + + vm.prank(admin); + vaultFactory.setSnapshotDelegation(address(newSnapshotDelegation)); + + vm.prank(nodeOperatorOwner); + vault.assignSnapshotDelegate(SNAPSHOT_SPACE_ID, snapshotDelegate); + + assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_SPACE_ID), address(0)); + assertEq(newSnapshotDelegation.delegateOf(address(vault), SNAPSHOT_SPACE_ID), snapshotDelegate); + } + + function test_vote() public { + vm.prank(nodeOperatorOwner); + vault.vote(42, true); + + assertEq(voting.lastVoter(), address(vault)); + assertEq(voting.lastVoteId(), 42); + assertTrue(voting.lastSupport()); + assertFalse(voting.lastExecutesIfDecided()); + } + + function test_votingCalls_RevertWhen_InvalidCaller() public { + vm.prank(stranger); + vm.expectRevert(IERC20LockVault.SenderIsNotNodeOperatorOwner.selector); + vault.vote(42, true); + } + + function test_snapshotDelegationCalls_RevertWhen_InvalidCaller() public { + vm.prank(stranger); + vm.expectRevert(IERC20LockVault.SenderIsNotNodeOperatorOwner.selector); + vault.assignSnapshotDelegate(SNAPSHOT_SPACE_ID, snapshotDelegate); + + vm.prank(stranger); + vm.expectRevert(IERC20LockVault.SenderIsNotNodeOperatorOwner.selector); + vault.unassignSnapshotDelegate(SNAPSHOT_SPACE_ID); + } +} From 6a48580c4fa5d64d07e938309fbb2264203098fa Mon Sep 17 00:00:00 2001 From: skhomuti Date: Thu, 2 Jul 2026 17:37:33 +0500 Subject: [PATCH 2/3] feat: add erc20 lock boost provider --- script/curated/DeployBase.s.sol | 69 ++++++++- script/curated/DeployHoodi.s.sol | 10 ++ script/curated/DeployLocalDevNet.s.sol | 10 ++ script/curated/DeployMainnet.s.sol | 10 ++ src/AdditionalBondRegistry.sol | 6 +- src/ERC20LockBoostProvider.sol | 28 ++-- src/ERC20LockVault.sol | 6 +- src/LidoGovernanceLockVault.sol | 29 ++-- src/LidoGovernanceLockVaultFactory.sol | 2 +- src/MetaRegistry.sol | 40 +++--- src/interfaces/IERC20LockBoostProvider.sol | 5 +- src/interfaces/IMetaRegistry.sol | 8 +- .../ISnapshotDelegationLockVault.sol | 10 +- src/lib/Constants.sol | 6 + .../deployment/PostDeploymentCommon.t.sol | 8 +- .../deployment/PostDeploymentCurated.t.sol | 136 ++++++++++++++++++ test/helpers/Fixtures.sol | 35 +++++ test/unit/AdditionalBondRegistry.t.sol | 5 +- test/unit/ERC20LockBoostProvider.t.sol | 92 +++++------- test/unit/MetaRegistry.t.sol | 106 +++++++------- 20 files changed, 442 insertions(+), 179 deletions(-) diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index d8c12c614..a91e627e3 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -19,6 +19,8 @@ import { ExitPenalties } from "../../src/ExitPenalties.sol"; import { MetaRegistry } from "../../src/MetaRegistry.sol"; import { AdditionalBondRegistry } from "../../src/AdditionalBondRegistry.sol"; import { BoostStep } from "../../src/interfaces/IAdditionalBondRegistry.sol"; +import { ERC20LockBoostProvider } from "../../src/ERC20LockBoostProvider.sol"; +import { LidoGovernanceLockVaultFactory } from "../../src/LidoGovernanceLockVaultFactory.sol"; import { CuratedGate } from "../../src/CuratedGate.sol"; import { MerkleGateFactory } from "../../src/MerkleGateFactory.sol"; @@ -28,6 +30,7 @@ import { BaseOracle } from "../../src/lib/base-oracle/BaseOracle.sol"; import { IVerifier } from "../../src/interfaces/IVerifier.sol"; import { IParametersRegistry } from "../../src/interfaces/IParametersRegistry.sol"; import { IBondCurve } from "../../src/interfaces/IBondCurve.sol"; +import { IERC20LockBoostProvider } from "../../src/interfaces/IERC20LockBoostProvider.sol"; import { IMetaRegistry } from "../../src/interfaces/IMetaRegistry.sol"; import { IWeightBoostProvider } from "../../src/interfaces/IWeightBoostProvider.sol"; @@ -68,6 +71,16 @@ struct AdditionalBondRegistryConfig { uint256[2][] boostSteps; } +struct ERC20LockBoostProviderConfig { + address token; + address votingContract; + address snapshotDelegation; + uint256 minLockPeriod; + uint256 maxLockPeriod; + uint256 lockPeriod; + IERC20LockBoostProvider.LockBoostStep[] lockBoostSteps; +} + struct CuratedDeployParams { // Lido addresses address lidoLocatorAddress; @@ -133,6 +146,8 @@ struct CuratedDeployParams { address secondAdminAddress; // AdditionalBondRegistry AdditionalBondRegistryConfig additionalBondRegistryConfig; + // LDO lock boost provider + ERC20LockBoostProviderConfig ldoLockBoostProviderConfig; } abstract contract DeployBase is Script { @@ -156,6 +171,9 @@ abstract contract DeployBase is Script { ParametersRegistry public parametersRegistry; MetaRegistry public metaRegistry; AdditionalBondRegistry public additionalBondRegistry; + ERC20LockBoostProvider public ldoLockBoostProvider; + ERC20LockBoostProvider public ldoLockBoostProviderImpl; + LidoGovernanceLockVaultFactory public ldoLockVaultFactory; MerkleGateFactory public curatedGateFactory; address[] public curatedGateInstances; address internal curatedGateImpl; @@ -245,6 +263,7 @@ abstract contract DeployBase is Script { oracle = FeeOracle(_deployProxy(deployer, address(dummyImpl))); metaRegistry = MetaRegistry(_deployProxy(deployer, address(dummyImpl))); additionalBondRegistry = AdditionalBondRegistry(_deployProxy(deployer, address(dummyImpl))); + ldoLockBoostProvider = ERC20LockBoostProvider(_deployProxy(deployer, address(dummyImpl))); FeeDistributor feeDistributorImpl = new FeeDistributor({ stETH: locator.lido(), @@ -354,12 +373,43 @@ abstract contract DeployBase is Script { additionalBondRegistryProxy.proxy__changeAdmin(config.proxyAdmin); } + // LDO lock boost provider + { + ERC20LockBoostProviderConfig storage ldoConfig = config.ldoLockBoostProviderConfig; + + ldoLockVaultFactory = new LidoGovernanceLockVaultFactory({ + admin: deployer, + votingContract: ldoConfig.votingContract, + snapshotDelegation_: ldoConfig.snapshotDelegation + }); + + ldoLockBoostProviderImpl = new ERC20LockBoostProvider({ + module: address(curatedModule), + token: ldoConfig.token, + vaultFactory: address(ldoLockVaultFactory), + minLockPeriod: ldoConfig.minLockPeriod, + maxLockPeriod: ldoConfig.maxLockPeriod + }); + + OssifiableProxy ldoLockBoostProviderProxy = OssifiableProxy(payable(address(ldoLockBoostProvider))); + ldoLockBoostProviderProxy.proxy__upgradeToAndCall( + address(ldoLockBoostProviderImpl), + abi.encodeCall(ERC20LockBoostProvider.initialize, (deployer, ldoConfig.lockPeriod)) + ); + ldoLockBoostProviderProxy.proxy__changeAdmin(config.proxyAdmin); + } + accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), address(additionalBondRegistry)); metaRegistry.addWeightBoostProvider( IWeightBoostProvider(address(additionalBondRegistry)), - IMetaRegistry.WeightBoostProviderMode.NodeOperator + IMetaRegistry.WeightBoostProviderMode.PerNodeOperator ); + metaRegistry.addWeightBoostProvider( + IWeightBoostProvider(address(ldoLockBoostProvider)), + IMetaRegistry.WeightBoostProviderMode.MaxPerGroup + ); + ldoLockBoostProvider.setLockBoostSteps(config.ldoLockBoostProviderConfig.lockBoostSteps); metaRegistry.grantRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); for (uint256 i = 0; i < gatesCount; i++) { @@ -560,6 +610,12 @@ abstract contract DeployBase is Script { additionalBondRegistry.grantRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), config.aragonAgent); additionalBondRegistry.revokeRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), deployer); + ldoLockBoostProvider.grantRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), config.aragonAgent); + ldoLockBoostProvider.revokeRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), deployer); + + ldoLockVaultFactory.grantRole(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE(), config.aragonAgent); + ldoLockVaultFactory.revokeRole(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE(), deployer); + verifier.grantRole(verifier.DEFAULT_ADMIN_ROLE(), config.aragonAgent); verifier.revokeRole(verifier.DEFAULT_ADMIN_ROLE(), deployer); @@ -586,6 +642,9 @@ abstract contract DeployBase is Script { deployJson.set("MetaRegistryImpl", address(metaRegistryImpl)); deployJson.set("AdditionalBondRegistry", address(additionalBondRegistry)); deployJson.set("AdditionalBondRegistryImpl", address(additionalBondRegistryImpl)); + deployJson.set("LDOLockBoostProvider", address(ldoLockBoostProvider)); + deployJson.set("LDOLockBoostProviderImpl", address(ldoLockBoostProviderImpl)); + deployJson.set("LDOLockVaultFactory", address(ldoLockVaultFactory)); deployJson.set("ParametersRegistry", address(parametersRegistry)); deployJson.set("ParametersRegistryImpl", address(parametersRegistryImpl)); deployJson.set("Accounting", address(accounting)); @@ -654,6 +713,12 @@ abstract contract DeployBase is Script { return gates; } + function _addLDOLockBoostStep(uint128 minAmount, uint32 multiplierBP) internal { + config.ldoLockBoostProviderConfig.lockBoostSteps.push( + IERC20LockBoostProvider.LockBoostStep({ minAmount: minAmount, multiplierBP: multiplierBP }) + ); + } + function _deployProxy(address admin, address implementation) internal returns (address) { return _deployProxy(admin, implementation, new bytes(0)); } @@ -682,6 +747,8 @@ abstract contract DeployBase is Script { parametersRegistry.grantRole(parametersRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); metaRegistry.grantRole(metaRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); additionalBondRegistry.grantRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); + ldoLockBoostProvider.grantRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); + ldoLockVaultFactory.grantRole(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); for (uint256 i = 0; i < curatedGateInstances.length; i++) { CuratedGate gate = CuratedGate(curatedGateInstances[i]); gate.grantRole(gate.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); diff --git a/script/curated/DeployHoodi.s.sol b/script/curated/DeployHoodi.s.sol index 099b8bbf1..0c8a7582f 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -205,6 +205,16 @@ contract DeployHoodi is DeployBase { config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); + // LDO lock boost provider + config.ldoLockBoostProviderConfig.token = 0xEf2573966D009CcEA0Fc74451dee2193564198dc; + config.ldoLockBoostProviderConfig.votingContract = 0x49B3512c44891bef83F8967d075121Bd1b07a01B; + config.ldoLockBoostProviderConfig.snapshotDelegation = address(1); // TODO: Fill in before deployment + config.ldoLockBoostProviderConfig.minLockPeriod = 30 days; + config.ldoLockBoostProviderConfig.maxLockPeriod = 365 days; + config.ldoLockBoostProviderConfig.lockPeriod = 30 days; + _addLDOLockBoostStep(100_000 ether, 11_000); + _addLDOLockBoostStep(200_000 ether, 11_500); + _setUp(); } } diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index d7a47d1c9..1307710d3 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -193,6 +193,16 @@ contract DeployLocalDevNet is DeployBase { config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); + // LDO lock boost provider + config.ldoLockBoostProviderConfig.token = vm.envAddress("CSM_LDO_TOKEN_ADDRESS"); + config.ldoLockBoostProviderConfig.votingContract = vm.envAddress("CSM_LDO_VOTING_ADDRESS"); + config.ldoLockBoostProviderConfig.snapshotDelegation = vm.envAddress("CSM_SNAPSHOT_DELEGATION_ADDRESS"); + config.ldoLockBoostProviderConfig.minLockPeriod = 1 days; + config.ldoLockBoostProviderConfig.maxLockPeriod = 365 days; + config.ldoLockBoostProviderConfig.lockPeriod = 1 days; + _addLDOLockBoostStep(100_000 ether, 11_000); + _addLDOLockBoostStep(200_000 ether, 11_500); + _setUp(); } diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index 40d4eeadf..0b95d784e 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -204,6 +204,16 @@ contract DeployMainnet is DeployBase { config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); + // LDO lock boost provider + config.ldoLockBoostProviderConfig.token = 0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32; + config.ldoLockBoostProviderConfig.votingContract = 0x2e59A20f205bB85a89C53f1936454680651E618e; + config.ldoLockBoostProviderConfig.snapshotDelegation = 0x469788fE6E9E9681C6ebF3bF78e7Fd26Fc015446; + config.ldoLockBoostProviderConfig.minLockPeriod = 30 days; + config.ldoLockBoostProviderConfig.maxLockPeriod = 365 days; + config.ldoLockBoostProviderConfig.lockPeriod = 30 days; + _addLDOLockBoostStep(100_000 ether, 11_000); + _addLDOLockBoostStep(200_000 ether, 11_500); + _setUp(); } } diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index 22221069b..854b2486e 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -11,7 +11,7 @@ import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; import { IAdditionalBondRegistry, BoostStep, PendingCurveMultiplierReduction } from "./interfaces/IAdditionalBondRegistry.sol"; import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; -import { MAX_BP } from "./lib/Constants.sol"; +import { MAX_BP, MAX_EFFECTIVE_MULTIPLIER_BP } from "./lib/Constants.sol"; /// @notice Maps an operator's curve multiplier to a weight multiplier via governance-set boost steps. contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, AccessControlEnumerableUpgradeable { @@ -23,8 +23,8 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces } // Sanity guard: effective multiplier <= 10x. - uint256 public constant MAX_CURVE_MULTIPLIER = 9 * MAX_BP; - uint256 public constant MAX_WEIGHT_MULTIPLIER = 9 * MAX_BP; + uint256 public constant MAX_CURVE_MULTIPLIER = MAX_EFFECTIVE_MULTIPLIER_BP - MAX_BP; + uint256 public constant MAX_WEIGHT_MULTIPLIER = MAX_EFFECTIVE_MULTIPLIER_BP - MAX_BP; // Requested curve multiplier must be a multiple of this (1%). uint256 public constant CURVE_MULTIPLIER_STEP = MAX_BP / 100; diff --git a/src/ERC20LockBoostProvider.sol b/src/ERC20LockBoostProvider.sol index eacda64ac..5b3ff96fd 100644 --- a/src/ERC20LockBoostProvider.sol +++ b/src/ERC20LockBoostProvider.sol @@ -16,7 +16,7 @@ import { IERC20LockVault } from "./interfaces/IERC20LockVault.sol"; import { IERC20LockVaultFactory } from "./interfaces/IERC20LockVaultFactory.sol"; import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; -import { MAX_BP } from "./lib/Constants.sol"; +import { MAX_BP, MAX_EFFECTIVE_MULTIPLIER_BP } from "./lib/Constants.sol"; /// @notice Stores operator-level ERC20 locks and exposes node operator boost for scoring. contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgradeable, IERC20LockBoostProvider { @@ -32,14 +32,12 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade ICuratedModule public immutable MODULE; IMetaRegistry public immutable META_REGISTRY; address public immutable TOKEN; - address public immutable VAULT_FACTORY; + IERC20LockVaultFactory public immutable VAULT_FACTORY; uint256 public immutable MIN_LOCK_PERIOD; uint256 public immutable MAX_LOCK_PERIOD; bytes32 public constant SET_LOCK_PERIOD_ROLE = keccak256("SET_LOCK_PERIOD_ROLE"); - uint256 internal constant MAX_LOCK_PROVIDER_MULTIPLIER_BP = 2 * MAX_BP; - // keccak256(abi.encode(uint256(keccak256("ERC20LockBoostProvider")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant ERC20_LOCK_BOOST_PROVIDER_STORAGE_LOCATION = 0x0d048d8a76e474169bd4c83d3eb46f84ff5b8d069b5f5174bf8047b6bd66fb00; @@ -55,7 +53,7 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade MODULE = curatedModule; META_REGISTRY = metaRegistry; TOKEN = token; - VAULT_FACTORY = vaultFactory; + VAULT_FACTORY = IERC20LockVaultFactory(vaultFactory); MIN_LOCK_PERIOD = minLockPeriod; MAX_LOCK_PERIOD = maxLockPeriod; @@ -77,7 +75,6 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade /// @inheritdoc IERC20LockBoostProvider function setLockPeriod(uint256 lockPeriod) external onlyRole(SET_LOCK_PERIOD_ROLE) { - if (_storage().lockPeriod == lockPeriod) revert SameLockPeriod(); _setLockPeriod(lockPeriod); } @@ -150,13 +147,12 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade address vault = lockInfo.vault; if (vault == address(0)) { - vault = IERC20LockVaultFactory(VAULT_FACTORY).createVault({ + vault = VAULT_FACTORY.createVault({ nodeOperatorId: nodeOperatorId, token: TOKEN, provider: address(this), module: address(MODULE) }); - if (vault == address(0)) revert ZeroAddress(); lockInfo.vault = vault; emit VaultCreated(nodeOperatorId, vault, TOKEN); } @@ -195,14 +191,14 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade } function _syncWeightAfterLockChange(uint256 nodeOperatorId, uint256 oldAmount, uint256 newAmount) internal { - uint16 oldMultiplierBP = _getMultiplierBP(oldAmount); - uint16 newMultiplierBP = _getMultiplierBP(newAmount); + uint256 oldMultiplierBP = _getMultiplierBP(oldAmount); + uint256 newMultiplierBP = _getMultiplierBP(newAmount); if (oldMultiplierBP != newMultiplierBP) META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); } - function _getMultiplierBP(uint256 amount) internal view returns (uint16 multiplierBP) { + function _getMultiplierBP(uint256 amount) internal view returns (uint256 multiplierBP) { ERC20LockBoostProviderStorage storage $ = _storage(); - multiplierBP = uint16(MAX_BP); + multiplierBP = MAX_BP; uint256 stepsCount = $.lockBoostSteps.length; for (uint256 i; i < stepsCount; ++i) { LockBoostStep storage step = $.lockBoostSteps[i]; @@ -230,13 +226,14 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade uint256 previousMinAmount = steps[0].minAmount; uint256 previousMultiplierBP = steps[0].multiplierBP; - if (steps[0].multiplierBP > MAX_LOCK_PROVIDER_MULTIPLIER_BP) revert InvalidLockBoostSteps(); + if (previousMinAmount == 0) revert InvalidLockBoostSteps(); + if (steps[0].multiplierBP > MAX_EFFECTIVE_MULTIPLIER_BP) revert InvalidLockBoostSteps(); for (uint256 i = 1; i < stepsCount; ++i) { LockBoostStep calldata step = steps[i]; if (step.minAmount <= previousMinAmount) revert InvalidLockBoostSteps(); - if (step.multiplierBP < previousMultiplierBP) revert InvalidLockBoostSteps(); - if (step.multiplierBP > MAX_LOCK_PROVIDER_MULTIPLIER_BP) revert InvalidLockBoostSteps(); + if (step.multiplierBP <= previousMultiplierBP) revert InvalidLockBoostSteps(); + if (step.multiplierBP > MAX_EFFECTIVE_MULTIPLIER_BP) revert InvalidLockBoostSteps(); previousMinAmount = step.minAmount; previousMultiplierBP = step.multiplierBP; @@ -245,6 +242,7 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade function _setLockPeriod(uint256 lockPeriod) internal { if (lockPeriod < MIN_LOCK_PERIOD || lockPeriod > MAX_LOCK_PERIOD) revert InvalidLockPeriod(); + if (_storage().lockPeriod == lockPeriod) revert SameLockPeriod(); _storage().lockPeriod = lockPeriod; emit LockPeriodSet(lockPeriod); diff --git a/src/ERC20LockVault.sol b/src/ERC20LockVault.sol index fd8aa41f9..85509e3f6 100644 --- a/src/ERC20LockVault.sol +++ b/src/ERC20LockVault.sol @@ -29,17 +29,17 @@ contract ERC20LockVault is IERC20LockVault { /// @inheritdoc IERC20LockVault function transferTokens(address receiver, uint256 amount) external { - _checkProvider(); + _onlyProvider(); if (receiver == address(0)) revert ZeroAddress(); IERC20(TOKEN).safeTransfer(receiver, amount); } - function _checkNodeOperatorOwner() internal view { + function _onlyNodeOperatorOwner() internal view { if (msg.sender != MODULE.getNodeOperatorOwner(NODE_OPERATOR_ID)) revert SenderIsNotNodeOperatorOwner(); } - function _checkProvider() internal view { + function _onlyProvider() internal view { if (msg.sender != PROVIDER) revert SenderIsNotProvider(); } } diff --git a/src/LidoGovernanceLockVault.sol b/src/LidoGovernanceLockVault.sol index 41f17285e..81bf35545 100644 --- a/src/LidoGovernanceLockVault.sol +++ b/src/LidoGovernanceLockVault.sol @@ -10,11 +10,12 @@ import { IERC20LockVault } from "./interfaces/IERC20LockVault.sol"; import { ILidoGovernanceLockVaultFactory } from "./interfaces/ILidoGovernanceLockVaultFactory.sol"; import { ISnapshotDelegation } from "./interfaces/ISnapshotDelegation.sol"; import { ISnapshotDelegationLockVault } from "./interfaces/ISnapshotDelegationLockVault.sol"; +import { SNAPSHOT_ALL_SPACES } from "./lib/Constants.sol"; /// @notice ERC20 lock vault with Lido Aragon Voting and Snapshot delegation capabilities. contract LidoGovernanceLockVault is ERC20LockVault, IAragonVotingLockVault, ISnapshotDelegationLockVault { address public immutable VOTING_CONTRACT; - ILidoGovernanceLockVaultFactory public immutable GOVERNANCE_CONFIG; + ILidoGovernanceLockVaultFactory public immutable VAULT_FACTORY; constructor( uint256 nodeOperatorId, @@ -22,53 +23,53 @@ contract LidoGovernanceLockVault is ERC20LockVault, IAragonVotingLockVault, ISna address provider, address module, address votingContract, - address governanceConfig + address vaultFactory ) ERC20LockVault(nodeOperatorId, token, provider, module) { - if (votingContract == address(0) || governanceConfig == address(0)) { + if (votingContract == address(0) || vaultFactory == address(0)) { revert IERC20LockVault.ZeroAddress(); } VOTING_CONTRACT = votingContract; - GOVERNANCE_CONFIG = ILidoGovernanceLockVaultFactory(governanceConfig); + VAULT_FACTORY = ILidoGovernanceLockVaultFactory(vaultFactory); } /// @inheritdoc IAragonVotingLockVault function assignVotingDelegate(address votingDelegate) external { - _checkNodeOperatorOwner(); + _onlyNodeOperatorOwner(); ILidoAragonVoting(VOTING_CONTRACT).assignDelegate(votingDelegate); } /// @inheritdoc IAragonVotingLockVault function unassignVotingDelegate() external { - _checkNodeOperatorOwner(); + _onlyNodeOperatorOwner(); ILidoAragonVoting(VOTING_CONTRACT).unassignDelegate(); } /// @inheritdoc ISnapshotDelegationLockVault - function assignSnapshotDelegate(bytes32 snapshotSpaceId, address snapshotDelegate) external { - _checkNodeOperatorOwner(); + function assignSnapshotDelegate(address snapshotDelegate) external { + _onlyNodeOperatorOwner(); - ISnapshotDelegation(snapshotDelegation()).setDelegate(snapshotSpaceId, snapshotDelegate); + ISnapshotDelegation(snapshotDelegation()).setDelegate(SNAPSHOT_ALL_SPACES, snapshotDelegate); } /// @inheritdoc ISnapshotDelegationLockVault - function unassignSnapshotDelegate(bytes32 snapshotSpaceId) external { - _checkNodeOperatorOwner(); + function unassignSnapshotDelegate() external { + _onlyNodeOperatorOwner(); - ISnapshotDelegation(snapshotDelegation()).clearDelegate(snapshotSpaceId); + ISnapshotDelegation(snapshotDelegation()).clearDelegate(SNAPSHOT_ALL_SPACES); } /// @inheritdoc IAragonVotingLockVault function vote(uint256 voteId, bool support) external { - _checkNodeOperatorOwner(); + _onlyNodeOperatorOwner(); ILidoAragonVoting(VOTING_CONTRACT).vote(voteId, support, false); } /// @inheritdoc ISnapshotDelegationLockVault function snapshotDelegation() public view returns (address) { - return GOVERNANCE_CONFIG.snapshotDelegation(); + return VAULT_FACTORY.snapshotDelegation(); } } diff --git a/src/LidoGovernanceLockVaultFactory.sol b/src/LidoGovernanceLockVaultFactory.sol index f61b34c61..17572558e 100644 --- a/src/LidoGovernanceLockVaultFactory.sol +++ b/src/LidoGovernanceLockVaultFactory.sol @@ -52,7 +52,7 @@ contract LidoGovernanceLockVaultFactory is ILidoGovernanceLockVaultFactory, Acce provider: provider, module: module, votingContract: VOTING_CONTRACT, - governanceConfig: address(this) + vaultFactory: address(this) }) ); } diff --git a/src/MetaRegistry.sol b/src/MetaRegistry.sol index 45bee6167..f71347021 100644 --- a/src/MetaRegistry.sol +++ b/src/MetaRegistry.sol @@ -188,7 +188,7 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp if (entry.enabled == enabled) revert SameWeightBoostProviderEnabled(); entry.enabled = enabled; - emit WeightBoostProviderEnabledSet(providerAddr, enabled); + emit WeightBoostProviderStateSet(providerAddr, enabled); _requestFullDepositInfoUpdate(); } @@ -233,12 +233,12 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp WeightBoostProviderEntry storage entry = $.weightBoostProviders[providerId]; if (!entry.enabled) return; - if (entry.mode == WeightBoostProviderMode.NodeOperator) { + if (entry.mode == WeightBoostProviderMode.PerNodeOperator) { _refreshOperatorWeight(groupId, nodeOperatorId); return; } - if (entry.mode == WeightBoostProviderMode.GroupMax) { + if (entry.mode == WeightBoostProviderMode.MaxPerGroup) { _refreshGroupWeights(groupId); return; } @@ -489,8 +489,8 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp MetaRegistryStorage storage $ = _storage(); CachedOperatorGroup storage group = $.groups[groupId]; uint256 providersCount = $.weightBoostProvidersCount; - uint256[] memory groupMaxMultipliersBP = new uint256[](providersCount); - bool[] memory groupMaxMultiplierCached = new bool[](providersCount); + uint256[] memory maxPerGroupMultipliersBP = new uint256[](providersCount); + bool[] memory maxPerGroupMultiplierCached = new bool[](providersCount); uint256 effectiveWeightSum; uint256 subOperatorsCount = group.subNodeOperatorIds.length; @@ -502,8 +502,8 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp uint256 multiplierBP = _getWeightBoostMultiplierBP( group, noId, - groupMaxMultipliersBP, - groupMaxMultiplierCached + maxPerGroupMultipliersBP, + maxPerGroupMultiplierCached ); uint256 effectiveWeight = _getLatestEffectiveWeight(noId, share, multiplierBP); _setEffectiveWeight(noId, effectiveWeight); @@ -581,12 +581,12 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp if (!entry.enabled) continue; IWeightBoostProvider provider = entry.provider; - if (entry.mode == WeightBoostProviderMode.NodeOperator) { + if (entry.mode == WeightBoostProviderMode.PerNodeOperator) { multiplierBP = Math.mulDiv(multiplierBP, provider.getWeightBoostMultiplierBP(nodeOperatorId), MAX_BP); - } else if (entry.mode == WeightBoostProviderMode.GroupMax) { + } else if (entry.mode == WeightBoostProviderMode.MaxPerGroup) { multiplierBP = Math.mulDiv( multiplierBP, - _getProviderGroupMaxWeightBoostMultiplierBP(provider, group), + _getProviderMaxPerGroupWeightBoostMultiplierBP(provider, group), MAX_BP ); } else { @@ -598,32 +598,32 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp function _getWeightBoostMultiplierBP( CachedOperatorGroup storage group, uint256 nodeOperatorId, - uint256[] memory groupMaxMultipliersBP, - bool[] memory groupMaxMultiplierCached + uint256[] memory maxPerGroupMultipliersBP, + bool[] memory maxPerGroupMultiplierCached ) internal view returns (uint256 multiplierBP) { MetaRegistryStorage storage $ = _storage(); multiplierBP = MAX_BP; - uint256 providersCount = groupMaxMultipliersBP.length; + uint256 providersCount = maxPerGroupMultipliersBP.length; for (uint256 i; i < providersCount; ++i) { WeightBoostProviderEntry storage entry = $.weightBoostProviders[i + 1]; if (!entry.enabled) continue; IWeightBoostProvider provider = entry.provider; - if (entry.mode == WeightBoostProviderMode.NodeOperator) { + if (entry.mode == WeightBoostProviderMode.PerNodeOperator) { multiplierBP = Math.mulDiv(multiplierBP, provider.getWeightBoostMultiplierBP(nodeOperatorId), MAX_BP); - } else if (entry.mode == WeightBoostProviderMode.GroupMax) { - if (!groupMaxMultiplierCached[i]) { - groupMaxMultipliersBP[i] = _getProviderGroupMaxWeightBoostMultiplierBP(provider, group); - groupMaxMultiplierCached[i] = true; + } else if (entry.mode == WeightBoostProviderMode.MaxPerGroup) { + if (!maxPerGroupMultiplierCached[i]) { + maxPerGroupMultipliersBP[i] = _getProviderMaxPerGroupWeightBoostMultiplierBP(provider, group); + maxPerGroupMultiplierCached[i] = true; } - multiplierBP = Math.mulDiv(multiplierBP, groupMaxMultipliersBP[i], MAX_BP); + multiplierBP = Math.mulDiv(multiplierBP, maxPerGroupMultipliersBP[i], MAX_BP); } else { revert InvalidWeightBoostProviderMode(); } } } - function _getProviderGroupMaxWeightBoostMultiplierBP( + function _getProviderMaxPerGroupWeightBoostMultiplierBP( IWeightBoostProvider provider, CachedOperatorGroup storage group ) internal view returns (uint256 maxMultiplierBP) { diff --git a/src/interfaces/IERC20LockBoostProvider.sol b/src/interfaces/IERC20LockBoostProvider.sol index 69c7dd21f..6ddb64e4d 100644 --- a/src/interfaces/IERC20LockBoostProvider.sol +++ b/src/interfaces/IERC20LockBoostProvider.sol @@ -4,12 +4,13 @@ pragma solidity 0.8.33; import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; +import { IERC20LockVaultFactory } from "./IERC20LockVaultFactory.sol"; /// @notice Operator-level ERC20 lock registry and weight boost provider. interface IERC20LockBoostProvider is IWeightBoostProvider { struct LockBoostStep { uint128 minAmount; - uint16 multiplierBP; + uint32 multiplierBP; } struct LockInfo { @@ -44,7 +45,7 @@ interface IERC20LockBoostProvider is IWeightBoostProvider { function TOKEN() external view returns (address); /// @notice Factory creating per-operator ERC20 lock vaults. - function VAULT_FACTORY() external view returns (address); + function VAULT_FACTORY() external view returns (IERC20LockVaultFactory); /// @notice Minimum lock period allowed by the registry. function MIN_LOCK_PERIOD() external view returns (uint256); diff --git a/src/interfaces/IMetaRegistry.sol b/src/interfaces/IMetaRegistry.sol index 729cd18cb..43db3e9eb 100644 --- a/src/interfaces/IMetaRegistry.sol +++ b/src/interfaces/IMetaRegistry.sol @@ -32,9 +32,11 @@ interface IMetaRegistry { ExternalOperator[] externalOperators; } + /// @dev `PerNodeOperator` applies the provider multiplier directly to each Node Operator. + /// `MaxPerGroup` applies the max provider multiplier among group sub-operators to the whole group. enum WeightBoostProviderMode { - NodeOperator, - GroupMax + PerNodeOperator, + MaxPerGroup } struct WeightBoostProviderEntry { @@ -48,7 +50,7 @@ interface IMetaRegistry { event OperatorGroupCleared(uint256 indexed groupId); event BondCurveWeightSet(uint256 indexed curveId, uint256 weight); event WeightBoostProviderAdded(address indexed provider, WeightBoostProviderMode mode); - event WeightBoostProviderEnabledSet(address indexed provider, bool enabled); + event WeightBoostProviderStateSet(address indexed provider, bool enabled); event WeightBoostProviderConfigChanged(address indexed provider); event GroupWeightsRefreshed(uint256 indexed groupId); event OperatorMetadataSet(uint256 indexed nodeOperatorId, OperatorMetadata metadata); diff --git a/src/interfaces/ISnapshotDelegationLockVault.sol b/src/interfaces/ISnapshotDelegationLockVault.sol index 2f4caaf59..8b72d5f12 100644 --- a/src/interfaces/ISnapshotDelegationLockVault.sol +++ b/src/interfaces/ISnapshotDelegationLockVault.sol @@ -8,12 +8,10 @@ interface ISnapshotDelegationLockVault { /// @notice Snapshot delegation registry used by this vault. function snapshotDelegation() external view returns (address); - /// @notice Assign a Snapshot delegate from the vault address for a space. - /// @param snapshotSpaceId Snapshot space ID. Zero ID means all spaces. + /// @notice Assign a Snapshot delegate from the vault address for all spaces. /// @param snapshotDelegate Address to assign as delegate. - function assignSnapshotDelegate(bytes32 snapshotSpaceId, address snapshotDelegate) external; + function assignSnapshotDelegate(address snapshotDelegate) external; - /// @notice Remove the current Snapshot delegate from the vault address for a space. - /// @param snapshotSpaceId Snapshot space ID. Zero ID means all spaces. - function unassignSnapshotDelegate(bytes32 snapshotSpaceId) external; + /// @notice Remove the current Snapshot delegate from the vault address for all spaces. + function unassignSnapshotDelegate() external; } diff --git a/src/lib/Constants.sol b/src/lib/Constants.sol index 694f04ed4..94107d61a 100644 --- a/src/lib/Constants.sol +++ b/src/lib/Constants.sol @@ -6,3 +6,9 @@ pragma solidity 0.8.33; /// @dev Basis points denominator (100 % = 10 000 bp). uint256 constant MAX_BP = 10_000; + +/// @dev Upper bound for full effective multipliers (10x = 100 000 bp). +uint256 constant MAX_EFFECTIVE_MULTIPLIER_BP = MAX_BP * 10; + +/// @dev Snapshot zero ID means all spaces. +bytes32 constant SNAPSHOT_ALL_SPACES = bytes32(0); diff --git a/test/fork/deployment/PostDeploymentCommon.t.sol b/test/fork/deployment/PostDeploymentCommon.t.sol index 0902c7f1e..b95a8b900 100644 --- a/test/fork/deployment/PostDeploymentCommon.t.sol +++ b/test/fork/deployment/PostDeploymentCommon.t.sol @@ -26,6 +26,7 @@ contract DeploymentBaseTest is Test, Utilities, DeploymentFixtures { uint256 expectedModuleScratchNonce; uint256 expectedModuleScratchNonceFromGates; uint256 expectedModuleScratchNonceFromWeightBoostProviders; + uint256 expectedModuleScratchNonceFromWeightBoostProviderConfigChanges; function setUp() public { Env memory env = envVars(); @@ -43,9 +44,14 @@ contract DeploymentBaseTest is Test, Utilities, DeploymentFixtures { // Each registered weight boost provider also requests full update and contributes one nonce. expectedModuleScratchNonceFromWeightBoostProviders = metaRegistry.getWeightBoostProvidersCount(); + // Each async weight boost provider config change also requests full update. + expectedModuleScratchNonceFromWeightBoostProviderConfigChanges = deployParams + .weightBoostProviderConfigChangesCount; + expectedModuleScratchNonce = expectedModuleScratchNonceFromGates + - expectedModuleScratchNonceFromWeightBoostProviders; + expectedModuleScratchNonceFromWeightBoostProviders + + expectedModuleScratchNonceFromWeightBoostProviderConfigChanges; } } } diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index 29909eefe..58bec9f9d 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -9,6 +9,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { CuratedDeployParams, CuratedGateConfig, GateCurveParams } from "script/curated/DeployBase.s.sol"; import { CuratedGate } from "src/CuratedGate.sol"; +import { IERC20LockBoostProvider } from "src/interfaces/IERC20LockBoostProvider.sol"; import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; import { BoostStep } from "src/interfaces/IAdditionalBondRegistry.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; @@ -124,6 +125,32 @@ contract MetaRegistryDeploymentTest is DeploymentBaseTest { "unexpected set bond curve weight role members count" ); } + + function test_weightBoostProviders_onlyFull() public view { + assertEq(metaRegistry.getWeightBoostProvidersCount(), 2, "unexpected weight boost providers count"); + + IMetaRegistry.WeightBoostProviderEntry memory additionalBondEntry = metaRegistry.getWeightBoostProvider(1); + assertEq( + address(additionalBondEntry.provider), + address(additionalBondRegistry), + "unexpected additional bond provider" + ); + assertEq( + uint256(additionalBondEntry.mode), + uint256(IMetaRegistry.WeightBoostProviderMode.PerNodeOperator), + "unexpected additional bond provider mode" + ); + assertTrue(additionalBondEntry.enabled, "additional bond provider disabled"); + + IMetaRegistry.WeightBoostProviderEntry memory ldoLockEntry = metaRegistry.getWeightBoostProvider(2); + assertEq(address(ldoLockEntry.provider), address(ldoLockBoostProvider), "unexpected LDO lock provider"); + assertEq( + uint256(ldoLockEntry.mode), + uint256(IMetaRegistry.WeightBoostProviderMode.MaxPerGroup), + "unexpected LDO lock provider mode" + ); + assertTrue(ldoLockEntry.enabled, "LDO lock provider disabled"); + } } contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { @@ -221,6 +248,115 @@ contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { } } +contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { + function test_state_onlyFull() public view { + assertEq(ldoLockBoostProvider.getInitializedVersion(), 1); + assertEq( + ldoLockBoostProvider.getLockPeriod(), + deployParams.ldoLockBoostProviderConfig.lockPeriod, + "LDO lock provider lock period" + ); + + IERC20LockBoostProvider.LockBoostStep[] memory actualSteps = ldoLockBoostProvider.getLockBoostSteps(); + uint256 stepsCount = deployParams.ldoLockBoostProviderConfig.lockBoostSteps.length; + assertEq(actualSteps.length, stepsCount, "LDO lock boost steps count"); + for (uint256 i; i < stepsCount; ++i) { + assertEq( + actualSteps[i].minAmount, + deployParams.ldoLockBoostProviderConfig.lockBoostSteps[i].minAmount, + "LDO lock boost step min amount" + ); + assertEq( + actualSteps[i].multiplierBP, + deployParams.ldoLockBoostProviderConfig.lockBoostSteps[i].multiplierBP, + "LDO lock boost step multiplier" + ); + } + } + + function test_immutables_onlyFull() public view { + assertEq(address(ldoLockBoostProvider.MODULE()), address(curatedModule), "LDO lock provider module"); + assertEq( + address(ldoLockBoostProvider.META_REGISTRY()), + address(metaRegistry), + "LDO lock provider meta registry" + ); + assertEq( + ldoLockBoostProvider.TOKEN(), + deployParams.ldoLockBoostProviderConfig.token, + "LDO lock provider token" + ); + assertEq( + address(ldoLockBoostProvider.VAULT_FACTORY()), + address(ldoLockVaultFactory), + "LDO lock provider vault factory" + ); + assertEq( + ldoLockBoostProvider.MIN_LOCK_PERIOD(), + deployParams.ldoLockBoostProviderConfig.minLockPeriod, + "LDO lock provider min lock period" + ); + assertEq( + ldoLockBoostProvider.MAX_LOCK_PERIOD(), + deployParams.ldoLockBoostProviderConfig.maxLockPeriod, + "LDO lock provider max lock period" + ); + } + + function test_roles_onlyFull() public view { + assertEq(ldoLockBoostProvider.getRoleMemberCount(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE()), adminsCount); + assertTrue(ldoLockBoostProvider.hasRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), deployParams.aragonAgent)); + } + + function test_factory_onlyFull() public view { + assertGt(address(ldoLockVaultFactory).code.length, 0, "LDO lock vault factory code"); + assertEq( + ldoLockVaultFactory.VOTING_CONTRACT(), + deployParams.ldoLockBoostProviderConfig.votingContract, + "LDO lock vault factory voting" + ); + assertEq( + ldoLockVaultFactory.snapshotDelegation(), + deployParams.ldoLockBoostProviderConfig.snapshotDelegation, + "LDO lock vault factory snapshot delegation" + ); + assertEq(ldoLockVaultFactory.getRoleMemberCount(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE()), adminsCount); + assertTrue(ldoLockVaultFactory.hasRole(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE(), deployParams.aragonAgent)); + } + + function test_initialization_onlyFull() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + ldoLockBoostProvider.initialize(deployParams.aragonAgent, deployParams.ldoLockBoostProviderConfig.lockPeriod); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + ldoLockBoostProviderImpl.initialize( + deployParams.aragonAgent, + deployParams.ldoLockBoostProviderConfig.lockPeriod + ); + } + + function test_proxy_onlyFull() public view { + OssifiableProxy proxy = OssifiableProxy(payable(address(ldoLockBoostProvider))); + assertEq( + proxy.proxy__getImplementation(), + address(ldoLockBoostProviderImpl), + "LDO lock provider proxy getter impl" + ); + assertEq( + ProxySlotUtils.getImplementation(address(ldoLockBoostProvider)), + address(ldoLockBoostProviderImpl), + "LDO lock provider proxy slot impl" + ); + assertEq(proxy.proxy__getAdmin(), address(deployParams.proxyAdmin), "LDO lock provider proxy getter admin"); + assertEq( + ProxySlotUtils.getAdmin(address(ldoLockBoostProvider)), + address(deployParams.proxyAdmin), + "LDO lock provider proxy slot admin" + ); + assertFalse(proxy.proxy__getIsOssified(), "LDO lock provider proxy ossified"); + } +} + contract CuratedGatesDeploymentTest is DeploymentBaseTest { function _expectedCurveId(uint256 gateIndex) internal view returns (uint256 curveId) { uint256 nextCustomCurveId = 1; diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index fcfc28fa3..346dc9db7 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -34,6 +34,8 @@ import { CuratedModule } from "src/CuratedModule.sol"; import { MetaRegistry } from "src/MetaRegistry.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; import { AdditionalBondRegistry } from "src/AdditionalBondRegistry.sol"; +import { ERC20LockBoostProvider } from "src/ERC20LockBoostProvider.sol"; +import { LidoGovernanceLockVaultFactory } from "src/LidoGovernanceLockVaultFactory.sol"; import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; import { CuratedGate } from "src/CuratedGate.sol"; import { DeployParams } from "script/csm/DeployBase.s.sol"; @@ -172,6 +174,7 @@ contract DeploymentHelpers is Test { uint256 defaultExitDelayFee; uint256 defaultMaxElWithdrawalRequestFee; address penaltiesManager; + uint256 weightBoostProviderConfigChangesCount; } struct DeploymentConfig { @@ -229,6 +232,9 @@ contract DeploymentHelpers is Test { address metaRegistryImpl; address additionalBondRegistry; address additionalBondRegistryImpl; + address ldoLockBoostProvider; + address ldoLockBoostProviderImpl; + address ldoLockVaultFactory; address curatedGateFactory; address curatedGateImpl; address[] curatedGates; @@ -413,6 +419,15 @@ contract DeploymentHelpers is Test { deploymentConfig.additionalBondRegistryImpl = vm.parseJsonAddress(config, ".AdditionalBondRegistryImpl"); vm.label(deploymentConfig.additionalBondRegistryImpl, "additionalBondRegistryImpl"); + deploymentConfig.ldoLockBoostProvider = vm.parseJsonAddress(config, ".LDOLockBoostProvider"); + vm.label(deploymentConfig.ldoLockBoostProvider, "ldoLockBoostProvider"); + + deploymentConfig.ldoLockBoostProviderImpl = vm.parseJsonAddress(config, ".LDOLockBoostProviderImpl"); + vm.label(deploymentConfig.ldoLockBoostProviderImpl, "ldoLockBoostProviderImpl"); + + deploymentConfig.ldoLockVaultFactory = vm.parseJsonAddress(config, ".LDOLockVaultFactory"); + vm.label(deploymentConfig.ldoLockVaultFactory, "ldoLockVaultFactory"); + if (vm.keyExistsJson(config, ".CuratedGateFactory")) { deploymentConfig.curatedGateFactory = vm.parseJsonAddress(config, ".CuratedGateFactory"); } @@ -550,6 +565,17 @@ contract DeploymentHelpers is Test { for (uint256 i; i < src.additionalBondRegistryConfig.boostSteps.length; ++i) { dst.additionalBondRegistryConfig.boostSteps.push(src.additionalBondRegistryConfig.boostSteps[i]); } + + // LDO lock boost provider + dst.ldoLockBoostProviderConfig.token = src.ldoLockBoostProviderConfig.token; + dst.ldoLockBoostProviderConfig.votingContract = src.ldoLockBoostProviderConfig.votingContract; + dst.ldoLockBoostProviderConfig.snapshotDelegation = src.ldoLockBoostProviderConfig.snapshotDelegation; + dst.ldoLockBoostProviderConfig.minLockPeriod = src.ldoLockBoostProviderConfig.minLockPeriod; + dst.ldoLockBoostProviderConfig.maxLockPeriod = src.ldoLockBoostProviderConfig.maxLockPeriod; + dst.ldoLockBoostProviderConfig.lockPeriod = src.ldoLockBoostProviderConfig.lockPeriod; + for (uint256 i; i < src.ldoLockBoostProviderConfig.lockBoostSteps.length; ++i) { + dst.ldoLockBoostProviderConfig.lockBoostSteps.push(src.ldoLockBoostProviderConfig.lockBoostSteps[i]); + } } function parseCommonDeployParams(string memory config) internal view returns (CommonDeployParams memory params) { @@ -631,6 +657,9 @@ contract DeploymentHelpers is Test { params.defaultExitDelayFee = decoded.defaultExitDelayFee; params.defaultMaxElWithdrawalRequestFee = decoded.defaultMaxElWithdrawalRequestFee; params.penaltiesManager = decoded.penaltiesManager; + if (decoded.ldoLockBoostProviderConfig.lockBoostSteps.length != 0) { + params.weightBoostProviderConfigChangesCount = 1; + } return params; } @@ -798,6 +827,9 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { MetaRegistry public metaRegistry; AdditionalBondRegistry public additionalBondRegistry; AdditionalBondRegistry public additionalBondRegistryImpl; + ERC20LockBoostProvider public ldoLockBoostProvider; + ERC20LockBoostProvider public ldoLockBoostProviderImpl; + LidoGovernanceLockVaultFactory public ldoLockVaultFactory; CuratedGate public curatedGateImpl; address[] public curatedGates; @@ -912,6 +944,9 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { metaRegistry = MetaRegistry(deploymentConfig.metaRegistry); additionalBondRegistry = AdditionalBondRegistry(deploymentConfig.additionalBondRegistry); additionalBondRegistryImpl = AdditionalBondRegistry(deploymentConfig.additionalBondRegistryImpl); + ldoLockBoostProvider = ERC20LockBoostProvider(deploymentConfig.ldoLockBoostProvider); + ldoLockBoostProviderImpl = ERC20LockBoostProvider(deploymentConfig.ldoLockBoostProviderImpl); + ldoLockVaultFactory = LidoGovernanceLockVaultFactory(deploymentConfig.ldoLockVaultFactory); curatedGateImpl = CuratedGate(deploymentConfig.curatedGateImpl); curatedGates = deploymentConfig.curatedGates; } diff --git a/test/unit/AdditionalBondRegistry.t.sol b/test/unit/AdditionalBondRegistry.t.sol index 216613e4d..a78f3a77c 100644 --- a/test/unit/AdditionalBondRegistry.t.sol +++ b/test/unit/AdditionalBondRegistry.t.sol @@ -9,6 +9,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { AdditionalBondRegistry } from "src/AdditionalBondRegistry.sol"; import { IAdditionalBondRegistry, BoostStep } from "src/interfaces/IAdditionalBondRegistry.sol"; +import { MAX_EFFECTIVE_MULTIPLIER_BP } from "src/lib/Constants.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; import { AccountingMock } from "../helpers/mocks/AccountingMock.sol"; @@ -67,8 +68,8 @@ contract AdditionalBondRegistryConstructorTest is AdditionalBondRegistryBaseTest assertEq(address(additionalBondRegistry.MODULE()), address(module)); assertEq(address(additionalBondRegistry.ACCOUNTING()), address(module.ACCOUNTING())); assertEq(address(additionalBondRegistry.META_REGISTRY()), address(metaRegistryMock)); - assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER(), 90_000); - assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(), 90_000); + assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER(), MAX_EFFECTIVE_MULTIPLIER_BP - MAX_BP); + assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(), MAX_EFFECTIVE_MULTIPLIER_BP - MAX_BP); assertEq(additionalBondRegistry.CURVE_MULTIPLIER_STEP(), 100); assertEq(additionalBondRegistry.CURVE_MULTIPLIER_REDUCTION_COOLDOWN(), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); } diff --git a/test/unit/ERC20LockBoostProvider.t.sol b/test/unit/ERC20LockBoostProvider.t.sol index e9ddcec61..bb655d776 100644 --- a/test/unit/ERC20LockBoostProvider.t.sol +++ b/test/unit/ERC20LockBoostProvider.t.sol @@ -20,6 +20,7 @@ import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; import { ISnapshotDelegationLockVault } from "src/interfaces/ISnapshotDelegationLockVault.sol"; import { MetaRegistry } from "src/MetaRegistry.sol"; import { NodeOperator, NodeOperatorManagementProperties } from "src/interfaces/IBaseModule.sol"; +import { MAX_EFFECTIVE_MULTIPLIER_BP } from "src/lib/Constants.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; import { StakingRouterMock } from "../helpers/mocks/StakingRouterMock.sol"; @@ -71,12 +72,6 @@ contract SnapshotDelegationMock { } } -contract ERC20LockVaultFactoryZeroMock is IERC20LockVaultFactory { - function createVault(uint256, address, address, address) external pure returns (address) { - return address(0); - } -} - contract ERC20LockBoostProviderForTest is ERC20LockBoostProvider { constructor( address module, @@ -123,7 +118,6 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { uint16 internal constant STEP_1_MULTIPLIER_BP = 11000; uint16 internal constant STEP_2_MULTIPLIER_BP = 11500; bytes32 internal constant SNAPSHOT_ALL_SPACES = bytes32(0); - bytes32 internal constant SNAPSHOT_SPACE_ID = bytes32("lido-snapshot-space"); function setUp() public virtual { admin = nextAddress("ADMIN"); @@ -176,7 +170,7 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { vm.startPrank(admin); provider.grantRole(provider.SET_LOCK_PERIOD_ROLE(), lockPeriodManager); - registry.addWeightBoostProvider(provider, IMetaRegistry.WeightBoostProviderMode.GroupMax); + registry.addWeightBoostProvider(provider, IMetaRegistry.WeightBoostProviderMode.MaxPerGroup); vm.stopPrank(); vm.prank(bondCurveWeightManager); @@ -237,9 +231,9 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { function _steps( uint128 amount0, - uint16 multiplier0, + uint32 multiplier0, uint128 amount1, - uint16 multiplier1 + uint32 multiplier1 ) internal pure returns (IERC20LockBoostProvider.LockBoostStep[] memory steps) { steps = new IERC20LockBoostProvider.LockBoostStep[](2); steps[0] = IERC20LockBoostProvider.LockBoostStep({ minAmount: amount0, multiplierBP: multiplier0 }); @@ -248,7 +242,7 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { function _steps1( uint128 amount, - uint16 multiplier + uint32 multiplier ) internal pure returns (IERC20LockBoostProvider.LockBoostStep[] memory steps) { steps = new IERC20LockBoostProvider.LockBoostStep[](1); steps[0] = IERC20LockBoostProvider.LockBoostStep({ minAmount: amount, multiplierBP: multiplier }); @@ -262,7 +256,7 @@ contract ERC20LockBoostProviderConstructorTest is ERC20LockBoostProviderBaseTest assertEq(address(provider.MODULE()), address(module)); assertEq(address(provider.META_REGISTRY()), address(registry)); assertEq(provider.TOKEN(), address(token)); - assertEq(provider.VAULT_FACTORY(), address(vaultFactory)); + assertEq(address(provider.VAULT_FACTORY()), address(vaultFactory)); assertEq(provider.MIN_LOCK_PERIOD(), MIN_LOCK_PERIOD); assertEq(provider.MAX_LOCK_PERIOD(), MAX_LOCK_PERIOD); } @@ -448,7 +442,10 @@ contract ERC20LockBoostProviderAdminTest is ERC20LockBoostProviderBaseTest { vm.startPrank(admin); vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); - provider.setLockBoostSteps(_steps1(STEP_1_AMOUNT, uint16(20001))); + provider.setLockBoostSteps(_steps1(0, STEP_1_MULTIPLIER_BP)); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); + provider.setLockBoostSteps(_steps1(STEP_1_AMOUNT, uint32(MAX_EFFECTIVE_MULTIPLIER_BP + 1))); vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); provider.setLockBoostSteps(_steps(STEP_1_AMOUNT, STEP_1_MULTIPLIER_BP, STEP_1_AMOUNT, STEP_2_MULTIPLIER_BP)); @@ -457,7 +454,12 @@ contract ERC20LockBoostProviderAdminTest is ERC20LockBoostProviderBaseTest { provider.setLockBoostSteps(_steps(STEP_1_AMOUNT, STEP_2_MULTIPLIER_BP, STEP_2_AMOUNT, STEP_1_MULTIPLIER_BP)); vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); - provider.setLockBoostSteps(_steps(STEP_1_AMOUNT, STEP_1_MULTIPLIER_BP, STEP_2_AMOUNT, uint16(20001))); + provider.setLockBoostSteps(_steps(STEP_1_AMOUNT, STEP_1_MULTIPLIER_BP, STEP_2_AMOUNT, STEP_1_MULTIPLIER_BP)); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); + provider.setLockBoostSteps( + _steps(STEP_1_AMOUNT, STEP_1_MULTIPLIER_BP, STEP_2_AMOUNT, uint32(MAX_EFFECTIVE_MULTIPLIER_BP + 1)) + ); vm.stopPrank(); } @@ -528,7 +530,7 @@ contract ERC20LockBoostProviderLockTest is ERC20LockBoostProviderBaseTest { assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), _weight(STEP_2_MULTIPLIER_BP)); } - function test_lock_GroupMaxBoostAppliesToWholeGroup() public { + function test_lock_MaxPerGroupBoostAppliesToWholeGroup() public { module.mock_setNodeOperatorsCount(2); _setDefaultSteps(); _lock(STEP_1_AMOUNT); @@ -572,26 +574,6 @@ contract ERC20LockBoostProviderLockTest is ERC20LockBoostProviderBaseTest { vm.expectRevert(IERC20LockBoostProvider.NodeOperatorDoesNotExist.selector); provider.lock(NODE_OPERATOR_ID + 1, 1 ether); } - - function test_lock_RevertWhen_FactoryReturnsZeroVault() public { - ERC20LockBoostProvider p = new ERC20LockBoostProvider( - address(module), - address(token), - address(new ERC20LockVaultFactoryZeroMock()), - MIN_LOCK_PERIOD, - MAX_LOCK_PERIOD - ); - _enableInitializers(address(p)); - p.initialize(admin, LOCK_PERIOD); - - token.mint(nodeOperatorOwner, 1 ether); - vm.prank(nodeOperatorOwner); - token.approve(address(p), 1 ether); - - vm.prank(nodeOperatorOwner); - vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); - p.lock(NODE_OPERATOR_ID, 1 ether); - } } contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { @@ -752,18 +734,18 @@ contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { ISnapshotDelegationLockVault vault = ISnapshotDelegationLockVault(lockInfo.vault); vm.prank(nodeOperatorOwner); - vault.assignSnapshotDelegate(SNAPSHOT_SPACE_ID, snapshotDelegate); + vault.assignSnapshotDelegate(snapshotDelegate); - assertEq(snapshotDelegation.delegateOf(lockInfo.vault, SNAPSHOT_SPACE_ID), snapshotDelegate); + assertEq(snapshotDelegation.delegateOf(lockInfo.vault, SNAPSHOT_ALL_SPACES), snapshotDelegate); assertEq(snapshotDelegation.lastDelegator(), lockInfo.vault); - assertEq(snapshotDelegation.lastId(), SNAPSHOT_SPACE_ID); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_ALL_SPACES); vm.prank(nodeOperatorOwner); - vault.unassignSnapshotDelegate(SNAPSHOT_SPACE_ID); + vault.unassignSnapshotDelegate(); - assertEq(snapshotDelegation.delegateOf(lockInfo.vault, SNAPSHOT_SPACE_ID), address(0)); + assertEq(snapshotDelegation.delegateOf(lockInfo.vault, SNAPSHOT_ALL_SPACES), address(0)); assertEq(snapshotDelegation.lastDelegator(), lockInfo.vault); - assertEq(snapshotDelegation.lastId(), SNAPSHOT_SPACE_ID); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_ALL_SPACES); } function test_assignVotingDelegate_ReassignsAndCanAssignAfterUnassign() public { @@ -834,7 +816,7 @@ contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { vm.startPrank(nodeOperatorOwner); IAragonVotingLockVault(lockInfo.vault).assignVotingDelegate(votingDelegate); - ISnapshotDelegationLockVault(lockInfo.vault).assignSnapshotDelegate(SNAPSHOT_ALL_SPACES, snapshotDelegate); + ISnapshotDelegationLockVault(lockInfo.vault).assignSnapshotDelegate(snapshotDelegate); vm.stopPrank(); assertEq(voting.delegateOf(lockInfo.vault), votingDelegate); @@ -1016,7 +998,7 @@ contract LidoGovernanceLockVaultTest is Test, Utilities { address public stranger; uint256 internal constant NODE_OPERATOR_ID = 0; - bytes32 internal constant SNAPSHOT_SPACE_ID = bytes32("lido-snapshot-space"); + bytes32 internal constant SNAPSHOT_ALL_SPACES = bytes32(0); function setUp() public { admin = nextAddress("ADMIN"); @@ -1054,7 +1036,7 @@ contract LidoGovernanceLockVaultTest is Test, Utilities { assertEq(vault.PROVIDER(), provider); assertEq(address(vault.MODULE()), address(module)); assertEq(vault.VOTING_CONTRACT(), address(voting)); - assertEq(address(vault.GOVERNANCE_CONFIG()), address(vaultFactory)); + assertEq(address(vault.VAULT_FACTORY()), address(vaultFactory)); assertEq(vault.snapshotDelegation(), address(snapshotDelegation)); } @@ -1094,18 +1076,18 @@ contract LidoGovernanceLockVaultTest is Test, Utilities { function test_assignAndUnassignSnapshotDelegate() public { vm.prank(nodeOperatorOwner); - vault.assignSnapshotDelegate(SNAPSHOT_SPACE_ID, snapshotDelegate); + vault.assignSnapshotDelegate(snapshotDelegate); - assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_SPACE_ID), snapshotDelegate); + assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_ALL_SPACES), snapshotDelegate); assertEq(snapshotDelegation.lastDelegator(), address(vault)); - assertEq(snapshotDelegation.lastId(), SNAPSHOT_SPACE_ID); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_ALL_SPACES); vm.prank(nodeOperatorOwner); - vault.unassignSnapshotDelegate(SNAPSHOT_SPACE_ID); + vault.unassignSnapshotDelegate(); - assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_SPACE_ID), address(0)); + assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_ALL_SPACES), address(0)); assertEq(snapshotDelegation.lastDelegator(), address(vault)); - assertEq(snapshotDelegation.lastId(), SNAPSHOT_SPACE_ID); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_ALL_SPACES); } function test_assignSnapshotDelegate_UsesCurrentSnapshotDelegation() public { @@ -1115,10 +1097,10 @@ contract LidoGovernanceLockVaultTest is Test, Utilities { vaultFactory.setSnapshotDelegation(address(newSnapshotDelegation)); vm.prank(nodeOperatorOwner); - vault.assignSnapshotDelegate(SNAPSHOT_SPACE_ID, snapshotDelegate); + vault.assignSnapshotDelegate(snapshotDelegate); - assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_SPACE_ID), address(0)); - assertEq(newSnapshotDelegation.delegateOf(address(vault), SNAPSHOT_SPACE_ID), snapshotDelegate); + assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_ALL_SPACES), address(0)); + assertEq(newSnapshotDelegation.delegateOf(address(vault), SNAPSHOT_ALL_SPACES), snapshotDelegate); } function test_vote() public { @@ -1140,10 +1122,10 @@ contract LidoGovernanceLockVaultTest is Test, Utilities { function test_snapshotDelegationCalls_RevertWhen_InvalidCaller() public { vm.prank(stranger); vm.expectRevert(IERC20LockVault.SenderIsNotNodeOperatorOwner.selector); - vault.assignSnapshotDelegate(SNAPSHOT_SPACE_ID, snapshotDelegate); + vault.assignSnapshotDelegate(snapshotDelegate); vm.prank(stranger); vm.expectRevert(IERC20LockVault.SenderIsNotNodeOperatorOwner.selector); - vault.unassignSnapshotDelegate(SNAPSHOT_SPACE_ID); + vault.unassignSnapshotDelegate(); } } diff --git a/test/unit/MetaRegistry.t.sol b/test/unit/MetaRegistry.t.sol index d79f02cc2..2b821e931 100644 --- a/test/unit/MetaRegistry.t.sol +++ b/test/unit/MetaRegistry.t.sol @@ -1094,10 +1094,10 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { WeightBoostProviderMock public provider; WeightBoostProviderMock public secondProvider; - IMetaRegistry.WeightBoostProviderMode internal constant NODE_OPERATOR_MODE = - IMetaRegistry.WeightBoostProviderMode.NodeOperator; - IMetaRegistry.WeightBoostProviderMode internal constant GROUP_MAX_MODE = - IMetaRegistry.WeightBoostProviderMode.GroupMax; + IMetaRegistry.WeightBoostProviderMode internal constant PER_NODE_OPERATOR_MODE = + IMetaRegistry.WeightBoostProviderMode.PerNodeOperator; + IMetaRegistry.WeightBoostProviderMode internal constant MAX_PER_GROUP_MODE = + IMetaRegistry.WeightBoostProviderMode.MaxPerGroup; function setUp() public override { super.setUp(); @@ -1118,7 +1118,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { assertEq(entry.enabled, expectedEnabled); } - function test_addWeightBoostProvider_StoresNodeOperatorProviderAndLeavesExistingGroupsStale() public { + function test_addWeightBoostProvider_StoresPerNodeOperatorProviderAndLeavesExistingGroupsStale() public { _setBondCurveWeight(0, CURVE_WEIGHT); uint256 groupId = _nextGroupId(); vm.prank(groupManager); @@ -1128,15 +1128,15 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); vm.expectEmit(address(registry)); - emit IMetaRegistry.WeightBoostProviderAdded(address(provider), NODE_OPERATOR_MODE); + emit IMetaRegistry.WeightBoostProviderAdded(address(provider), PER_NODE_OPERATOR_MODE); vm.prank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); IWeightBoostProvider[] memory providers = registry.getWeightBoostProviders(); assertEq(providers.length, 1); assertEq(address(providers[0]), address(provider)); - _assertWeightBoostProvider(1, provider, NODE_OPERATOR_MODE, true); - assertEq(uint256(registry.getWeightBoostProviderMode(1)), uint256(NODE_OPERATOR_MODE)); + _assertWeightBoostProvider(1, provider, PER_NODE_OPERATOR_MODE, true); + assertEq(uint256(registry.getWeightBoostProviderMode(1)), uint256(PER_NODE_OPERATOR_MODE)); assertEq(registry.getWeightBoostProviderId(address(provider)), 1); assertEq(registry.getWeightBoostProvidersCount(), 1); assertEq(registry.getNodeOperatorWeight(0), CURVE_WEIGHT); @@ -1145,7 +1145,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { assertEq(registry.getNodeOperatorWeight(0), 11000); } - function test_addWeightBoostProvider_StoresGroupMaxProviderAndLeavesExistingGroupsStale() public { + function test_addWeightBoostProvider_StoresMaxPerGroupProviderAndLeavesExistingGroupsStale() public { _setBondCurveWeight(0, CURVE_WEIGHT); uint256 groupId = _nextGroupId(); vm.prank(groupManager); @@ -1162,12 +1162,12 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); vm.expectEmit(address(registry)); - emit IMetaRegistry.WeightBoostProviderAdded(address(secondProvider), GROUP_MAX_MODE); + emit IMetaRegistry.WeightBoostProviderAdded(address(secondProvider), MAX_PER_GROUP_MODE); vm.prank(admin); - registry.addWeightBoostProvider(secondProvider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(secondProvider, MAX_PER_GROUP_MODE); assertEq(registry.getWeightBoostProvidersCount(), 1); - _assertWeightBoostProvider(1, secondProvider, GROUP_MAX_MODE, true); + _assertWeightBoostProvider(1, secondProvider, MAX_PER_GROUP_MODE, true); assertEq(registry.getNodeOperatorWeight(0), 5000); assertEq(registry.getNodeOperatorWeight(1), 5000); @@ -1180,12 +1180,12 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { vm.startPrank(admin); vm.expectRevert(IMetaRegistry.InvalidWeightBoostProvider.selector); - registry.addWeightBoostProvider(IWeightBoostProvider(address(0)), NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(IWeightBoostProvider(address(0)), PER_NODE_OPERATOR_MODE); - registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(provider, MAX_PER_GROUP_MODE); vm.expectRevert(IMetaRegistry.WeightBoostProviderAlreadyAdded.selector); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); vm.stopPrank(); } @@ -1193,7 +1193,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { function test_addWeightBoostProvider_RevertWhen_NoRole() public { expectRoleRevert(stranger, registry.DEFAULT_ADMIN_ROLE()); vm.prank(stranger); - registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(provider, MAX_PER_GROUP_MODE); } function test_setWeightBoostProviderEnabled_DisablesProviderAndLeavesExistingGroupsStale() public { @@ -1204,18 +1204,18 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { provider.mock_setMultiplierBP(0, 11000); vm.prank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); registry.refreshGroupWeights(groupId); assertEq(registry.getNodeOperatorWeight(0), 11000); vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); vm.expectEmit(address(registry)); - emit IMetaRegistry.WeightBoostProviderEnabledSet(address(provider), false); + emit IMetaRegistry.WeightBoostProviderStateSet(address(provider), false); vm.prank(admin); registry.setWeightBoostProviderEnabled(1, false); - _assertWeightBoostProvider(1, provider, NODE_OPERATOR_MODE, false); + _assertWeightBoostProvider(1, provider, PER_NODE_OPERATOR_MODE, false); assertEq(registry.getNodeOperatorWeight(0), 11000); registry.refreshGroupWeights(groupId); @@ -1228,8 +1228,8 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { secondProvider.mock_setMultiplierBP(0, 11000); vm.startPrank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); - registry.addWeightBoostProvider(secondProvider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(secondProvider, PER_NODE_OPERATOR_MODE); registry.setWeightBoostProviderEnabled(1, false); vm.stopPrank(); @@ -1245,7 +1245,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { function test_setWeightBoostProviderEnabled_EnablesProviderAndLeavesExistingGroupsStale() public { provider.mock_setMultiplierBP(0, 11000); vm.startPrank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); registry.setWeightBoostProviderEnabled(1, false); vm.stopPrank(); @@ -1257,20 +1257,20 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); vm.expectEmit(address(registry)); - emit IMetaRegistry.WeightBoostProviderEnabledSet(address(provider), true); + emit IMetaRegistry.WeightBoostProviderStateSet(address(provider), true); vm.prank(admin); registry.setWeightBoostProviderEnabled(1, true); - _assertWeightBoostProvider(1, provider, NODE_OPERATOR_MODE, true); + _assertWeightBoostProvider(1, provider, PER_NODE_OPERATOR_MODE, true); assertEq(registry.getNodeOperatorWeight(0), CURVE_WEIGHT); registry.refreshGroupWeights(groupId); assertEq(registry.getNodeOperatorWeight(0), 11000); } - function test_refreshGroupWeights_UsesDefaultGroupMaxMultiplierForEmptyGroup() public { + function test_refreshGroupWeights_UsesDefaultMaxPerGroupMultiplierForEmptyGroup() public { vm.prank(admin); - registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(provider, MAX_PER_GROUP_MODE); uint256 groupId = _nextGroupId(); vm.startPrank(groupManager); @@ -1283,13 +1283,13 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { assertEq(registry.getNodeOperatorWeight(0), 0); } - function test_setWeightBoostProviderEnabled_SkipsDisabledGroupMaxProvider() public { + function test_setWeightBoostProviderEnabled_SkipsDisabledMaxPerGroupProvider() public { _setBondCurveWeight(0, CURVE_WEIGHT); provider.mock_setMultiplierBP(0, 11000); provider.mock_setMultiplierBP(1, 12000); vm.startPrank(admin); - registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(provider, MAX_PER_GROUP_MODE); registry.setWeightBoostProviderEnabled(1, false); vm.stopPrank(); @@ -1320,7 +1320,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { registry.setWeightBoostProviderEnabled(1, false); vm.prank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); vm.prank(admin); vm.expectRevert(IMetaRegistry.SameWeightBoostProviderEnabled.selector); @@ -1344,7 +1344,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); vm.startPrank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); registry.setWeightBoostProviderEnabled(1, false); vm.stopPrank(); @@ -1357,7 +1357,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { function test_notifyWeightBoostChanged_NoOpWhenOperatorHasNoGroup() public { vm.prank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); provider.mock_setMultiplierBP(0, 11000); vm.prank(address(provider)); @@ -1368,7 +1368,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { function test_notifyWeightBoostProviderConfigChanged_RequestsFullDepositInfoUpdate() public { vm.prank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); vm.expectEmit(address(registry)); @@ -1385,7 +1385,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { function test_notifyWeightBoostProviderConfigChanged_NoOpWhenProviderDisabled() public { vm.startPrank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); registry.setWeightBoostProviderEnabled(1, false); vm.stopPrank(); @@ -1399,13 +1399,13 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { registry.notifyWeightBoostProviderConfigChanged(); } - function test_createAndUpdateGroup_RecalculatesGroupMaxFromComposition() public { + function test_createAndUpdateGroup_RecalculatesMaxPerGroupFromComposition() public { _setBondCurveWeight(0, CURVE_WEIGHT); provider.mock_setMultiplierBP(0, 11000); provider.mock_setMultiplierBP(1, 12000); vm.prank(admin); - registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(provider, MAX_PER_GROUP_MODE); uint256 groupId = _nextGroupId(); vm.prank(groupManager); @@ -1429,7 +1429,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { assertEq(registry.getNodeOperatorWeight(0), 0); } - function test_notifyWeightBoostChanged_FromGroupMaxProviderRefreshesWholeGroupWhenMaxChanges() public { + function test_notifyWeightBoostChanged_FromMaxPerGroupProviderRefreshesWholeGroupWhenMaxChanges() public { _setBondCurveWeight(0, CURVE_WEIGHT); vm.prank(groupManager); _createGroup( @@ -1441,7 +1441,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { ); vm.prank(admin); - registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(provider, MAX_PER_GROUP_MODE); provider.mock_setMultiplierBP(0, 12000); vm.prank(address(provider)); @@ -1451,13 +1451,13 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { assertEq(registry.getNodeOperatorWeight(1), 6000); } - function test_notifyWeightBoostChanged_FromGroupMaxProviderRefreshesWholeGroupWhenMaxUnchanged() public { + function test_notifyWeightBoostChanged_FromMaxPerGroupProviderRefreshesWholeGroupWhenMaxUnchanged() public { _setBondCurveWeight(0, CURVE_WEIGHT); provider.mock_setMultiplierBP(0, 11000); provider.mock_setMultiplierBP(1, 12000); vm.prank(admin); - registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(provider, MAX_PER_GROUP_MODE); uint256 groupId = _nextGroupId(); vm.prank(groupManager); _createGroup( @@ -1479,7 +1479,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { assertEq(registry.getNodeOperatorWeight(1), 6000); } - function test_notifyWeightBoostChanged_FromNodeOperatorProviderRefreshesOnlyOperator() public { + function test_notifyWeightBoostChanged_FromPerNodeOperatorProviderRefreshesOnlyOperator() public { _setBondCurveWeight(0, CURVE_WEIGHT); vm.prank(groupManager); _createGroup( @@ -1491,7 +1491,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { ); vm.prank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); provider.mock_setMultiplierBP(0, 11000); vm.prank(address(provider)); @@ -1507,8 +1507,8 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { secondProvider.mock_setMultiplierBP(0, 11000); vm.startPrank(admin); - registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); - registry.addWeightBoostProvider(secondProvider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, MAX_PER_GROUP_MODE); + registry.addWeightBoostProvider(secondProvider, PER_NODE_OPERATOR_MODE); vm.stopPrank(); vm.prank(groupManager); @@ -1517,7 +1517,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { assertEq(registry.getNodeOperatorWeight(0), 13200); } - function test_refreshGroupWeights_CachesGroupMaxProviderMultiplierPerRefresh() public { + function test_refreshGroupWeights_CachesMaxPerGroupProviderMultiplierPerRefresh() public { _setBondCurveWeight(0, CURVE_WEIGHT); provider.mock_setMultiplierBP(0, 11000); provider.mock_setMultiplierBP(1, 12000); @@ -1533,7 +1533,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { _createGroup(subOperators, _extOperatorsArr0()); vm.prank(admin); - registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(provider, MAX_PER_GROUP_MODE); vm.expectCall( address(provider), @@ -1567,9 +1567,9 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { thirdProvider.mock_setMultiplierBP(0, 13527); vm.startPrank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); - registry.addWeightBoostProvider(secondProvider, GROUP_MAX_MODE); - registry.addWeightBoostProvider(thirdProvider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(secondProvider, MAX_PER_GROUP_MODE); + registry.addWeightBoostProvider(thirdProvider, PER_NODE_OPERATOR_MODE); vm.stopPrank(); uint256 groupId = _nextGroupId(); @@ -1591,7 +1591,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); vm.prank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); provider.mock_setMultiplierBP(0, 9000); registry.refreshOperatorWeight(0); @@ -1619,7 +1619,7 @@ contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); vm.prank(admin); - registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(provider, PER_NODE_OPERATOR_MODE); provider.mock_setMultiplierBP(0, 11000); registry.refreshOperatorWeight(0); @@ -1764,7 +1764,7 @@ contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { uint64 noId = 0; vm.prank(admin); - registry.addWeightBoostProvider(additionalBondRegistry, IMetaRegistry.WeightBoostProviderMode.NodeOperator); + registry.addWeightBoostProvider(additionalBondRegistry, IMetaRegistry.WeightBoostProviderMode.PerNodeOperator); vm.prank(groupManager); _createGroup(_subOperatorsArr1(noId, MAX_BP), _extOperatorsArr0()); @@ -1785,7 +1785,7 @@ contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { }); vm.prank(admin); - registry.addWeightBoostProvider(additionalBondRegistry, IMetaRegistry.WeightBoostProviderMode.NodeOperator); + registry.addWeightBoostProvider(additionalBondRegistry, IMetaRegistry.WeightBoostProviderMode.PerNodeOperator); vm.prank(groupManager); _createGroup(_subOperatorsArr2(op0, op1), _extOperatorsArr0()); From 0a0c6c2f14294aa5b0f6585d7533ef688152ae3f Mon Sep 17 00:00:00 2001 From: Sergey K Date: Fri, 24 Jul 2026 12:00:42 +0500 Subject: [PATCH 3/3] refactor: use pinned beacon vault proxies --- script/curated/DeployBase.s.sol | 23 +- src/ERC20LockBoostProvider.sol | 23 +- src/ERC20LockVault.sol | 29 +- src/LidoGovernanceLockVault.sol | 14 +- src/LidoGovernanceLockVaultFactory.sol | 59 ---- src/interfaces/IERC20LockBoostProvider.sol | 7 +- src/interfaces/IERC20LockVault.sol | 9 +- src/interfaces/IERC20LockVaultFactory.sol | 22 -- .../ILidoGovernanceLockVaultFactory.sol | 23 -- src/interfaces/IPinnedBeaconProxy.sol | 13 + src/lib/proxy/PinnedBeaconProxy.sol | 31 ++ src/lib/proxy/PinnedBeaconUtils.sol | 32 ++ .../deployment/PostDeploymentCurated.t.sol | 33 +- test/helpers/Fixtures.sol | 19 +- test/unit/ERC20LockBoostProvider.t.sol | 319 ++++++++++-------- 15 files changed, 350 insertions(+), 306 deletions(-) delete mode 100644 src/LidoGovernanceLockVaultFactory.sol delete mode 100644 src/interfaces/IERC20LockVaultFactory.sol delete mode 100644 src/interfaces/ILidoGovernanceLockVaultFactory.sol create mode 100644 src/interfaces/IPinnedBeaconProxy.sol create mode 100644 src/lib/proxy/PinnedBeaconProxy.sol create mode 100644 src/lib/proxy/PinnedBeaconUtils.sol diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index a91e627e3..19d78c512 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -5,6 +5,8 @@ pragma solidity 0.8.33; import { Script } from "forge-std/Script.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; + import { HashConsensus } from "../../src/lib/base-oracle/HashConsensus.sol"; import { OssifiableProxy } from "../../src/lib/proxy/OssifiableProxy.sol"; import { CuratedModule } from "../../src/CuratedModule.sol"; @@ -20,7 +22,7 @@ import { MetaRegistry } from "../../src/MetaRegistry.sol"; import { AdditionalBondRegistry } from "../../src/AdditionalBondRegistry.sol"; import { BoostStep } from "../../src/interfaces/IAdditionalBondRegistry.sol"; import { ERC20LockBoostProvider } from "../../src/ERC20LockBoostProvider.sol"; -import { LidoGovernanceLockVaultFactory } from "../../src/LidoGovernanceLockVaultFactory.sol"; +import { LidoGovernanceLockVault } from "../../src/LidoGovernanceLockVault.sol"; import { CuratedGate } from "../../src/CuratedGate.sol"; import { MerkleGateFactory } from "../../src/MerkleGateFactory.sol"; @@ -173,7 +175,8 @@ abstract contract DeployBase is Script { AdditionalBondRegistry public additionalBondRegistry; ERC20LockBoostProvider public ldoLockBoostProvider; ERC20LockBoostProvider public ldoLockBoostProviderImpl; - LidoGovernanceLockVaultFactory public ldoLockVaultFactory; + LidoGovernanceLockVault public ldoLockVaultImpl; + UpgradeableBeacon public ldoLockVaultBeacon; MerkleGateFactory public curatedGateFactory; address[] public curatedGateInstances; address internal curatedGateImpl; @@ -377,16 +380,19 @@ abstract contract DeployBase is Script { { ERC20LockBoostProviderConfig storage ldoConfig = config.ldoLockBoostProviderConfig; - ldoLockVaultFactory = new LidoGovernanceLockVaultFactory({ - admin: deployer, + ldoLockVaultImpl = new LidoGovernanceLockVault({ + token: ldoConfig.token, + provider: address(ldoLockBoostProvider), + module: address(curatedModule), votingContract: ldoConfig.votingContract, snapshotDelegation_: ldoConfig.snapshotDelegation }); + ldoLockVaultBeacon = new UpgradeableBeacon(address(ldoLockVaultImpl), deployer); ldoLockBoostProviderImpl = new ERC20LockBoostProvider({ module: address(curatedModule), token: ldoConfig.token, - vaultFactory: address(ldoLockVaultFactory), + vaultBeacon: address(ldoLockVaultBeacon), minLockPeriod: ldoConfig.minLockPeriod, maxLockPeriod: ldoConfig.maxLockPeriod }); @@ -613,8 +619,7 @@ abstract contract DeployBase is Script { ldoLockBoostProvider.grantRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), config.aragonAgent); ldoLockBoostProvider.revokeRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), deployer); - ldoLockVaultFactory.grantRole(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE(), config.aragonAgent); - ldoLockVaultFactory.revokeRole(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE(), deployer); + ldoLockVaultBeacon.transferOwnership(config.aragonAgent); verifier.grantRole(verifier.DEFAULT_ADMIN_ROLE(), config.aragonAgent); verifier.revokeRole(verifier.DEFAULT_ADMIN_ROLE(), deployer); @@ -644,7 +649,8 @@ abstract contract DeployBase is Script { deployJson.set("AdditionalBondRegistryImpl", address(additionalBondRegistryImpl)); deployJson.set("LDOLockBoostProvider", address(ldoLockBoostProvider)); deployJson.set("LDOLockBoostProviderImpl", address(ldoLockBoostProviderImpl)); - deployJson.set("LDOLockVaultFactory", address(ldoLockVaultFactory)); + deployJson.set("LDOLockVaultImpl", address(ldoLockVaultImpl)); + deployJson.set("LDOLockVaultBeacon", address(ldoLockVaultBeacon)); deployJson.set("ParametersRegistry", address(parametersRegistry)); deployJson.set("ParametersRegistryImpl", address(parametersRegistryImpl)); deployJson.set("Accounting", address(accounting)); @@ -748,7 +754,6 @@ abstract contract DeployBase is Script { metaRegistry.grantRole(metaRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); additionalBondRegistry.grantRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); ldoLockBoostProvider.grantRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); - ldoLockVaultFactory.grantRole(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); for (uint256 i = 0; i < curatedGateInstances.length; i++) { CuratedGate gate = CuratedGate(curatedGateInstances[i]); gate.grantRole(gate.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); diff --git a/src/ERC20LockBoostProvider.sol b/src/ERC20LockBoostProvider.sol index 5b3ff96fd..fa90b0485 100644 --- a/src/ERC20LockBoostProvider.sol +++ b/src/ERC20LockBoostProvider.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.33; import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { IBeacon } from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; @@ -13,10 +14,10 @@ import { NodeOperator } from "./interfaces/IBaseModule.sol"; import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; import { IERC20LockBoostProvider } from "./interfaces/IERC20LockBoostProvider.sol"; import { IERC20LockVault } from "./interfaces/IERC20LockVault.sol"; -import { IERC20LockVaultFactory } from "./interfaces/IERC20LockVaultFactory.sol"; import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; import { MAX_BP, MAX_EFFECTIVE_MULTIPLIER_BP } from "./lib/Constants.sol"; +import { PinnedBeaconProxy } from "./lib/proxy/PinnedBeaconProxy.sol"; /// @notice Stores operator-level ERC20 locks and exposes node operator boost for scoring. contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgradeable, IERC20LockBoostProvider { @@ -32,7 +33,7 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade ICuratedModule public immutable MODULE; IMetaRegistry public immutable META_REGISTRY; address public immutable TOKEN; - IERC20LockVaultFactory public immutable VAULT_FACTORY; + IBeacon public immutable VAULT_BEACON; uint256 public immutable MIN_LOCK_PERIOD; uint256 public immutable MAX_LOCK_PERIOD; @@ -42,8 +43,8 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade bytes32 private constant ERC20_LOCK_BOOST_PROVIDER_STORAGE_LOCATION = 0x0d048d8a76e474169bd4c83d3eb46f84ff5b8d069b5f5174bf8047b6bd66fb00; - constructor(address module, address token, address vaultFactory, uint256 minLockPeriod, uint256 maxLockPeriod) { - if (module == address(0) || token == address(0) || vaultFactory == address(0)) revert ZeroAddress(); + constructor(address module, address token, address vaultBeacon, uint256 minLockPeriod, uint256 maxLockPeriod) { + if (module == address(0) || token == address(0) || vaultBeacon == address(0)) revert ZeroAddress(); if (minLockPeriod == 0 || minLockPeriod > maxLockPeriod) revert InvalidLockPeriod(); if (maxLockPeriod > type(uint128).max) revert InvalidLockPeriod(); @@ -53,7 +54,7 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade MODULE = curatedModule; META_REGISTRY = metaRegistry; TOKEN = token; - VAULT_FACTORY = IERC20LockVaultFactory(vaultFactory); + VAULT_BEACON = IBeacon(vaultBeacon); MIN_LOCK_PERIOD = minLockPeriod; MAX_LOCK_PERIOD = maxLockPeriod; @@ -147,12 +148,12 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade address vault = lockInfo.vault; if (vault == address(0)) { - vault = VAULT_FACTORY.createVault({ - nodeOperatorId: nodeOperatorId, - token: TOKEN, - provider: address(this), - module: address(MODULE) - }); + vault = address( + new PinnedBeaconProxy({ + beacon: address(VAULT_BEACON), + data: abi.encodeCall(IERC20LockVault.initialize, (nodeOperatorId)) + }) + ); lockInfo.vault = vault; emit VaultCreated(nodeOperatorId, vault, TOKEN); } diff --git a/src/ERC20LockVault.sol b/src/ERC20LockVault.sol index 85509e3f6..3e629bbae 100644 --- a/src/ERC20LockVault.sol +++ b/src/ERC20LockVault.sol @@ -3,28 +3,42 @@ pragma solidity 0.8.33; +import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { IBaseModule } from "./interfaces/IBaseModule.sol"; import { IERC20LockVault } from "./interfaces/IERC20LockVault.sol"; +import { PinnedBeaconUtils } from "./lib/proxy/PinnedBeaconUtils.sol"; /// @notice Minimal per-operator vault holding locked ERC20 tokens. -contract ERC20LockVault is IERC20LockVault { +contract ERC20LockVault is IERC20LockVault, Initializable { using SafeERC20 for IERC20; - uint256 public immutable NODE_OPERATOR_ID; + uint256 private _nodeOperatorId; address public immutable TOKEN; address public immutable PROVIDER; IBaseModule public immutable MODULE; - constructor(uint256 nodeOperatorId, address token, address provider, address module) { + constructor(address token, address provider, address module) { if (token == address(0) || provider == address(0) || module == address(0)) revert ZeroAddress(); - NODE_OPERATOR_ID = nodeOperatorId; TOKEN = token; PROVIDER = provider; MODULE = IBaseModule(module); + + _disableInitializers(); + } + + /// @inheritdoc IERC20LockVault + function initialize(uint256 nodeOperatorId_) external virtual initializer { + _nodeOperatorId = nodeOperatorId_; + } + + /// @inheritdoc IERC20LockVault + function ossify() external { + _onlyNodeOperatorOwner(); + PinnedBeaconUtils.ossify(); } /// @inheritdoc IERC20LockVault @@ -35,8 +49,13 @@ contract ERC20LockVault is IERC20LockVault { IERC20(TOKEN).safeTransfer(receiver, amount); } + /// @inheritdoc IERC20LockVault + function nodeOperatorId() external view returns (uint256) { + return _nodeOperatorId; + } + function _onlyNodeOperatorOwner() internal view { - if (msg.sender != MODULE.getNodeOperatorOwner(NODE_OPERATOR_ID)) revert SenderIsNotNodeOperatorOwner(); + if (msg.sender != MODULE.getNodeOperatorOwner(_nodeOperatorId)) revert SenderIsNotNodeOperatorOwner(); } function _onlyProvider() internal view { diff --git a/src/LidoGovernanceLockVault.sol b/src/LidoGovernanceLockVault.sol index 81bf35545..11142ffdd 100644 --- a/src/LidoGovernanceLockVault.sol +++ b/src/LidoGovernanceLockVault.sol @@ -7,7 +7,6 @@ import { ERC20LockVault } from "./ERC20LockVault.sol"; import { IAragonVotingLockVault } from "./interfaces/IAragonVotingLockVault.sol"; import { ILidoAragonVoting } from "./interfaces/ILidoAragonVoting.sol"; import { IERC20LockVault } from "./interfaces/IERC20LockVault.sol"; -import { ILidoGovernanceLockVaultFactory } from "./interfaces/ILidoGovernanceLockVaultFactory.sol"; import { ISnapshotDelegation } from "./interfaces/ISnapshotDelegation.sol"; import { ISnapshotDelegationLockVault } from "./interfaces/ISnapshotDelegationLockVault.sol"; import { SNAPSHOT_ALL_SPACES } from "./lib/Constants.sol"; @@ -15,22 +14,21 @@ import { SNAPSHOT_ALL_SPACES } from "./lib/Constants.sol"; /// @notice ERC20 lock vault with Lido Aragon Voting and Snapshot delegation capabilities. contract LidoGovernanceLockVault is ERC20LockVault, IAragonVotingLockVault, ISnapshotDelegationLockVault { address public immutable VOTING_CONTRACT; - ILidoGovernanceLockVaultFactory public immutable VAULT_FACTORY; + address public immutable SNAPSHOT_DELEGATION; constructor( - uint256 nodeOperatorId, address token, address provider, address module, address votingContract, - address vaultFactory - ) ERC20LockVault(nodeOperatorId, token, provider, module) { - if (votingContract == address(0) || vaultFactory == address(0)) { + address snapshotDelegation_ + ) ERC20LockVault(token, provider, module) { + if (votingContract == address(0) || snapshotDelegation_ == address(0)) { revert IERC20LockVault.ZeroAddress(); } VOTING_CONTRACT = votingContract; - VAULT_FACTORY = ILidoGovernanceLockVaultFactory(vaultFactory); + SNAPSHOT_DELEGATION = snapshotDelegation_; } /// @inheritdoc IAragonVotingLockVault @@ -70,6 +68,6 @@ contract LidoGovernanceLockVault is ERC20LockVault, IAragonVotingLockVault, ISna /// @inheritdoc ISnapshotDelegationLockVault function snapshotDelegation() public view returns (address) { - return VAULT_FACTORY.snapshotDelegation(); + return SNAPSHOT_DELEGATION; } } diff --git a/src/LidoGovernanceLockVaultFactory.sol b/src/LidoGovernanceLockVaultFactory.sol deleted file mode 100644 index 17572558e..000000000 --- a/src/LidoGovernanceLockVaultFactory.sol +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Lido -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity 0.8.33; - -import { AccessControlEnumerable } from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; - -import { ILidoGovernanceLockVaultFactory } from "./interfaces/ILidoGovernanceLockVaultFactory.sol"; -import { LidoGovernanceLockVault } from "./LidoGovernanceLockVault.sol"; - -/// @notice Factory creating ERC20 lock vaults with Lido governance capabilities. -contract LidoGovernanceLockVaultFactory is ILidoGovernanceLockVaultFactory, AccessControlEnumerable { - address public immutable VOTING_CONTRACT; - address public snapshotDelegation; - - constructor(address admin, address votingContract, address snapshotDelegation_) { - if (admin == address(0) || votingContract == address(0) || snapshotDelegation_ == address(0)) { - revert ZeroAddress(); - } - - VOTING_CONTRACT = votingContract; - snapshotDelegation = snapshotDelegation_; - _grantRole(DEFAULT_ADMIN_ROLE, admin); - } - - /// @notice Set Snapshot delegation registry used by all created vaults. - /// @param snapshotDelegation_ New Snapshot delegation registry. - function setSnapshotDelegation(address snapshotDelegation_) external onlyRole(DEFAULT_ADMIN_ROLE) { - if (snapshotDelegation_ == address(0)) revert ZeroAddress(); - if (snapshotDelegation == snapshotDelegation_) revert SameSnapshotDelegation(); - - snapshotDelegation = snapshotDelegation_; - emit SnapshotDelegationSet(snapshotDelegation_); - } - - /// @notice Create a vault for the provided Node Operator. - /// @param nodeOperatorId Node Operator ID. - /// @param token ERC20 token locked in the vault. - /// @param provider ERC20 lock boost provider allowed to move locked tokens. - /// @param module Module used by optional vault capabilities to resolve the current owner. - /// @return vault Created vault address. - function createVault( - uint256 nodeOperatorId, - address token, - address provider, - address module - ) external returns (address vault) { - vault = address( - new LidoGovernanceLockVault({ - nodeOperatorId: nodeOperatorId, - token: token, - provider: provider, - module: module, - votingContract: VOTING_CONTRACT, - vaultFactory: address(this) - }) - ); - } -} diff --git a/src/interfaces/IERC20LockBoostProvider.sol b/src/interfaces/IERC20LockBoostProvider.sol index 6ddb64e4d..00ec2c314 100644 --- a/src/interfaces/IERC20LockBoostProvider.sol +++ b/src/interfaces/IERC20LockBoostProvider.sol @@ -3,8 +3,9 @@ pragma solidity 0.8.33; +import { IBeacon } from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; + import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; -import { IERC20LockVaultFactory } from "./IERC20LockVaultFactory.sol"; /// @notice Operator-level ERC20 lock registry and weight boost provider. interface IERC20LockBoostProvider is IWeightBoostProvider { @@ -44,8 +45,8 @@ interface IERC20LockBoostProvider is IWeightBoostProvider { /// @notice ERC20 token address. function TOKEN() external view returns (address); - /// @notice Factory creating per-operator ERC20 lock vaults. - function VAULT_FACTORY() external view returns (IERC20LockVaultFactory); + /// @notice Beacon used by per-operator ERC20 lock vault proxies. + function VAULT_BEACON() external view returns (IBeacon); /// @notice Minimum lock period allowed by the registry. function MIN_LOCK_PERIOD() external view returns (uint256); diff --git a/src/interfaces/IERC20LockVault.sol b/src/interfaces/IERC20LockVault.sol index b6c037ffd..f8888468f 100644 --- a/src/interfaces/IERC20LockVault.sol +++ b/src/interfaces/IERC20LockVault.sol @@ -12,7 +12,7 @@ interface IERC20LockVault { error SenderIsNotNodeOperatorOwner(); /// @notice Node Operator ID this vault belongs to. - function NODE_OPERATOR_ID() external view returns (uint256); + function nodeOperatorId() external view returns (uint256); /// @notice ERC20 token address. function TOKEN() external view returns (address); @@ -23,6 +23,13 @@ interface IERC20LockVault { /// @notice Module used to resolve the current Node Operator owner. function MODULE() external view returns (IBaseModule); + /// @notice Initialize per-vault state. + /// @param nodeOperatorId Node Operator ID this vault belongs to. + function initialize(uint256 nodeOperatorId) external; + + /// @notice Permanently pin the vault's current implementation. + function ossify() external; + /// @notice Transfer tokens to the receiver. /// @param receiver Address to receive tokens. /// @param amount Token amount to transfer. diff --git a/src/interfaces/IERC20LockVaultFactory.sol b/src/interfaces/IERC20LockVaultFactory.sol deleted file mode 100644 index f8b953553..000000000 --- a/src/interfaces/IERC20LockVaultFactory.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Lido -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity 0.8.33; - -/// @notice Factory creating per-operator ERC20 lock vaults. -interface IERC20LockVaultFactory { - error ZeroAddress(); - - /// @notice Create a vault for the provided Node Operator. - /// @param nodeOperatorId Node Operator ID. - /// @param token ERC20 token locked in the vault. - /// @param provider ERC20 lock boost provider allowed to move locked tokens. - /// @param module Module used by optional vault capabilities to resolve the current owner. - /// @return vault Created vault address. - function createVault( - uint256 nodeOperatorId, - address token, - address provider, - address module - ) external returns (address vault); -} diff --git a/src/interfaces/ILidoGovernanceLockVaultFactory.sol b/src/interfaces/ILidoGovernanceLockVaultFactory.sol deleted file mode 100644 index 557c0f5db..000000000 --- a/src/interfaces/ILidoGovernanceLockVaultFactory.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Lido -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity 0.8.33; - -import { IERC20LockVaultFactory } from "./IERC20LockVaultFactory.sol"; - -/// @notice Factory and runtime governance config for Lido governance lock vaults. -interface ILidoGovernanceLockVaultFactory is IERC20LockVaultFactory { - event SnapshotDelegationSet(address snapshotDelegation); - - error SameSnapshotDelegation(); - - /// @notice Lido Aragon Voting contract used by created vaults. - function VOTING_CONTRACT() external view returns (address); - - /// @notice Snapshot delegation registry used by created vaults. - function snapshotDelegation() external view returns (address); - - /// @notice Set Snapshot delegation registry used by all created vaults. - /// @param snapshotDelegation New Snapshot delegation registry. - function setSnapshotDelegation(address snapshotDelegation) external; -} diff --git a/src/interfaces/IPinnedBeaconProxy.sol b/src/interfaces/IPinnedBeaconProxy.sol new file mode 100644 index 000000000..61d732330 --- /dev/null +++ b/src/interfaces/IPinnedBeaconProxy.sol @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +/// @notice Beacon proxy that can permanently pin an implementation per proxy instance. +interface IPinnedBeaconProxy { + /// @notice Whether this proxy has pinned its implementation. + function isOssified() external view returns (bool); + + /// @notice Implementation currently used by this proxy. + function implementation() external view returns (address); +} diff --git a/src/lib/proxy/PinnedBeaconProxy.sol b/src/lib/proxy/PinnedBeaconProxy.sol new file mode 100644 index 000000000..4e3445144 --- /dev/null +++ b/src/lib/proxy/PinnedBeaconProxy.sol @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; + +import { IPinnedBeaconProxy } from "../../interfaces/IPinnedBeaconProxy.sol"; +import { PinnedBeaconUtils } from "./PinnedBeaconUtils.sol"; + +/// @notice Beacon proxy that can permanently pin an implementation per proxy instance. +contract PinnedBeaconProxy is BeaconProxy, IPinnedBeaconProxy { + constructor(address beacon, bytes memory data) payable BeaconProxy(beacon, data) {} + + /// @inheritdoc IPinnedBeaconProxy + function isOssified() external view returns (bool) { + return PinnedBeaconUtils.getPinnedImplementation() != address(0); + } + + /// @inheritdoc IPinnedBeaconProxy + function implementation() external view returns (address) { + return _implementation(); + } + + function _implementation() internal view override returns (address) { + address pinnedImplementation = PinnedBeaconUtils.getPinnedImplementation(); + if (pinnedImplementation != address(0)) return pinnedImplementation; + + return super._implementation(); + } +} diff --git a/src/lib/proxy/PinnedBeaconUtils.sol b/src/lib/proxy/PinnedBeaconUtils.sol new file mode 100644 index 000000000..ff8ffbb1d --- /dev/null +++ b/src/lib/proxy/PinnedBeaconUtils.sol @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { IBeacon } from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; +import { ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import { StorageSlot } from "@openzeppelin/contracts/utils/StorageSlot.sol"; + +/// @notice Manages the implementation pinned by an individual beacon proxy. +library PinnedBeaconUtils { + /// @dev bytes32(uint256(keccak256("PinnedBeaconProxy.pinnedImplementation")) - 1) + bytes32 internal constant PINNED_IMPLEMENTATION_STORAGE_SLOT = + 0x48cff194361e753f73884dc53566173bfa5edf4b6bfce4d4c02f2b6bb9ba95db; + + event PinnedImplementationSet(address indexed implementation); + + error AlreadyOssified(); + + /// @notice Pin the beacon's current implementation for the calling proxy. + function ossify() internal { + if (getPinnedImplementation() != address(0)) revert AlreadyOssified(); + + address implementation = IBeacon(ERC1967Utils.getBeacon()).implementation(); + StorageSlot.getAddressSlot(PINNED_IMPLEMENTATION_STORAGE_SLOT).value = implementation; + emit PinnedImplementationSet(implementation); + } + + function getPinnedImplementation() internal view returns (address) { + return StorageSlot.getAddressSlot(PINNED_IMPLEMENTATION_STORAGE_SLOT).value; + } +} diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index 58bec9f9d..8fd8867aa 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -287,9 +287,9 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { "LDO lock provider token" ); assertEq( - address(ldoLockBoostProvider.VAULT_FACTORY()), - address(ldoLockVaultFactory), - "LDO lock provider vault factory" + address(ldoLockBoostProvider.VAULT_BEACON()), + address(ldoLockVaultBeacon), + "LDO lock provider vault beacon" ); assertEq( ldoLockBoostProvider.MIN_LOCK_PERIOD(), @@ -308,20 +308,28 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { assertTrue(ldoLockBoostProvider.hasRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), deployParams.aragonAgent)); } - function test_factory_onlyFull() public view { - assertGt(address(ldoLockVaultFactory).code.length, 0, "LDO lock vault factory code"); + function test_vaultBeacon_onlyFull() public view { + assertGt(address(ldoLockVaultImpl).code.length, 0, "LDO lock vault impl code"); + assertGt(address(ldoLockVaultBeacon).code.length, 0, "LDO lock vault beacon code"); + assertEq(ldoLockVaultBeacon.implementation(), address(ldoLockVaultImpl), "LDO lock vault beacon impl"); + assertEq(ldoLockVaultBeacon.owner(), deployParams.aragonAgent, "LDO lock vault beacon owner"); + } + + function test_vaultImplementation_onlyFull() public view { + assertEq(ldoLockVaultImpl.nodeOperatorId(), 0, "LDO lock vault impl node operator ID"); + assertEq(ldoLockVaultImpl.TOKEN(), deployParams.ldoLockBoostProviderConfig.token, "LDO lock vault impl token"); + assertEq(ldoLockVaultImpl.PROVIDER(), address(ldoLockBoostProvider), "LDO lock vault impl provider"); + assertEq(address(ldoLockVaultImpl.MODULE()), address(curatedModule), "LDO lock vault impl module"); assertEq( - ldoLockVaultFactory.VOTING_CONTRACT(), + ldoLockVaultImpl.VOTING_CONTRACT(), deployParams.ldoLockBoostProviderConfig.votingContract, - "LDO lock vault factory voting" + "LDO lock vault impl voting" ); assertEq( - ldoLockVaultFactory.snapshotDelegation(), + ldoLockVaultImpl.snapshotDelegation(), deployParams.ldoLockBoostProviderConfig.snapshotDelegation, - "LDO lock vault factory snapshot delegation" + "LDO lock vault impl snapshot delegation" ); - assertEq(ldoLockVaultFactory.getRoleMemberCount(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE()), adminsCount); - assertTrue(ldoLockVaultFactory.hasRole(ldoLockVaultFactory.DEFAULT_ADMIN_ROLE(), deployParams.aragonAgent)); } function test_initialization_onlyFull() public { @@ -333,6 +341,9 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { deployParams.aragonAgent, deployParams.ldoLockBoostProviderConfig.lockPeriod ); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + ldoLockVaultImpl.initialize(0); } function test_proxy_onlyFull() public view { diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index 346dc9db7..e9044ea26 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -35,9 +35,10 @@ import { MetaRegistry } from "src/MetaRegistry.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; import { AdditionalBondRegistry } from "src/AdditionalBondRegistry.sol"; import { ERC20LockBoostProvider } from "src/ERC20LockBoostProvider.sol"; -import { LidoGovernanceLockVaultFactory } from "src/LidoGovernanceLockVaultFactory.sol"; +import { LidoGovernanceLockVault } from "src/LidoGovernanceLockVault.sol"; import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; import { CuratedGate } from "src/CuratedGate.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import { DeployParams } from "script/csm/DeployBase.s.sol"; import { DeployCSM0x02Params } from "script/csm0x02/DeployCSM0x02Base.s.sol"; import { CuratedDeployParams } from "script/curated/DeployBase.s.sol"; @@ -234,7 +235,8 @@ contract DeploymentHelpers is Test { address additionalBondRegistryImpl; address ldoLockBoostProvider; address ldoLockBoostProviderImpl; - address ldoLockVaultFactory; + address ldoLockVaultImpl; + address ldoLockVaultBeacon; address curatedGateFactory; address curatedGateImpl; address[] curatedGates; @@ -425,8 +427,11 @@ contract DeploymentHelpers is Test { deploymentConfig.ldoLockBoostProviderImpl = vm.parseJsonAddress(config, ".LDOLockBoostProviderImpl"); vm.label(deploymentConfig.ldoLockBoostProviderImpl, "ldoLockBoostProviderImpl"); - deploymentConfig.ldoLockVaultFactory = vm.parseJsonAddress(config, ".LDOLockVaultFactory"); - vm.label(deploymentConfig.ldoLockVaultFactory, "ldoLockVaultFactory"); + deploymentConfig.ldoLockVaultImpl = vm.parseJsonAddress(config, ".LDOLockVaultImpl"); + vm.label(deploymentConfig.ldoLockVaultImpl, "ldoLockVaultImpl"); + + deploymentConfig.ldoLockVaultBeacon = vm.parseJsonAddress(config, ".LDOLockVaultBeacon"); + vm.label(deploymentConfig.ldoLockVaultBeacon, "ldoLockVaultBeacon"); if (vm.keyExistsJson(config, ".CuratedGateFactory")) { deploymentConfig.curatedGateFactory = vm.parseJsonAddress(config, ".CuratedGateFactory"); @@ -829,7 +834,8 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { AdditionalBondRegistry public additionalBondRegistryImpl; ERC20LockBoostProvider public ldoLockBoostProvider; ERC20LockBoostProvider public ldoLockBoostProviderImpl; - LidoGovernanceLockVaultFactory public ldoLockVaultFactory; + LidoGovernanceLockVault public ldoLockVaultImpl; + UpgradeableBeacon public ldoLockVaultBeacon; CuratedGate public curatedGateImpl; address[] public curatedGates; @@ -946,7 +952,8 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { additionalBondRegistryImpl = AdditionalBondRegistry(deploymentConfig.additionalBondRegistryImpl); ldoLockBoostProvider = ERC20LockBoostProvider(deploymentConfig.ldoLockBoostProvider); ldoLockBoostProviderImpl = ERC20LockBoostProvider(deploymentConfig.ldoLockBoostProviderImpl); - ldoLockVaultFactory = LidoGovernanceLockVaultFactory(deploymentConfig.ldoLockVaultFactory); + ldoLockVaultImpl = LidoGovernanceLockVault(deploymentConfig.ldoLockVaultImpl); + ldoLockVaultBeacon = UpgradeableBeacon(deploymentConfig.ldoLockVaultBeacon); curatedGateImpl = CuratedGate(deploymentConfig.curatedGateImpl); curatedGates = deploymentConfig.curatedGates; } diff --git a/test/unit/ERC20LockBoostProvider.t.sol b/test/unit/ERC20LockBoostProvider.t.sol index bb655d776..b09acdf61 100644 --- a/test/unit/ERC20LockBoostProvider.t.sol +++ b/test/unit/ERC20LockBoostProvider.t.sol @@ -6,18 +6,19 @@ pragma solidity 0.8.33; import { Test } from "forge-std/Test.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import { ERC20LockBoostProvider } from "src/ERC20LockBoostProvider.sol"; import { ERC20LockVault } from "src/ERC20LockVault.sol"; import { LidoGovernanceLockVault } from "src/LidoGovernanceLockVault.sol"; -import { LidoGovernanceLockVaultFactory } from "src/LidoGovernanceLockVaultFactory.sol"; import { IAragonVotingLockVault } from "src/interfaces/IAragonVotingLockVault.sol"; import { IERC20LockBoostProvider } from "src/interfaces/IERC20LockBoostProvider.sol"; import { IERC20LockVault } from "src/interfaces/IERC20LockVault.sol"; -import { IERC20LockVaultFactory } from "src/interfaces/IERC20LockVaultFactory.sol"; -import { ILidoGovernanceLockVaultFactory } from "src/interfaces/ILidoGovernanceLockVaultFactory.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; +import { IPinnedBeaconProxy } from "src/interfaces/IPinnedBeaconProxy.sol"; import { ISnapshotDelegationLockVault } from "src/interfaces/ISnapshotDelegationLockVault.sol"; +import { PinnedBeaconProxy } from "src/lib/proxy/PinnedBeaconProxy.sol"; +import { PinnedBeaconUtils } from "src/lib/proxy/PinnedBeaconUtils.sol"; import { MetaRegistry } from "src/MetaRegistry.sol"; import { NodeOperator, NodeOperatorManagementProperties } from "src/interfaces/IBaseModule.sol"; import { MAX_EFFECTIVE_MULTIPLIER_BP } from "src/lib/Constants.sol"; @@ -76,10 +77,10 @@ contract ERC20LockBoostProviderForTest is ERC20LockBoostProvider { constructor( address module, address token, - address vaultFactory, + address vaultBeacon, uint256 minLockPeriod, uint256 maxLockPeriod - ) ERC20LockBoostProvider(module, token, vaultFactory, minLockPeriod, maxLockPeriod) {} + ) ERC20LockBoostProvider(module, token, vaultBeacon, minLockPeriod, maxLockPeriod) {} function mock_withdraw(uint256 nodeOperatorId, uint256 amount, address receiver) external { _withdraw(nodeOperatorId, amount, receiver); @@ -93,7 +94,8 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { ERC20Testable public token; LidoAragonVotingMock public voting; SnapshotDelegationMock public snapshotDelegation; - LidoGovernanceLockVaultFactory public vaultFactory; + LidoGovernanceLockVault public vaultImpl; + UpgradeableBeacon public vaultBeacon; ERC20LockBoostProvider public provider; address public admin; @@ -157,14 +159,23 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { token = new ERC20Testable(); voting = new LidoAragonVotingMock(); snapshotDelegation = new SnapshotDelegationMock(); - vaultFactory = new LidoGovernanceLockVaultFactory(admin, address(voting), address(snapshotDelegation)); + address expectedProvider = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 2); + vaultImpl = new LidoGovernanceLockVault( + address(token), + expectedProvider, + address(module), + address(voting), + address(snapshotDelegation) + ); + vaultBeacon = new UpgradeableBeacon(address(vaultImpl), admin); provider = new ERC20LockBoostProvider( address(module), address(token), - address(vaultFactory), + address(vaultBeacon), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD ); + assertEq(address(provider), expectedProvider); _enableInitializers(address(provider)); provider.initialize(admin, LOCK_PERIOD); @@ -256,23 +267,17 @@ contract ERC20LockBoostProviderConstructorTest is ERC20LockBoostProviderBaseTest assertEq(address(provider.MODULE()), address(module)); assertEq(address(provider.META_REGISTRY()), address(registry)); assertEq(provider.TOKEN(), address(token)); - assertEq(address(provider.VAULT_FACTORY()), address(vaultFactory)); + assertEq(address(provider.VAULT_BEACON()), address(vaultBeacon)); assertEq(provider.MIN_LOCK_PERIOD(), MIN_LOCK_PERIOD); assertEq(provider.MAX_LOCK_PERIOD(), MAX_LOCK_PERIOD); } function test_constructor_RevertWhen_ZeroAddresses() public { vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); - new ERC20LockBoostProvider(address(0), address(token), address(vaultFactory), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD); + new ERC20LockBoostProvider(address(0), address(token), address(vaultBeacon), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD); vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); - new ERC20LockBoostProvider( - address(module), - address(0), - address(vaultFactory), - MIN_LOCK_PERIOD, - MAX_LOCK_PERIOD - ); + new ERC20LockBoostProvider(address(module), address(0), address(vaultBeacon), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD); vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); new ERC20LockBoostProvider(address(module), address(token), address(0), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD); @@ -280,13 +285,13 @@ contract ERC20LockBoostProviderConstructorTest is ERC20LockBoostProviderBaseTest function test_constructor_RevertWhen_InvalidLockPeriodBounds() public { vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); - new ERC20LockBoostProvider(address(module), address(token), address(vaultFactory), 0, MAX_LOCK_PERIOD); + new ERC20LockBoostProvider(address(module), address(token), address(vaultBeacon), 0, MAX_LOCK_PERIOD); vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); new ERC20LockBoostProvider( address(module), address(token), - address(vaultFactory), + address(vaultBeacon), MAX_LOCK_PERIOD, MIN_LOCK_PERIOD ); @@ -295,7 +300,7 @@ contract ERC20LockBoostProviderConstructorTest is ERC20LockBoostProviderBaseTest new ERC20LockBoostProvider( address(module), address(token), - address(vaultFactory), + address(vaultBeacon), MIN_LOCK_PERIOD, uint256(type(uint128).max) + 1 ); @@ -313,7 +318,7 @@ contract ERC20LockBoostProviderInitializeTest is ERC20LockBoostProviderBaseTest ERC20LockBoostProvider p = new ERC20LockBoostProvider( address(module), address(token), - address(vaultFactory), + address(vaultBeacon), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD ); @@ -327,7 +332,7 @@ contract ERC20LockBoostProviderInitializeTest is ERC20LockBoostProviderBaseTest ERC20LockBoostProvider p = new ERC20LockBoostProvider( address(module), address(token), - address(vaultFactory), + address(vaultBeacon), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD ); @@ -483,6 +488,8 @@ contract ERC20LockBoostProviderLockTest is ERC20LockBoostProviderBaseTest { assertEq(provider.getVault(NODE_OPERATOR_ID), lockInfo.vault); assertEq(LidoGovernanceLockVault(lockInfo.vault).VOTING_CONTRACT(), address(voting)); assertEq(LidoGovernanceLockVault(lockInfo.vault).snapshotDelegation(), address(snapshotDelegation)); + assertFalse(IPinnedBeaconProxy(lockInfo.vault).isOssified()); + assertEq(IPinnedBeaconProxy(lockInfo.vault).implementation(), address(vaultImpl)); assertEq(lockInfo.amount, amount); assertEq(lockInfo.lockUntil, nowTs + LOCK_PERIOD); assertEq(token.balanceOf(lockInfo.vault), amount); @@ -608,6 +615,36 @@ contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { assertEq(lockInfo.lockUntil, 0); } + function test_withdraw_AfterVaultImplementationUpgrade() public { + _lock(10 ether); + IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockVault vault = IERC20LockVault(lockInfo.vault); + SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); + LidoGovernanceLockVault newVaultImpl = new LidoGovernanceLockVault( + address(token), + address(provider), + address(module), + address(voting), + address(newSnapshotDelegation) + ); + + vm.prank(admin); + vaultBeacon.upgradeTo(address(newVaultImpl)); + + assertEq(vault.nodeOperatorId(), NODE_OPERATOR_ID); + assertEq(vault.TOKEN(), address(token)); + assertEq(vault.PROVIDER(), address(provider)); + assertEq(address(vault.MODULE()), address(module)); + assertEq(token.balanceOf(lockInfo.vault), 10 ether); + + vm.warp(lockInfo.lockUntil); + vm.prank(nodeOperatorOwner); + provider.withdraw(NODE_OPERATOR_ID, 10 ether, receiver); + + assertEq(token.balanceOf(receiver), 10 ether); + assertEq(token.balanceOf(lockInfo.vault), 0); + } + function test_withdraw_DoesNotRefreshRegistryWhenBoostUnchanged() public { _setDefaultSteps(); _lock(STEP_1_AMOUNT + 10 ether); @@ -701,7 +738,7 @@ contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { ERC20LockBoostProviderForTest p = new ERC20LockBoostProviderForTest( address(module), address(token), - address(vaultFactory), + address(vaultBeacon), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD ); @@ -828,103 +865,13 @@ contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { } } -contract LidoGovernanceLockVaultFactoryTest is Test, Utilities { - CuratedMock public module; - ERC20Testable public token; - LidoAragonVotingMock public voting; - SnapshotDelegationMock public snapshotDelegation; - LidoGovernanceLockVaultFactory public vaultFactory; - - address public admin; - address public provider; - address public stranger; - - uint256 internal constant NODE_OPERATOR_ID = 0; - - function setUp() public { - admin = nextAddress("ADMIN"); - provider = nextAddress("PROVIDER"); - stranger = nextAddress("STRANGER"); - - module = new CuratedMock(); - token = new ERC20Testable(); - voting = new LidoAragonVotingMock(); - snapshotDelegation = new SnapshotDelegationMock(); - vaultFactory = new LidoGovernanceLockVaultFactory(admin, address(voting), address(snapshotDelegation)); - } - - function test_constructor_SetsImmutables() public view { - assertEq(vaultFactory.VOTING_CONTRACT(), address(voting)); - assertEq(vaultFactory.snapshotDelegation(), address(snapshotDelegation)); - assertTrue(vaultFactory.hasRole(vaultFactory.DEFAULT_ADMIN_ROLE(), admin)); - } - - function test_constructor_RevertWhen_ZeroAddresses() public { - vm.expectRevert(IERC20LockVaultFactory.ZeroAddress.selector); - new LidoGovernanceLockVaultFactory(address(0), address(voting), address(snapshotDelegation)); - - vm.expectRevert(IERC20LockVaultFactory.ZeroAddress.selector); - new LidoGovernanceLockVaultFactory(admin, address(0), address(snapshotDelegation)); - - vm.expectRevert(IERC20LockVaultFactory.ZeroAddress.selector); - new LidoGovernanceLockVaultFactory(admin, address(voting), address(0)); - } - - function test_setSnapshotDelegation() public { - SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); - - vm.expectEmit(address(vaultFactory)); - emit ILidoGovernanceLockVaultFactory.SnapshotDelegationSet(address(newSnapshotDelegation)); - vm.prank(admin); - vaultFactory.setSnapshotDelegation(address(newSnapshotDelegation)); - - assertEq(vaultFactory.snapshotDelegation(), address(newSnapshotDelegation)); - } - - function test_setSnapshotDelegation_UpdatesCreatedVaults() public { - address vaultAddress = vaultFactory.createVault(NODE_OPERATOR_ID, address(token), provider, address(module)); - LidoGovernanceLockVault vault = LidoGovernanceLockVault(vaultAddress); - SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); - - vm.prank(admin); - vaultFactory.setSnapshotDelegation(address(newSnapshotDelegation)); - - assertEq(vault.snapshotDelegation(), address(newSnapshotDelegation)); - } - - function test_setSnapshotDelegation_RevertWhen_InvalidInputOrNoRole() public { - SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); - - vm.prank(admin); - vm.expectRevert(IERC20LockVaultFactory.ZeroAddress.selector); - vaultFactory.setSnapshotDelegation(address(0)); - - vm.prank(admin); - vm.expectRevert(ILidoGovernanceLockVaultFactory.SameSnapshotDelegation.selector); - vaultFactory.setSnapshotDelegation(address(snapshotDelegation)); - - expectRoleRevert(stranger, vaultFactory.DEFAULT_ADMIN_ROLE()); - vm.prank(stranger); - vaultFactory.setSnapshotDelegation(address(newSnapshotDelegation)); - } - - function test_createVault() public { - address vaultAddress = vaultFactory.createVault(NODE_OPERATOR_ID, address(token), provider, address(module)); - LidoGovernanceLockVault vault = LidoGovernanceLockVault(vaultAddress); - - assertEq(vault.NODE_OPERATOR_ID(), NODE_OPERATOR_ID); - assertEq(vault.TOKEN(), address(token)); - assertEq(vault.PROVIDER(), provider); - assertEq(address(vault.MODULE()), address(module)); - assertEq(vault.VOTING_CONTRACT(), address(voting)); - assertEq(vault.snapshotDelegation(), address(snapshotDelegation)); - } -} - contract ERC20LockVaultTest is Test, Utilities { ERC20Testable public token; + ERC20LockVault public vaultImpl; + UpgradeableBeacon public vaultBeacon; ERC20LockVault public vault; + address public admin; address public provider; address public module; address public receiver; @@ -933,17 +880,27 @@ contract ERC20LockVaultTest is Test, Utilities { uint256 internal constant NODE_OPERATOR_ID = 13; function setUp() public { + admin = nextAddress("ADMIN"); provider = nextAddress("PROVIDER"); module = nextAddress("MODULE"); receiver = nextAddress("RECEIVER"); stranger = nextAddress("STRANGER"); token = new ERC20Testable(); - vault = new ERC20LockVault(NODE_OPERATOR_ID, address(token), provider, module); + vaultImpl = new ERC20LockVault(address(token), provider, module); + vaultBeacon = new UpgradeableBeacon(address(vaultImpl), admin); + vault = ERC20LockVault( + address( + new PinnedBeaconProxy({ + beacon: address(vaultBeacon), + data: abi.encodeCall(IERC20LockVault.initialize, (NODE_OPERATOR_ID)) + }) + ) + ); } - function test_constructor_SetsImmutables() public view { - assertEq(vault.NODE_OPERATOR_ID(), NODE_OPERATOR_ID); + function test_initialize_SetsState() public view { + assertEq(vault.nodeOperatorId(), NODE_OPERATOR_ID); assertEq(vault.TOKEN(), address(token)); assertEq(vault.PROVIDER(), provider); assertEq(address(vault.MODULE()), module); @@ -951,13 +908,18 @@ contract ERC20LockVaultTest is Test, Utilities { function test_constructor_RevertWhen_ZeroAddresses() public { vm.expectRevert(IERC20LockVault.ZeroAddress.selector); - new ERC20LockVault(NODE_OPERATOR_ID, address(0), provider, module); + new ERC20LockVault(address(0), provider, module); vm.expectRevert(IERC20LockVault.ZeroAddress.selector); - new ERC20LockVault(NODE_OPERATOR_ID, address(token), address(0), module); + new ERC20LockVault(address(token), address(0), module); vm.expectRevert(IERC20LockVault.ZeroAddress.selector); - new ERC20LockVault(NODE_OPERATOR_ID, address(token), provider, address(0)); + new ERC20LockVault(address(token), provider, address(0)); + } + + function test_initialize_RevertWhen_DoubleCall() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + vault.initialize(NODE_OPERATOR_ID); } function test_transferTokens() public { @@ -987,7 +949,8 @@ contract LidoGovernanceLockVaultTest is Test, Utilities { ERC20Testable public token; LidoAragonVotingMock public voting; SnapshotDelegationMock public snapshotDelegation; - LidoGovernanceLockVaultFactory public vaultFactory; + LidoGovernanceLockVault public vaultImpl; + UpgradeableBeacon public vaultBeacon; LidoGovernanceLockVault public vault; address public admin; @@ -1019,47 +982,40 @@ contract LidoGovernanceLockVaultTest is Test, Utilities { token = new ERC20Testable(); voting = new LidoAragonVotingMock(); snapshotDelegation = new SnapshotDelegationMock(); - vaultFactory = new LidoGovernanceLockVaultFactory(admin, address(voting), address(snapshotDelegation)); - vault = new LidoGovernanceLockVault( - NODE_OPERATOR_ID, + vaultImpl = new LidoGovernanceLockVault( address(token), provider, address(module), address(voting), - address(vaultFactory) + address(snapshotDelegation) + ); + vaultBeacon = new UpgradeableBeacon(address(vaultImpl), admin); + vault = LidoGovernanceLockVault( + address( + new PinnedBeaconProxy({ + beacon: address(vaultBeacon), + data: abi.encodeCall(IERC20LockVault.initialize, (NODE_OPERATOR_ID)) + }) + ) ); } - function test_constructor_SetsImmutables() public view { - assertEq(vault.NODE_OPERATOR_ID(), NODE_OPERATOR_ID); + function test_initialize_SetsStateAndImmutables() public view { + assertEq(vault.nodeOperatorId(), NODE_OPERATOR_ID); assertEq(vault.TOKEN(), address(token)); assertEq(vault.PROVIDER(), provider); assertEq(address(vault.MODULE()), address(module)); assertEq(vault.VOTING_CONTRACT(), address(voting)); - assertEq(address(vault.VAULT_FACTORY()), address(vaultFactory)); + assertEq(vault.SNAPSHOT_DELEGATION(), address(snapshotDelegation)); assertEq(vault.snapshotDelegation(), address(snapshotDelegation)); } function test_constructor_RevertWhen_ZeroGovernanceAddresses() public { vm.expectRevert(IERC20LockVault.ZeroAddress.selector); - new LidoGovernanceLockVault( - NODE_OPERATOR_ID, - address(token), - provider, - address(module), - address(0), - address(vaultFactory) - ); + new LidoGovernanceLockVault(address(token), provider, address(module), address(0), address(snapshotDelegation)); vm.expectRevert(IERC20LockVault.ZeroAddress.selector); - new LidoGovernanceLockVault( - NODE_OPERATOR_ID, - address(token), - provider, - address(module), - address(voting), - address(0) - ); + new LidoGovernanceLockVault(address(token), provider, address(module), address(voting), address(0)); } function test_assignAndUnassignVotingDelegate() public { @@ -1090,17 +1046,84 @@ contract LidoGovernanceLockVaultTest is Test, Utilities { assertEq(snapshotDelegation.lastId(), SNAPSHOT_ALL_SPACES); } - function test_assignSnapshotDelegate_UsesCurrentSnapshotDelegation() public { + function test_assignSnapshotDelegate_UsesCurrentBeaconImplementation() public { SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); + LidoGovernanceLockVault newVaultImpl = new LidoGovernanceLockVault( + address(token), + provider, + address(module), + address(voting), + address(newSnapshotDelegation) + ); vm.prank(admin); - vaultFactory.setSnapshotDelegation(address(newSnapshotDelegation)); + vaultBeacon.upgradeTo(address(newVaultImpl)); vm.prank(nodeOperatorOwner); vault.assignSnapshotDelegate(snapshotDelegate); + assertEq(vault.snapshotDelegation(), address(newSnapshotDelegation)); assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_ALL_SPACES), address(0)); assertEq(newSnapshotDelegation.delegateOf(address(vault), SNAPSHOT_ALL_SPACES), snapshotDelegate); + assertFalse(IPinnedBeaconProxy(address(vault)).isOssified()); + assertEq(IPinnedBeaconProxy(address(vault)).implementation(), address(newVaultImpl)); + } + + function test_ossify_PinsCurrentImplementation() public { + IPinnedBeaconProxy proxy = IPinnedBeaconProxy(address(vault)); + + vm.prank(nodeOperatorOwner); + vault.ossify(); + + assertTrue(proxy.isOssified()); + assertEq(proxy.implementation(), address(vaultImpl)); + + SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); + LidoGovernanceLockVault newVaultImpl = new LidoGovernanceLockVault( + address(token), + provider, + address(module), + address(voting), + address(newSnapshotDelegation) + ); + vm.prank(admin); + vaultBeacon.upgradeTo(address(newVaultImpl)); + + assertEq(proxy.implementation(), address(vaultImpl)); + assertEq(vault.snapshotDelegation(), address(snapshotDelegation)); + } + + function test_ossify_RevertWhen_InvalidCallerOrAlreadyOssified() public { + vm.prank(stranger); + vm.expectRevert(IERC20LockVault.SenderIsNotNodeOperatorOwner.selector); + vault.ossify(); + + vm.prank(nodeOperatorOwner); + vault.ossify(); + + vm.prank(nodeOperatorOwner); + vm.expectRevert(PinnedBeaconUtils.AlreadyOssified.selector); + vault.ossify(); + } + + function test_ossify_UsesCurrentNodeOperatorOwner() public { + address newOwner = nextAddress("NEW_NODE_OPERATOR_OWNER"); + module.mock_setNodeOperatorManagementProperties( + NodeOperatorManagementProperties({ + managerAddress: newOwner, + rewardAddress: newOwner, + extendedManagerPermissions: true + }) + ); + + vm.prank(nodeOperatorOwner); + vm.expectRevert(IERC20LockVault.SenderIsNotNodeOperatorOwner.selector); + vault.ossify(); + + vm.prank(newOwner); + vault.ossify(); + + assertTrue(IPinnedBeaconProxy(address(vault)).isOssified()); } function test_vote() public {