diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index d8c12c614..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"; @@ -19,6 +21,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 { LidoGovernanceLockVault } from "../../src/LidoGovernanceLockVault.sol"; import { CuratedGate } from "../../src/CuratedGate.sol"; import { MerkleGateFactory } from "../../src/MerkleGateFactory.sol"; @@ -28,6 +32,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 +73,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 +148,8 @@ struct CuratedDeployParams { address secondAdminAddress; // AdditionalBondRegistry AdditionalBondRegistryConfig additionalBondRegistryConfig; + // LDO lock boost provider + ERC20LockBoostProviderConfig ldoLockBoostProviderConfig; } abstract contract DeployBase is Script { @@ -156,6 +173,10 @@ abstract contract DeployBase is Script { ParametersRegistry public parametersRegistry; MetaRegistry public metaRegistry; AdditionalBondRegistry public additionalBondRegistry; + ERC20LockBoostProvider public ldoLockBoostProvider; + ERC20LockBoostProvider public ldoLockBoostProviderImpl; + LidoGovernanceLockVault public ldoLockVaultImpl; + UpgradeableBeacon public ldoLockVaultBeacon; MerkleGateFactory public curatedGateFactory; address[] public curatedGateInstances; address internal curatedGateImpl; @@ -245,6 +266,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 +376,46 @@ abstract contract DeployBase is Script { additionalBondRegistryProxy.proxy__changeAdmin(config.proxyAdmin); } + // LDO lock boost provider + { + ERC20LockBoostProviderConfig storage ldoConfig = config.ldoLockBoostProviderConfig; + + 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, + vaultBeacon: address(ldoLockVaultBeacon), + 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 +616,11 @@ 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); + + ldoLockVaultBeacon.transferOwnership(config.aragonAgent); + verifier.grantRole(verifier.DEFAULT_ADMIN_ROLE(), config.aragonAgent); verifier.revokeRole(verifier.DEFAULT_ADMIN_ROLE(), deployer); @@ -586,6 +647,10 @@ 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("LDOLockVaultImpl", address(ldoLockVaultImpl)); + deployJson.set("LDOLockVaultBeacon", address(ldoLockVaultBeacon)); deployJson.set("ParametersRegistry", address(parametersRegistry)); deployJson.set("ParametersRegistryImpl", address(parametersRegistryImpl)); deployJson.set("Accounting", address(accounting)); @@ -654,6 +719,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 +753,7 @@ 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); 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 new file mode 100644 index 000000000..fa90b0485 --- /dev/null +++ b/src/ERC20LockBoostProvider.sol @@ -0,0 +1,257 @@ +// 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 { 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"; + +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 { 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 { + 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; + IBeacon public immutable VAULT_BEACON; + uint256 public immutable MIN_LOCK_PERIOD; + uint256 public immutable MAX_LOCK_PERIOD; + + bytes32 public constant SET_LOCK_PERIOD_ROLE = keccak256("SET_LOCK_PERIOD_ROLE"); + + // 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 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(); + + ICuratedModule curatedModule = ICuratedModule(module); + IMetaRegistry metaRegistry = curatedModule.META_REGISTRY(); + + MODULE = curatedModule; + META_REGISTRY = metaRegistry; + TOKEN = token; + VAULT_BEACON = IBeacon(vaultBeacon); + 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) { + _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 = address( + new PinnedBeaconProxy({ + beacon: address(VAULT_BEACON), + data: abi.encodeCall(IERC20LockVault.initialize, (nodeOperatorId)) + }) + ); + 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 { + uint256 oldMultiplierBP = _getMultiplierBP(oldAmount); + uint256 newMultiplierBP = _getMultiplierBP(newAmount); + if (oldMultiplierBP != newMultiplierBP) META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + } + + function _getMultiplierBP(uint256 amount) internal view returns (uint256 multiplierBP) { + ERC20LockBoostProviderStorage storage $ = _storage(); + multiplierBP = 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 (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_EFFECTIVE_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(); + if (_storage().lockPeriod == lockPeriod) revert SameLockPeriod(); + + _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..3e629bbae --- /dev/null +++ b/src/ERC20LockVault.sol @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +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, Initializable { + using SafeERC20 for IERC20; + + uint256 private _nodeOperatorId; + address public immutable TOKEN; + address public immutable PROVIDER; + IBaseModule public immutable MODULE; + + constructor(address token, address provider, address module) { + if (token == address(0) || provider == address(0) || module == address(0)) revert ZeroAddress(); + + 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 + function transferTokens(address receiver, uint256 amount) external { + _onlyProvider(); + if (receiver == address(0)) revert ZeroAddress(); + + IERC20(TOKEN).safeTransfer(receiver, amount); + } + + /// @inheritdoc IERC20LockVault + function nodeOperatorId() external view returns (uint256) { + return _nodeOperatorId; + } + + function _onlyNodeOperatorOwner() internal view { + if (msg.sender != MODULE.getNodeOperatorOwner(_nodeOperatorId)) revert SenderIsNotNodeOperatorOwner(); + } + + function _onlyProvider() internal view { + if (msg.sender != PROVIDER) revert SenderIsNotProvider(); + } +} diff --git a/src/LidoGovernanceLockVault.sol b/src/LidoGovernanceLockVault.sol new file mode 100644 index 000000000..11142ffdd --- /dev/null +++ b/src/LidoGovernanceLockVault.sol @@ -0,0 +1,73 @@ +// 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 { 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; + address public immutable SNAPSHOT_DELEGATION; + + constructor( + address token, + address provider, + address module, + address votingContract, + address snapshotDelegation_ + ) ERC20LockVault(token, provider, module) { + if (votingContract == address(0) || snapshotDelegation_ == address(0)) { + revert IERC20LockVault.ZeroAddress(); + } + + VOTING_CONTRACT = votingContract; + SNAPSHOT_DELEGATION = snapshotDelegation_; + } + + /// @inheritdoc IAragonVotingLockVault + function assignVotingDelegate(address votingDelegate) external { + _onlyNodeOperatorOwner(); + + ILidoAragonVoting(VOTING_CONTRACT).assignDelegate(votingDelegate); + } + + /// @inheritdoc IAragonVotingLockVault + function unassignVotingDelegate() external { + _onlyNodeOperatorOwner(); + + ILidoAragonVoting(VOTING_CONTRACT).unassignDelegate(); + } + + /// @inheritdoc ISnapshotDelegationLockVault + function assignSnapshotDelegate(address snapshotDelegate) external { + _onlyNodeOperatorOwner(); + + ISnapshotDelegation(snapshotDelegation()).setDelegate(SNAPSHOT_ALL_SPACES, snapshotDelegate); + } + + /// @inheritdoc ISnapshotDelegationLockVault + function unassignSnapshotDelegate() external { + _onlyNodeOperatorOwner(); + + ISnapshotDelegation(snapshotDelegation()).clearDelegate(SNAPSHOT_ALL_SPACES); + } + + /// @inheritdoc IAragonVotingLockVault + function vote(uint256 voteId, bool support) external { + _onlyNodeOperatorOwner(); + + ILidoAragonVoting(VOTING_CONTRACT).vote(voteId, support, false); + } + + /// @inheritdoc ISnapshotDelegationLockVault + function snapshotDelegation() public view returns (address) { + return SNAPSHOT_DELEGATION; + } +} 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/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..00ec2c314 --- /dev/null +++ b/src/interfaces/IERC20LockBoostProvider.sol @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { IBeacon } from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; + +import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; + +/// @notice Operator-level ERC20 lock registry and weight boost provider. +interface IERC20LockBoostProvider is IWeightBoostProvider { + struct LockBoostStep { + uint128 minAmount; + uint32 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 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); + + /// @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..f8888468f --- /dev/null +++ b/src/interfaces/IERC20LockVault.sol @@ -0,0 +1,37 @@ +// 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 nodeOperatorId() 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 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. + function transferTokens(address receiver, uint256 amount) external; +} 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/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/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/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..8b72d5f12 --- /dev/null +++ b/src/interfaces/ISnapshotDelegationLockVault.sol @@ -0,0 +1,17 @@ +// 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 all spaces. + /// @param snapshotDelegate Address to assign as delegate. + function assignSnapshotDelegate(address snapshotDelegate) 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/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/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..8fd8867aa 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,126 @@ 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_BEACON()), + address(ldoLockVaultBeacon), + "LDO lock provider vault beacon" + ); + 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_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( + ldoLockVaultImpl.VOTING_CONTRACT(), + deployParams.ldoLockBoostProviderConfig.votingContract, + "LDO lock vault impl voting" + ); + assertEq( + ldoLockVaultImpl.snapshotDelegation(), + deployParams.ldoLockBoostProviderConfig.snapshotDelegation, + "LDO lock vault impl snapshot delegation" + ); + } + + 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 + ); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + ldoLockVaultImpl.initialize(0); + } + + 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..e9044ea26 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -34,8 +34,11 @@ 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 { 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"; @@ -172,6 +175,7 @@ contract DeploymentHelpers is Test { uint256 defaultExitDelayFee; uint256 defaultMaxElWithdrawalRequestFee; address penaltiesManager; + uint256 weightBoostProviderConfigChangesCount; } struct DeploymentConfig { @@ -229,6 +233,10 @@ contract DeploymentHelpers is Test { address metaRegistryImpl; address additionalBondRegistry; address additionalBondRegistryImpl; + address ldoLockBoostProvider; + address ldoLockBoostProviderImpl; + address ldoLockVaultImpl; + address ldoLockVaultBeacon; address curatedGateFactory; address curatedGateImpl; address[] curatedGates; @@ -413,6 +421,18 @@ 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.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"); } @@ -550,6 +570,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 +662,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 +832,10 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { MetaRegistry public metaRegistry; AdditionalBondRegistry public additionalBondRegistry; AdditionalBondRegistry public additionalBondRegistryImpl; + ERC20LockBoostProvider public ldoLockBoostProvider; + ERC20LockBoostProvider public ldoLockBoostProviderImpl; + LidoGovernanceLockVault public ldoLockVaultImpl; + UpgradeableBeacon public ldoLockVaultBeacon; CuratedGate public curatedGateImpl; address[] public curatedGates; @@ -912,6 +950,10 @@ 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); + ldoLockVaultImpl = LidoGovernanceLockVault(deploymentConfig.ldoLockVaultImpl); + ldoLockVaultBeacon = UpgradeableBeacon(deploymentConfig.ldoLockVaultBeacon); 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 new file mode 100644 index 000000000..b09acdf61 --- /dev/null +++ b/test/unit/ERC20LockBoostProvider.t.sol @@ -0,0 +1,1154 @@ +// 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 { 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 { IAragonVotingLockVault } from "src/interfaces/IAragonVotingLockVault.sol"; +import { IERC20LockBoostProvider } from "src/interfaces/IERC20LockBoostProvider.sol"; +import { IERC20LockVault } from "src/interfaces/IERC20LockVault.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"; + +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 ERC20LockBoostProviderForTest is ERC20LockBoostProvider { + constructor( + address module, + address token, + address vaultBeacon, + uint256 minLockPeriod, + uint256 maxLockPeriod + ) ERC20LockBoostProvider(module, token, vaultBeacon, 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; + LidoGovernanceLockVault public vaultImpl; + UpgradeableBeacon public vaultBeacon; + 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); + + 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(); + 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(vaultBeacon), + MIN_LOCK_PERIOD, + MAX_LOCK_PERIOD + ); + assertEq(address(provider), expectedProvider); + _enableInitializers(address(provider)); + provider.initialize(admin, LOCK_PERIOD); + + vm.startPrank(admin); + provider.grantRole(provider.SET_LOCK_PERIOD_ROLE(), lockPeriodManager); + registry.addWeightBoostProvider(provider, IMetaRegistry.WeightBoostProviderMode.MaxPerGroup); + 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, + uint32 multiplier0, + uint128 amount1, + uint32 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, + uint32 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(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(vaultBeacon), MIN_LOCK_PERIOD, MAX_LOCK_PERIOD); + + vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); + 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); + } + + function test_constructor_RevertWhen_InvalidLockPeriodBounds() public { + vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); + new ERC20LockBoostProvider(address(module), address(token), address(vaultBeacon), 0, MAX_LOCK_PERIOD); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); + new ERC20LockBoostProvider( + address(module), + address(token), + address(vaultBeacon), + MAX_LOCK_PERIOD, + MIN_LOCK_PERIOD + ); + + vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); + new ERC20LockBoostProvider( + address(module), + address(token), + address(vaultBeacon), + 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(vaultBeacon), + 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(vaultBeacon), + 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(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)); + + 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, 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(); + } +} + +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)); + 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); + 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_MaxPerGroupBoostAppliesToWholeGroup() 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); + } +} + +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_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); + 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(vaultBeacon), + 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(snapshotDelegate); + + assertEq(snapshotDelegation.delegateOf(lockInfo.vault, SNAPSHOT_ALL_SPACES), snapshotDelegate); + assertEq(snapshotDelegation.lastDelegator(), lockInfo.vault); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_ALL_SPACES); + + vm.prank(nodeOperatorOwner); + vault.unassignSnapshotDelegate(); + + assertEq(snapshotDelegation.delegateOf(lockInfo.vault, SNAPSHOT_ALL_SPACES), address(0)); + assertEq(snapshotDelegation.lastDelegator(), lockInfo.vault); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_ALL_SPACES); + } + + 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(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 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; + address public stranger; + + 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(); + 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_initialize_SetsState() public view { + assertEq(vault.nodeOperatorId(), 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(address(0), provider, module); + + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + new ERC20LockVault(address(token), address(0), module); + + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + 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 { + 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; + LidoGovernanceLockVault public vaultImpl; + UpgradeableBeacon public vaultBeacon; + 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_ALL_SPACES = bytes32(0); + + 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(); + vaultImpl = new LidoGovernanceLockVault( + address(token), + provider, + address(module), + address(voting), + 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_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(vault.SNAPSHOT_DELEGATION(), address(snapshotDelegation)); + assertEq(vault.snapshotDelegation(), address(snapshotDelegation)); + } + + function test_constructor_RevertWhen_ZeroGovernanceAddresses() public { + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + new LidoGovernanceLockVault(address(token), provider, address(module), address(0), address(snapshotDelegation)); + + vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + new LidoGovernanceLockVault(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(snapshotDelegate); + + assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_ALL_SPACES), snapshotDelegate); + assertEq(snapshotDelegation.lastDelegator(), address(vault)); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_ALL_SPACES); + + vm.prank(nodeOperatorOwner); + vault.unassignSnapshotDelegate(); + + assertEq(snapshotDelegation.delegateOf(address(vault), SNAPSHOT_ALL_SPACES), address(0)); + assertEq(snapshotDelegation.lastDelegator(), address(vault)); + assertEq(snapshotDelegation.lastId(), SNAPSHOT_ALL_SPACES); + } + + 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); + 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 { + 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(snapshotDelegate); + + vm.prank(stranger); + vm.expectRevert(IERC20LockVault.SenderIsNotNodeOperatorOwner.selector); + 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());