diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 338dc712d..8de0edf11 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -21,9 +21,10 @@ import { ExitPenalties } from "../../src/ExitPenalties.sol"; import { MetaRegistry } from "../../src/MetaRegistry.sol"; import { AdditionalBondRegistry } from "../../src/AdditionalBondRegistry.sol"; import { NodeOperatorStrikes } from "../../src/NodeOperatorStrikes.sol"; -import { BoostStep } from "../../src/interfaces/IAdditionalBondRegistry.sol"; +import { CustomFeeRegistry } from "../../src/CustomFeeRegistry.sol"; import { ERC20LockBoostProvider } from "../../src/ERC20LockBoostProvider.sol"; import { LidoGovernanceLockVault } from "../../src/LidoGovernanceLockVault.sol"; +import { Step } from "../../src/interfaces/IStepwiseWeightBoost.sol"; import { CuratedGate } from "../../src/CuratedGate.sol"; import { MerkleGateFactory } from "../../src/MerkleGateFactory.sol"; @@ -33,10 +34,8 @@ 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"; -import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { JsonObj, Json } from "../utils/Json.sol"; import { Dummy } from "../utils/Dummy.sol"; @@ -48,6 +47,7 @@ struct GateCurveParams { IParametersRegistry.MarkedUint248 generalDelayedPenaltyAdditionalFine; IParametersRegistry.MarkedUint248 keysLimit; uint256[2][] avgPerfLeewayData; + // Legacy ParametersRegistry compatibility value. Curated fees are sourced from CustomFeeRegistry. uint256[2][] rewardShareData; IParametersRegistry.MarkedUint248 strikesLifetimeFrames; IParametersRegistry.MarkedUint248 strikesThreshold; @@ -70,9 +70,15 @@ struct CuratedGateConfig { } struct AdditionalBondRegistryConfig { - uint256 curveMultiplierCooldown; - // Each entry is [minCurveMultiplier, weightMultiplier] (increments above MAX_BP). - uint256[2][] boostSteps; + uint256 curveMultiplierReductionCooldown; + // `threshold` is a curve multiplier increment and `value` a weight multiplier increment, both above MAX_BP. + Step[] boostSteps; +} + +struct NodeOperatorStrikesConfig { + address committee; + // `threshold` is the minimum active strike count and `value` the weight reduction from MAX_BP. + Step[] thresholds; } struct ERC20LockBoostProviderConfig { @@ -81,7 +87,20 @@ struct ERC20LockBoostProviderConfig { address snapshotDelegation; uint256 minLockPeriod; uint256 lockPeriod; - IERC20LockBoostProvider.LockBoostStep[] lockBoostSteps; + Step[] lockBoostSteps; +} + +struct CurveFeeModifierConfig { + uint256 curveId; + uint256 value; + bool negative; +} + +struct CustomFeeRegistryConfig { + uint256 defaultMaxFeeDiscount; + uint256 feeDiscountCutCooldown; + Step[] feeDiscountWeightSteps; + CurveFeeModifierConfig[] feeModifiers; } struct CuratedDeployParams { @@ -122,6 +141,7 @@ struct CuratedDeployParams { uint256 defaultGeneralDelayedPenaltyAdditionalFine; uint256 defaultKeysLimit; uint256 defaultAvgPerfLeewayBP; + // Legacy ParametersRegistry compatibility value. Curated fees are sourced from CustomFeeRegistry. uint256 defaultRewardShareBP; uint256 defaultStrikesLifetimeFrames; uint256 defaultStrikesThreshold; @@ -150,10 +170,11 @@ struct CuratedDeployParams { // AdditionalBondRegistry AdditionalBondRegistryConfig additionalBondRegistryConfig; // NodeOperatorStrikes - address strikesCommittee; - StrikeThreshold[] strikesThresholds; + NodeOperatorStrikesConfig nodeOperatorStrikesConfig; // LDO lock boost provider ERC20LockBoostProviderConfig ldoLockBoostProviderConfig; + // CustomFeeRegistry + CustomFeeRegistryConfig customFeeRegistryConfig; } abstract contract DeployBase is Script { @@ -182,6 +203,8 @@ abstract contract DeployBase is Script { ERC20LockBoostProvider public ldoLockBoostProviderImpl; LidoGovernanceLockVault public ldoLockVaultImpl; UpgradeableBeacon public ldoLockVaultBeacon; + CustomFeeRegistry public customFeeRegistry; + CustomFeeRegistry public customFeeRegistryImpl; MerkleGateFactory public curatedGateFactory; address[] public curatedGateInstances; address internal curatedGateImpl; @@ -273,6 +296,7 @@ abstract contract DeployBase is Script { additionalBondRegistry = AdditionalBondRegistry(_deployProxy(deployer, address(dummyImpl))); nodeOperatorStrikes = NodeOperatorStrikes(_deployProxy(deployer, address(dummyImpl))); ldoLockBoostProvider = ERC20LockBoostProvider(_deployProxy(deployer, address(dummyImpl))); + customFeeRegistry = CustomFeeRegistry(_deployProxy(deployer, address(dummyImpl))); FeeDistributor feeDistributorImpl = new FeeDistributor({ stETH: locator.lido(), @@ -342,56 +366,44 @@ abstract contract DeployBase is Script { metaRegistry: address(metaRegistry) }); - { - OssifiableProxy moduleProxy = OssifiableProxy(payable(address(curatedModule))); - moduleProxy.proxy__upgradeToAndCall( - address(curatedModuleImpl), - abi.encodeCall(CuratedModule.initialize, (deployer)) - ); - moduleProxy.proxy__changeAdmin(config.proxyAdmin); - } + _upgradeAndHandoffProxy( + address(curatedModule), + address(curatedModuleImpl), + abi.encodeCall(CuratedModule.initialize, (deployer)) + ); - MetaRegistry metaRegistryImpl = new MetaRegistry({ - module: address(curatedModule), - additionalBondRegistry: address(additionalBondRegistry) - }); + MetaRegistry metaRegistryImpl = new MetaRegistry({ module: address(curatedModule) }); - { - OssifiableProxy metaRegistryProxy = OssifiableProxy(payable(address(metaRegistry))); - metaRegistryProxy.proxy__upgradeToAndCall( - address(metaRegistryImpl), - abi.encodeCall(MetaRegistry.initialize, (deployer)) - ); - metaRegistryProxy.proxy__changeAdmin(config.proxyAdmin); - } + _upgradeAndHandoffProxy( + address(metaRegistry), + address(metaRegistryImpl), + abi.encodeCall(MetaRegistry.initialize, (deployer)) + ); AdditionalBondRegistry additionalBondRegistryImpl = new AdditionalBondRegistry({ - module: address(curatedModule), - curveMultiplierCooldown: config.additionalBondRegistryConfig.curveMultiplierCooldown + module: address(curatedModule) }); - { - OssifiableProxy additionalBondRegistryProxy = OssifiableProxy(payable(address(additionalBondRegistry))); - BoostStep[] memory initialBoostSteps = CommonScriptUtils.arraysToBoostSteps( - config.additionalBondRegistryConfig.boostSteps - ); - additionalBondRegistryProxy.proxy__upgradeToAndCall( - address(additionalBondRegistryImpl), - abi.encodeCall(AdditionalBondRegistry.initialize, (deployer, initialBoostSteps)) - ); - additionalBondRegistryProxy.proxy__changeAdmin(config.proxyAdmin); - } + _upgradeAndHandoffProxy( + address(additionalBondRegistry), + address(additionalBondRegistryImpl), + abi.encodeCall( + AdditionalBondRegistry.initialize, + ( + deployer, + config.additionalBondRegistryConfig.curveMultiplierReductionCooldown, + config.additionalBondRegistryConfig.boostSteps + ) + ) + ); NodeOperatorStrikes nodeOperatorStrikesImpl = new NodeOperatorStrikes({ module: address(curatedModule) }); - { - OssifiableProxy nodeOperatorStrikesProxy = OssifiableProxy(payable(address(nodeOperatorStrikes))); - nodeOperatorStrikesProxy.proxy__upgradeToAndCall( - address(nodeOperatorStrikesImpl), - abi.encodeCall(NodeOperatorStrikes.initialize, (deployer, config.strikesThresholds)) - ); - nodeOperatorStrikesProxy.proxy__changeAdmin(config.proxyAdmin); - } + _upgradeAndHandoffProxy( + address(nodeOperatorStrikes), + address(nodeOperatorStrikesImpl), + abi.encodeCall(NodeOperatorStrikes.initialize, (deployer, config.nodeOperatorStrikesConfig.thresholds)) + ); // LDO lock boost provider { @@ -413,30 +425,48 @@ abstract contract DeployBase is Script { minLockPeriod: ldoConfig.minLockPeriod }); - OssifiableProxy ldoLockBoostProviderProxy = OssifiableProxy(payable(address(ldoLockBoostProvider))); - ldoLockBoostProviderProxy.proxy__upgradeToAndCall( + _upgradeAndHandoffProxy( + address(ldoLockBoostProvider), address(ldoLockBoostProviderImpl), - abi.encodeCall(ERC20LockBoostProvider.initialize, (deployer, ldoConfig.lockPeriod)) + abi.encodeCall( + ERC20LockBoostProvider.initialize, + (deployer, ldoConfig.lockPeriod, ldoConfig.lockBoostSteps) + ) ); - ldoLockBoostProviderProxy.proxy__changeAdmin(config.proxyAdmin); } + customFeeRegistryImpl = new CustomFeeRegistry({ module: address(curatedModule) }); + + _upgradeAndHandoffProxy( + address(customFeeRegistry), + address(customFeeRegistryImpl), + abi.encodeCall( + CustomFeeRegistry.initialize, + ( + deployer, + config.customFeeRegistryConfig.defaultMaxFeeDiscount, + config.customFeeRegistryConfig.feeDiscountCutCooldown, + config.customFeeRegistryConfig.feeDiscountWeightSteps + ) + ) + ); + accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), address(additionalBondRegistry)); - metaRegistry.addWeightBoostProvider( - IWeightBoostProvider(address(additionalBondRegistry)), + _addWeightBoostProvider( + address(additionalBondRegistry), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator ); - metaRegistry.addWeightBoostProvider( - IWeightBoostProvider(address(nodeOperatorStrikes)), + _addWeightBoostProvider( + address(nodeOperatorStrikes), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator ); - nodeOperatorStrikes.grantRole(nodeOperatorStrikes.STRIKES_COMMITTEE_ROLE(), config.strikesCommittee); - metaRegistry.addWeightBoostProvider( - IWeightBoostProvider(address(ldoLockBoostProvider)), - IMetaRegistry.WeightBoostProviderMode.MaxPerGroup + _addWeightBoostProvider(address(ldoLockBoostProvider), IMetaRegistry.WeightBoostProviderMode.MaxPerGroup); + _addWeightBoostProvider(address(customFeeRegistry), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator); + nodeOperatorStrikes.grantRole( + nodeOperatorStrikes.STRIKES_COMMITTEE_ROLE(), + config.nodeOperatorStrikesConfig.committee ); - ldoLockBoostProvider.setLockBoostSteps(config.ldoLockBoostProviderConfig.lockBoostSteps); metaRegistry.grantRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); for (uint256 i = 0; i < gatesCount; i++) { @@ -503,6 +533,12 @@ abstract contract DeployBase is Script { parametersRegistry.setMaxElWithdrawalRequestFee(curveId, params.maxElWithdrawalRequestFee.value); } } + + for (uint256 i; i < config.customFeeRegistryConfig.feeModifiers.length; ++i) { + CurveFeeModifierConfig storage feeModifier = config.customFeeRegistryConfig.feeModifiers[i]; + customFeeRegistry.setFeeModifier(feeModifier.curveId, feeModifier.value, feeModifier.negative); + } + accounting.revokeRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); metaRegistry.revokeRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); @@ -644,6 +680,8 @@ abstract contract DeployBase is Script { ldoLockBoostProvider.revokeRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), deployer); ldoLockVaultBeacon.transferOwnership(config.aragonAgent); + customFeeRegistry.grantRole(customFeeRegistry.DEFAULT_ADMIN_ROLE(), config.aragonAgent); + customFeeRegistry.revokeRole(customFeeRegistry.DEFAULT_ADMIN_ROLE(), deployer); verifier.grantRole(verifier.DEFAULT_ADMIN_ROLE(), config.aragonAgent); verifier.revokeRole(verifier.DEFAULT_ADMIN_ROLE(), deployer); @@ -677,6 +715,8 @@ abstract contract DeployBase is Script { deployJson.set("LDOLockBoostProviderImpl", address(ldoLockBoostProviderImpl)); deployJson.set("LDOLockVaultImpl", address(ldoLockVaultImpl)); deployJson.set("LDOLockVaultBeacon", address(ldoLockVaultBeacon)); + deployJson.set("CustomFeeRegistry", address(customFeeRegistry)); + deployJson.set("CustomFeeRegistryImpl", address(customFeeRegistryImpl)); deployJson.set("ParametersRegistry", address(parametersRegistry)); deployJson.set("ParametersRegistryImpl", address(parametersRegistryImpl)); deployJson.set("Accounting", address(accounting)); @@ -744,10 +784,16 @@ abstract contract DeployBase is Script { return gates; } - function _addLDOLockBoostStep(uint128 minAmount, uint32 weightBoostBP) internal { - config.ldoLockBoostProviderConfig.lockBoostSteps.push( - IERC20LockBoostProvider.LockBoostStep({ minAmount: minAmount, weightBoostBP: weightBoostBP }) - ); + /// @dev Points the proxy at `impl`, runs the initializer, and hands proxy administration to the DAO. + function _upgradeAndHandoffProxy(address proxyAddress, address impl, bytes memory initCalldata) internal { + OssifiableProxy proxy = OssifiableProxy(payable(proxyAddress)); + proxy.proxy__upgradeToAndCall(impl, initCalldata); + proxy.proxy__changeAdmin(config.proxyAdmin); + } + + /// @dev Registration order assigns the provider ids, so keep the call sequence stable. + function _addWeightBoostProvider(address provider, IMetaRegistry.WeightBoostProviderMode mode) internal { + metaRegistry.addWeightBoostProvider(IWeightBoostProvider(provider), mode); } function _deployProxy(address admin, address implementation) internal returns (address) { @@ -780,6 +826,7 @@ abstract contract DeployBase is Script { additionalBondRegistry.grantRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); nodeOperatorStrikes.grantRole(nodeOperatorStrikes.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); ldoLockBoostProvider.grantRole(ldoLockBoostProvider.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); + customFeeRegistry.grantRole(customFeeRegistry.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 77993022a..71706d242 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -3,8 +3,8 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; -import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; +import { DeployBase, CuratedGateConfig, CurveFeeModifierConfig } from "./DeployBase.s.sol"; +import { Step } from "../../src/interfaces/IStepwiseWeightBoost.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployHoodi is DeployBase { @@ -65,6 +65,7 @@ contract DeployHoodi is DeployBase { config.defaultGeneralDelayedPenaltyAdditionalFine = 0.1 ether; config.defaultKeysLimit = 80; config.defaultAvgPerfLeewayBP = 10000; + // Legacy ParametersRegistry compatibility value; Curated fees come from CustomFeeRegistry. config.defaultRewardShareBP = 6250; // 62.5% of 4% = 2.5% of the total config.defaultStrikesLifetimeFrames = 6; config.defaultStrikesThreshold = 3; @@ -201,18 +202,18 @@ contract DeployHoodi is DeployBase { config.secondAdminAddress = 0x4AF43Ee34a6fcD1fEcA1e1F832124C763561dA53; // Dev team EOA // CurveMultiplier - config.additionalBondRegistryConfig.curveMultiplierCooldown = 7 days; + config.additionalBondRegistryConfig.curveMultiplierReductionCooldown = 7 days; // TODO: reconsider — placeholder initial boost steps. - config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); - config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); + config.additionalBondRegistryConfig.boostSteps.push(Step({ threshold: 5_000, value: 2_000 })); + config.additionalBondRegistryConfig.boostSteps.push(Step({ threshold: 10_000, value: 8_000 })); // NodeOperatorStrikes - config.strikesCommittee = 0x84DffcfB232594975C608DE92544Ff239a24c9E9; // CMC on Hoodi + config.nodeOperatorStrikesConfig.committee = 0x84DffcfB232594975C608DE92544Ff239a24c9E9; // CMC on Hoodi // TODO: finalize strike weight-reduction thresholds - config.strikesThresholds.push(StrikeThreshold({ minCount: 2, reductionBP: 2_500 })); - config.strikesThresholds.push(StrikeThreshold({ minCount: 3, reductionBP: 5_000 })); - config.strikesThresholds.push(StrikeThreshold({ minCount: 4, reductionBP: 7_500 })); - config.strikesThresholds.push(StrikeThreshold({ minCount: 5, reductionBP: 10_000 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 2, value: 2_500 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 3, value: 5_000 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 4, value: 7_500 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 5, value: 10_000 })); // LDO lock boost provider config.ldoLockBoostProviderConfig.token = 0xEf2573966D009CcEA0Fc74451dee2193564198dc; @@ -220,8 +221,20 @@ contract DeployHoodi is DeployBase { config.ldoLockBoostProviderConfig.snapshotDelegation = address(1); // TODO: Fill in before deployment config.ldoLockBoostProviderConfig.minLockPeriod = 30 days; config.ldoLockBoostProviderConfig.lockPeriod = 30 days; - _addLDOLockBoostStep(100_000 ether, 1_000); - _addLDOLockBoostStep(200_000 ether, 1_500); + config.ldoLockBoostProviderConfig.lockBoostSteps.push(Step({ threshold: 100_000 ether, value: 1_000 })); + config.ldoLockBoostProviderConfig.lockBoostSteps.push(Step({ threshold: 200_000 ether, value: 1_500 })); + + // CustomFeeRegistry + // TODO: finalize custom fee parameters. + config.customFeeRegistryConfig.defaultMaxFeeDiscount = 6_250; + config.customFeeRegistryConfig.feeDiscountCutCooldown = 15 days; + for (uint128 i = 1; i < 35; ++i) { + config.customFeeRegistryConfig.feeDiscountWeightSteps.push(Step({ threshold: i * 250, value: i * 400 })); + } + // Professional Operator uses the default bond curve (curve 0): 8_750 - 2_500 = 6_250. + config.customFeeRegistryConfig.feeModifiers.push( + CurveFeeModifierConfig({ curveId: 0, value: 2_500, negative: true }) + ); _setUp(); } diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index d07272d30..bcafef133 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -3,8 +3,8 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; -import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; +import { DeployBase, CuratedGateConfig, CurveFeeModifierConfig } from "./DeployBase.s.sol"; +import { Step } from "../../src/interfaces/IStepwiseWeightBoost.sol"; import { GIndices } from "../constants/GIndices.sol"; import { BaseOracle } from "../../src/lib/base-oracle/BaseOracle.sol"; import { HashConsensus } from "../../src/lib/base-oracle/HashConsensus.sol"; @@ -55,6 +55,7 @@ contract DeployLocalDevNet is DeployBase { config.defaultGeneralDelayedPenaltyAdditionalFine = 0.1 ether; config.defaultKeysLimit = 100; config.defaultAvgPerfLeewayBP = 10000; + // Legacy ParametersRegistry compatibility value; Curated fees come from CustomFeeRegistry. config.defaultRewardShareBP = 6250; // 62.5% of 4% = 2.5% of the total config.defaultStrikesLifetimeFrames = 6; config.defaultStrikesThreshold = 3; @@ -189,17 +190,17 @@ contract DeployLocalDevNet is DeployBase { config.secondAdminAddress = vm.envOr("CSM_SECOND_ADMIN_ADDRESS", address(0)); // CurveMultiplier - config.additionalBondRegistryConfig.curveMultiplierCooldown = 1 days; + config.additionalBondRegistryConfig.curveMultiplierReductionCooldown = 1 days; // TODO: reconsider — placeholder initial boost steps. - config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); - config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); + config.additionalBondRegistryConfig.boostSteps.push(Step({ threshold: 5_000, value: 2_000 })); + config.additionalBondRegistryConfig.boostSteps.push(Step({ threshold: 10_000, value: 8_000 })); // NodeOperatorStrikes - config.strikesCommittee = vm.envAddress("CSM_FIRST_ADMIN_ADDRESS"); // Dev team EOA - config.strikesThresholds.push(StrikeThreshold({ minCount: 2, reductionBP: 2_500 })); - config.strikesThresholds.push(StrikeThreshold({ minCount: 3, reductionBP: 5_000 })); - config.strikesThresholds.push(StrikeThreshold({ minCount: 4, reductionBP: 7_500 })); - config.strikesThresholds.push(StrikeThreshold({ minCount: 5, reductionBP: 10_000 })); + config.nodeOperatorStrikesConfig.committee = vm.envAddress("CSM_FIRST_ADMIN_ADDRESS"); // Dev team EOA + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 2, value: 2_500 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 3, value: 5_000 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 4, value: 7_500 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 5, value: 10_000 })); // LDO lock boost provider config.ldoLockBoostProviderConfig.token = vm.envAddress("CSM_LDO_TOKEN_ADDRESS"); @@ -207,8 +208,19 @@ contract DeployLocalDevNet is DeployBase { config.ldoLockBoostProviderConfig.snapshotDelegation = vm.envAddress("CSM_SNAPSHOT_DELEGATION_ADDRESS"); config.ldoLockBoostProviderConfig.minLockPeriod = 1 days; config.ldoLockBoostProviderConfig.lockPeriod = 1 days; - _addLDOLockBoostStep(100_000 ether, 1_000); - _addLDOLockBoostStep(200_000 ether, 1_500); + config.ldoLockBoostProviderConfig.lockBoostSteps.push(Step({ threshold: 100_000 ether, value: 1_000 })); + config.ldoLockBoostProviderConfig.lockBoostSteps.push(Step({ threshold: 200_000 ether, value: 1_500 })); + + // CustomFeeRegistry + config.customFeeRegistryConfig.defaultMaxFeeDiscount = 6_250; + config.customFeeRegistryConfig.feeDiscountCutCooldown = 15 days; + for (uint128 i = 1; i < 35; ++i) { + config.customFeeRegistryConfig.feeDiscountWeightSteps.push(Step({ threshold: i * 250, value: i * 400 })); + } + // Professional Operator uses the default bond curve (curve 0): 8_750 - 2_500 = 6_250. + config.customFeeRegistryConfig.feeModifiers.push( + CurveFeeModifierConfig({ curveId: 0, value: 2_500, negative: true }) + ); _setUp(); } diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index f5eda7734..8bb26cd39 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -3,8 +3,8 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; -import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; +import { DeployBase, CuratedGateConfig, CurveFeeModifierConfig } from "./DeployBase.s.sol"; +import { Step } from "../../src/interfaces/IStepwiseWeightBoost.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployMainnet is DeployBase { @@ -62,6 +62,7 @@ contract DeployMainnet is DeployBase { config.defaultGeneralDelayedPenaltyAdditionalFine = 0.1 ether; config.defaultKeysLimit = 100; config.defaultAvgPerfLeewayBP = 10000; + // Legacy ParametersRegistry compatibility value; Curated fees come from CustomFeeRegistry. config.defaultRewardShareBP = 6250; // 62.5% of 4% = 2.5% of the total config.defaultStrikesLifetimeFrames = 6; config.defaultStrikesThreshold = 3; @@ -200,18 +201,18 @@ contract DeployMainnet is DeployBase { config.resealManager = 0x7914b5a1539b97Bd0bbd155757F25FD79A522d24; // CurveMultiplier - config.additionalBondRegistryConfig.curveMultiplierCooldown = 7 days; + config.additionalBondRegistryConfig.curveMultiplierReductionCooldown = 7 days; // TODO: reconsider — placeholder initial boost steps. - config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); - config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); + config.additionalBondRegistryConfig.boostSteps.push(Step({ threshold: 5_000, value: 2_000 })); + config.additionalBondRegistryConfig.boostSteps.push(Step({ threshold: 10_000, value: 8_000 })); // NodeOperatorStrikes - config.strikesCommittee = 0x2570e0b22AD904501dfB0d49575991ACB801dD91; // CMC https://docs.lido.fi/multisigs/committees#220-curated-module-committee-cmc + config.nodeOperatorStrikesConfig.committee = 0x2570e0b22AD904501dfB0d49575991ACB801dD91; // CMC https://docs.lido.fi/multisigs/committees#220-curated-module-committee-cmc // TODO: finalize strike weight-reduction thresholds - config.strikesThresholds.push(StrikeThreshold({ minCount: 2, reductionBP: 2_500 })); - config.strikesThresholds.push(StrikeThreshold({ minCount: 3, reductionBP: 5_000 })); - config.strikesThresholds.push(StrikeThreshold({ minCount: 4, reductionBP: 7_500 })); - config.strikesThresholds.push(StrikeThreshold({ minCount: 5, reductionBP: 10_000 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 2, value: 2_500 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 3, value: 5_000 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 4, value: 7_500 })); + config.nodeOperatorStrikesConfig.thresholds.push(Step({ threshold: 5, value: 10_000 })); // LDO lock boost provider config.ldoLockBoostProviderConfig.token = 0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32; @@ -219,8 +220,20 @@ contract DeployMainnet is DeployBase { config.ldoLockBoostProviderConfig.snapshotDelegation = 0x469788fE6E9E9681C6ebF3bF78e7Fd26Fc015446; config.ldoLockBoostProviderConfig.minLockPeriod = 30 days; config.ldoLockBoostProviderConfig.lockPeriod = 30 days; - _addLDOLockBoostStep(100_000 ether, 1_000); - _addLDOLockBoostStep(200_000 ether, 1_500); + config.ldoLockBoostProviderConfig.lockBoostSteps.push(Step({ threshold: 100_000 ether, value: 1_000 })); + config.ldoLockBoostProviderConfig.lockBoostSteps.push(Step({ threshold: 200_000 ether, value: 1_500 })); + + // CustomFeeRegistry + // TODO: finalize custom fee parameters. + config.customFeeRegistryConfig.defaultMaxFeeDiscount = 6_250; + config.customFeeRegistryConfig.feeDiscountCutCooldown = 15 days; + for (uint128 i = 1; i < 35; ++i) { + config.customFeeRegistryConfig.feeDiscountWeightSteps.push(Step({ threshold: i * 250, value: i * 400 })); + } + // Professional Operator uses the default bond curve (curve 0): 8_750 - 2_500 = 6_250. + config.customFeeRegistryConfig.feeModifiers.push( + CurveFeeModifierConfig({ curveId: 0, value: 2_500, negative: true }) + ); _setUp(); } diff --git a/script/utils/Common.sol b/script/utils/Common.sol index 3f60a3c4a..194e6b41d 100644 --- a/script/utils/Common.sol +++ b/script/utils/Common.sol @@ -5,7 +5,6 @@ pragma solidity 0.8.33; import { IParametersRegistry } from "../../src/interfaces/IParametersRegistry.sol"; import { IBondCurve } from "../../src/interfaces/IBondCurve.sol"; -import { BoostStep } from "../../src/interfaces/IAdditionalBondRegistry.sol"; library CommonScriptUtils { function arraysToKeyIndexValueIntervals( @@ -33,15 +32,4 @@ library CommonScriptUtils { } return bondCurveInputs; } - - function arraysToBoostSteps(uint256[2][] memory data) internal pure returns (BoostStep[] memory) { - BoostStep[] memory boostSteps = new BoostStep[](data.length); - for (uint256 i = 0; i < data.length; i++) { - boostSteps[i] = BoostStep({ - minCurveMultiplier: uint128(data[i][0]), - weightMultiplier: uint128(data[i][1]) - }); - } - return boostSteps; - } } diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index c9b66b143..8c250e832 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -3,69 +3,54 @@ 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 { StepwiseWeightBoost } from "./abstract/StepwiseWeightBoost.sol"; import { IAccounting } from "./interfaces/IAccounting.sol"; -import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; -import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; -import { IAdditionalBondRegistry, BoostStep, PendingCurveMultiplierReduction } from "./interfaces/IAdditionalBondRegistry.sol"; +import { IAdditionalBondRegistry, PendingCurveMultiplierReduction } from "./interfaces/IAdditionalBondRegistry.sol"; +import { Step } from "./interfaces/IStepwiseWeightBoost.sol"; import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; -import { MAX_BP, MAX_WEIGHT_BOOST_BP } from "./lib/Constants.sol"; +import { MAX_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 { +/// @notice Maps an operator's curve multiplier to a weight multiplier via governance-set steps. See +/// IAdditionalBondRegistry for the model. +contract AdditionalBondRegistry is IAdditionalBondRegistry, StepwiseWeightBoost { /// @custom:storage-location erc7201:AdditionalBondRegistry struct AdditionalBondRegistryStorage { - BoostStep[] boostSteps; + uint256 curveMultiplierReductionCooldown; /// @dev Downgrade cooldown and pending curve multiplier increment, per operator. mapping(uint256 nodeOperatorId => PendingCurveMultiplierReduction) pending; } // Sanity guard: effective multiplier <= 10x. - uint256 public constant MAX_CURVE_MULTIPLIER = MAX_WEIGHT_BOOST_BP; - uint256 public constant MAX_WEIGHT_MULTIPLIER = MAX_WEIGHT_BOOST_BP; + uint256 public constant MAX_CURVE_MULTIPLIER = 9 * MAX_BP; // Requested curve multiplier must be a multiple of this (1%). uint256 public constant CURVE_MULTIPLIER_STEP = MAX_BP / 100; + uint256 public constant MAX_CURVE_MULTIPLIER_REDUCTION_COOLDOWN = 365 days; - ICuratedModule public immutable MODULE; IAccounting public immutable ACCOUNTING; - IMetaRegistry public immutable META_REGISTRY; - uint256 public immutable CURVE_MULTIPLIER_REDUCTION_COOLDOWN; // keccak256(abi.encode(uint256(keccak256("AdditionalBondRegistry")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant ADDITIONAL_BOND_REGISTRY_STORAGE_LOCATION = 0xe06435b00cfe5ab72c52612ef2f4c7b5f9c4cc44634ef79a78a1888f5b1eb300; - /// @param module CuratedModule address. - /// @param curveMultiplierCooldown Cooldown in seconds after a downgrade before `applyCurveMultiplier` can be called. - constructor(address module, uint256 curveMultiplierCooldown) { - MODULE = ICuratedModule(module); + /// @param module CuratedModule proxy address. + constructor(address module) StepwiseWeightBoost(module) { ACCOUNTING = IAccounting(MODULE.ACCOUNTING()); - META_REGISTRY = IMetaRegistry(MODULE.META_REGISTRY()); - - CURVE_MULTIPLIER_REDUCTION_COOLDOWN = curveMultiplierCooldown; - - _disableInitializers(); } /// @inheritdoc IAdditionalBondRegistry - function initialize(address admin, BoostStep[] calldata boostSteps) external initializer { - if (admin == address(0)) revert ZeroAdminAddress(); - _grantRole(DEFAULT_ADMIN_ROLE, admin); - _setBoostSteps(boostSteps); - } - - /// @inheritdoc IAdditionalBondRegistry - function setBoostSteps(BoostStep[] calldata boostSteps) external onlyRole(DEFAULT_ADMIN_ROLE) { - _setBoostSteps(boostSteps); - META_REGISTRY.notifyWeightBoostProviderConfigChanged(); + function initialize( + address admin, + uint256 curveMultiplierReductionCooldown, + Step[] calldata steps + ) external initializer { + _setCurveMultiplierReductionCooldown(curveMultiplierReductionCooldown); + StepwiseWeightBoost._initialize(admin, steps); } /// @inheritdoc IAdditionalBondRegistry function requestCurveMultiplier(uint256 nodeOperatorId, uint256 curveMultiplier) external { AdditionalBondRegistryStorage storage $ = _storage(); - _checkOperatorOwner(nodeOperatorId); + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); if (curveMultiplier > MAX_CURVE_MULTIPLIER || curveMultiplier % CURVE_MULTIPLIER_STEP != 0) { revert InvalidCurveMultiplier(); @@ -75,108 +60,127 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces uint256 curMul = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); if (newMul == curMul) revert SameCurveMultiplier(); + PendingCurveMultiplierReduction storage pending = $.pending[nodeOperatorId]; + uint256 previousTargetCurveMultiplier = _getTargetCurveMultiplier(pending, curMul); + if (newMul > curMul) { // NOTE: Takes into account current bond amount and keys count. // Value `0` as a second arg for the following method means current keys count. if (ACCOUNTING.getRequiredBondForNextKeys(nodeOperatorId, 0, newMul) > 0) { revert InsufficientBond(); } - if ($.pending[nodeOperatorId].cooldownUntil != 0) { - _removeCurveMultiplierReductionCooldown(nodeOperatorId); + if (pending.cooldownUntil != 0) { + delete $.pending[nodeOperatorId]; + emit CurveMultiplierReductionCancelled(nodeOperatorId); } ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, curveMultiplier); } else { - _setCurveMultiplierReductionCooldown(nodeOperatorId, curveMultiplier); - emit CurveMultiplierReductionRequested(nodeOperatorId, curveMultiplier); + uint256 cooldownUntil = block.timestamp + $.curveMultiplierReductionCooldown; + $.pending[nodeOperatorId] = PendingCurveMultiplierReduction({ + cooldownUntil: uint128(cooldownUntil), + curveMultiplier: uint128(curveMultiplier) + }); + emit CurveMultiplierReductionRequested(nodeOperatorId, curveMultiplier, cooldownUntil); } - META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged( + nodeOperatorId, + previousTargetCurveMultiplier, + curveMultiplier + ); } /// @inheritdoc IAdditionalBondRegistry - function applyCurveMultiplier(uint256 nodeOperatorId) external { - _checkOperatorOwner(nodeOperatorId); + function applyCurveMultiplierReduction(uint256 nodeOperatorId) external { + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); PendingCurveMultiplierReduction storage p = _storage().pending[nodeOperatorId]; if (p.cooldownUntil == 0) revert NoCurveMultiplierReductionCooldown(); if (p.cooldownUntil > block.timestamp) revert CurveMultiplierReductionCooldownNotElapsed(); uint256 curveMultiplier = p.curveMultiplier; - _removeCurveMultiplierReductionCooldown(nodeOperatorId); + delete _storage().pending[nodeOperatorId]; + emit CurveMultiplierReductionApplied(nodeOperatorId, curveMultiplier); ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, curveMultiplier); } /// @inheritdoc IAdditionalBondRegistry - function getBoostSteps() external view returns (BoostStep[] memory) { - return _storage().boostSteps; + function cancelCurveMultiplierReduction(uint256 nodeOperatorId) external { + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); + + PendingCurveMultiplierReduction storage pending = _storage().pending[nodeOperatorId]; + if (pending.cooldownUntil == 0) revert NoCurveMultiplierReductionCooldown(); + + uint256 previousCurveMultiplier = pending.curveMultiplier; + // Dropping the pending target hands the weight back to the multiplier Accounting still holds. + uint256 currentCurveMultiplier = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId) - MAX_BP; + + delete _storage().pending[nodeOperatorId]; + emit CurveMultiplierReductionCancelled(nodeOperatorId); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged( + nodeOperatorId, + previousCurveMultiplier, + currentCurveMultiplier + ); } - /// @inheritdoc IWeightBoostProvider - function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { - PendingCurveMultiplierReduction storage p = _storage().pending[nodeOperatorId]; - // During a downgrade cooldown, weight follows the pending (lower) multiplier; Accounting still holds the higher one. - uint256 curveMultiplier = p.cooldownUntil != 0 - ? p.curveMultiplier - : ACCOUNTING.getBondCurveMultiplier(nodeOperatorId) - MAX_BP; - multiplierBP = _weightMultiplierFor(curveMultiplier); + /// @inheritdoc IAdditionalBondRegistry + function setCurveMultiplierReductionCooldown( + uint256 curveMultiplierReductionCooldown + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setCurveMultiplierReductionCooldown(curveMultiplierReductionCooldown); } - function _setBoostSteps(BoostStep[] calldata boostSteps) internal { - if (boostSteps.length == 0) revert EmptyBoostSteps(); - AdditionalBondRegistryStorage storage $ = _storage(); - delete $.boostSteps; - for (uint256 i = 0; i < boostSteps.length; ++i) { - _validateBoostStep(boostSteps, i); - $.boostSteps.push(boostSteps[i]); - } - emit BoostStepsSet(boostSteps); + /// @inheritdoc IAdditionalBondRegistry + function getCurveMultiplierReductionCooldown() external view returns (uint256) { + return _storage().curveMultiplierReductionCooldown; } - /// @dev Starts the cooldown and stores the pending curve multiplier increment. - function _setCurveMultiplierReductionCooldown(uint256 nodeOperatorId, uint256 curveMultiplier) internal { - uint256 cooldownUntil = block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN; - _storage().pending[nodeOperatorId] = PendingCurveMultiplierReduction({ - cooldownUntil: uint128(cooldownUntil), - curveMultiplier: uint128(curveMultiplier) - }); - emit CurveMultiplierReductionCooldownSet(nodeOperatorId, cooldownUntil); + /// @inheritdoc IAdditionalBondRegistry + function getPendingCurveMultiplier(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().pending[nodeOperatorId].curveMultiplier; } - function _removeCurveMultiplierReductionCooldown(uint256 nodeOperatorId) internal { - delete _storage().pending[nodeOperatorId]; - emit CurveMultiplierReductionCooldownRemoved(nodeOperatorId); + /// @inheritdoc IAdditionalBondRegistry + function getCurveMultiplierReductionCooldownUntil(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().pending[nodeOperatorId].cooldownUntil; + } + + /// @inheritdoc IWeightBoostProvider + function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { + PendingCurveMultiplierReduction storage pending = _storage().pending[nodeOperatorId]; + uint256 currentMultiplierBP = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); + multiplierBP = + MAX_BP + + StepwiseWeightBoost._stepValueAt(_getTargetCurveMultiplier(pending, currentMultiplierBP)); } - /// @dev Weight multiplier for a curve multiplier increment: MAX_BP + the highest step at or below it, else MAX_BP. - function _weightMultiplierFor(uint256 curveMultiplier) internal view returns (uint256 weightMul) { - BoostStep[] storage boostSteps = _storage().boostSteps; - weightMul = MAX_BP; - uint256 len = boostSteps.length; - for (uint256 i = 0; i < len; ++i) { - if (curveMultiplier < boostSteps[i].minCurveMultiplier) break; - weightMul = MAX_BP + boostSteps[i].weightMultiplier; + function _setCurveMultiplierReductionCooldown(uint256 curveMultiplierReductionCooldown) internal { + if ( + curveMultiplierReductionCooldown == 0 || + curveMultiplierReductionCooldown > MAX_CURVE_MULTIPLIER_REDUCTION_COOLDOWN + ) { + revert InvalidCurveMultiplierReductionCooldown(); } + _storage().curveMultiplierReductionCooldown = curveMultiplierReductionCooldown; + emit CurveMultiplierReductionCooldownSet(curveMultiplierReductionCooldown); } - // TODO: Have the same in many places. Move to lib - function _checkOperatorOwner(uint256 nodeOperatorId) internal view { - if (msg.sender != MODULE.getNodeOperatorOwner(nodeOperatorId)) revert SenderIsNotOperatorOwner(); + /// @dev The multiplier increment the allocation weight follows: the pending one during a downgrade + /// cooldown, otherwise the one Accounting currently holds. + function _getTargetCurveMultiplier( + PendingCurveMultiplierReduction storage pending, + uint256 currentMultiplierBP + ) internal view returns (uint256) { + return pending.cooldownUntil != 0 ? pending.curveMultiplier : currentMultiplierBP - MAX_BP; } - /// @dev Validates step `i`: within bounds and strictly above the previous. Fields are increments (0 allowed). - function _validateBoostStep(BoostStep[] calldata boostSteps, uint256 i) internal pure { - BoostStep calldata s = boostSteps[i]; - if (s.minCurveMultiplier > MAX_CURVE_MULTIPLIER) revert InvalidCurveMultiplier(); - if (s.weightMultiplier > MAX_WEIGHT_MULTIPLIER) revert InvalidWeightMultiplier(); - if (i == 0) return; - // Strictly increasing: a higher curve multiplier maps to a higher weight. - if (s.minCurveMultiplier <= boostSteps[i - 1].minCurveMultiplier) revert InvalidCurveMultiplier(); - if (s.weightMultiplier <= boostSteps[i - 1].weightMultiplier) revert InvalidWeightMultiplier(); + function _isValidStep(Step calldata step) internal pure override returns (bool) { + return step.threshold <= MAX_CURVE_MULTIPLIER; } function _storage() internal pure returns (AdditionalBondRegistryStorage storage $) { assembly ("memory-safe") { - // keccak256(abi.encode(uint256(keccak256("AdditionalBondRegistry")) - 1)) & ~bytes32(uint256(0xff)) $.slot := ADDITIONAL_BOND_REGISTRY_STORAGE_LOCATION } } diff --git a/src/CustomFeeRegistry.sol b/src/CustomFeeRegistry.sol new file mode 100644 index 000000000..64395e375 --- /dev/null +++ b/src/CustomFeeRegistry.sol @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import { StepwiseWeightBoost } from "./abstract/StepwiseWeightBoost.sol"; +import { IAccounting } from "./interfaces/IAccounting.sol"; +import { IBondCurve } from "./interfaces/IBondCurve.sol"; +import { ICustomFeeRegistry, FeeDiscountState, FeeModifier } from "./interfaces/ICustomFeeRegistry.sol"; +import { Step } from "./interfaces/IStepwiseWeightBoost.sol"; +import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; +import { MAX_BP } from "./lib/Constants.sol"; + +/// @notice Per-operator fee discounts and the allocation weight boost derived from them. See +/// ICustomFeeRegistry for the model. +contract CustomFeeRegistry is ICustomFeeRegistry, StepwiseWeightBoost { + using SafeCast for uint256; + + /// @custom:storage-location erc7201:CustomFeeRegistry + struct CustomFeeRegistryStorage { + uint256 defaultMaxFeeDiscount; + uint256 feeDiscountCutCooldown; + mapping(uint256 curveId => FeeModifier) feeModifier; + mapping(uint256 nodeOperatorId => FeeDiscountState) feeDiscounts; + } + + // All fees and discounts are basis points of the operator's own rewards. + uint256 public constant FEE_GRANULARITY = 250; // 2.5% + // The operator's share of the module's rewards, mirroring the protocol reward share config. + uint256 public constant BASE_FEE = 8_750; // 87.5% + uint256 public constant MAX_FEE_DISCOUNT_CUT_COOLDOWN = 365 days; + + IAccounting public immutable ACCOUNTING; + + // keccak256(abi.encode(uint256(keccak256("CustomFeeRegistry")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CUSTOM_FEE_REGISTRY_STORAGE_LOCATION = + 0x231651de169707bf46042ddc0065dcd629da5632e3ccd31978f43f09a90a1200; + + /// @param module Curated module address. + constructor(address module) StepwiseWeightBoost(module) { + ACCOUNTING = IAccounting(MODULE.ACCOUNTING()); + } + + /// @inheritdoc ICustomFeeRegistry + function initialize( + address admin, + uint256 defaultMaxFeeDiscount, + uint256 feeDiscountCutCooldown, + Step[] calldata steps + ) external initializer { + _setDefaultMaxFeeDiscount(defaultMaxFeeDiscount); + _setFeeDiscountCutCooldown(feeDiscountCutCooldown); + StepwiseWeightBoost._initialize(admin, steps); + } + + /// @inheritdoc ICustomFeeRegistry + function requestFeeDiscount(uint256 nodeOperatorId, uint256 feeDiscount) external { + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); + + uint256 currentFeeDiscount = _storage().feeDiscounts[nodeOperatorId].currentFeeDiscount; + if (feeDiscount == currentFeeDiscount) revert SameFeeDiscount(); + + _validateFeeDiscount(nodeOperatorId, feeDiscount); + + if (feeDiscount > currentFeeDiscount) { + _setCurrentFeeDiscount(nodeOperatorId, feeDiscount); + } else { + _scheduleFeeDiscountCut(nodeOperatorId, feeDiscount); + } + } + + /// @inheritdoc ICustomFeeRegistry + function cancelFeeDiscountCut(uint256 nodeOperatorId) external { + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); + + FeeDiscountState storage state = _storage().feeDiscounts[nodeOperatorId]; + if (state.cooldownUntil == 0) revert NoPendingFeeDiscountCut(); + + uint256 previousFeeDiscount = _getTargetFeeDiscount(nodeOperatorId); + uint256 currentFeeDiscount = state.currentFeeDiscount; + + state.pendingFeeDiscount = 0; + state.cooldownUntil = 0; + emit FeeDiscountCutCancelled(nodeOperatorId); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged(nodeOperatorId, previousFeeDiscount, currentFeeDiscount); + } + + /// @inheritdoc ICustomFeeRegistry + function applyFeeDiscountCut(uint256 nodeOperatorId) external { + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); + + FeeDiscountState storage state = _storage().feeDiscounts[nodeOperatorId]; + if (state.cooldownUntil == 0) revert NoPendingFeeDiscountCut(); + if (state.cooldownUntil > block.timestamp) revert FeeDiscountCutCooldownNotElapsed(); + + uint16 pendingFeeDiscount = state.pendingFeeDiscount; + // A curve or modifier change during the cooldown may have invalidated it. + _validateFeeDiscount(nodeOperatorId, pendingFeeDiscount); + + state.currentFeeDiscount = pendingFeeDiscount; + state.pendingFeeDiscount = 0; + state.cooldownUntil = 0; + emit FeeDiscountCutApplied(nodeOperatorId, pendingFeeDiscount); + // No notification: the allocation weight changed when the cut was requested. + } + + /// @inheritdoc ICustomFeeRegistry + function normalizeFeeDiscounts(uint256[] calldata nodeOperatorIds) external returns (uint256[] memory normalized) { + normalized = new uint256[](nodeOperatorIds.length); + uint256 count; + for (uint256 i; i < nodeOperatorIds.length; ++i) { + uint256 nodeOperatorId = nodeOperatorIds[i]; + if (_normalizeFeeDiscount(nodeOperatorId)) { + normalized[count] = nodeOperatorId; + ++count; + } + } + + assembly ("memory-safe") { + // Shrink the array to the operators normalized; the unused tail stays allocated but unreferenced. + mstore(normalized, count) + } + } + + /// @inheritdoc ICustomFeeRegistry + function setDefaultMaxFeeDiscount(uint256 defaultMaxFeeDiscount) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setDefaultMaxFeeDiscount(defaultMaxFeeDiscount); + } + + /// @inheritdoc ICustomFeeRegistry + function setFeeModifier(uint256 curveId, uint256 value, bool negative) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (curveId >= ACCOUNTING.getCurvesCount()) revert IBondCurve.InvalidBondCurveId(); + + CustomFeeRegistryStorage storage $ = _storage(); + // Positive: keeps the effective fee within MAX_BP. Negative: keeps the per-type ceiling non-negative. + uint256 maxValue = negative ? $.defaultMaxFeeDiscount : MAX_BP - BASE_FEE; + if (value > maxValue || value % FEE_GRANULARITY != 0) revert InvalidFeeModifier(); + + $.feeModifier[curveId] = FeeModifier({ value: value.toUint248(), negative: negative }); + emit FeeModifierSet(curveId, value, negative); + // No weight notification: the modifier shifts only the effective fee. + } + + /// @inheritdoc ICustomFeeRegistry + function setFeeDiscountCutCooldown(uint256 feeDiscountCutCooldown) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setFeeDiscountCutCooldown(feeDiscountCutCooldown); + } + + /// @inheritdoc ICustomFeeRegistry + function getDefaultMaxFeeDiscount() external view returns (uint256) { + return _storage().defaultMaxFeeDiscount; + } + + /// @inheritdoc ICustomFeeRegistry + function getFeeDiscountCutCooldown() external view returns (uint256) { + return _storage().feeDiscountCutCooldown; + } + + /// @inheritdoc ICustomFeeRegistry + function getFeeModifier(uint256 curveId) external view returns (FeeModifier memory) { + return _storage().feeModifier[curveId]; + } + + /// @inheritdoc ICustomFeeRegistry + function getFeeDiscount(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().feeDiscounts[nodeOperatorId].currentFeeDiscount; + } + + /// @inheritdoc ICustomFeeRegistry + function getPendingFeeDiscount(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().feeDiscounts[nodeOperatorId].pendingFeeDiscount; + } + + /// @inheritdoc ICustomFeeRegistry + function getFeeDiscountCutCooldownUntil(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().feeDiscounts[nodeOperatorId].cooldownUntil; + } + + /// @inheritdoc ICustomFeeRegistry + function getMaxFeeDiscount(uint256 nodeOperatorId) external view returns (uint256) { + return _getMaxFeeDiscount(nodeOperatorId); + } + + /// @inheritdoc IWeightBoostProvider + function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { + multiplierBP = MAX_BP + StepwiseWeightBoost._stepValueAt(_getTargetFeeDiscount(nodeOperatorId)); + } + + /// @inheritdoc ICustomFeeRegistry + function getEffectiveFee(uint256 nodeOperatorId) external view returns (uint256) { + uint256 currentFee = _asFee(_storage().feeDiscounts[nodeOperatorId].currentFeeDiscount); + FeeModifier storage feeModifier = _storage().feeModifier[ACCOUNTING.getBondCurveId(nodeOperatorId)]; + if (feeModifier.negative) { + // A curve or modifier change can leave the fee below the negative modifier. + return currentFee > feeModifier.value ? currentFee - feeModifier.value : 0; + } + + // The modifier bounds keep the sum within MAX_BP. + return currentFee + feeModifier.value; + } + + function _setCurrentFeeDiscount(uint256 nodeOperatorId, uint256 newCurrentFeeDiscount) internal { + FeeDiscountState storage state = _storage().feeDiscounts[nodeOperatorId]; + bool hadPendingCut = state.cooldownUntil != 0; + uint256 previousFeeDiscount = _getTargetFeeDiscount(nodeOperatorId); + + state.currentFeeDiscount = newCurrentFeeDiscount.toUint16(); + state.pendingFeeDiscount = 0; + state.cooldownUntil = 0; + if (hadPendingCut) emit FeeDiscountCutCancelled(nodeOperatorId); + emit FeeDiscountSet(nodeOperatorId, newCurrentFeeDiscount); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged( + nodeOperatorId, + previousFeeDiscount, + newCurrentFeeDiscount + ); + } + + function _scheduleFeeDiscountCut(uint256 nodeOperatorId, uint256 pendingFeeDiscount) internal { + CustomFeeRegistryStorage storage $ = _storage(); + FeeDiscountState storage state = $.feeDiscounts[nodeOperatorId]; + uint256 previousFeeDiscount = _getTargetFeeDiscount(nodeOperatorId); + uint256 cooldownUntil = block.timestamp + $.feeDiscountCutCooldown; + state.pendingFeeDiscount = pendingFeeDiscount.toUint16(); + state.cooldownUntil = cooldownUntil.toUint64(); + emit FeeDiscountCutRequested(nodeOperatorId, pendingFeeDiscount, cooldownUntil); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged(nodeOperatorId, previousFeeDiscount, pendingFeeDiscount); + } + + /// @dev Only upwards, so discounts already set stay valid; storage is zero on initialization. The + /// BASE_FEE bound keeps a non-zero fee for the operator. + function _setDefaultMaxFeeDiscount(uint256 defaultMaxFeeDiscount) internal { + CustomFeeRegistryStorage storage $ = _storage(); + if ( + defaultMaxFeeDiscount <= $.defaultMaxFeeDiscount || + defaultMaxFeeDiscount >= BASE_FEE || + defaultMaxFeeDiscount % FEE_GRANULARITY != 0 + ) { + revert InvalidDefaultMaxFeeDiscount(); + } + $.defaultMaxFeeDiscount = defaultMaxFeeDiscount; + emit DefaultMaxFeeDiscountSet(defaultMaxFeeDiscount); + } + + function _setFeeDiscountCutCooldown(uint256 feeDiscountCutCooldown) internal { + if (feeDiscountCutCooldown == 0 || feeDiscountCutCooldown > MAX_FEE_DISCOUNT_CUT_COOLDOWN) { + revert InvalidFeeDiscountCutCooldown(); + } + _storage().feeDiscountCutCooldown = feeDiscountCutCooldown; + emit FeeDiscountCutCooldownSet(feeDiscountCutCooldown); + } + + function _normalizeFeeDiscount(uint256 nodeOperatorId) internal returns (bool normalized) { + uint256 maxFeeDiscount = _getMaxFeeDiscount(nodeOperatorId); + if (_storage().feeDiscounts[nodeOperatorId].currentFeeDiscount <= maxFeeDiscount) return false; + + _setCurrentFeeDiscount(nodeOperatorId, maxFeeDiscount); + return true; + } + + function _validateFeeDiscount(uint256 nodeOperatorId, uint256 feeDiscount) internal view { + if (feeDiscount > _getMaxFeeDiscount(nodeOperatorId) || feeDiscount % FEE_GRANULARITY != 0) { + revert InvalidFeeDiscount(); + } + } + + /// @dev A negative modifier lowers the per-type ceiling, keeping the effective fee at or above the + /// protocol floor. Stored discounts may exceed it until normalized. + function _getMaxFeeDiscount(uint256 nodeOperatorId) internal view returns (uint256) { + CustomFeeRegistryStorage storage $ = _storage(); + FeeModifier storage feeModifier = $.feeModifier[ACCOUNTING.getBondCurveId(nodeOperatorId)]; + uint256 maxFeeDiscount = $.defaultMaxFeeDiscount; + // NOTE: No underflow: a negative modifier is capped at the default ceiling, which only grows. + return feeModifier.negative ? maxFeeDiscount - feeModifier.value : maxFeeDiscount; + } + + /// @dev The discount the allocation weight follows: the pending one while a cut is scheduled. + function _getTargetFeeDiscount(uint256 nodeOperatorId) internal view returns (uint256) { + FeeDiscountState storage state = _storage().feeDiscounts[nodeOperatorId]; + return state.cooldownUntil != 0 ? state.pendingFeeDiscount : state.currentFeeDiscount; + } + + /// @dev The only crossing back into fee space, for the off-chain reward distribution. + function _asFee(uint256 feeDiscount) internal pure returns (uint256) { + return BASE_FEE - feeDiscount; + } + + function _isValidStep(Step calldata step) internal pure override returns (bool) { + return step.threshold < BASE_FEE && step.threshold % FEE_GRANULARITY == 0; + } + + function _storage() internal pure returns (CustomFeeRegistryStorage storage $) { + assembly ("memory-safe") { + $.slot := CUSTOM_FEE_REGISTRY_STORAGE_LOCATION + } + } +} diff --git a/src/ERC20LockBoostProvider.sol b/src/ERC20LockBoostProvider.sol index 378fbc73f..44a824aac 100644 --- a/src/ERC20LockBoostProvider.sol +++ b/src/ERC20LockBoostProvider.sol @@ -3,103 +3,73 @@ 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 { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.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 { StepwiseWeightBoost } from "./abstract/StepwiseWeightBoost.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 { Step } from "./interfaces/IStepwiseWeightBoost.sol"; import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; -import { MAX_BP, MAX_WEIGHT_BOOST_BP } from "./lib/Constants.sol"; +import { MAX_BP } from "./lib/Constants.sol"; -/// @notice Stores operator-level ERC20 locks and exposes node operator boost for scoring. -contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgradeable, IERC20LockBoostProvider { +/// @notice Holds operator-level ERC20 locks in per-operator vaults and serves the resulting weight +/// boost to MetaRegistry. +contract ERC20LockBoostProvider is IERC20LockBoostProvider, StepwiseWeightBoost { using SafeCast for uint256; using SafeERC20 for IERC20; struct ERC20LockBoostProviderStorage { - mapping(uint256 nodeOperatorId => LockInfo) locks; + mapping(uint256 nodeOperatorId => OperatorLock) 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; - bytes32 public constant SET_LOCK_PERIOD_ROLE = keccak256("SET_LOCK_PERIOD_ROLE"); uint256 public constant MAX_LOCK_PERIOD = 365 days; // 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) { - if (module == address(0) || token == address(0) || vaultBeacon == address(0)) revert ZeroAddress(); + constructor(address module, address token, address vaultBeacon, uint256 minLockPeriod) StepwiseWeightBoost(module) { + if (token == address(0)) revert ZeroTokenAddress(); + if (vaultBeacon == address(0)) revert ZeroVaultBeaconAddress(); if (minLockPeriod == 0 || minLockPeriod > MAX_LOCK_PERIOD) 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; - - _disableInitializers(); } /// @inheritdoc IERC20LockBoostProvider - function initialize(address admin, uint256 lockPeriod) external initializer { - if (admin == address(0)) revert ZeroAdminAddress(); - - _grantRole(DEFAULT_ADMIN_ROLE, admin); + function initialize(address admin, uint256 lockPeriod, Step[] calldata steps) external initializer { _setLockPeriod(lockPeriod); + StepwiseWeightBoost._initialize(admin, steps); } /// @inheritdoc IERC20LockBoostProvider - function setLockPeriod(uint256 lockPeriod) external onlyRole(SET_LOCK_PERIOD_ROLE) { + function setLockPeriod(uint256 lockPeriod) external onlyRole(DEFAULT_ADMIN_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, weightBoostBP: steps[i].weightBoostBP }) - ); - } - - emit LockBoostStepsSet(steps); - META_REGISTRY.notifyWeightBoostProviderConfigChanged(); - } - /// @inheritdoc IERC20LockBoostProvider function lock(uint256 nodeOperatorId, uint256 amount) external { - _onlyNodeOperatorOwner(nodeOperatorId); + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); _lockTokens(nodeOperatorId, amount); } /// @inheritdoc IERC20LockBoostProvider function withdraw(uint256 nodeOperatorId, uint256 amount, address receiver) external { - _onlyNodeOperatorOwner(nodeOperatorId); + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); - LockInfo storage lockInfo = _storage().locks[nodeOperatorId]; + OperatorLock storage lockInfo = _storage().locks[nodeOperatorId]; if (lockInfo.amount == 0) revert NoTokensLocked(); if (block.timestamp < lockInfo.lockUntil && !_isEarlyWithdrawalAllowed(nodeOperatorId)) { revert LockPeriodNotEnded(); @@ -108,24 +78,14 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade _withdraw(nodeOperatorId, amount, receiver); } - /// @inheritdoc IERC20LockBoostProvider - function getInitializedVersion() external view returns (uint64) { - return _getInitializedVersion(); - } - - /// @inheritdoc IERC20LockBoostProvider - function getLockBoostSteps() external view returns (LockBoostStep[] memory steps) { - steps = _storage().lockBoostSteps; - } - /// @inheritdoc IWeightBoostProvider function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { - multiplierBP = _getMultiplierBP(_storage().locks[nodeOperatorId].amount); + multiplierBP = MAX_BP + StepwiseWeightBoost._stepValueAt(_storage().locks[nodeOperatorId].amount); } /// @inheritdoc IERC20LockBoostProvider - function getNodeOperatorLock(uint256 nodeOperatorId) external view returns (LockInfo memory lockInfo) { - lockInfo = _storage().locks[nodeOperatorId]; + function getLock(uint256 nodeOperatorId) external view returns (OperatorLock memory operatorLock) { + operatorLock = _storage().locks[nodeOperatorId]; } /// @inheritdoc IERC20LockBoostProvider @@ -142,7 +102,7 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade if (amount == 0) revert InvalidAmount(); ERC20LockBoostProviderStorage storage $ = _storage(); - LockInfo storage lockInfo = $.locks[nodeOperatorId]; + OperatorLock storage lockInfo = $.locks[nodeOperatorId]; address vault = lockInfo.vault; if (vault == address(0)) { @@ -166,13 +126,13 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade IERC20(TOKEN).safeTransferFrom(msg.sender, vault, amount); emit TokensLocked(nodeOperatorId, amount, lockUntil); - _syncWeightAfterLockChange(nodeOperatorId, oldAmount, newAmount); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged(nodeOperatorId, oldAmount, newAmount); } function _withdraw(uint256 nodeOperatorId, uint256 amount, address receiver) internal { if (amount == 0) revert InvalidAmount(); - LockInfo storage lockInfo = _storage().locks[nodeOperatorId]; + OperatorLock storage lockInfo = _storage().locks[nodeOperatorId]; uint256 oldAmount = lockInfo.amount; if (oldAmount == 0) revert NoTokensLocked(); if (amount > oldAmount) revert InvalidAmount(); @@ -186,13 +146,7 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade 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); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged(nodeOperatorId, oldAmount, newAmount); } function _setLockPeriod(uint256 lockPeriod) internal { @@ -203,23 +157,8 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade emit LockPeriodSet(lockPeriod); } - 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 = MAX_BP + step.weightBoostBP; - } - } - - function _onlyNodeOperatorOwner(uint256 nodeOperatorId) internal view { - address owner = MODULE.getNodeOperatorOwner(nodeOperatorId); - if (owner == address(0)) revert NodeOperatorDoesNotExist(); - if (owner != msg.sender) revert SenderIsNotNodeOperatorOwner(); - } - + /// @dev The lock only backs allocation weight, so it can be released early once the operator is + /// outside any group and has neither active nor depositable validators. function _isEarlyWithdrawalAllowed(uint256 nodeOperatorId) internal view returns (bool) { if (META_REGISTRY.getNodeOperatorGroupId(nodeOperatorId) != 0) return false; @@ -227,27 +166,8 @@ contract ERC20LockBoostProvider is Initializable, AccessControlEnumerableUpgrade return no.totalDepositedKeys == no.totalWithdrawnKeys && no.depositableValidatorsCount == 0; } - function _checkLockBoostSteps(LockBoostStep[] calldata steps) internal pure { - uint256 stepsCount = steps.length; - if (stepsCount == 0) revert InvalidLockBoostSteps(); - - if (steps[0].minAmount == 0) revert InvalidLockBoostSteps(); - if (steps[0].weightBoostBP > MAX_WEIGHT_BOOST_BP) revert InvalidLockBoostSteps(); - - uint256 previousMinAmount = steps[0].minAmount; - uint256 previousWeightBoostBP = steps[0].weightBoostBP; - - for (uint256 i = 1; i < stepsCount; ++i) { - LockBoostStep calldata step = steps[i]; - if ( - step.minAmount <= previousMinAmount || - step.weightBoostBP <= previousWeightBoostBP || - step.weightBoostBP > MAX_WEIGHT_BOOST_BP - ) revert InvalidLockBoostSteps(); - - previousMinAmount = step.minAmount; - previousWeightBoostBP = step.weightBoostBP; - } + function _isValidStep(Step calldata step) internal pure override returns (bool) { + return step.threshold != 0; } function _storage() internal pure returns (ERC20LockBoostProviderStorage storage $) { diff --git a/src/ERC20LockVault.sol b/src/ERC20LockVault.sol index 4a4ee7cf3..5523b479c 100644 --- a/src/ERC20LockVault.sol +++ b/src/ERC20LockVault.sol @@ -20,7 +20,9 @@ contract ERC20LockVault is IERC20LockVault, Initializable { IBaseModule public immutable MODULE; constructor(address token, address provider, address module) { - if (token == address(0) || provider == address(0) || module == address(0)) revert ZeroAddress(); + if (token == address(0)) revert ZeroTokenAddress(); + if (provider == address(0)) revert ZeroProviderAddress(); + if (module == address(0)) revert ZeroModuleAddress(); TOKEN = token; PROVIDER = provider; @@ -37,7 +39,7 @@ contract ERC20LockVault is IERC20LockVault, Initializable { /// @inheritdoc IERC20LockVault function transferTokens(address receiver, uint256 amount) external { _onlyProvider(); - if (receiver == address(0)) revert ZeroAddress(); + if (receiver == address(0)) revert ZeroReceiverAddress(); IERC20(TOKEN).safeTransfer(receiver, amount); } diff --git a/src/LidoGovernanceLockVault.sol b/src/LidoGovernanceLockVault.sol index 2d42ec33f..b964a14a9 100644 --- a/src/LidoGovernanceLockVault.sol +++ b/src/LidoGovernanceLockVault.sol @@ -6,7 +6,6 @@ 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"; @@ -24,9 +23,8 @@ contract LidoGovernanceLockVault is ERC20LockVault, IAragonVotingLockVault, ISna address votingContract, address snapshotDelegation_ ) ERC20LockVault(token, provider, module) { - if (votingContract == address(0) || snapshotDelegation_ == address(0)) { - revert IERC20LockVault.ZeroAddress(); - } + if (votingContract == address(0)) revert ZeroVotingContractAddress(); + if (snapshotDelegation_ == address(0)) revert ZeroSnapshotDelegationAddress(); VOTING_CONTRACT = votingContract; SNAPSHOT_DELEGATION = snapshotDelegation_; diff --git a/src/MetaRegistry.sol b/src/MetaRegistry.sol index 9b70cc04b..8a4e4a106 100644 --- a/src/MetaRegistry.sol +++ b/src/MetaRegistry.sol @@ -16,7 +16,6 @@ import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; import { IStakingModule } from "./interfaces/IStakingModule.sol"; import { IStakingRouter } from "./interfaces/IStakingRouter.sol"; import { IMetaRegistry, OperatorMetadata } from "./interfaces/IMetaRegistry.sol"; -import { IAdditionalBondRegistry } from "./interfaces/IAdditionalBondRegistry.sol"; import { ExternalOperatorLib, OperatorType } from "./lib/ExternalOperatorLib.sol"; import { MAX_BP } from "./lib/Constants.sol"; @@ -67,7 +66,6 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp ICuratedModule public immutable MODULE; IAccounting public immutable ACCOUNTING; IStakingRouter public immutable STAKING_ROUTER; - IAdditionalBondRegistry public immutable ADDITIONAL_BOND_REGISTRY; uint256 internal constant EXTERNAL_STAKE_PER_VALIDATOR = 32 ether; uint256 internal constant MAX_NAME_LENGTH = 256; @@ -77,15 +75,13 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp bytes32 private constant META_REGISTRY_STORAGE_LOCATION = 0xa7ec41e1a061c67796a04fcd9cc7cab9545b0a750beebc54139d9ed9d2251c00; - /// @param module CuratedModule proxy address. - /// @param additionalBondRegistry AdditionalBondRegistry proxy address. - constructor(address module, address additionalBondRegistry) { + /// @param module CuratedModule proxy address. + constructor(address module) { if (module == address(0)) revert ZeroModuleAddress(); MODULE = ICuratedModule(module); ACCOUNTING = IAccounting(MODULE.ACCOUNTING()); STAKING_ROUTER = IStakingRouter(MODULE.LIDO_LOCATOR().stakingRouter()); - ADDITIONAL_BOND_REGISTRY = IAdditionalBondRegistry(additionalBondRegistry); _disableInitializers(); } @@ -210,11 +206,8 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp /// @inheritdoc IMetaRegistry function notifyWeightBoostProviderConfigChanged() external { - MetaRegistryStorage storage $ = _storage(); - uint256 providerId = $.weightBoostProviderIdByAddress[msg.sender]; - if (providerId == 0) revert WeightBoostProviderNotFound(); - - if (!$.weightBoostProviders[providerId].enabled) return; + WeightBoostProviderEntry storage entry = _callerWeightBoostProvider(); + if (!entry.enabled) return; emit WeightBoostProviderConfigChanged(msg.sender); _requestFullDepositInfoUpdate(); @@ -222,15 +215,12 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp /// @inheritdoc IMetaRegistry function notifyWeightBoostChanged(uint256 nodeOperatorId) external { - MetaRegistryStorage storage $ = _storage(); - uint256 providerId = $.weightBoostProviderIdByAddress[msg.sender]; - if (providerId == 0) revert WeightBoostProviderNotFound(); + WeightBoostProviderEntry storage entry = _callerWeightBoostProvider(); - uint256 groupId = $.groupIndex.groupIdByOperatorId[nodeOperatorId]; + uint256 groupId = _storage().groupIndex.groupIdByOperatorId[nodeOperatorId]; // Provider notifications are node-operator scoped; operators outside groups have no group cache to refresh. if (groupId == NO_GROUP_ID) return; - WeightBoostProviderEntry storage entry = $.weightBoostProviders[providerId]; if (!entry.enabled) return; if (entry.mode == WeightBoostProviderMode.PerNodeOperator) { @@ -569,30 +559,20 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp return _storage().bondCurveWeight[ACCOUNTING.getBondCurveId(nodeOperatorId)]; } + /// @dev Single-operator variant; allocates a fresh per-call cache so both refresh paths share one + /// implementation and cannot diverge. function _getWeightBoostMultiplierBP( CachedOperatorGroup storage group, uint256 nodeOperatorId ) internal view returns (uint256 multiplierBP) { - MetaRegistryStorage storage $ = _storage(); - multiplierBP = MAX_BP; - uint256 providersCount = $.weightBoostProvidersCount; - for (uint256 i; i < providersCount; ++i) { - WeightBoostProviderEntry storage entry = $.weightBoostProviders[i + 1]; - if (!entry.enabled) continue; - - IWeightBoostProvider provider = entry.provider; - if (entry.mode == WeightBoostProviderMode.PerNodeOperator) { - multiplierBP = Math.mulDiv(multiplierBP, provider.getWeightBoostMultiplierBP(nodeOperatorId), MAX_BP); - } else if (entry.mode == WeightBoostProviderMode.MaxPerGroup) { - multiplierBP = Math.mulDiv( - multiplierBP, - _getProviderMaxPerGroupWeightBoostMultiplierBP(provider, group), - MAX_BP - ); - } else { - revert InvalidWeightBoostProviderMode(); - } - } + uint256 providersCount = _storage().weightBoostProvidersCount; + return + _getWeightBoostMultiplierBP( + group, + nodeOperatorId, + new uint256[](providersCount), + new bool[](providersCount) + ); } function _getWeightBoostMultiplierBP( @@ -639,6 +619,15 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp } } + /// @dev Resolves the calling weight boost provider; reverts for unregistered callers. + function _callerWeightBoostProvider() internal view returns (WeightBoostProviderEntry storage entry) { + MetaRegistryStorage storage $ = _storage(); + uint256 providerId = $.weightBoostProviderIdByAddress[msg.sender]; + if (providerId == 0) revert WeightBoostProviderNotFound(); + + entry = $.weightBoostProviders[providerId]; + } + /// @dev Returns the cached module address. Reverts if the address was /// never resolved via `_getOrCacheModuleAddress`. function _getCachedModuleAddress(uint8 moduleId) internal view returns (address addr) { diff --git a/src/NodeOperatorStrikes.sol b/src/NodeOperatorStrikes.sol index 16af0fc82..f25b1f70a 100644 --- a/src/NodeOperatorStrikes.sol +++ b/src/NodeOperatorStrikes.sol @@ -3,19 +3,16 @@ 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 { ICuratedModule } from "./interfaces/ICuratedModule.sol"; -import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; -import { INodeOperatorStrikes, StrikeInput, Strike, StrikeThreshold } from "./interfaces/INodeOperatorStrikes.sol"; +import { StepwiseWeightBoost } from "./abstract/StepwiseWeightBoost.sol"; +import { INodeOperatorStrikes, StrikeInput, Strike } from "./interfaces/INodeOperatorStrikes.sol"; +import { Step } from "./interfaces/IStepwiseWeightBoost.sol"; import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; import { MAX_BP } from "./lib/Constants.sol"; /// @notice Committee-issued, operator-level strikes that cumulatively reduce /// a Node Operator's allocation weight. Strikes persist until removed; /// removal is permissionless once a strike's lifetime elapses. -contract NodeOperatorStrikes is INodeOperatorStrikes, Initializable, AccessControlEnumerableUpgradeable { +contract NodeOperatorStrikes is INodeOperatorStrikes, StepwiseWeightBoost { struct OperatorStrikes { /// @dev Monotonic strike ID counter; removed IDs are never reused. uint64 lastId; @@ -26,44 +23,30 @@ contract NodeOperatorStrikes is INodeOperatorStrikes, Initializable, AccessContr /// @custom:storage-location erc7201:NodeOperatorStrikes struct NodeOperatorStrikesStorage { - StrikeThreshold[] thresholds; mapping(uint256 nodeOperatorId => OperatorStrikes) operatorStrikes; } bytes32 public constant STRIKES_COMMITTEE_ROLE = keccak256("STRIKES_COMMITTEE_ROLE"); - uint256 public constant MAX_THRESHOLDS = 16; uint256 public constant MAX_DESCRIPTION_LENGTH = 1024; - ICuratedModule public immutable MODULE; - IMetaRegistry public immutable META_REGISTRY; - // keccak256(abi.encode(uint256(keccak256("NodeOperatorStrikes")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant NODE_OPERATOR_STRIKES_STORAGE_LOCATION = 0x510f8e4bbf34090117edc1d950679ffb8abd223dc216175d997628968b892400; /// @param module CuratedModule proxy address. - constructor(address module) { - if (module == address(0)) revert ZeroModuleAddress(); - - MODULE = ICuratedModule(module); - META_REGISTRY = ICuratedModule(module).META_REGISTRY(); - - _disableInitializers(); - } + constructor(address module) StepwiseWeightBoost(module) {} /// @inheritdoc INodeOperatorStrikes - function initialize(address admin, StrikeThreshold[] calldata thresholds) external initializer { - if (admin == address(0)) revert ZeroAdminAddress(); - _grantRole(DEFAULT_ADMIN_ROLE, admin); - _setStrikeThresholds(thresholds); + function initialize(address admin, Step[] calldata steps) external initializer { + StepwiseWeightBoost._initialize(admin, steps); } /// @inheritdoc INodeOperatorStrikes function issueStrike( StrikeInput calldata input ) external onlyRole(STRIKES_COMMITTEE_ROLE) returns (uint256 strikeId) { - _onlyExistingOperator(input.nodeOperatorId); + StepwiseWeightBoost._onlyExistingNodeOperator(input.nodeOperatorId); uint256 descLength = bytes(input.description).length; if (descLength == 0 || descLength > MAX_DESCRIPTION_LENGTH) revert InvalidDescription(); @@ -74,6 +57,7 @@ contract NodeOperatorStrikes is INodeOperatorStrikes, Initializable, AccessContr if (expiry > type(uint64).max) revert LifetimeTooLong(); OperatorStrikes storage rec = _storage().operatorStrikes[input.nodeOperatorId]; + uint256 previousCount = rec.activeIds.length; strikeId = ++rec.lastId; rec.activeIds.push(strikeId); rec.strikes[strikeId] = Strike({ @@ -91,25 +75,26 @@ contract NodeOperatorStrikes is INodeOperatorStrikes, Initializable, AccessContr description: input.description }); - META_REGISTRY.notifyWeightBoostChanged(input.nodeOperatorId); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged(input.nodeOperatorId, previousCount, previousCount + 1); } /// @inheritdoc INodeOperatorStrikes function removeStrike(uint256 nodeOperatorId, uint256 strikeId) external onlyRole(STRIKES_COMMITTEE_ROLE) { OperatorStrikes storage rec = _storage().operatorStrikes[nodeOperatorId]; + uint256 previousCount = rec.activeIds.length; _removeStrike(rec, _activeIndex(rec, strikeId), strikeId); emit StrikeRemoved(nodeOperatorId, strikeId); - META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged(nodeOperatorId, previousCount, previousCount - 1); } /// @inheritdoc INodeOperatorStrikes function removeExpiredStrikes(uint256 nodeOperatorId) external { OperatorStrikes storage rec = _storage().operatorStrikes[nodeOperatorId]; uint256[] storage activeIds = rec.activeIds; + uint256 previousCount = activeIds.length; // Back-to-front so swap-pop never skips an id. - bool removed; uint256 i = activeIds.length; while (i > 0) { --i; @@ -117,33 +102,17 @@ contract NodeOperatorStrikes is INodeOperatorStrikes, Initializable, AccessContr if (rec.strikes[strikeId].expiry > block.timestamp) continue; _removeStrike(rec, i, strikeId); emit ExpiredStrikeRemoved(nodeOperatorId, strikeId); - removed = true; } - if (removed) META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); - } - - /// @inheritdoc INodeOperatorStrikes - function setStrikeThresholds(StrikeThreshold[] calldata thresholds) external onlyRole(DEFAULT_ADMIN_ROLE) { - _setStrikeThresholds(thresholds); - META_REGISTRY.notifyWeightBoostProviderConfigChanged(); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged(nodeOperatorId, previousCount, activeIds.length); } /// @inheritdoc IWeightBoostProvider /// @dev Expiry only enables permissionless removal; a strike keeps reducing weight until it is removed. function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { - NodeOperatorStrikesStorage storage $ = _storage(); - uint256 count = $.operatorStrikes[nodeOperatorId].activeIds.length; - - StrikeThreshold[] storage thresholds = $.thresholds; - uint256 reductionBP; - uint256 len = thresholds.length; - for (uint256 i; i < len; ++i) { - if (count < thresholds[i].minCount) break; // thresholds ascend by minCount - reductionBP = thresholds[i].reductionBP; - } + uint256 count = _storage().operatorStrikes[nodeOperatorId].activeIds.length; unchecked { - multiplierBP = MAX_BP - reductionBP; + multiplierBP = MAX_BP - StepwiseWeightBoost._stepValueAt(count); } } @@ -156,7 +125,7 @@ contract NodeOperatorStrikes is INodeOperatorStrikes, Initializable, AccessContr function getStrike(uint256 nodeOperatorId, uint256 strikeId) external view returns (Strike memory strike) { strike = _storage().operatorStrikes[nodeOperatorId].strikes[strikeId]; // expiry == 0 means removed or never issued. - if (strike.expiry == 0) revert StrikeNotExist(); + if (strike.expiry == 0) revert StrikeDoesNotExist(); } /// @inheritdoc INodeOperatorStrikes @@ -171,11 +140,6 @@ contract NodeOperatorStrikes is INodeOperatorStrikes, Initializable, AccessContr } } - /// @inheritdoc INodeOperatorStrikes - function getStrikeThresholds() external view returns (StrikeThreshold[] memory thresholds) { - return _storage().thresholds; - } - /// @dev Swap-pops the id and deletes the record. Caller emits and refreshes the weight (once per batch). function _removeStrike(OperatorStrikes storage rec, uint256 idx, uint256 strikeId) internal { uint256[] storage activeIds = rec.activeIds; @@ -187,47 +151,19 @@ contract NodeOperatorStrikes is INodeOperatorStrikes, Initializable, AccessContr delete rec.strikes[strikeId]; } - function _setStrikeThresholds(StrikeThreshold[] calldata thresholds) internal { - _validateStrikeThresholds(thresholds); - - NodeOperatorStrikesStorage storage $ = _storage(); - delete $.thresholds; - for (uint256 i; i < thresholds.length; ++i) { - $.thresholds.push(thresholds[i]); - } - - emit StrikeThresholdsSet(thresholds); - } - - function _onlyExistingOperator(uint256 nodeOperatorId) internal view { - if (nodeOperatorId >= MODULE.getNodeOperatorsCount()) revert NodeOperatorDoesNotExist(); - } - - /// @dev Index of `strikeId` in `activeIds`; reverts `StrikeNotExist` if absent. + /// @dev Index of `strikeId` in `activeIds`; reverts `StrikeDoesNotExist` if absent. function _activeIndex(OperatorStrikes storage rec, uint256 strikeId) internal view returns (uint256) { uint256[] storage activeIds = rec.activeIds; uint256 len = activeIds.length; for (uint256 i; i < len; ++i) { if (activeIds[i] == strikeId) return i; } - revert StrikeNotExist(); + revert StrikeDoesNotExist(); } - function _validateStrikeThresholds(StrikeThreshold[] calldata thresholds) internal pure { - uint256 len = thresholds.length; - if (len == 0 || len > MAX_THRESHOLDS) revert InvalidStrikeThresholds(); - if (thresholds[0].minCount == 0) revert InvalidStrikeThresholds(); - if (thresholds[0].reductionBP == 0 || thresholds[0].reductionBP > MAX_BP) { - revert InvalidStrikeThresholds(); - } - - for (uint256 i = 1; i < len; ++i) { - StrikeThreshold calldata current = thresholds[i]; - StrikeThreshold calldata previous = thresholds[i - 1]; - if (current.minCount <= previous.minCount) revert InvalidStrikeThresholds(); - if (current.reductionBP <= previous.reductionBP) revert InvalidStrikeThresholds(); - if (current.reductionBP > MAX_BP) revert InvalidStrikeThresholds(); - } + /// @dev A strike reduces weight, so a reduction above MAX_BP could never be applied. + function _isValidStep(Step calldata step) internal pure override returns (bool) { + return step.threshold != 0 && step.value != 0 && step.value <= MAX_BP; } function _storage() internal pure returns (NodeOperatorStrikesStorage storage $) { diff --git a/src/abstract/StepwiseWeightBoost.sol b/src/abstract/StepwiseWeightBoost.sol new file mode 100644 index 000000000..64628268a --- /dev/null +++ b/src/abstract/StepwiseWeightBoost.sol @@ -0,0 +1,130 @@ +// 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 { IMetaRegistry } from "../interfaces/IMetaRegistry.sol"; +import { ICuratedModule } from "../interfaces/ICuratedModule.sol"; +import { IStepwiseWeightBoost, Step } from "../interfaces/IStepwiseWeightBoost.sol"; +import { MAX_BP } from "../lib/Constants.sol"; + +/// @notice Shared base of the weight boost providers built on a governance-configurable step function. +abstract contract StepwiseWeightBoost is IStepwiseWeightBoost, AccessControlEnumerableUpgradeable { + /// @custom:storage-location erc7201:StepwiseWeightBoost + struct StepwiseWeightBoostStorage { + Step[] steps; + } + + ICuratedModule public immutable MODULE; + IMetaRegistry public immutable META_REGISTRY; + + uint256 public constant MAX_STEPS = 35; + uint256 public constant MAX_STEP_VALUE = 9 * MAX_BP; + + // keccak256(abi.encode(uint256(keccak256("StepwiseWeightBoost")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant STEPWISE_WEIGHT_BOOST_STORAGE_LOCATION = + 0x852fd528c3d50d3563ef75d3ae6120a75c34ba905ef4b17904bd8502a3b92900; + + constructor(address module) { + if (module == address(0)) revert ZeroModuleAddress(); + MODULE = ICuratedModule(module); + META_REGISTRY = IMetaRegistry(address(MODULE.META_REGISTRY())); + + _disableInitializers(); + } + + /// @inheritdoc IStepwiseWeightBoost + function setSteps(Step[] calldata steps) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setSteps(steps); + META_REGISTRY.notifyWeightBoostProviderConfigChanged(); + } + + /// @inheritdoc IStepwiseWeightBoost + function getSteps() external view returns (Step[] memory) { + return _stepwiseWeightBoostStorage().steps; + } + + /// @inheritdoc IStepwiseWeightBoost + function getInitializedVersion() external view returns (uint64) { + return _getInitializedVersion(); + } + + /// @dev Unlike `setSteps`, does not notify MetaRegistry: there are no cached weights yet. + function _initialize(address admin, Step[] calldata steps) internal onlyInitializing { + if (admin == address(0)) revert ZeroAdminAddress(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _setSteps(steps); + } + + /// @dev Skips the refresh while the input stays within one step: the weight has not moved. + function _notifyMetaRegistryIfWeightChanged( + uint256 nodeOperatorId, + uint256 previousInput, + uint256 newInput + ) internal { + if (_stepValueAt(previousInput) != _stepValueAt(newInput)) { + META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + } + } + + /// @dev Reverts unless the Node Operator exists and the caller owns it. + function _onlyNodeOperatorOwner(uint256 nodeOperatorId) internal view { + address owner = MODULE.getNodeOperatorOwner(nodeOperatorId); + if (owner == address(0)) revert NodeOperatorDoesNotExist(); + if (owner != msg.sender) revert SenderIsNotNodeOperatorOwner(); + } + + /// @dev Ids are sequential, so an id below the operators count exists. + function _onlyExistingNodeOperator(uint256 nodeOperatorId) internal view { + if (nodeOperatorId >= MODULE.getNodeOperatorsCount()) revert NodeOperatorDoesNotExist(); + } + + /// @dev Returns zero before the first threshold and the last reached step's value otherwise. + function _stepValueAt(uint256 input) internal view returns (uint256 value) { + Step[] storage steps = _stepwiseWeightBoostStorage().steps; + uint256 low; + uint256 high = steps.length; + + // Find the first step whose threshold is greater than the input. + while (low < high) { + uint256 mid = (low + high) / 2; + if (steps[mid].threshold <= input) { + low = mid + 1; + } else { + high = mid; + } + } + + if (low != 0) value = steps[low - 1].value; + } + + /// @dev Provider-specific bounds, including any ceiling stricter than MAX_STEP_VALUE. Ordering is + /// enforced by the base contract. + function _isValidStep(Step calldata step) internal pure virtual returns (bool); + + function _setSteps(Step[] calldata steps) private { + uint256 length = steps.length; + if (length == 0 || length > MAX_STEPS) revert InvalidStepCount(); + + for (uint256 i; i < length; ++i) { + if (steps[i].value > MAX_STEP_VALUE || !_isValidStep(steps[i])) revert InvalidStep(i); + if (i != 0 && (steps[i].threshold <= steps[i - 1].threshold || steps[i].value <= steps[i - 1].value)) + revert UnorderedSteps(i); + } + + StepwiseWeightBoostStorage storage $ = _stepwiseWeightBoostStorage(); + delete $.steps; + for (uint256 i; i < length; ++i) { + $.steps.push(steps[i]); + } + emit StepsSet(steps); + } + + function _stepwiseWeightBoostStorage() private pure returns (StepwiseWeightBoostStorage storage $) { + assembly ("memory-safe") { + $.slot := STEPWISE_WEIGHT_BOOST_STORAGE_LOCATION + } + } +} diff --git a/src/interfaces/IAdditionalBondRegistry.sol b/src/interfaces/IAdditionalBondRegistry.sol index 2b8a247cb..e9529b3a7 100644 --- a/src/interfaces/IAdditionalBondRegistry.sol +++ b/src/interfaces/IAdditionalBondRegistry.sol @@ -4,16 +4,7 @@ pragma solidity 0.8.33; import { IAccounting } from "./IAccounting.sol"; -import { ICuratedModule } from "./ICuratedModule.sol"; -import { IMetaRegistry } from "./IMetaRegistry.sol"; -import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; - -/// @dev A boost step: curve multiplier increments at or above `minCurveMultiplier` map to -/// `weightMultiplier`. Both are increments above MAX_BP in basis points (0 = no scaling). -struct BoostStep { - uint128 minCurveMultiplier; - uint128 weightMultiplier; -} +import { IStepwiseWeightBoost, Step } from "./IStepwiseWeightBoost.sol"; /// @dev A pending downgrade: the cooldown deadline and the curve multiplier increment to apply once it /// elapses. `cooldownUntil == 0` means no active cooldown. Packed into a single slot. @@ -22,68 +13,82 @@ struct PendingCurveMultiplierReduction { uint128 curveMultiplier; } -/// @notice Maps an operator's curve multiplier to a weight multiplier via governance-set boost steps. +/// @notice Maps an operator's curve multiplier to a weight multiplier via governance-set steps. /// The curve multiplier itself lives in Accounting; this registry only requests changes and serves /// the resulting weight boost. Lowering the weight applies immediately, while the curve multiplier -/// decrease is deferred until the cooldown elapses. -interface IAdditionalBondRegistry is IWeightBoostProvider { - event BoostStepsSet(BoostStep[] boostSteps); - event CurveMultiplierReductionRequested(uint256 indexed nodeOperatorId, uint256 curveMultiplier); - event CurveMultiplierReductionCooldownSet(uint256 indexed nodeOperatorId, uint256 cooldownUntil); - event CurveMultiplierReductionCooldownRemoved(uint256 indexed nodeOperatorId); - - error ZeroAdminAddress(); - error EmptyBoostSteps(); +/// decrease is deferred until the cooldown elapses. In this provider, `Step.threshold` is a curve +/// multiplier increment and `Step.value` is a weight multiplier increment above MAX_BP. +interface IAdditionalBondRegistry is IStepwiseWeightBoost { + event CurveMultiplierReductionRequested( + uint256 indexed nodeOperatorId, + uint256 curveMultiplier, + uint256 cooldownUntil + ); + event CurveMultiplierReductionApplied(uint256 indexed nodeOperatorId, uint256 curveMultiplier); + event CurveMultiplierReductionCancelled(uint256 indexed nodeOperatorId); + event CurveMultiplierReductionCooldownSet(uint256 curveMultiplierReductionCooldown); + error InvalidCurveMultiplier(); - error InvalidWeightMultiplier(); + error InvalidCurveMultiplierReductionCooldown(); error InsufficientBond(); error SameCurveMultiplier(); - error SenderIsNotOperatorOwner(); error NoCurveMultiplierReductionCooldown(); error CurveMultiplierReductionCooldownNotElapsed(); - function MODULE() external view returns (ICuratedModule); - - /// @dev Holding bond curves and the operator curve multiplier. + /// @notice Accounting contract holding bond curves and the operator curve multiplier. function ACCOUNTING() external view returns (IAccounting); - /// @dev Notified via `notifyWeightBoostChanged` on weight changes. - function META_REGISTRY() external view returns (IMetaRegistry); - - /// @dev Upper bound for a boost step's curve multiplier increment (above MAX_BP, in basis points). + /// @notice Upper bound for a curve multiplier increment (above MAX_BP, in basis points). function MAX_CURVE_MULTIPLIER() external view returns (uint256); - /// @dev Upper bound for a boost step's weight multiplier increment (above MAX_BP, in basis points). - function MAX_WEIGHT_MULTIPLIER() external view returns (uint256); + /// @notice Maximum configurable curve multiplier reduction cooldown in seconds. + function MAX_CURVE_MULTIPLIER_REDUCTION_COOLDOWN() external view returns (uint256); - /// @dev Cooldown in seconds after a downgrade request before `applyCurveMultiplier` can be called. - function CURVE_MULTIPLIER_REDUCTION_COOLDOWN() external view returns (uint256); - - /// @dev Requested curve multiplier must be a multiple of this (1%). + /// @notice Granularity of a requested curve multiplier increment (1%). function CURVE_MULTIPLIER_STEP() external view returns (uint256); /// @notice Initialize the provider. /// @param admin Address to receive DEFAULT_ADMIN_ROLE. - /// @param boostSteps Initial boost steps; must be non-empty (same rules as `setBoostSteps`). - function initialize(address admin, BoostStep[] calldata boostSteps) external; - - /// @notice Replace the boost steps. The list must be non-empty and strictly ascending by both fields, - /// each an increment above MAX_BP in [0, MAX_CURVE_MULTIPLIER] / [0, MAX_WEIGHT_MULTIPLIER]. - /// @param boostSteps New boost steps. - function setBoostSteps(BoostStep[] calldata boostSteps) external; - - /// @notice Request a curve multiplier for the Node Operator. Raising it applies immediately (needs enough - /// bond) and clears any pending downgrade; lowering it drops the weight now but reduces the - /// multiplier in Accounting only after the cooldown, via `applyCurveMultiplier`. Reverts if unchanged. + /// @param curveMultiplierReductionCooldown Stored cooldown duration in seconds, in + /// [1, MAX_CURVE_MULTIPLIER_REDUCTION_COOLDOWN]. + /// @param steps Initial steps. Thresholds may start at zero and must not exceed MAX_CURVE_MULTIPLIER; + /// values must not exceed MAX_STEP_VALUE. + function initialize(address admin, uint256 curveMultiplierReductionCooldown, Step[] calldata steps) external; + + /// @notice Request a curve multiplier for the Node Operator. Only the Node Operator owner. Raising it + /// applies immediately (needs enough bond) and clears any pending downgrade; lowering it drops + /// the weight now but reduces the multiplier in Accounting only after the cooldown, via + /// `applyCurveMultiplierReduction`. Reverts if it equals the multiplier currently stored in + /// Accounting. /// @param nodeOperatorId ID of the Node Operator. /// @param curveMultiplier Curve multiplier increment (above MAX_BP), a multiple of CURVE_MULTIPLIER_STEP; 0 = no boost. function requestCurveMultiplier(uint256 nodeOperatorId, uint256 curveMultiplier) external; /// @notice Apply a pending downgrade after its cooldown elapses, lowering the curve multiplier in - /// Accounting to the requested value. Callable only by the Node Operator owner. + /// Accounting to the requested value. Only the Node Operator owner. /// @param nodeOperatorId ID of the Node Operator. - function applyCurveMultiplier(uint256 nodeOperatorId) external; + function applyCurveMultiplierReduction(uint256 nodeOperatorId) external; - /// @notice The current boost steps. - function getBoostSteps() external view returns (BoostStep[] memory); + /// @notice Cancel a pending downgrade. Only the Node Operator owner. Restores allocation weight to + /// the multiplier still held in Accounting and reverts if no downgrade is pending. + /// @param nodeOperatorId ID of the Node Operator. + function cancelCurveMultiplierReduction(uint256 nodeOperatorId) external; + + /// @notice Set the cooldown used by future downgrade requests. Only DEFAULT_ADMIN_ROLE; existing + /// pending deadlines are unchanged. + /// @param curveMultiplierReductionCooldown Stored duration in seconds, in + /// [1, MAX_CURVE_MULTIPLIER_REDUCTION_COOLDOWN]. + function setCurveMultiplierReductionCooldown(uint256 curveMultiplierReductionCooldown) external; + + /// @notice Curve multiplier reduction cooldown in seconds. + function getCurveMultiplierReductionCooldown() external view returns (uint256); + + /// @notice Pending downgrade target increment. Meaningful only while a cooldown is active, since a + /// legitimate target may be zero. + /// @param nodeOperatorId ID of the Node Operator. + function getPendingCurveMultiplier(uint256 nodeOperatorId) external view returns (uint256); + + /// @notice Earliest timestamp at which the pending downgrade passes its time check, or zero if none. + /// @param nodeOperatorId ID of the Node Operator. + function getCurveMultiplierReductionCooldownUntil(uint256 nodeOperatorId) external view returns (uint256); } diff --git a/src/interfaces/IAragonVotingLockVault.sol b/src/interfaces/IAragonVotingLockVault.sol index 76fc0cb25..fd86ae52b 100644 --- a/src/interfaces/IAragonVotingLockVault.sol +++ b/src/interfaces/IAragonVotingLockVault.sol @@ -5,6 +5,8 @@ pragma solidity 0.8.33; /// @notice Optional Aragon Voting capability for an ERC20 lock vault. interface IAragonVotingLockVault { + error ZeroVotingContractAddress(); + /// @notice Lido Aragon Voting contract used by this vault. function VOTING_CONTRACT() external view returns (address); diff --git a/src/interfaces/ICustomFeeRegistry.sol b/src/interfaces/ICustomFeeRegistry.sol new file mode 100644 index 000000000..70d5f3c31 --- /dev/null +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { IAccounting } from "./IAccounting.sol"; +import { IStepwiseWeightBoost, Step } from "./IStepwiseWeightBoost.sol"; + +/// @dev Fee discount state of a Node Operator, packed into one slot. Zero `currentFeeDiscount` means no +/// discount, set or not. A non-zero `cooldownUntil` flags a pending cut, as zero is a valid target. +struct FeeDiscountState { + uint16 currentFeeDiscount; + uint16 pendingFeeDiscount; + uint64 cooldownUntil; +} + +/// @dev Shift applied to the effective fee of a Node Operator type, packed into one slot. `value` basis +/// points are subtracted from the fee kept when `negative` is true, added otherwise. +struct FeeModifier { + uint248 value; + bool negative; +} + +/* + * ── Discount scale and per-type ranges ───────────────────────────────────────── + * + * All values are basis points (BP) of the operator's own rewards; the axes show + * the fee the operator keeps, the matching discount and, for scale, that fee as a + * share of all protocol staking rewards at a 4% module share. One character is 125 BP. + * An operator picks a discount between zero and its current getMaxFeeDiscount(id); + * a negative fee modifier lowers that ceiling below defaultMaxFeeDiscount. The + * larger the discount, the lower the fee kept and the higher the allocation weight. + * ● fee kept (BASE_FEE - discount) ○ effective (drives reward distribution) + * + * ┌ BASE_FEE + * fee kept BP 0 2500 5000 6250 8750 10000 + * ├───────────────────┼───────────────────┼─────────┼───────────────────┼─────────┤ + * total rewards 0% 1% 2% 2.5% 3.5% 4% + * discount BP 6250 3750 2500 0 + * └ defaultMaxFeeDiscount + * + * Node Operator type A — modifier +1250, effective = fee kept + 1250: + * fee kept ●─────────────────────────────────────────────────● + * effective ○─────────────────────────────────────────────────○ + * + * Node Operator type B — modifier -2500, effective = fee kept - 2500; the ceiling drops to 3750: + * fee kept ●─────────────────────────────● + * effective ○─────────────────────────────○ + * + * A and B both pick discount = 3750: equal weights, different effective fees: + * fee kept A = B ● + * effective A ○ + * effective B ○ + * + * ── Discount cut timeline and oracle frames ──────────────────────────────────── + * + * Z is the discount-cut cooldown. Off-chain reward distribution is expected to + * snapshot getEffectiveFee at the report refSlot and use that snapshot for the + * corresponding frame. Under this convention, keeping + * Z >= frame + margin (a governance invariant, not enforced by this contract) + * leaves at least one report at the old fee while the allocation weight already + * follows the pending discount. Raising the discount applies at once and cancels + * any pending cut. Below, an operator cuts its discount from 3750 to 2500. + * + * requestFeeDiscount(2500) + * │ applyFeeDiscountCut() + * │ │ + * time ────────────●━━━━━ Z >= frame ━━━━━━●────────────────────────────────────► + * frames ├───────────────────────┼───────────────────────┼───────────────────────┤ + * frame k frame k+1 frame k+2 + * weight ── high ────▼ low ─────────────────────────────────────────────────────── + * pending ├───────────────────────┤ + * oracle samples ▲ old ▲ new ▲ new + * frame billed old * new ** new + * + * pending: an explicit cancellation or a discount raise clears it; a new + * cut overwrites it and restarts the cooldown. + * * under the snapshot convention, the weight is already low while frame k + * still uses the old fee. + * ** the new fee is used once it is present at the selected refSlot. + */ +/// @notice Per-operator fee discounts and the allocation weight boost derived from them: the larger the +/// discount from BASE_FEE, the higher the weight. `Step.threshold` is a discount, `Step.value` the +/// weight multiplier increment above MAX_BP. The effective fee drives off-chain reward +/// distribution, whose timing this registry does not enforce. See the diagrams above. +interface ICustomFeeRegistry is IStepwiseWeightBoost { + event FeeDiscountSet(uint256 indexed nodeOperatorId, uint256 feeDiscount); + event FeeDiscountCutRequested(uint256 indexed nodeOperatorId, uint256 pendingFeeDiscount, uint256 cooldownUntil); + event FeeDiscountCutApplied(uint256 indexed nodeOperatorId, uint256 feeDiscount); + event FeeDiscountCutCancelled(uint256 indexed nodeOperatorId); + event DefaultMaxFeeDiscountSet(uint256 defaultMaxFeeDiscount); + event FeeModifierSet(uint256 indexed curveId, uint256 value, bool negative); + event FeeDiscountCutCooldownSet(uint256 feeDiscountCutCooldown); + + error InvalidFeeDiscount(); + error SameFeeDiscount(); + error NoPendingFeeDiscountCut(); + error FeeDiscountCutCooldownNotElapsed(); + error InvalidDefaultMaxFeeDiscount(); + error InvalidFeeModifier(); + error InvalidFeeDiscountCutCooldown(); + + /// @notice Accounting contract holding bond curves; an operator's type is its curve id. + function ACCOUNTING() external view returns (IAccounting); + + /// @notice Fee kept with no discount, in basis points. A positive fee modifier can lift the effective + /// fee above it. + function BASE_FEE() external view returns (uint256); + + /// @notice Grid every fee discount, fee modifier, and step threshold must align to, in basis points. + function FEE_GRANULARITY() external view returns (uint256); + + /// @notice Maximum configurable discount-cut cooldown in seconds. + function MAX_FEE_DISCOUNT_CUT_COOLDOWN() external view returns (uint256); + + /// @notice Initialize the provider. + /// @param admin Address to receive DEFAULT_ADMIN_ROLE. + /// @param defaultMaxFeeDiscount Initial ceiling: a non-zero multiple of FEE_GRANULARITY below BASE_FEE. + /// @param feeDiscountCutCooldown Duration in seconds, in [1, MAX_FEE_DISCOUNT_CUT_COOLDOWN]. + /// @param steps Initial steps: FEE_GRANULARITY-aligned thresholds below BASE_FEE, values up to + /// MAX_STEP_VALUE. + function initialize( + address admin, + uint256 defaultMaxFeeDiscount, + uint256 feeDiscountCutCooldown, + Step[] calldata steps + ) external; + + /// @notice Request a fee discount. Only the Node Operator owner. Raising it applies at once; lowering + /// schedules a cut for `applyFeeDiscountCut`, while the allocation weight follows the request + /// at once. Requesting the current discount reverts. + /// @param nodeOperatorId ID of the Node Operator. + /// @param feeDiscount Discount from BASE_FEE in basis points, a multiple of FEE_GRANULARITY within + /// [0, getMaxFeeDiscount(nodeOperatorId)]. + function requestFeeDiscount(uint256 nodeOperatorId, uint256 feeDiscount) external; + + /// @notice Cancel a pending discount cut, returning the allocation weight to the stored discount. + /// Only the Node Operator owner. + /// @param nodeOperatorId ID of the Node Operator. + function cancelFeeDiscountCut(uint256 nodeOperatorId) external; + + /// @notice Settle a pending discount cut once its cooldown elapsed. Only the Node Operator owner. + /// Reverts if a curve or modifier change invalidated the pending discount meanwhile. + /// @param nodeOperatorId ID of the Node Operator. + function applyFeeDiscountCut(uint256 nodeOperatorId) external; + + /// @notice Permissionlessly clamp discounts left above their ceiling by a curve or modifier change, + /// cancelling any pending cut. Already valid discounts are skipped. Call it off-chain to + /// learn which of the given operators need it. + /// @param nodeOperatorIds IDs of the Node Operators. + /// @return normalized IDs of the Node Operators whose discount was clamped. + function normalizeFeeDiscounts(uint256[] calldata nodeOperatorIds) external returns (uint256[] memory normalized); + + /// @notice Raise the default discount ceiling. Only DEFAULT_ADMIN_ROLE; must stay below BASE_FEE. + /// @param defaultMaxFeeDiscount New ceiling in basis points, a non-zero multiple of FEE_GRANULARITY. + function setDefaultMaxFeeDiscount(uint256 defaultMaxFeeDiscount) external; + + /// @notice Set the fee modifier of an existing Node Operator type (bond curve). Only DEFAULT_ADMIN_ROLE. + /// Shifts the effective fee only, never the weight; a negative modifier lowers the type's ceiling. + /// @param curveId Bond curve ID of the type. + /// @param value Magnitude in basis points, a multiple of FEE_GRANULARITY: at most + /// MAX_BP - BASE_FEE when positive, the default discount ceiling when negative. + /// @param negative Whether the modifier is subtracted from the fee. + function setFeeModifier(uint256 curveId, uint256 value, bool negative) external; + + /// @notice Set the cooldown for future discount cuts; pending cuts are unaffected. Only + /// DEFAULT_ADMIN_ROLE. + /// @dev Governance invariant, not enforced on-chain: keep it >= one oracle frame + margin; + /// raise it when frames are lengthened. + /// @param feeDiscountCutCooldown Stored duration in seconds, in [1, MAX_FEE_DISCOUNT_CUT_COOLDOWN]. + function setFeeDiscountCutCooldown(uint256 feeDiscountCutCooldown) external; + + /// @notice Default discount ceiling in basis points. + function getDefaultMaxFeeDiscount() external view returns (uint256); + + /// @notice Discount cut cooldown in seconds. + function getFeeDiscountCutCooldown() external view returns (uint256); + + /// @notice Fee modifier of a Node Operator type. + /// @param curveId Bond curve ID of the type. + function getFeeModifier(uint256 curveId) external view returns (FeeModifier memory); + + /// @notice Stored discount of the Node Operator; zero if never set, and unaffected by a pending cut. + /// @param nodeOperatorId ID of the Node Operator. + function getFeeDiscount(uint256 nodeOperatorId) external view returns (uint256); + + /// @notice Pending cut target. Meaningful only while a cooldown is active, since zero is a valid target. + /// @param nodeOperatorId ID of the Node Operator. + function getPendingFeeDiscount(uint256 nodeOperatorId) external view returns (uint256); + + /// @notice End of the pending cut's cooldown, or zero if none; it does not clear once elapsed. + /// @param nodeOperatorId ID of the Node Operator. + function getFeeDiscountCutCooldownUntil(uint256 nodeOperatorId) external view returns (uint256); + + /// @notice Discount ceiling of the Node Operator: the default ceiling less the type's negative modifier. + /// @param nodeOperatorId ID of the Node Operator. + function getMaxFeeDiscount(uint256 nodeOperatorId) external view returns (uint256); + + /// @notice Effective fee in basis points: the stored discount adjusted by the fee modifier, clamped at + /// zero. Excludes a pending cut. Exposed for off-chain reward distribution. + /// @param nodeOperatorId ID of the Node Operator. + function getEffectiveFee(uint256 nodeOperatorId) external view returns (uint256); +} diff --git a/src/interfaces/IERC20LockBoostProvider.sol b/src/interfaces/IERC20LockBoostProvider.sol index 78b4bb2f5..47fb0ff62 100644 --- a/src/interfaces/IERC20LockBoostProvider.sol +++ b/src/interfaces/IERC20LockBoostProvider.sol @@ -5,16 +5,14 @@ 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 weightBoostBP; - } - - struct LockInfo { +import { IStepwiseWeightBoost, Step } from "./IStepwiseWeightBoost.sol"; + +/// @notice Operator-level ERC20 lock registry and weight boost provider. In this provider, +/// `Step.threshold` is the locked token amount and `Step.value` is the weight multiplier increment above MAX_BP. +interface IERC20LockBoostProvider is IStepwiseWeightBoost { + /// @dev Lock state of a Node Operator. `vault` is zero until the first lock creates it; `lockUntil` + /// restarts on every lock and is zeroed once the lock is fully withdrawn. + struct OperatorLock { address vault; uint128 amount; uint128 lockUntil; @@ -29,16 +27,12 @@ interface IERC20LockBoostProvider is IWeightBoostProvider { ); event VaultCreated(uint256 indexed nodeOperatorId, address indexed vault, address indexed token); event LockPeriodSet(uint256 lockPeriod); - event LockBoostStepsSet(LockBoostStep[] steps); - error ZeroAddress(); - error ZeroAdminAddress(); + error ZeroTokenAddress(); + error ZeroVaultBeaconAddress(); error InvalidAmount(); error InvalidLockPeriod(); - error InvalidLockBoostSteps(); error SameLockPeriod(); - error NodeOperatorDoesNotExist(); - error SenderIsNotNodeOperatorOwner(); error NoTokensLocked(); error LockPeriodNotEnded(); @@ -48,63 +42,48 @@ interface IERC20LockBoostProvider is IWeightBoostProvider { /// @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. + /// @notice Minimum lock period allowed by the provider. function MIN_LOCK_PERIOD() external view returns (uint256); - /// @notice Maximum lock period allowed by the registry. + /// @notice Maximum lock period allowed by the provider. 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); - - // TODO: Add initialize functions to the remaining initializable contract interfaces. /// @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); + /// @param steps Initial steps. Thresholds must be nonzero; values must not exceed MAX_STEP_VALUE. + function initialize(address admin, uint256 lockPeriod, Step[] calldata steps) external; /// @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. + /// @notice Set the default lock period applied to new locks and top-ups. Only DEFAULT_ADMIN_ROLE; + /// existing locks keep their deadlines. + /// @param lockPeriod New lock period, in [MIN_LOCK_PERIOD, MAX_LOCK_PERIOD]. function setLockPeriod(uint256 lockPeriod) external; - /// @notice Set token lock weight multiplier steps. - /// @param steps Ordered lock amount thresholds with weight boost increments in basis points. - /// @dev Each step applies from minAmount inclusive until the next step minAmount. - /// The effective multiplier is 10_000 + weightBoostBP; zero means no scaling. - /// 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. + /// @notice Lock tokens for a Node Operator or add tokens to an existing lock. Only the Node + /// Operator owner. + /// @dev Each call restarts the lock period for the whole locked amount. + /// @param nodeOperatorId ID of the Node Operator. /// @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. + /// @notice Withdraw locked tokens. Only the Node Operator owner. Allowed after the lock period, or + /// early when the operator is outside any group and has neither active nor depositable + /// validators. + /// @param nodeOperatorId ID of the Node Operator. /// @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 Returns the Node Operator lock state. + /// @param nodeOperatorId ID of the Node Operator. + /// @return operatorLock Stored lock state. + function getLock(uint256 nodeOperatorId) external view returns (OperatorLock memory operatorLock); - /// @notice Get Node Operator vault address. - /// @param nodeOperatorId Node Operator ID. + /// @notice Returns the Node Operator vault address. + /// @param nodeOperatorId ID of the Node Operator. /// @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 index ffe3c2895..293a90279 100644 --- a/src/interfaces/IERC20LockVault.sol +++ b/src/interfaces/IERC20LockVault.sol @@ -7,7 +7,10 @@ import { IBaseModule } from "./IBaseModule.sol"; /// @notice Per-operator ERC20 vault used by the ERC20 lock boost provider. interface IERC20LockVault { - error ZeroAddress(); + error ZeroTokenAddress(); + error ZeroProviderAddress(); + error ZeroModuleAddress(); + error ZeroReceiverAddress(); error SenderIsNotProvider(); error SenderIsNotNodeOperatorOwner(); diff --git a/src/interfaces/IMetaRegistry.sol b/src/interfaces/IMetaRegistry.sol index 43db3e9eb..0aac98c7a 100644 --- a/src/interfaces/IMetaRegistry.sol +++ b/src/interfaces/IMetaRegistry.sol @@ -5,7 +5,6 @@ pragma solidity 0.8.33; import { IAccounting } from "./IAccounting.sol"; import { ICuratedModule } from "./ICuratedModule.sol"; -import { IAdditionalBondRegistry } from "./IAdditionalBondRegistry.sol"; import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; /// @notice Stored operator metadata. @@ -15,7 +14,7 @@ struct OperatorMetadata { bool ownerEditsRestricted; } -/// @notice Meta registry for curated node operator groups. +/// @notice Meta registry for curated Node Operator groups. interface IMetaRegistry { struct SubNodeOperator { uint64 nodeOperatorId; @@ -90,15 +89,13 @@ interface IMetaRegistry { /// @notice Role allowed to set bond curve weights. function SET_BOND_CURVE_WEIGHT_ROLE() external view returns (bytes32); - /// @notice Curated module allowed to call module-only hooks. + /// @notice Curated module the registry serves: weight changes and deposit info update requests are + /// pushed to it, and it is the source of operator existence and ownership. function MODULE() external view returns (ICuratedModule); /// @notice Accounting contract used for bond curve lookups. function ACCOUNTING() external view returns (IAccounting); - /// @notice Tier provider that manages operator bond tiers. - function ADDITIONAL_BOND_REGISTRY() external view returns (IAdditionalBondRegistry); - /// @notice Returns configured weight boost providers. function getWeightBoostProviders() external view returns (IWeightBoostProvider[] memory providers); @@ -107,10 +104,11 @@ interface IMetaRegistry { /// @notice Returns configured weight boost provider entry by ID. /// @param providerId Provider ID. - /// @return entry Configured boost provider entry. + /// @return entry Configured boost provider entry; zeroed for an unknown ID. function getWeightBoostProvider(uint256 providerId) external view returns (WeightBoostProviderEntry memory entry); /// @notice Returns configured weight boost provider mode by ID. + /// @dev An unknown ID reads as `PerNodeOperator`; check the entry's provider address first. /// @param providerId Provider ID. /// @return mode Provider aggregation mode. function getWeightBoostProviderMode(uint256 providerId) external view returns (WeightBoostProviderMode mode); @@ -127,13 +125,13 @@ interface IMetaRegistry { /// @notice Returns the initialized version of the contract. function getInitializedVersion() external view returns (uint64); - /// @notice Set or update metadata for a node operator (callable by SET_OPERATOR_INFO_ROLE). - /// @param nodeOperatorId Node operator ID. + /// @notice Set or update metadata for a Node Operator (callable by SET_OPERATOR_INFO_ROLE). + /// @param nodeOperatorId ID of the Node Operator. /// @param metadata Metadata payload to persist. function setOperatorMetadataAsAdmin(uint256 nodeOperatorId, OperatorMetadata calldata metadata) external; - /// @notice Set or update metadata by the node operator owner. - /// @param nodeOperatorId Node operator ID. + /// @notice Set or update metadata by the Node Operator owner. + /// @param nodeOperatorId ID of the Node Operator. /// @param name Display name. /// @param description Long description. /// @dev Reverts if module does not support IBaseModule interface. @@ -143,8 +141,8 @@ interface IMetaRegistry { string calldata description ) external; - /// @notice Get metadata for a node operator. - /// @param nodeOperatorId Node operator ID. + /// @notice Returns metadata of a Node Operator. + /// @param nodeOperatorId ID of the Node Operator. /// @return metadata Stored metadata struct. function getOperatorMetadata(uint256 nodeOperatorId) external view returns (OperatorMetadata memory metadata); @@ -163,12 +161,12 @@ interface IMetaRegistry { /// @notice Returns total operator groups count. function getOperatorGroupsCount() external view returns (uint256 count); - /// @notice Get Node Operator group ID (returns NO_GROUP_ID if the operator is not in any group). - /// @param nodeOperatorId Node operator ID to query. + /// @notice Returns the Node Operator group ID ( NO_GROUP_ID if the operator is not in any group). + /// @param nodeOperatorId ID of the Node Operator. /// @return operatorGroupId Group ID. function getNodeOperatorGroupId(uint256 nodeOperatorId) external view returns (uint256 operatorGroupId); - /// @notice Get External Operator group ID (returns NO_GROUP_ID if the operator is not in any group). + /// @notice Returns the External Operator group ID ( NO_GROUP_ID if the operator is not in any group). /// @param op External operator. /// @return operatorGroupId Group ID. function getExternalOperatorGroupId(ExternalOperator calldata op) external view returns (uint256 operatorGroupId); @@ -200,15 +198,15 @@ interface IMetaRegistry { /// @param enabled Whether the provider should participate in weight calculations. function setWeightBoostProviderEnabled(uint256 providerId, bool enabled) external; - /// @notice Returns effective weight for the node operator. - /// @param nodeOperatorId Node operator ID to query. + /// @notice Returns effective weight for the Node Operator. + /// @param nodeOperatorId ID of the Node Operator. /// @return weight Effective allocation weight. /// @dev Returns the cached effective weight. /// @dev Operators outside any group are expected to have zero cached weight. function getNodeOperatorWeight(uint256 nodeOperatorId) external view returns (uint256 weight); - /// @notice Returns effective weight and external stake for the node operator. - /// @param nodeOperatorId Node operator ID to query. + /// @notice Returns effective weight and external stake for the Node Operator. + /// @param nodeOperatorId ID of the Node Operator. /// @return weight Effective allocation weight. /// @return externalStake External stake amount in wei. /// @dev Returns (0, 0) if the operator is not in a group. @@ -219,15 +217,16 @@ interface IMetaRegistry { uint256 nodeOperatorId ) external view returns (uint256 weight, uint256 externalStake); - /// @notice Returns allocation weights for the given node operators. - /// @param nodeOperatorIds Node operator IDs to query. + /// @notice Returns allocation weights for the given Node Operators. + /// @param nodeOperatorIds IDs of the Node Operators. /// @return operatorWeights Weights aligned with nodeOperatorIds. function getOperatorWeights( uint256[] calldata nodeOperatorIds ) external view returns (uint256[] memory operatorWeights); /// @notice Trigger the operator weight update routine in the registry. - /// @param nodeOperatorId Node operator ID to trigger the update for. + /// @dev No-op for operators outside any group. + /// @param nodeOperatorId ID of the Node Operator to trigger the update for. function refreshOperatorWeight(uint256 nodeOperatorId) external; /// @notice Trigger the group weight update routine in the registry. @@ -236,12 +235,16 @@ interface IMetaRegistry { /// notifyWeightBoostProviderConfigChanged(), and setWeightBoostProviderEnabled(). function refreshGroupWeights(uint256 groupId) external; - /// @notice Notify the registry that a configured provider changed a node operator boost. - /// @param nodeOperatorId Node operator ID whose provider boost changed. + /// @notice Notify the registry that a configured provider changed a Node Operator boost. + /// @dev Reverts for callers that are not registered providers. No-op for operators outside any + /// group and for disabled providers. A `PerNodeOperator` provider refreshes only the operator's + /// cached weight; a `MaxPerGroup` provider refreshes the whole group. + /// @param nodeOperatorId ID of the Node Operator whose provider boost changed. function notifyWeightBoostChanged(uint256 nodeOperatorId) external; /// @notice Notify the registry that a configured provider changed global boost parameters. - /// @dev Requests a full deposit info update when the sender is an enabled provider. - /// Unregistered or disabled providers are ignored since cached weights do not depend on them. + /// @dev Reverts for callers that are not registered providers. Requests a full deposit info update + /// when the sender is enabled; disabled providers are ignored since cached weights do not + /// depend on them. function notifyWeightBoostProviderConfigChanged() external; } diff --git a/src/interfaces/INodeOperatorStrikes.sol b/src/interfaces/INodeOperatorStrikes.sol index f3dcf3f81..b5f21d448 100644 --- a/src/interfaces/INodeOperatorStrikes.sol +++ b/src/interfaces/INodeOperatorStrikes.sol @@ -3,9 +3,7 @@ pragma solidity 0.8.33; -import { ICuratedModule } from "./ICuratedModule.sol"; -import { IMetaRegistry } from "./IMetaRegistry.sol"; -import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; +import { IStepwiseWeightBoost, Step } from "./IStepwiseWeightBoost.sol"; /// @dev Payload describing a strike to issue. struct StrikeInput { @@ -24,15 +22,11 @@ struct Strike { string description; } -/// @dev Cumulative weight reduction step. At `minCount` active strikes the operator's weight is -/// reduced by `reductionBP` basis points (effective multiplier = MAX_BP - reductionBP). -struct StrikeThreshold { - uint128 minCount; - uint128 reductionBP; -} - -/// @notice Committee-issued strikes act as a weight-reduction provider consumed by MetaRegistry. -interface INodeOperatorStrikes is IWeightBoostProvider { +/// @notice Committee-issued strikes act as a weight-reduction provider consumed by MetaRegistry. A strike +/// keeps reducing weight until removed; its expiry only enables permissionless removal. In this +/// provider, `Step.threshold` is the minimum active strike count and `Step.value` is the weight +/// reduction from MAX_BP. +interface INodeOperatorStrikes is IStepwiseWeightBoost { event StrikeIssued( uint256 indexed nodeOperatorId, uint256 indexed strikeId, @@ -42,36 +36,23 @@ interface INodeOperatorStrikes is IWeightBoostProvider { ); event StrikeRemoved(uint256 indexed nodeOperatorId, uint256 indexed strikeId); event ExpiredStrikeRemoved(uint256 indexed nodeOperatorId, uint256 indexed strikeId); - event StrikeThresholdsSet(StrikeThreshold[] thresholds); - error ZeroModuleAddress(); - error ZeroAdminAddress(); - error NodeOperatorDoesNotExist(); - error StrikeNotExist(); + error StrikeDoesNotExist(); error ZeroLifetime(); error LifetimeTooLong(); - error InvalidStrikeThresholds(); error InvalidDescription(); /// @notice Role allowed to issue and remove strikes. function STRIKES_COMMITTEE_ROLE() external view returns (bytes32); - /// @notice Maximum number of weight-reduction thresholds. - function MAX_THRESHOLDS() external view returns (uint256); - /// @notice Maximum byte length of a strike description. function MAX_DESCRIPTION_LENGTH() external view returns (uint256); - /// @notice Curated module used to check operator existence. - function MODULE() external view returns (ICuratedModule); - - /// @notice MetaRegistry called back via `notifyWeightBoostChanged` on every strike change. - function META_REGISTRY() external view returns (IMetaRegistry); - - /// @notice Initialize the contract. - /// @param admin Address to receive DEFAULT_ADMIN_ROLE. - /// @param thresholds Initial weight-reduction thresholds. - function initialize(address admin, StrikeThreshold[] calldata thresholds) external; + /// @notice Initialize the provider. + /// @param admin Address to receive DEFAULT_ADMIN_ROLE. + /// @param steps Initial steps. Thresholds and values must be nonzero; a value is a weight reduction + /// and cannot exceed MAX_BP. + function initialize(address admin, Step[] calldata steps) external; /// @notice Issue a strike against a Node Operator (callable by STRIKES_COMMITTEE_ROLE). /// @param input Strike payload. @@ -81,7 +62,7 @@ interface INodeOperatorStrikes is IWeightBoostProvider { /// @notice Remove any strike (callable by STRIKES_COMMITTEE_ROLE). For permissionless cleanup of /// expired strikes use `removeExpiredStrikes`. /// @param nodeOperatorId ID of the Node Operator. - /// @param strikeId ID of the strike to remove. + /// @param strikeId ID of the strike to remove. function removeStrike(uint256 nodeOperatorId, uint256 strikeId) external; /// @notice Permissionlessly remove all of a Node Operator's strikes whose lifetime has elapsed. @@ -89,24 +70,16 @@ interface INodeOperatorStrikes is IWeightBoostProvider { /// @param nodeOperatorId ID of the Node Operator. function removeExpiredStrikes(uint256 nodeOperatorId) external; - /// @notice Set the global weight-reduction thresholds (callable by DEFAULT_ADMIN_ROLE). - /// @dev Notifies MetaRegistry of the config change so affected operator weights are refreshed. - /// @param thresholds Step function mapping active strike count to weight reduction. - function setStrikeThresholds(StrikeThreshold[] calldata thresholds) external; - /// @notice Number of active (non-removed) strikes of a Node Operator. /// @param nodeOperatorId ID of the Node Operator. function getActiveStrikesCount(uint256 nodeOperatorId) external view returns (uint256 count); - /// @notice Return a single strike record. Reverts with `StrikeNotExist` if removed or never issued. + /// @notice Returns a single strike record. Reverts with `StrikeDoesNotExist` if removed or never issued. /// @param nodeOperatorId ID of the Node Operator. - /// @param strikeId ID of the strike. + /// @param strikeId ID of the strike. function getStrike(uint256 nodeOperatorId, uint256 strikeId) external view returns (Strike memory strike); - /// @notice Return all of a Node Operator's active (non-removed) strikes. + /// @notice Returns all of a Node Operator's active (non-removed) strikes. /// @param nodeOperatorId ID of the Node Operator. function getStrikes(uint256 nodeOperatorId) external view returns (Strike[] memory strikes); - - /// @notice Return the configured global weight-reduction thresholds. - function getStrikeThresholds() external view returns (StrikeThreshold[] memory thresholds); } diff --git a/src/interfaces/ISnapshotDelegationLockVault.sol b/src/interfaces/ISnapshotDelegationLockVault.sol index 8b72d5f12..35af328cc 100644 --- a/src/interfaces/ISnapshotDelegationLockVault.sol +++ b/src/interfaces/ISnapshotDelegationLockVault.sol @@ -5,6 +5,8 @@ pragma solidity 0.8.33; /// @notice Optional Snapshot delegation capability for an ERC20 lock vault. interface ISnapshotDelegationLockVault { + error ZeroSnapshotDelegationAddress(); + /// @notice Snapshot delegation registry used by this vault. function snapshotDelegation() external view returns (address); diff --git a/src/interfaces/IStepwiseWeightBoost.sol b/src/interfaces/IStepwiseWeightBoost.sol new file mode 100644 index 000000000..b306ef481 --- /dev/null +++ b/src/interfaces/IStepwiseWeightBoost.sol @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { ICuratedModule } from "./ICuratedModule.sol"; +import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; + +/// @dev A point of a monotonically increasing step function, packed into one storage slot. At an input +/// greater than or equal to `threshold`, `value` applies until the next threshold is reached. +struct Step { + uint128 threshold; + uint128 value; +} + +/// @notice A governance-configurable monotonically increasing step function. +/// @dev Thresholds and values must each be strictly increasing. Before the first threshold the function +/// returns zero; at and after a threshold it returns that step's value; after the final threshold the +/// final value continues to apply. Provider-specific validation may impose additional bounds or forbid +/// a zero threshold or value. +interface IStepwiseWeightBoost is IWeightBoostProvider { + event StepsSet(Step[] steps); + + error ZeroModuleAddress(); + error ZeroAdminAddress(); + error NodeOperatorDoesNotExist(); + error SenderIsNotNodeOperatorOwner(); + error InvalidStepCount(); + error InvalidStep(uint256 index); + error UnorderedSteps(uint256 index); + + /// @notice Replace the complete step function and request a provider-wide weight refresh. + /// @dev The caller must have DEFAULT_ADMIN_ROLE. MetaRegistry is notified after the new steps are stored. + function setSteps(Step[] calldata steps) external; + + /// @notice Returns the complete configured step function in ascending threshold order. + function getSteps() external view returns (Step[] memory); + + /// @notice Curated module the provider serves; its MetaRegistry consumes the weight boost. + function MODULE() external view returns (ICuratedModule); + + /// @notice Returns the initialized version of the contract. + function getInitializedVersion() external view returns (uint64); + + /// @notice Maximum number of configured steps. + function MAX_STEPS() external view returns (uint256); + + /// @notice Capability-wide ceiling for a step value. + /// @dev A provider with a narrower domain may reject values below this ceiling through its own + /// step validation; see the provider interface for its effective bound. + function MAX_STEP_VALUE() external view returns (uint256); +} diff --git a/src/interfaces/IWeightBoostProvider.sol b/src/interfaces/IWeightBoostProvider.sol index 24cacb2db..7a8d59edf 100644 --- a/src/interfaces/IWeightBoostProvider.sol +++ b/src/interfaces/IWeightBoostProvider.sol @@ -5,8 +5,8 @@ pragma solidity 0.8.33; /// @notice Weight multiplier provider consumed by MetaRegistry. interface IWeightBoostProvider { - /// @notice Return weight multiplier in basis points for a node operator. - /// @param nodeOperatorId Node Operator ID. + /// @notice Returns the weight multiplier in basis points for a Node Operator. + /// @param nodeOperatorId ID of the Node Operator. /// @return multiplierBP Full multiplier in basis points. 10_000 means no scaling. function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP); } diff --git a/src/lib/Constants.sol b/src/lib/Constants.sol index 52f9da867..694f04ed4 100644 --- a/src/lib/Constants.sol +++ b/src/lib/Constants.sol @@ -6,6 +6,3 @@ pragma solidity 0.8.33; /// @dev Basis points denominator (100 % = 10 000 bp). uint256 constant MAX_BP = 10_000; - -/// @dev Maximum weight boost increment; with the baseline, this results in a 10x multiplier. -uint256 constant MAX_WEIGHT_BOOST_BP = 90_000; diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index f69ba2c2a..6fb97271d 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -9,27 +9,85 @@ 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 { StrikeThreshold } from "src/interfaces/INodeOperatorStrikes.sol"; +import { FeeModifier } from "src/interfaces/ICustomFeeRegistry.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; import { IParametersRegistry } from "src/interfaces/IParametersRegistry.sol"; +import { Step } from "src/interfaces/IStepwiseWeightBoost.sol"; import { OssifiableProxy } from "src/lib/proxy/OssifiableProxy.sol"; import { Utilities } from "../../helpers/Utilities.sol"; import { DeploymentFixtures } from "../../helpers/Fixtures.sol"; import { ProxySlotUtils } from "../../helpers/ProxySlotUtils.sol"; +/// @dev Minimal view of the wiring every weight boost provider exposes, so assertions can be shared across +/// providers with different concrete types. +interface IStepwiseProviderWiring { + function MODULE() external view returns (address); + + function META_REGISTRY() external view returns (address); +} + contract DeploymentBaseTest is Test, Utilities, DeploymentFixtures { CuratedDeployParams internal deployParams; CuratedGateConfig[] internal deployGateConfigs; + uint256 internal adminsCount; + + /// @dev Asserts the proxy serves `impl`, is administered by the configured admin, and is not ossified. + function _assertProxy(address proxyAddress, address impl, string memory label) internal view { + OssifiableProxy proxy = OssifiableProxy(payable(proxyAddress)); + assertEq(proxy.proxy__getImplementation(), impl, string.concat(label, " proxy getter impl")); + assertEq(ProxySlotUtils.getImplementation(proxyAddress), impl, string.concat(label, " proxy slot impl")); + assertEq(proxy.proxy__getAdmin(), deployParams.proxyAdmin, string.concat(label, " proxy getter admin")); + assertEq( + ProxySlotUtils.getAdmin(proxyAddress), + deployParams.proxyAdmin, + string.concat(label, " proxy slot admin") + ); + assertFalse(proxy.proxy__getIsOssified(), string.concat(label, " proxy ossified")); + } + + /// @dev Asserts neither the proxy nor its implementation can be initialized again. + function _assertNotReinitializable(address proxyAddress, address impl, bytes memory initCalldata) internal { + _assertInvalidInitialization(proxyAddress, initCalldata); + _assertInvalidInitialization(impl, initCalldata); + } + + /// @dev Asserts the provider points at the deployed module and MetaRegistry. + function _assertProviderWiring(address provider, string memory label) internal view { + assertEq( + IStepwiseProviderWiring(provider).MODULE(), + address(curatedModule), + string.concat(label, " module wiring") + ); + assertEq( + IStepwiseProviderWiring(provider).META_REGISTRY(), + address(metaRegistry), + string.concat(label, " meta registry wiring") + ); + } + + /// @dev Asserts the configured step function round-trips through the provider. + function _assertSteps(Step[] memory actual, Step[] memory expected) internal view { + assertEq(actual.length, expected.length, "unexpected steps count"); + for (uint256 i; i < expected.length; ++i) { + assertEq(actual[i].threshold, expected[i].threshold, "unexpected step threshold"); + assertEq(actual[i].value, expected[i].value, "unexpected step value"); + } + } + + function _assertInvalidInitialization(address target, bytes memory initCalldata) private { + (bool success, bytes memory returnData) = target.call(initCalldata); + assertFalse(success, "initialize did not revert"); + assertEq(bytes4(returnData), Initializable.InvalidInitialization.selector, "unexpected initialize revert"); + } function setUp() public { Env memory env = envVars(); vm.createSelectFork(env.RPC_URL); initializeFromDeployment(); if (moduleType != ModuleType.Curated) vm.skip(true, "Current deployment is not Curated module type"); + adminsCount = block.chainid == 1 ? 1 : 2; string memory config = vm.readFile(env.DEPLOY_CONFIG); // mutates storage variable updateCuratedDeployParams(deployParams, env.DEPLOY_CONFIG); @@ -65,32 +123,20 @@ contract ModuleDeploymentTest is DeploymentBaseTest { } function test_proxy_onlyFull() public view { - OssifiableProxy proxy = OssifiableProxy(payable(address(curatedModule))); - assertEq(proxy.proxy__getImplementation(), address(moduleImpl), "curated module proxy getter impl"); - assertEq( - ProxySlotUtils.getImplementation(address(curatedModule)), - address(moduleImpl), - "curated module proxy slot impl" - ); - assertEq(proxy.proxy__getAdmin(), address(deployParams.proxyAdmin), "curated module proxy getter admin"); - assertEq( - ProxySlotUtils.getAdmin(address(curatedModule)), - address(deployParams.proxyAdmin), - "curated module proxy slot admin" - ); - assertFalse(proxy.proxy__getIsOssified(), "curated module proxy ossified"); + _assertProxy(address(curatedModule), address(moduleImpl), "curated module"); } } contract MetaRegistryDeploymentTest is DeploymentBaseTest { function _assertWeightBoostProvider( + uint256 expectedProviderId, address expectedProvider, IMetaRegistry.WeightBoostProviderMode expectedMode ) internal view { uint256 providerId = metaRegistry.getWeightBoostProviderId(expectedProvider); - assertNotEq(providerId, 0, "weight boost provider not registered"); + assertEq(providerId, expectedProviderId, "unexpected weight boost provider ID"); - IMetaRegistry.WeightBoostProviderEntry memory entry = metaRegistry.getWeightBoostProvider(providerId); + IMetaRegistry.WeightBoostProviderEntry memory entry = metaRegistry.getWeightBoostProvider(expectedProviderId); assertEq(address(entry.provider), expectedProvider, "unexpected weight boost provider"); assertEq(uint256(entry.mode), uint256(expectedMode), "unexpected weight boost provider mode"); assertTrue(entry.enabled, "weight boost provider disabled"); @@ -138,54 +184,58 @@ contract MetaRegistryDeploymentTest is DeploymentBaseTest { } function test_weightBoostProviders_onlyFull() public view { - assertEq(metaRegistry.getWeightBoostProvidersCount(), 3, "unexpected weight boost providers count"); + assertEq(metaRegistry.getWeightBoostProvidersCount(), 4, "unexpected weight boost providers count"); _assertWeightBoostProvider( + 1, address(additionalBondRegistry), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator ); - _assertWeightBoostProvider(address(nodeOperatorStrikes), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator); - _assertWeightBoostProvider(address(ldoLockBoostProvider), IMetaRegistry.WeightBoostProviderMode.MaxPerGroup); + _assertWeightBoostProvider( + 2, + address(nodeOperatorStrikes), + IMetaRegistry.WeightBoostProviderMode.PerNodeOperator + ); + _assertWeightBoostProvider(3, address(ldoLockBoostProvider), IMetaRegistry.WeightBoostProviderMode.MaxPerGroup); + _assertWeightBoostProvider( + 4, + address(customFeeRegistry), + IMetaRegistry.WeightBoostProviderMode.PerNodeOperator + ); } } contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { function test_state_onlyFull() public view { - BoostStep[] memory boostSteps = additionalBondRegistry.getBoostSteps(); - uint256[2][] memory expected = deployParams.additionalBondRegistryConfig.boostSteps; - assertEq(boostSteps.length, expected.length); - for (uint256 i; i < expected.length; ++i) { - assertEq(boostSteps[i].minCurveMultiplier, expected[i][0]); - assertEq(boostSteps[i].weightMultiplier, expected[i][1]); - } + assertEq(additionalBondRegistry.getInitializedVersion(), 1); + // Curve multiplier thresholds map to weight multiplier increments. + _assertSteps(additionalBondRegistry.getSteps(), deployParams.additionalBondRegistryConfig.boostSteps); + assertEq( + additionalBondRegistry.getCurveMultiplierReductionCooldown(), + deployParams.additionalBondRegistryConfig.curveMultiplierReductionCooldown, + "additional bond registry cooldown" + ); } function test_immutables_onlyFull() public view { - assertEq(address(additionalBondRegistry.MODULE()), address(curatedModule), "additional bond registry module"); + _assertProviderWiring(address(additionalBondRegistry), "additional bond registry"); assertEq( address(additionalBondRegistry.ACCOUNTING()), address(accounting), "additional bond registry accounting" ); - assertEq( - address(additionalBondRegistry.META_REGISTRY()), - address(metaRegistry), - "additional bond registry meta registry" - ); - assertEq( - additionalBondRegistry.CURVE_MULTIPLIER_REDUCTION_COOLDOWN(), - deployParams.additionalBondRegistryConfig.curveMultiplierCooldown, - "additional bond registry cooldown" - ); assertEq( additionalBondRegistry.MAX_CURVE_MULTIPLIER(), 90_000, "additional bond registry max curve multiplier" ); + assertEq(additionalBondRegistry.CURVE_MULTIPLIER_STEP(), 100, "additional bond registry curve multiplier step"); assertEq( - additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(), - 90_000, - "additional bond registry max weight multiplier" + additionalBondRegistry.MAX_CURVE_MULTIPLIER_REDUCTION_COOLDOWN(), + 365 days, + "additional bond registry max cooldown" ); + assertEq(additionalBondRegistry.MAX_STEPS(), 35, "additional bond registry max steps"); + assertEq(additionalBondRegistry.MAX_STEP_VALUE(), 90_000, "additional bond registry max weight multiplier"); } function test_roles_onlyFull() public view { @@ -198,62 +248,31 @@ contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { ); } - function test_wiring_onlyFull() public view { - assertEq( - address(metaRegistry.ADDITIONAL_BOND_REGISTRY()), + function test_initialization_onlyFull() public { + _assertNotReinitializable( address(additionalBondRegistry), - "meta registry additional bond registry wiring" + address(additionalBondRegistryImpl), + abi.encodeCall(additionalBondRegistry.initialize, (deployParams.aragonAgent, 7 days, new Step[](0))) ); } - function test_initialization_onlyFull() public { - vm.expectRevert(Initializable.InvalidInitialization.selector); - additionalBondRegistry.initialize(deployParams.aragonAgent, new BoostStep[](0)); - - vm.expectRevert(Initializable.InvalidInitialization.selector); - additionalBondRegistryImpl.initialize(deployParams.aragonAgent, new BoostStep[](0)); - } - function test_proxy_onlyFull() public view { - OssifiableProxy proxy = OssifiableProxy(payable(address(additionalBondRegistry))); - assertEq( - proxy.proxy__getImplementation(), - address(additionalBondRegistryImpl), - "additional bond registry proxy getter impl" - ); - assertEq( - ProxySlotUtils.getImplementation(address(additionalBondRegistry)), - address(additionalBondRegistryImpl), - "additional bond registry proxy slot impl" - ); - assertEq( - proxy.proxy__getAdmin(), - address(deployParams.proxyAdmin), - "additional bond registry proxy getter admin" - ); - assertEq( - ProxySlotUtils.getAdmin(address(additionalBondRegistry)), - address(deployParams.proxyAdmin), - "additional bond registry proxy slot admin" - ); - assertFalse(proxy.proxy__getIsOssified(), "additional bond registry proxy ossified"); + _assertProxy(address(additionalBondRegistry), address(additionalBondRegistryImpl), "additional bond registry"); } } contract NodeOperatorStrikesDeploymentTest is DeploymentBaseTest { function test_state_onlyFull() public view { - StrikeThreshold[] memory thresholds = nodeOperatorStrikes.getStrikeThresholds(); - StrikeThreshold[] storage expected = deployParams.strikesThresholds; - assertEq(thresholds.length, expected.length); - for (uint256 i; i < expected.length; ++i) { - assertEq(thresholds[i].minCount, expected[i].minCount); - assertEq(thresholds[i].reductionBP, expected[i].reductionBP); - } + assertEq(nodeOperatorStrikes.getInitializedVersion(), 1); + // Strike count thresholds map to weight reductions in basis points. + _assertSteps(nodeOperatorStrikes.getSteps(), deployParams.nodeOperatorStrikesConfig.thresholds); } function test_immutables_onlyFull() public view { - assertEq(address(nodeOperatorStrikes.MODULE()), address(curatedModule), "node operator strikes module"); - assertEq(address(nodeOperatorStrikes.META_REGISTRY()), address(metaRegistry), "node operator strikes registry"); + _assertProviderWiring(address(nodeOperatorStrikes), "node operator strikes"); + assertEq(nodeOperatorStrikes.MAX_DESCRIPTION_LENGTH(), 1024, "strikes max description length"); + assertEq(nodeOperatorStrikes.MAX_STEPS(), 35, "strikes max steps"); + assertEq(nodeOperatorStrikes.MAX_STEP_VALUE(), 90_000, "strikes max step value"); } function test_roles_onlyFull() public view { @@ -261,32 +280,19 @@ contract NodeOperatorStrikesDeploymentTest is DeploymentBaseTest { bytes32 committeeRole = nodeOperatorStrikes.STRIKES_COMMITTEE_ROLE(); assertEq(nodeOperatorStrikes.getRoleMemberCount(committeeRole), 1); - assertTrue(nodeOperatorStrikes.hasRole(committeeRole, deployParams.strikesCommittee)); + assertTrue(nodeOperatorStrikes.hasRole(committeeRole, deployParams.nodeOperatorStrikesConfig.committee)); } function test_initialization_onlyFull() public { - vm.expectRevert(Initializable.InvalidInitialization.selector); - nodeOperatorStrikes.initialize(deployParams.aragonAgent, new StrikeThreshold[](0)); - - vm.expectRevert(Initializable.InvalidInitialization.selector); - nodeOperatorStrikesImpl.initialize(deployParams.aragonAgent, new StrikeThreshold[](0)); + _assertNotReinitializable( + address(nodeOperatorStrikes), + address(nodeOperatorStrikesImpl), + abi.encodeCall(nodeOperatorStrikes.initialize, (deployParams.aragonAgent, new Step[](0))) + ); } function test_proxy_onlyFull() public view { - OssifiableProxy proxy = OssifiableProxy(payable(address(nodeOperatorStrikes))); - assertEq(proxy.proxy__getImplementation(), address(nodeOperatorStrikesImpl), "strikes proxy getter impl"); - assertEq( - ProxySlotUtils.getImplementation(address(nodeOperatorStrikes)), - address(nodeOperatorStrikesImpl), - "strikes proxy slot impl" - ); - assertEq(proxy.proxy__getAdmin(), address(deployParams.proxyAdmin), "strikes proxy getter admin"); - assertEq( - ProxySlotUtils.getAdmin(address(nodeOperatorStrikes)), - address(deployParams.proxyAdmin), - "strikes proxy slot admin" - ); - assertFalse(proxy.proxy__getIsOssified(), "strikes proxy ossified"); + _assertProxy(address(nodeOperatorStrikes), address(nodeOperatorStrikesImpl), "strikes"); } } @@ -299,30 +305,12 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { "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].weightBoostBP, - deployParams.ldoLockBoostProviderConfig.lockBoostSteps[i].weightBoostBP, - "LDO lock boost step weight boost" - ); - } + // Locked amount thresholds map to weight boosts in basis points. + _assertSteps(ldoLockBoostProvider.getSteps(), deployParams.ldoLockBoostProviderConfig.lockBoostSteps); } 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" - ); + _assertProviderWiring(address(ldoLockBoostProvider), "LDO lock provider"); assertEq( ldoLockBoostProvider.TOKEN(), deployParams.ldoLockBoostProviderConfig.token, @@ -339,6 +327,8 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { "LDO lock provider min lock period" ); assertEq(ldoLockBoostProvider.MAX_LOCK_PERIOD(), 365 days, "LDO lock provider max lock period"); + assertEq(ldoLockBoostProvider.MAX_STEPS(), 35, "LDO lock provider max steps"); + assertEq(ldoLockBoostProvider.MAX_STEP_VALUE(), 90_000, "LDO lock provider max step value"); } function test_roles_onlyFull() public view { @@ -370,13 +360,17 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { } 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 + _assertNotReinitializable( + address(ldoLockBoostProvider), + address(ldoLockBoostProviderImpl), + abi.encodeCall( + ldoLockBoostProvider.initialize, + ( + deployParams.aragonAgent, + deployParams.ldoLockBoostProviderConfig.lockPeriod, + deployParams.ldoLockBoostProviderConfig.lockBoostSteps + ) + ) ); vm.expectRevert(Initializable.InvalidInitialization.selector); @@ -384,24 +378,74 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { } function test_proxy_onlyFull() public view { - OssifiableProxy proxy = OssifiableProxy(payable(address(ldoLockBoostProvider))); + _assertProxy(address(ldoLockBoostProvider), address(ldoLockBoostProviderImpl), "LDO lock provider"); + } +} + +contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { + function test_state_onlyFull() public view { + assertEq(customFeeRegistry.getInitializedVersion(), 1); assertEq( - proxy.proxy__getImplementation(), - address(ldoLockBoostProviderImpl), - "LDO lock provider proxy getter impl" + customFeeRegistry.getDefaultMaxFeeDiscount(), + deployParams.customFeeRegistryConfig.defaultMaxFeeDiscount ); assertEq( - ProxySlotUtils.getImplementation(address(ldoLockBoostProvider)), - address(ldoLockBoostProviderImpl), - "LDO lock provider proxy slot impl" + customFeeRegistry.getFeeDiscountCutCooldown(), + deployParams.customFeeRegistryConfig.feeDiscountCutCooldown ); - assertEq(proxy.proxy__getAdmin(), address(deployParams.proxyAdmin), "LDO lock provider proxy getter admin"); + + // Fee discount thresholds map to weight multiplier increments. + _assertSteps(customFeeRegistry.getSteps(), deployParams.customFeeRegistryConfig.feeDiscountWeightSteps); + + for (uint256 i; i < deployParams.customFeeRegistryConfig.feeModifiers.length; ++i) { + FeeModifier memory actual = customFeeRegistry.getFeeModifier( + deployParams.customFeeRegistryConfig.feeModifiers[i].curveId + ); + assertEq(actual.value, deployParams.customFeeRegistryConfig.feeModifiers[i].value); + assertEq(actual.negative, deployParams.customFeeRegistryConfig.feeModifiers[i].negative); + } + + uint256 defaultFee = customFeeRegistry.BASE_FEE(); + for (uint256 curveId; curveId < accounting.getCurvesCount(); ++curveId) { + FeeModifier memory feeModifier = customFeeRegistry.getFeeModifier(curveId); + uint256 effectiveFee = feeModifier.negative + ? defaultFee - feeModifier.value + : defaultFee + feeModifier.value; + assertEq(effectiveFee, curveId == 0 ? 6_250 : 8_750, "unexpected initial effective fee"); + } + } + + function test_immutables_onlyFull() public view { + _assertProviderWiring(address(customFeeRegistry), "custom fee registry"); + assertEq(address(customFeeRegistry.ACCOUNTING()), address(accounting), "custom fee registry accounting"); + assertEq(customFeeRegistry.FEE_GRANULARITY(), 250, "custom fee step"); + assertEq(customFeeRegistry.BASE_FEE(), 8_750, "custom fee default max"); + assertEq(customFeeRegistry.MAX_STEPS(), 35, "custom fee max weight steps"); + assertEq(customFeeRegistry.MAX_STEP_VALUE(), 90_000, "custom fee max weight multiplier"); assertEq( - ProxySlotUtils.getAdmin(address(ldoLockBoostProvider)), - address(deployParams.proxyAdmin), - "LDO lock provider proxy slot admin" + customFeeRegistry.MAX_FEE_DISCOUNT_CUT_COOLDOWN(), + 365 days, + "custom fee max discount decrease cooldown" + ); + } + + function test_roles_onlyFull() public view { + _checkAdminRole(address(customFeeRegistry), deployParams.aragonAgent, deployParams.secondAdminAddress); + } + + function test_initialization_onlyFull() public { + _assertNotReinitializable( + address(customFeeRegistry), + address(customFeeRegistryImpl), + abi.encodeCall( + customFeeRegistry.initialize, + (deployParams.aragonAgent, 6_250, 15 days, deployParams.customFeeRegistryConfig.feeDiscountWeightSteps) + ) ); - assertFalse(proxy.proxy__getIsOssified(), "LDO lock provider proxy ossified"); + } + + function test_proxy_onlyFull() public view { + _assertProxy(address(customFeeRegistry), address(customFeeRegistryImpl), "custom fee"); } } @@ -591,16 +635,7 @@ contract CuratedGatesDeploymentTest is DeploymentBaseTest { address implementation = address(curatedGateImpl); assertTrue(implementation != address(0), "factory implementation zero"); for (uint256 i = 0; i < gatesCount; ++i) { - OssifiableProxy proxy = OssifiableProxy(payable(curatedGates[i])); - assertEq(proxy.proxy__getImplementation(), implementation, "curated gate proxy getter impl"); - assertEq(ProxySlotUtils.getImplementation(curatedGates[i]), implementation, "curated gate proxy slot impl"); - assertEq(proxy.proxy__getAdmin(), deployParams.proxyAdmin, "curated gate proxy getter admin"); - assertEq( - ProxySlotUtils.getAdmin(curatedGates[i]), - deployParams.proxyAdmin, - "curated gate proxy slot admin" - ); - assertFalse(proxy.proxy__getIsOssified(), "curated gate proxy ossified"); + _assertProxy(curatedGates[i], implementation, "curated gate"); } } diff --git a/test/helpers/CuratedProviderFixture.sol b/test/helpers/CuratedProviderFixture.sol new file mode 100644 index 000000000..ab164cd41 --- /dev/null +++ b/test/helpers/CuratedProviderFixture.sol @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { NodeOperatorManagementProperties } from "src/interfaces/IBaseModule.sol"; + +import { CuratedMock } from "./mocks/CuratedMock.sol"; +import { MetaRegistryMock } from "./mocks/MetaRegistryMock.sol"; + +/// @notice Mock wiring shared by the weight boost provider suites that drive a curated module and a +/// MetaRegistry stub. Deliberately dependency-free so suites can mix it into any base. +abstract contract CuratedProviderFixture { + CuratedMock public module; + MetaRegistryMock public metaRegistryMock; + + /// @dev Deploys a curated module mock wired to a MetaRegistry mock. + function _deployModuleWithMetaRegistryMock(uint256 nodeOperatorsCount) internal { + module = new CuratedMock(); + module.mock_setNodeOperatorsCount(nodeOperatorsCount); + + metaRegistryMock = new MetaRegistryMock(); + module.mock_setMetaRegistry(address(metaRegistryMock)); + } + + /// @dev Makes `owner` both manager and reward address of every operator in the mock. + function _setNodeOperatorOwner(address owner) internal { + module.mock_setNodeOperatorManagementProperties( + NodeOperatorManagementProperties({ + managerAddress: owner, + rewardAddress: owner, + extendedManagerPermissions: true + }) + ); + } +} diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index 6caaad508..cf35bf3ee 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -37,6 +37,7 @@ import { AdditionalBondRegistry } from "src/AdditionalBondRegistry.sol"; import { NodeOperatorStrikes } from "src/NodeOperatorStrikes.sol"; import { ERC20LockBoostProvider } from "src/ERC20LockBoostProvider.sol"; import { LidoGovernanceLockVault } from "src/LidoGovernanceLockVault.sol"; +import { CustomFeeRegistry } from "src/CustomFeeRegistry.sol"; import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; import { CuratedGate } from "src/CuratedGate.sol"; import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; @@ -251,6 +252,8 @@ contract DeploymentHelpers is Test { address ldoLockBoostProviderImpl; address ldoLockVaultImpl; address ldoLockVaultBeacon; + address customFeeRegistry; + address customFeeRegistryImpl; address curatedGateFactory; address curatedGateImpl; address[] curatedGates; @@ -452,6 +455,12 @@ contract DeploymentHelpers is Test { deploymentConfig.ldoLockVaultBeacon = vm.parseJsonAddress(config, ".LDOLockVaultBeacon"); vm.label(deploymentConfig.ldoLockVaultBeacon, "ldoLockVaultBeacon"); + deploymentConfig.customFeeRegistry = vm.parseJsonAddress(config, ".CustomFeeRegistry"); + vm.label(deploymentConfig.customFeeRegistry, "customFeeRegistry"); + + deploymentConfig.customFeeRegistryImpl = vm.parseJsonAddress(config, ".CustomFeeRegistryImpl"); + vm.label(deploymentConfig.customFeeRegistryImpl, "customFeeRegistryImpl"); + if (vm.keyExistsJson(config, ".CuratedGateFactory")) { deploymentConfig.curatedGateFactory = vm.parseJsonAddress(config, ".CuratedGateFactory"); } @@ -586,17 +595,17 @@ contract DeploymentHelpers is Test { dst.secondAdminAddress = src.secondAdminAddress; // AdditionalBondRegistry - dst.additionalBondRegistryConfig.curveMultiplierCooldown = src + dst.additionalBondRegistryConfig.curveMultiplierReductionCooldown = src .additionalBondRegistryConfig - .curveMultiplierCooldown; + .curveMultiplierReductionCooldown; for (uint256 i; i < src.additionalBondRegistryConfig.boostSteps.length; ++i) { dst.additionalBondRegistryConfig.boostSteps.push(src.additionalBondRegistryConfig.boostSteps[i]); } // NodeOperatorStrikes - dst.strikesCommittee = src.strikesCommittee; - for (uint256 i; i < src.strikesThresholds.length; ++i) { - dst.strikesThresholds.push(src.strikesThresholds[i]); + dst.nodeOperatorStrikesConfig.committee = src.nodeOperatorStrikesConfig.committee; + for (uint256 i; i < src.nodeOperatorStrikesConfig.thresholds.length; ++i) { + dst.nodeOperatorStrikesConfig.thresholds.push(src.nodeOperatorStrikesConfig.thresholds[i]); } // LDO lock boost provider @@ -608,6 +617,18 @@ contract DeploymentHelpers is Test { for (uint256 i; i < src.ldoLockBoostProviderConfig.lockBoostSteps.length; ++i) { dst.ldoLockBoostProviderConfig.lockBoostSteps.push(src.ldoLockBoostProviderConfig.lockBoostSteps[i]); } + + // CustomFeeRegistry + dst.customFeeRegistryConfig.defaultMaxFeeDiscount = src.customFeeRegistryConfig.defaultMaxFeeDiscount; + dst.customFeeRegistryConfig.feeDiscountCutCooldown = src.customFeeRegistryConfig.feeDiscountCutCooldown; + for (uint256 i; i < src.customFeeRegistryConfig.feeDiscountWeightSteps.length; ++i) { + dst.customFeeRegistryConfig.feeDiscountWeightSteps.push( + src.customFeeRegistryConfig.feeDiscountWeightSteps[i] + ); + } + for (uint256 i; i < src.customFeeRegistryConfig.feeModifiers.length; ++i) { + dst.customFeeRegistryConfig.feeModifiers.push(src.customFeeRegistryConfig.feeModifiers[i]); + } } function parseCommonDeployParams(string memory config) internal view returns (CommonDeployParams memory params) { @@ -689,9 +710,6 @@ 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; } @@ -865,6 +883,8 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { ERC20LockBoostProvider public ldoLockBoostProviderImpl; LidoGovernanceLockVault public ldoLockVaultImpl; UpgradeableBeacon public ldoLockVaultBeacon; + CustomFeeRegistry public customFeeRegistry; + CustomFeeRegistry public customFeeRegistryImpl; CuratedGate public curatedGateImpl; address[] public curatedGates; @@ -985,6 +1005,8 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { ldoLockBoostProviderImpl = ERC20LockBoostProvider(deploymentConfig.ldoLockBoostProviderImpl); ldoLockVaultImpl = LidoGovernanceLockVault(deploymentConfig.ldoLockVaultImpl); ldoLockVaultBeacon = UpgradeableBeacon(deploymentConfig.ldoLockVaultBeacon); + customFeeRegistry = CustomFeeRegistry(deploymentConfig.customFeeRegistry); + customFeeRegistryImpl = CustomFeeRegistry(deploymentConfig.customFeeRegistryImpl); curatedGateImpl = CuratedGate(deploymentConfig.curatedGateImpl); curatedGates = deploymentConfig.curatedGates; } diff --git a/test/helpers/StepwiseWeightBoostBehaviour.sol b/test/helpers/StepwiseWeightBoostBehaviour.sol new file mode 100644 index 000000000..0b89fb9bf --- /dev/null +++ b/test/helpers/StepwiseWeightBoostBehaviour.sol @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { Test } from "forge-std/Test.sol"; + +import { IStepwiseWeightBoost, Step } from "src/interfaces/IStepwiseWeightBoost.sol"; + +import { Utilities } from "./Utilities.sol"; + +/// @dev `DEFAULT_ADMIN_ROLE` is a constant of the AccessControl implementation rather than a member of +/// its interface, so the behaviour reads it through this minimal view. +interface IStepwiseAdminRole { + function DEFAULT_ADMIN_ROLE() external view returns (bytes32); +} + +/// @notice Behaviour shared by every StepwiseWeightBoost provider. A suite mixes this in and supplies the +/// provider, its admin, and a domain-valid step function; the checks below then cover the +/// capability itself so provider suites only assert their own domain rules. +abstract contract StepwiseWeightBoostBehaviour is Test, Utilities { + /// @dev Provider under test, already initialized. + function _stepwise() internal view virtual returns (IStepwiseWeightBoost); + + /// @dev Holder of DEFAULT_ADMIN_ROLE on the provider. + function _stepwiseAdmin() internal view virtual returns (address); + + /// @dev `count` domain-valid steps with strictly ascending thresholds and values. + function _stepwiseSteps(uint256 count) internal view virtual returns (Step[] memory); + + function test_setSteps_StoresStepsInOrder() public { + Step[] memory steps = _stepwiseSteps(3); + + vm.prank(_stepwiseAdmin()); + _stepwise().setSteps(steps); + + Step[] memory stored = _stepwise().getSteps(); + assertEq(stored.length, steps.length); + for (uint256 i; i < steps.length; ++i) { + assertEq(stored[i].threshold, steps[i].threshold); + assertEq(stored[i].value, steps[i].value); + } + } + + function test_setSteps_ReplacesPreviousSteps() public { + vm.startPrank(_stepwiseAdmin()); + _stepwise().setSteps(_stepwiseSteps(3)); + _stepwise().setSteps(_stepwiseSteps(1)); + vm.stopPrank(); + + assertEq(_stepwise().getSteps().length, 1); + } + + function test_setSteps_EmitsStepsSet() public { + Step[] memory steps = _stepwiseSteps(2); + + vm.expectEmit(address(_stepwise())); + emit IStepwiseWeightBoost.StepsSet(steps); + vm.prank(_stepwiseAdmin()); + _stepwise().setSteps(steps); + } + + function test_setSteps_AllowsMaxSteps() public { + Step[] memory steps = _stepwiseSteps(_stepwise().MAX_STEPS()); + + vm.prank(_stepwiseAdmin()); + _stepwise().setSteps(steps); + + assertEq(_stepwise().getSteps().length, steps.length); + } + + function test_setSteps_RevertWhen_NotAdmin() public { + address stranger = makeAddr("stepwiseStranger"); + Step[] memory steps = _stepwiseSteps(1); + + expectRoleRevert(stranger, IStepwiseAdminRole(address(_stepwise())).DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + _stepwise().setSteps(steps); + } + + function test_setSteps_RevertWhen_Empty() public { + vm.expectRevert(IStepwiseWeightBoost.InvalidStepCount.selector); + vm.prank(_stepwiseAdmin()); + _stepwise().setSteps(new Step[](0)); + } + + function test_setSteps_RevertWhen_AboveMaxSteps() public { + // The count guard runs before per-step validation, so the payload needs no domain-valid values. + Step[] memory steps = new Step[](_stepwise().MAX_STEPS() + 1); + + vm.expectRevert(IStepwiseWeightBoost.InvalidStepCount.selector); + vm.prank(_stepwiseAdmin()); + _stepwise().setSteps(steps); + } + + function test_setSteps_RevertWhen_ThresholdsNotAscending() public { + Step[] memory steps = _stepwiseSteps(2); + steps[1].threshold = steps[0].threshold; + + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.UnorderedSteps.selector, 1)); + vm.prank(_stepwiseAdmin()); + _stepwise().setSteps(steps); + } + + function test_setSteps_RevertWhen_ValuesNotAscending() public { + Step[] memory steps = _stepwiseSteps(2); + steps[1].value = steps[0].value; + + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.UnorderedSteps.selector, 1)); + vm.prank(_stepwiseAdmin()); + _stepwise().setSteps(steps); + } + + function test_setSteps_RevertWhen_ValueAboveMaxStepValue() public { + Step[] memory steps = _stepwiseSteps(1); + steps[0].value = uint128(_stepwise().MAX_STEP_VALUE() + 1); + + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); + vm.prank(_stepwiseAdmin()); + _stepwise().setSteps(steps); + } + + function test_getInitializedVersion() public view { + assertEq(_stepwise().getInitializedVersion(), 1); + } +} diff --git a/test/unit/AdditionalBondRegistry.t.sol b/test/unit/AdditionalBondRegistry.t.sol index 5f6cc8407..be67f8680 100644 --- a/test/unit/AdditionalBondRegistry.t.sol +++ b/test/unit/AdditionalBondRegistry.t.sol @@ -8,20 +8,17 @@ import { Test } from "forge-std/Test.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { AdditionalBondRegistry } from "src/AdditionalBondRegistry.sol"; -import { IAdditionalBondRegistry, BoostStep } from "src/interfaces/IAdditionalBondRegistry.sol"; -import { MAX_WEIGHT_BOOST_BP } from "src/lib/Constants.sol"; +import { IAdditionalBondRegistry } from "src/interfaces/IAdditionalBondRegistry.sol"; +import { IStepwiseWeightBoost, Step } from "src/interfaces/IStepwiseWeightBoost.sol"; -import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; import { AccountingMock } from "../helpers/mocks/AccountingMock.sol"; -import { MetaRegistryMock } from "../helpers/mocks/MetaRegistryMock.sol"; -import { NodeOperatorManagementProperties } from "src/interfaces/IBaseModule.sol"; +import { CuratedProviderFixture } from "../helpers/CuratedProviderFixture.sol"; +import { StepwiseWeightBoostBehaviour } from "../helpers/StepwiseWeightBoostBehaviour.sol"; import { Utilities } from "../helpers/Utilities.sol"; import { Fixtures } from "../helpers/Fixtures.sol"; -contract AdditionalBondRegistryBaseTest is Test, Utilities, Fixtures { - CuratedMock public module; +contract AdditionalBondRegistryBaseTest is Test, Utilities, Fixtures, CuratedProviderFixture { AdditionalBondRegistry public additionalBondRegistry; - MetaRegistryMock public metaRegistryMock; AccountingMock internal acct; address public admin; @@ -36,28 +33,15 @@ contract AdditionalBondRegistryBaseTest is Test, Utilities, Fixtures { nodeOperatorOwner = nextAddress("NODE_OPERATOR_OWNER"); stranger = nextAddress("STRANGER"); - module = new CuratedMock(); - module.mock_setNodeOperatorsCount(3); - module.mock_setNodeOperatorManagementProperties( - NodeOperatorManagementProperties({ - managerAddress: nodeOperatorOwner, - rewardAddress: nodeOperatorOwner, - extendedManagerPermissions: true - }) - ); - - metaRegistryMock = new MetaRegistryMock(); - module.mock_setMetaRegistry(address(metaRegistryMock)); - - additionalBondRegistry = new AdditionalBondRegistry({ - module: address(module), - curveMultiplierCooldown: CURVE_MULTIPLIER_REDUCTION_COOLDOWN - }); - // Non-empty placeholder; suites that care about the scale replace it via setBoostSteps. - BoostStep[] memory boostSteps = new BoostStep[](1); - boostSteps[0] = BoostStep({ minCurveMultiplier: 100, weightMultiplier: 100 }); + _deployModuleWithMetaRegistryMock(3); + _setNodeOperatorOwner(nodeOperatorOwner); + + additionalBondRegistry = new AdditionalBondRegistry({ module: address(module) }); + // Non-empty placeholder; suites that care about the scale replace it via setSteps. + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: 100, value: 100 }); _enableInitializers(address(additionalBondRegistry)); - additionalBondRegistry.initialize(admin, boostSteps); + additionalBondRegistry.initialize(admin, CURVE_MULTIPLIER_REDUCTION_COOLDOWN, steps); acct = AccountingMock(address(module.ACCOUNTING())); } @@ -68,10 +52,15 @@ 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(), MAX_WEIGHT_BOOST_BP); - assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(), MAX_WEIGHT_BOOST_BP); + assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER(), 9 * uint256(MAX_BP)); + assertEq(additionalBondRegistry.MAX_STEP_VALUE(), 9 * uint256(MAX_BP)); assertEq(additionalBondRegistry.CURVE_MULTIPLIER_STEP(), 100); - assertEq(additionalBondRegistry.CURVE_MULTIPLIER_REDUCTION_COOLDOWN(), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); + assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER_REDUCTION_COOLDOWN(), 365 days); + } + + function test_constructor_RevertWhen_ZeroModule() public { + vm.expectRevert(IStepwiseWeightBoost.ZeroModuleAddress.selector); + new AdditionalBondRegistry(address(0)); } } @@ -80,139 +69,130 @@ contract AdditionalBondRegistryInitializeTest is AdditionalBondRegistryBaseTest assertTrue(additionalBondRegistry.hasRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), admin)); } + function test_initialize_SetsCooldown() public view { + assertEq(additionalBondRegistry.getCurveMultiplierReductionCooldown(), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); + } + + function test_initialize_RevertWhen_ZeroCooldown() public { + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module)); + _enableInitializers(address(tp)); + vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplierReductionCooldown.selector); + tp.initialize(admin, 0, new Step[](0)); + } + + function test_initialize_RevertWhen_CooldownExceedsMax() public { + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module)); + _enableInitializers(address(tp)); + vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplierReductionCooldown.selector); + tp.initialize(admin, 365 days + 1, new Step[](0)); + } + function test_initialize_RevertWhen_ZeroAdmin() public { - AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module)); _enableInitializers(address(tp)); - vm.expectRevert(IAdditionalBondRegistry.ZeroAdminAddress.selector); - tp.initialize(address(0), new BoostStep[](0)); + vm.expectRevert(IStepwiseWeightBoost.ZeroAdminAddress.selector); + tp.initialize(address(0), CURVE_MULTIPLIER_REDUCTION_COOLDOWN, new Step[](0)); } function test_initialize_RevertWhen_DoubleCall() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - additionalBondRegistry.initialize(admin, new BoostStep[](0)); + additionalBondRegistry.initialize(admin, CURVE_MULTIPLIER_REDUCTION_COOLDOWN, new Step[](0)); } - function test_initialize_RevertWhen_EmptyBoostSteps() public { - AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); + function test_initialize_RevertWhen_EmptySteps() public { + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module)); _enableInitializers(address(tp)); - vm.expectRevert(IAdditionalBondRegistry.EmptyBoostSteps.selector); - tp.initialize(admin, new BoostStep[](0)); + vm.expectRevert(IStepwiseWeightBoost.InvalidStepCount.selector); + tp.initialize(admin, CURVE_MULTIPLIER_REDUCTION_COOLDOWN, new Step[](0)); } - function test_initialize_SetsBoostSteps() public { - AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); + function test_initialize_SetsSteps() public { + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module)); _enableInitializers(address(tp)); - BoostStep[] memory boostSteps = new BoostStep[](1); - boostSteps[0] = BoostStep({ minCurveMultiplier: 5_000, weightMultiplier: 2_000 }); - tp.initialize(admin, boostSteps); + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: 5_000, value: 2_000 }); + tp.initialize(admin, CURVE_MULTIPLIER_REDUCTION_COOLDOWN, steps); - assertEq(tp.getBoostSteps().length, 1); - assertEq(tp.getBoostSteps()[0].minCurveMultiplier, 5_000); - assertEq(tp.getBoostSteps()[0].weightMultiplier, 2_000); + assertEq(tp.getSteps().length, 1); + assertEq(tp.getSteps()[0].threshold, 5_000); + assertEq(tp.getSteps()[0].value, 2_000); } } -contract AdditionalBondRegistrySetBoostStepsTest is AdditionalBondRegistryBaseTest { +contract AdditionalBondRegistrySetStepsTest is AdditionalBondRegistryBaseTest, StepwiseWeightBoostBehaviour { uint256 constant T1_BOND = 5_000; uint256 constant T1_WEIGHT = 2_000; uint256 constant T2_BOND = 10_000; uint256 constant T2_WEIGHT = 8_000; - function _boostSteps() internal pure returns (BoostStep[] memory boostSteps) { - boostSteps = new BoostStep[](2); - boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T1_WEIGHT) }); - boostSteps[1] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T2_WEIGHT) }); + function _stepwise() internal view override returns (IStepwiseWeightBoost) { + return IStepwiseWeightBoost(address(additionalBondRegistry)); } - function _setBoostSteps(BoostStep[] memory boostSteps) internal { - vm.prank(admin); - additionalBondRegistry.setBoostSteps(boostSteps); + function _stepwiseAdmin() internal view override returns (address) { + return admin; } - function test_setBoostSteps() public { - BoostStep[] memory boostSteps = _boostSteps(); - vm.expectEmit(address(additionalBondRegistry)); - emit IAdditionalBondRegistry.BoostStepsSet(boostSteps); - _setBoostSteps(boostSteps); - - BoostStep[] memory stored = additionalBondRegistry.getBoostSteps(); - assertEq(stored.length, 2); - assertEq(stored[0].minCurveMultiplier, T1_BOND); - assertEq(stored[0].weightMultiplier, T1_WEIGHT); - assertEq(stored[1].minCurveMultiplier, T2_BOND); - assertEq(stored[1].weightMultiplier, T2_WEIGHT); + function _stepwiseSteps(uint256 count) internal view override returns (Step[] memory steps) { + steps = new Step[](count); + for (uint256 i; i < count; ++i) { + // Curve multiplier increments are 1%-aligned and may start at zero. + steps[i] = Step({ threshold: uint128(i * 100), value: uint128((i + 1) * 100) }); + } } - function test_setBoostSteps_NotifiesProviderConfigChanged() public { - _setBoostSteps(_boostSteps()); - assertEq(metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(), 1); - } - - function test_setBoostSteps_Replaces() public { - _setBoostSteps(_boostSteps()); - BoostStep[] memory boostSteps = new BoostStep[](1); - boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T2_WEIGHT) }); - _setBoostSteps(boostSteps); - assertEq(additionalBondRegistry.getBoostSteps().length, 1); + function _steps() internal pure returns (Step[] memory steps) { + steps = new Step[](2); + steps[0] = Step({ threshold: uint128(T1_BOND), value: uint128(T1_WEIGHT) }); + steps[1] = Step({ threshold: uint128(T2_BOND), value: uint128(T2_WEIGHT) }); } - function test_setBoostSteps_AllowsZeroIncrement() public { - BoostStep[] memory boostSteps = new BoostStep[](1); - boostSteps[0] = BoostStep({ minCurveMultiplier: 0, weightMultiplier: 0 }); - _setBoostSteps(boostSteps); - assertEq(additionalBondRegistry.getBoostSteps()[0].minCurveMultiplier, 0); - } - - function test_setBoostSteps_AllowsMaxIncrement() public { - uint256 maxCurve = additionalBondRegistry.MAX_CURVE_MULTIPLIER(); - uint256 maxWeight = additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(); - BoostStep[] memory boostSteps = new BoostStep[](1); - boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(maxCurve), weightMultiplier: uint128(maxWeight) }); - _setBoostSteps(boostSteps); - assertEq(additionalBondRegistry.getBoostSteps()[0].minCurveMultiplier, maxCurve); + function _setSteps(Step[] memory steps) internal { + vm.prank(admin); + additionalBondRegistry.setSteps(steps); } - function test_setBoostSteps_RevertWhen_NotAdmin() public { - vm.expectRevert(); - vm.prank(stranger); - additionalBondRegistry.setBoostSteps(_boostSteps()); - } + function test_setSteps() public { + Step[] memory steps = _steps(); + vm.expectEmit(address(additionalBondRegistry)); + emit IStepwiseWeightBoost.StepsSet(steps); + _setSteps(steps); - function test_setBoostSteps_RevertWhen_Empty() public { - vm.expectRevert(IAdditionalBondRegistry.EmptyBoostSteps.selector); - _setBoostSteps(new BoostStep[](0)); + Step[] memory stored = additionalBondRegistry.getSteps(); + assertEq(stored.length, 2); + assertEq(stored[0].threshold, T1_BOND); + assertEq(stored[0].value, T1_WEIGHT); + assertEq(stored[1].threshold, T2_BOND); + assertEq(stored[1].value, T2_WEIGHT); } - function test_setBoostSteps_RevertWhen_CurveMulAboveMax() public { - uint256 aboveMax = additionalBondRegistry.MAX_CURVE_MULTIPLIER() + 1; - BoostStep[] memory boostSteps = new BoostStep[](1); - boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(aboveMax), weightMultiplier: uint128(T1_WEIGHT) }); - vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplier.selector); - _setBoostSteps(boostSteps); + function test_setSteps_NotifiesProviderConfigChanged() public { + _setSteps(_steps()); + assertEq(metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(), 1); } - function test_setBoostSteps_RevertWhen_WeightMulAboveMax() public { - uint256 aboveMax = additionalBondRegistry.MAX_WEIGHT_MULTIPLIER() + 1; - BoostStep[] memory boostSteps = new BoostStep[](1); - boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(aboveMax) }); - vm.expectRevert(IAdditionalBondRegistry.InvalidWeightMultiplier.selector); - _setBoostSteps(boostSteps); + function test_setSteps_AllowsZeroStep() public { + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: 0, value: 0 }); + _setSteps(steps); + assertEq(additionalBondRegistry.getSteps()[0].threshold, 0); } - function test_setBoostSteps_RevertWhen_CurveNotAscending() public { - BoostStep[] memory boostSteps = new BoostStep[](2); - boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T1_WEIGHT) }); - boostSteps[1] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T2_WEIGHT) }); - vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplier.selector); - _setBoostSteps(boostSteps); + function test_setSteps_AllowsMaxThresholdAndValue() public { + uint256 maxCurve = additionalBondRegistry.MAX_CURVE_MULTIPLIER(); + uint256 maxWeight = additionalBondRegistry.MAX_STEP_VALUE(); + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: uint128(maxCurve), value: uint128(maxWeight) }); + _setSteps(steps); + assertEq(additionalBondRegistry.getSteps()[0].threshold, maxCurve); } - function test_setBoostSteps_RevertWhen_WeightNotAscending() public { - BoostStep[] memory boostSteps = new BoostStep[](2); - boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T2_WEIGHT) }); - boostSteps[1] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T1_WEIGHT) }); - vm.expectRevert(IAdditionalBondRegistry.InvalidWeightMultiplier.selector); - _setBoostSteps(boostSteps); + function test_setSteps_RevertWhen_ThresholdAboveMax() public { + uint256 aboveMax = additionalBondRegistry.MAX_CURVE_MULTIPLIER() + 1; + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: uint128(aboveMax), value: uint128(T1_WEIGHT) }); + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); + _setSteps(steps); } } @@ -222,19 +202,19 @@ contract AdditionalBondRegistryRequestCurveMultiplierBaseTest is AdditionalBondR uint256 constant T2_BOND = 10_000; uint256 constant T2_WEIGHT = 8_000; - function _setBoostSteps() internal { - BoostStep[] memory boostSteps = new BoostStep[](2); - boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T1_WEIGHT) }); - boostSteps[1] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T2_WEIGHT) }); + function _setSteps() internal { + Step[] memory steps = new Step[](2); + steps[0] = Step({ threshold: uint128(T1_BOND), value: uint128(T1_WEIGHT) }); + steps[1] = Step({ threshold: uint128(T2_BOND), value: uint128(T2_WEIGHT) }); vm.prank(admin); - additionalBondRegistry.setBoostSteps(boostSteps); + additionalBondRegistry.setSteps(steps); } } contract AdditionalBondRegistryRequestCurveMultiplierTest is AdditionalBondRegistryRequestCurveMultiplierBaseTest { function setUp() public override { super.setUp(); - _setBoostSteps(); + _setSteps(); } function test_requestCurveMultiplier_Upgrade_Tier0ToTier1() public { @@ -264,15 +244,15 @@ contract AdditionalBondRegistryRequestCurveMultiplierTest is AdditionalBondRegis uint256 expectedCooldown = block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN; vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); - emit IAdditionalBondRegistry.CurveMultiplierReductionCooldownSet(0, expectedCooldown); - vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); - emit IAdditionalBondRegistry.CurveMultiplierReductionRequested(0, 0); + emit IAdditionalBondRegistry.CurveMultiplierReductionRequested(0, 0, expectedCooldown); vm.prank(nodeOperatorOwner); additionalBondRegistry.requestCurveMultiplier(0, 0); // The bond multiplier stays until apply, so it remains above MAX_BP while the weight already dropped. assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP); + assertEq(additionalBondRegistry.getPendingCurveMultiplier(0), 0); + assertEq(additionalBondRegistry.getCurveMultiplierReductionCooldownUntil(0), expectedCooldown); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 2); } @@ -285,10 +265,11 @@ contract AdditionalBondRegistryRequestCurveMultiplierTest is AdditionalBondRegis vm.prank(nodeOperatorOwner); vm.expectEmit(true, false, false, false, address(additionalBondRegistry)); - emit IAdditionalBondRegistry.CurveMultiplierReductionCooldownRemoved(0); + emit IAdditionalBondRegistry.CurveMultiplierReductionCancelled(0); additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); + assertEq(additionalBondRegistry.getCurveMultiplierReductionCooldownUntil(0), 0); } function test_requestCurveMultiplier_WithinStep() public { @@ -296,15 +277,17 @@ contract AdditionalBondRegistryRequestCurveMultiplierTest is AdditionalBondRegis uint256 within = T1_BOND + additionalBondRegistry.CURVE_MULTIPLIER_STEP(); // 1%-aligned, still first step vm.prank(nodeOperatorOwner); additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); vm.prank(nodeOperatorOwner); additionalBondRegistry.requestCurveMultiplier(0, within); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + within); assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } function test_requestCurveMultiplier_RevertWhen_NotOwner() public { - vm.expectRevert(IAdditionalBondRegistry.SenderIsNotOperatorOwner.selector); + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); vm.prank(stranger); additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); } @@ -366,18 +349,33 @@ contract AdditionalBondRegistryRequestCurveMultiplierTest is AdditionalBondRegis additionalBondRegistry.requestCurveMultiplier(0, 0); assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP); // weight follows new pending (0) - assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); // bond untouched until applyCurveMultiplier + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); // bond untouched until applyCurveMultiplierReduction + } + + function test_requestCurveMultiplier_LowerAgainWithinStepDoesNotNotify() public { + uint256 withinFirstStep = T1_BOND + additionalBondRegistry.CURVE_MULTIPLIER_STEP(); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, withinFirstStep); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); + + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } function test_requestCurveMultiplier_LowerToIntermediateResetsCooldown() public { // A middle step at 7_000: an intermediate value is below the committed multiplier, so it is a lower — // it resets the pending target and weight but never touches the bond in Accounting. - BoostStep[] memory boostSteps = new BoostStep[](3); - boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T1_WEIGHT) }); - boostSteps[1] = BoostStep({ minCurveMultiplier: 7_000, weightMultiplier: 4_000 }); - boostSteps[2] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T2_WEIGHT) }); + Step[] memory steps = new Step[](3); + steps[0] = Step({ threshold: uint128(T1_BOND), value: uint128(T1_WEIGHT) }); + steps[1] = Step({ threshold: 7_000, value: 4_000 }); + steps[2] = Step({ threshold: uint128(T2_BOND), value: uint128(T2_WEIGHT) }); vm.prank(admin); - additionalBondRegistry.setBoostSteps(boostSteps); + additionalBondRegistry.setSteps(steps); vm.prank(nodeOperatorOwner); additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); @@ -392,28 +390,32 @@ contract AdditionalBondRegistryRequestCurveMultiplierTest is AdditionalBondRegis } } -contract AdditionalBondRegistryApplyCurveMultiplierTest is AdditionalBondRegistryRequestCurveMultiplierBaseTest { +contract AdditionalBondRegistryApplyCurveMultiplierReductionTest is + AdditionalBondRegistryRequestCurveMultiplierBaseTest +{ function setUp() public override { super.setUp(); - _setBoostSteps(); + _setSteps(); vm.prank(nodeOperatorOwner); additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); vm.prank(nodeOperatorOwner); additionalBondRegistry.requestCurveMultiplier(0, 0); } - function test_applyCurveMultiplier() public { + function test_applyCurveMultiplierReduction() public { vm.warp(block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN + 1); - vm.expectEmit(true, false, false, false, address(additionalBondRegistry)); - emit IAdditionalBondRegistry.CurveMultiplierReductionCooldownRemoved(0); + vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.CurveMultiplierReductionApplied(0, 0); vm.prank(nodeOperatorOwner); - additionalBondRegistry.applyCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplierReduction(0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP); + assertEq(additionalBondRegistry.getPendingCurveMultiplier(0), 0); + assertEq(additionalBondRegistry.getCurveMultiplierReductionCooldownUntil(0), 0); } - function test_applyCurveMultiplier_SettlesToRequestedNotDefault() public { + function test_applyCurveMultiplierReduction_SettlesToRequestedNotDefault() public { vm.prank(nodeOperatorOwner); additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); vm.prank(nodeOperatorOwner); @@ -422,45 +424,148 @@ contract AdditionalBondRegistryApplyCurveMultiplierTest is AdditionalBondRegistr vm.warp(block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN + 1); vm.prank(nodeOperatorOwner); - additionalBondRegistry.applyCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplierReduction(0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); } - function test_applyCurveMultiplier_RevertWhen_NotOwner() public { + function test_applyCurveMultiplierReduction_RevertWhen_NotOwner() public { vm.warp(block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN + 1); - vm.expectRevert(IAdditionalBondRegistry.SenderIsNotOperatorOwner.selector); + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); vm.prank(stranger); - additionalBondRegistry.applyCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplierReduction(0); } - function test_applyCurveMultiplier_RevertWhen_NoCurveMultiplierReductionCooldown() public { + function test_applyCurveMultiplierReduction_RevertWhen_NoCurveMultiplierReductionCooldown() public { vm.warp(block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN + 1); vm.prank(nodeOperatorOwner); - additionalBondRegistry.applyCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplierReduction(0); vm.expectRevert(IAdditionalBondRegistry.NoCurveMultiplierReductionCooldown.selector); vm.prank(nodeOperatorOwner); - additionalBondRegistry.applyCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplierReduction(0); } - function test_applyCurveMultiplier_RevertWhen_CurveMultiplierReductionCooldownNotElapsed() public { + function test_applyCurveMultiplierReduction_RevertWhen_CurveMultiplierReductionCooldownNotElapsed() public { vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierReductionCooldownNotElapsed.selector); vm.prank(nodeOperatorOwner); - additionalBondRegistry.applyCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplierReduction(0); + } +} + +contract AdditionalBondRegistryCancelCurveMultiplierReductionTest is + AdditionalBondRegistryRequestCurveMultiplierBaseTest +{ + function setUp() public override { + super.setUp(); + _setSteps(); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, 0); + } + + function test_cancelCurveMultiplierReduction() public { + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + vm.expectEmit(true, false, false, false, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.CurveMultiplierReductionCancelled(0); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.cancelCurveMultiplierReduction(0); + + // The weight returns to the multiplier Accounting still holds; the bond was never touched. + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); + assertEq(additionalBondRegistry.getCurveMultiplierReductionCooldownUntil(0), 0); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore + 1); + } + + function test_cancelCurveMultiplierReduction_AfterCooldown() public { + vm.warp(block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN + 1); + + vm.prank(nodeOperatorOwner); + additionalBondRegistry.cancelCurveMultiplierReduction(0); + + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); + assertEq(additionalBondRegistry.getCurveMultiplierReductionCooldownUntil(0), 0); + } + + function test_cancelCurveMultiplierReduction_DoesNotNotifyWhenWithinStep() public { + uint256 withinFirstStep = T1_BOND + additionalBondRegistry.CURVE_MULTIPLIER_STEP(); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, withinFirstStep); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); // pending, same band as current + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + vm.prank(nodeOperatorOwner); + additionalBondRegistry.cancelCurveMultiplierReduction(0); + + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_cancelCurveMultiplierReduction_RevertWhen_NotOwner() public { + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); + vm.prank(stranger); + additionalBondRegistry.cancelCurveMultiplierReduction(0); + } + + function test_cancelCurveMultiplierReduction_RevertWhen_NoPendingReduction() public { + vm.prank(nodeOperatorOwner); + additionalBondRegistry.cancelCurveMultiplierReduction(0); + + vm.expectRevert(IAdditionalBondRegistry.NoCurveMultiplierReductionCooldown.selector); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.cancelCurveMultiplierReduction(0); + } +} + +contract AdditionalBondRegistrySetCurveMultiplierReductionCooldownTest is AdditionalBondRegistryBaseTest { + function test_setCurveMultiplierReductionCooldown() public { + vm.expectEmit(address(additionalBondRegistry)); + emit IAdditionalBondRegistry.CurveMultiplierReductionCooldownSet(30 days); + vm.prank(admin); + additionalBondRegistry.setCurveMultiplierReductionCooldown(30 days); + + assertEq(additionalBondRegistry.getCurveMultiplierReductionCooldown(), 30 days); + } + + function test_setCurveMultiplierReductionCooldown_Max() public { + vm.prank(admin); + additionalBondRegistry.setCurveMultiplierReductionCooldown(365 days); + + assertEq(additionalBondRegistry.getCurveMultiplierReductionCooldown(), 365 days); + } + + function test_setCurveMultiplierReductionCooldown_RevertWhen_NotAdmin() public { + expectRoleRevert(stranger, additionalBondRegistry.DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + additionalBondRegistry.setCurveMultiplierReductionCooldown(30 days); + } + + function test_setCurveMultiplierReductionCooldown_RevertWhen_Zero() public { + vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplierReductionCooldown.selector); + vm.prank(admin); + additionalBondRegistry.setCurveMultiplierReductionCooldown(0); + } + + function test_setCurveMultiplierReductionCooldown_RevertWhen_ExceedsMax() public { + vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplierReductionCooldown.selector); + vm.prank(admin); + additionalBondRegistry.setCurveMultiplierReductionCooldown(365 days + 1); } } contract AdditionalBondRegistryViewsTest is AdditionalBondRegistryRequestCurveMultiplierBaseTest { function setUp() public override { super.setUp(); - _setBoostSteps(); + _setSteps(); } - function test_getBoostSteps() public view { - BoostStep[] memory boostSteps = additionalBondRegistry.getBoostSteps(); - assertEq(boostSteps.length, 2); - assertEq(boostSteps[0].minCurveMultiplier, T1_BOND); - assertEq(boostSteps[0].weightMultiplier, T1_WEIGHT); + function test_getSteps() public view { + Step[] memory steps = additionalBondRegistry.getSteps(); + assertEq(steps.length, 2); + assertEq(steps[0].threshold, T1_BOND); + assertEq(steps[0].value, T1_WEIGHT); } function test_getWeightBoostMultiplierBP_Default() public view { @@ -473,6 +578,27 @@ contract AdditionalBondRegistryViewsTest is AdditionalBondRegistryRequestCurveMu assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); } + function test_getWeightBoostMultiplierBP_BinarySearchBoundaries() public { + uint256 curveStep = additionalBondRegistry.CURVE_MULTIPLIER_STEP(); + + vm.startPrank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND - curveStep); // before first threshold + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP); + + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); // exactly first threshold + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); + + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND - curveStep); // between thresholds + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); + + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); // exactly final threshold + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T2_WEIGHT); + + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND + curveStep); // after final threshold + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T2_WEIGHT); + vm.stopPrank(); + } + function test_getWeightBoostMultiplierBP_DuringDowngradeCooldown() public { vm.prank(nodeOperatorOwner); additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); diff --git a/test/unit/CustomFeeRegistry.t.sol b/test/unit/CustomFeeRegistry.t.sol new file mode 100644 index 000000000..46a51cf3b --- /dev/null +++ b/test/unit/CustomFeeRegistry.t.sol @@ -0,0 +1,962 @@ +// 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 { CustomFeeRegistry } from "src/CustomFeeRegistry.sol"; +import { ICustomFeeRegistry, FeeModifier } from "src/interfaces/ICustomFeeRegistry.sol"; +import { IBondCurve } from "src/interfaces/IBondCurve.sol"; +import { IStepwiseWeightBoost, Step } from "src/interfaces/IStepwiseWeightBoost.sol"; + +import { AccountingMock } from "../helpers/mocks/AccountingMock.sol"; +import { CuratedProviderFixture } from "../helpers/CuratedProviderFixture.sol"; +import { StepwiseWeightBoostBehaviour } from "../helpers/StepwiseWeightBoostBehaviour.sol"; +import { Utilities } from "../helpers/Utilities.sol"; +import { Fixtures } from "../helpers/Fixtures.sol"; + +contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures, CuratedProviderFixture { + CustomFeeRegistry public feeRegistry; + AccountingMock internal _accounting; + + address public admin; + address public nodeOperatorOwner; + address public stranger; + + uint256 internal constant MAX_BP = 10_000; + uint256 internal constant GRANULARITY = 250; + uint256 internal constant BASE_FEE = 8_750; + uint256 internal constant MAX_FEE_DISCOUNT = 6_250; + uint256 internal constant COOLDOWN = 15 days; + + uint256 internal constant NO_ID = 0; + + function setUp() public virtual { + admin = nextAddress("ADMIN"); + nodeOperatorOwner = nextAddress("NODE_OPERATOR_OWNER"); + stranger = nextAddress("STRANGER"); + + _deployModuleWithMetaRegistryMock(3); + _setNodeOperatorOwner(nodeOperatorOwner); + + feeRegistry = new CustomFeeRegistry(address(module)); + _enableInitializers(address(feeRegistry)); + feeRegistry.initialize(admin, MAX_FEE_DISCOUNT, COOLDOWN, _steps()); + + _accounting = AccountingMock(address(module.ACCOUNTING())); + } + + function _steps() internal pure returns (Step[] memory steps) { + // Fee discount thresholds map to weight multiplier increments. + steps = new Step[](4); + steps[0] = Step({ threshold: 1_250, value: 2_000 }); + steps[1] = Step({ threshold: 2_500, value: 5_000 }); + steps[2] = Step({ threshold: 3_750, value: 10_000 }); + steps[3] = Step({ threshold: 6_250, value: 15_000 }); + } + + function _weight(uint256 discount) internal pure returns (uint256) { + if (discount >= 6_250) return 25_000; + if (discount >= 3_750) return 20_000; + if (discount >= 2_500) return 15_000; + if (discount >= 1_250) return 12_000; + return MAX_BP; + } + + function _requestFeeDiscount(uint256 discount) internal { + vm.prank(nodeOperatorOwner); + feeRegistry.requestFeeDiscount(NO_ID, discount); + } + + function _applyFeeDiscountCut() internal { + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeDiscountCut(NO_ID); + } + + function _cancelFeeDiscountCut() internal { + vm.prank(nodeOperatorOwner); + feeRegistry.cancelFeeDiscountCut(NO_ID); + } + + function _assertNoPendingFeeDiscountCut() internal view { + assertEq(feeRegistry.getPendingFeeDiscount(NO_ID), 0); + assertEq(feeRegistry.getFeeDiscountCutCooldownUntil(NO_ID), 0); + } + + function _setFeeModifier(uint256 curveId, uint256 value, bool negative) internal { + vm.prank(admin); + feeRegistry.setFeeModifier(curveId, value, negative); + } +} + +contract CustomFeeRegistryConstructorTest is CustomFeeRegistryBaseTest { + function test_constructor_SetsImmutables() public view { + assertEq(address(feeRegistry.MODULE()), address(module)); + assertEq(address(feeRegistry.ACCOUNTING()), address(module.ACCOUNTING())); + assertEq(address(feeRegistry.META_REGISTRY()), address(metaRegistryMock)); + } + + function test_constructor_Constants() public view { + assertEq(feeRegistry.FEE_GRANULARITY(), GRANULARITY); + assertEq(feeRegistry.BASE_FEE(), BASE_FEE); + assertEq(feeRegistry.MAX_STEPS(), 35); + assertEq(feeRegistry.MAX_STEP_VALUE(), 9 * MAX_BP); + assertEq(feeRegistry.MAX_FEE_DISCOUNT_CUT_COOLDOWN(), 365 days); + } + + function test_constructor_RevertWhen_ZeroModule() public { + vm.expectRevert(IStepwiseWeightBoost.ZeroModuleAddress.selector); + new CustomFeeRegistry(address(0)); + } +} + +contract CustomFeeRegistryInitializeTest is CustomFeeRegistryBaseTest { + function _newRegistry() internal returns (CustomFeeRegistry registry) { + registry = new CustomFeeRegistry(address(module)); + _enableInitializers(address(registry)); + } + + function test_initialize() public view { + assertTrue(feeRegistry.hasRole(feeRegistry.DEFAULT_ADMIN_ROLE(), admin)); + assertEq(feeRegistry.getDefaultMaxFeeDiscount(), MAX_FEE_DISCOUNT); + assertEq(feeRegistry.getFeeDiscountCutCooldown(), COOLDOWN); + Step[] memory actual = feeRegistry.getSteps(); + Step[] memory expected = _steps(); + assertEq(actual.length, expected.length); + for (uint256 i; i < expected.length; ++i) { + assertEq(actual[i].threshold, expected[i].threshold); + assertEq(actual[i].value, expected[i].value); + } + } + + function test_initialize_EmitsEvents() public { + CustomFeeRegistry registry = _newRegistry(); + vm.expectEmit(address(registry)); + emit ICustomFeeRegistry.DefaultMaxFeeDiscountSet(MAX_FEE_DISCOUNT); + vm.expectEmit(address(registry)); + emit ICustomFeeRegistry.FeeDiscountCutCooldownSet(COOLDOWN); + vm.expectEmit(address(registry)); + emit IStepwiseWeightBoost.StepsSet(_steps()); + registry.initialize(admin, MAX_FEE_DISCOUNT, COOLDOWN, _steps()); + } + + function test_initialize_RevertWhen_ZeroAdmin() public { + CustomFeeRegistry registry = _newRegistry(); + vm.expectRevert(IStepwiseWeightBoost.ZeroAdminAddress.selector); + registry.initialize(address(0), MAX_FEE_DISCOUNT, COOLDOWN, _steps()); + } + + function test_initialize_RevertWhen_DoubleCall() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + feeRegistry.initialize(admin, MAX_FEE_DISCOUNT, COOLDOWN, _steps()); + } + + function test_initialize_RevertWhen_ZeroMaxFeeDiscount() public { + CustomFeeRegistry registry = _newRegistry(); + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); + registry.initialize(admin, 0, COOLDOWN, _steps()); + } + + function test_initialize_RevertWhen_MaxFeeDiscountAtOrAboveBaseFee() public { + CustomFeeRegistry registry = _newRegistry(); + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); + registry.initialize(admin, BASE_FEE, COOLDOWN, _steps()); + } + + function test_initialize_RevertWhen_MaxFeeDiscountNotGranularityAligned() public { + CustomFeeRegistry registry = _newRegistry(); + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); + registry.initialize(admin, MAX_FEE_DISCOUNT + 1, COOLDOWN, _steps()); + } + + function test_initialize_RevertWhen_ZeroCooldown() public { + CustomFeeRegistry registry = _newRegistry(); + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscountCutCooldown.selector); + registry.initialize(admin, MAX_FEE_DISCOUNT, 0, _steps()); + } + + function test_initialize_RevertWhen_CooldownExceedsMax() public { + CustomFeeRegistry registry = _newRegistry(); + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscountCutCooldown.selector); + registry.initialize(admin, MAX_FEE_DISCOUNT, 365 days + 1, _steps()); + } +} + +contract CustomFeeRegistryRequestFeeDiscountTest is CustomFeeRegistryBaseTest { + function test_requestFeeDiscount_Raise_AppliesImmediately() public { + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountSet(NO_ID, 3_750); + _requestFeeDiscount(3_750); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(3_750)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 1); + assertEq(metaRegistryMock.lastChangedBoostOperatorId(), NO_ID); + } + + function test_requestFeeDiscount_Raise_ToMaxFeeDiscount() public { + _requestFeeDiscount(MAX_FEE_DISCOUNT); + assertEq(feeRegistry.getFeeDiscount(NO_ID), MAX_FEE_DISCOUNT); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(MAX_FEE_DISCOUNT)); + } + + function test_requestFeeDiscount_Raise_DoesNotNotifyWhenStepValueUnchanged() public { + _requestFeeDiscount(1_250); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _requestFeeDiscount(1_500); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_500); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_250)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_requestFeeDiscount_Cut_SetsPending() public { + _requestFeeDiscount(3_750); + + uint256 cooldownUntil = block.timestamp + COOLDOWN; + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountCutRequested(NO_ID, 2_750, cooldownUntil); + _requestFeeDiscount(2_750); + + // The discount itself is unchanged, but the weight already follows the pending decrease. + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(2_750)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 2); + } + + function test_requestFeeDiscount_Cut_OverwritesPendingAndRestartsCooldown() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); + + vm.warp(block.timestamp + 1 days); + uint256 cooldownUntil = block.timestamp + COOLDOWN; + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountCutRequested(NO_ID, 1_750, cooldownUntil); + _requestFeeDiscount(1_750); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_750)); + + // The old deadline is void: the decrease applies only at the restarted one. + vm.warp(cooldownUntil - 1 days); + vm.expectRevert(ICustomFeeRegistry.FeeDiscountCutCooldownNotElapsed.selector); + _applyFeeDiscountCut(); + + vm.warp(cooldownUntil); + _applyFeeDiscountCut(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_750); + } + + function test_requestFeeDiscount_Cut_DoesNotNotifyWhenFeeDiscountBandUnchanged() public { + _requestFeeDiscount(4_000); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _requestFeeDiscount(3_750); + + assertEq(feeRegistry.getPendingFeeDiscount(NO_ID), 3_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(4_000)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_requestFeeDiscount_Cut_OverwriteDoesNotNotifyWhenFeeDiscountBandUnchanged() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _requestFeeDiscount(2_500); + + assertEq(feeRegistry.getPendingFeeDiscount(NO_ID), 2_500); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(2_750)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_requestFeeDiscount_Cut_RaisingPendingRaisesWeight() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(1_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_750)); + + _requestFeeDiscount(2_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(2_750)); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); + } + + function test_requestFeeDiscount_Raise_CancelsPending() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); + + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountCutCancelled(NO_ID); + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountSet(NO_ID, 4_750); + _requestFeeDiscount(4_750); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 4_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(4_750)); + + vm.warp(block.timestamp + COOLDOWN + 1); + vm.expectRevert(ICustomFeeRegistry.NoPendingFeeDiscountCut.selector); + _applyFeeDiscountCut(); + } + + function test_requestFeeDiscount_RevertWhen_NotOwner() public { + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); + vm.prank(stranger); + feeRegistry.requestFeeDiscount(NO_ID, 3_750); + } + + function test_requestFeeDiscount_RevertWhen_AboveMaxFeeDiscount() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _requestFeeDiscount(MAX_FEE_DISCOUNT + GRANULARITY); + } + + function test_requestFeeDiscount_RevertWhen_NotGranularityAligned() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _requestFeeDiscount(3_750 + 1); + } + + function test_requestFeeDiscount_RevertWhen_AboveTypeCeiling() public { + _setFeeModifier(0, 2_500, true); + // The negative modifier lowers the operator's discount ceiling to 3_750. + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _requestFeeDiscount(4_000); + } + + function test_requestFeeDiscount_RevertWhen_SameFeeDiscount_Unset() public { + vm.expectRevert(ICustomFeeRegistry.SameFeeDiscount.selector); + _requestFeeDiscount(0); + } + + function test_requestFeeDiscount_RevertWhen_SameFeeDiscount_Set() public { + _requestFeeDiscount(3_750); + vm.expectRevert(ICustomFeeRegistry.SameFeeDiscount.selector); + _requestFeeDiscount(3_750); + } + + function test_requestFeeDiscount_RevertWhen_SameFeeDiscountWithPendingCut() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); + + vm.expectRevert(ICustomFeeRegistry.SameFeeDiscount.selector); + _requestFeeDiscount(3_750); + } +} + +contract CustomFeeRegistryCancelFeeDiscountCutTest is CustomFeeRegistryBaseTest { + function setUp() public override { + super.setUp(); + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); + } + + function test_cancelFeeDiscountCut() public { + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountCutCancelled(NO_ID); + _cancelFeeDiscountCut(); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); + _assertNoPendingFeeDiscountCut(); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(3_750)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore + 1); + } + + function test_cancelFeeDiscountCut_AfterCooldown() public { + vm.warp(block.timestamp + COOLDOWN + 1); + _cancelFeeDiscountCut(); + _assertNoPendingFeeDiscountCut(); + } + + function test_cancelFeeDiscountCut_DoesNotNotifyWhenFeeDiscountBandUnchanged() public { + _cancelFeeDiscountCut(); + _requestFeeDiscount(4_000); + _requestFeeDiscount(3_750); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _cancelFeeDiscountCut(); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 4_000); + _assertNoPendingFeeDiscountCut(); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_cancelFeeDiscountCut_WhenCurrentFeeDiscountAboveNewCeiling() public { + _setFeeModifier(0, 4_000, true); // The new ceiling is 2_250. + _cancelFeeDiscountCut(); + + _assertNoPendingFeeDiscountCut(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); + } + + function test_cancelFeeDiscountCut_RevertWhen_NotOwner() public { + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); + vm.prank(stranger); + feeRegistry.cancelFeeDiscountCut(NO_ID); + } + + function test_cancelFeeDiscountCut_RevertWhen_NoPendingCut() public { + _cancelFeeDiscountCut(); + vm.expectRevert(ICustomFeeRegistry.NoPendingFeeDiscountCut.selector); + _cancelFeeDiscountCut(); + } +} + +contract CustomFeeRegistryApplyFeeDiscountCutTest is CustomFeeRegistryBaseTest { + uint256 internal cooldownUntil; + + function setUp() public override { + super.setUp(); + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); + cooldownUntil = block.timestamp + COOLDOWN; + } + + function test_applyFeeDiscountCut() public { + vm.warp(cooldownUntil + 1); + + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountCutApplied(NO_ID, 2_750); + _applyFeeDiscountCut(); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 2_750); + // The weight followed the pending discount already, so no new notification. + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(2_750)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_applyFeeDiscountCut_AtExactDeadline() public { + vm.warp(cooldownUntil); + _applyFeeDiscountCut(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 2_750); + } + + function test_applyFeeDiscountCut_CooldownChangeDoesNotAffectPending() public { + vm.prank(admin); + feeRegistry.setFeeDiscountCutCooldown(COOLDOWN * 2); + + vm.warp(cooldownUntil); + _applyFeeDiscountCut(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 2_750); + } + + function test_applyFeeDiscountCut_PendingSurvivesAnInvalidatingModifier() public { + _setFeeModifier(0, 4_000, true); // The new ceiling is 2_250, the pending discount is 2_750. + vm.warp(cooldownUntil); + + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _applyFeeDiscountCut(); + + // The pending discount is untouched and applies once the ceiling allows it again. + _setFeeModifier(0, 2_500, true); + _applyFeeDiscountCut(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 2_750); + } + + function test_applyFeeDiscountCut_InvalidPendingCanBeNormalizedPermissionlessly() public { + _setFeeModifier(0, 4_000, true); // The new ceiling is 2_250, the pending discount is 2_750. + vm.warp(cooldownUntil); + + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _applyFeeDiscountCut(); + + vm.prank(stranger); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 2_250); + _assertNoPendingFeeDiscountCut(); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(2_250)); + } + + function test_applyFeeDiscountCut_RevertWhen_NotOwner() public { + vm.warp(cooldownUntil + 1); + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); + vm.prank(stranger); + feeRegistry.applyFeeDiscountCut(NO_ID); + } + + function test_applyFeeDiscountCut_RevertWhen_NotElapsed() public { + vm.warp(cooldownUntil - 1); + vm.expectRevert(ICustomFeeRegistry.FeeDiscountCutCooldownNotElapsed.selector); + _applyFeeDiscountCut(); + } + + function test_applyFeeDiscountCut_RevertWhen_NoPending() public { + vm.warp(cooldownUntil + 1); + _applyFeeDiscountCut(); + + vm.expectRevert(ICustomFeeRegistry.NoPendingFeeDiscountCut.selector); + _applyFeeDiscountCut(); + } +} + +contract CustomFeeRegistryNormalizeFeeDiscountsTest is CustomFeeRegistryBaseTest { + function setUp() public override { + super.setUp(); + + // Operator 0 is left above the new ceiling; the other operators remain unset and valid. + _setFeeModifier(0, 2_500, true); + _requestFeeDiscount(3_750); + _setFeeModifier(0, 5_000, true); + } + + function test_normalizeFeeDiscounts() public { + uint256[] memory normalized = feeRegistry.normalizeFeeDiscounts(UintArr(0, 1, 2)); + + assertEq(normalized.length, 1); + assertEq(normalized[0], NO_ID); + assertEq(feeRegistry.getFeeDiscount(0), 1_250); + assertEq(feeRegistry.getFeeDiscount(1), 0); + assertEq(feeRegistry.getFeeDiscount(2), 0); + } + + function test_normalizeFeeDiscounts_SetsFeeDiscountToCeilingAndRestoresEffectiveFee() public { + assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); // max(0, 5_000 - 5_000) + + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountSet(NO_ID, 1_250); + vm.prank(stranger); // permissionless + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_250); + // The effective fee is back at the floor implied by the default discount ceiling. + assertEq(feeRegistry.getEffectiveFee(NO_ID), BASE_FEE - MAX_FEE_DISCOUNT); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_250)); + } + + function test_normalizeFeeDiscounts_ClampsToZeroWhenTypeGrantsNoDiscount() public { + _setFeeModifier(0, MAX_FEE_DISCOUNT, true); // The ceiling drops to zero. + + vm.prank(stranger); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 0); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP); + } + + function test_normalizeFeeDiscounts_CancelsPendingCut() public { + _requestFeeDiscount(750); + + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountCutCancelled(NO_ID); + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountSet(NO_ID, 1_250); + vm.prank(stranger); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_250); + _assertNoPendingFeeDiscountCut(); + } + + function test_normalizeFeeDiscounts_CancelsExpiredPendingCut() public { + _requestFeeDiscount(750); + vm.warp(block.timestamp + COOLDOWN + 1); + + vm.prank(stranger); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_250); + _assertNoPendingFeeDiscountCut(); + } + + function test_normalizeFeeDiscounts_DoesNotNotifyWhenPendingFeeDiscountEqualsCeiling() public { + _requestFeeDiscount(1_250); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + vm.prank(stranger); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); + + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_250); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_normalizeFeeDiscounts_SkipsDuplicatesAndValidOperators() public { + uint256[] memory nodeOperatorIds = new uint256[](4); + nodeOperatorIds[0] = 0; + nodeOperatorIds[1] = 0; + nodeOperatorIds[2] = 2; + nodeOperatorIds[3] = 1; + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + uint256[] memory normalized = feeRegistry.normalizeFeeDiscounts(nodeOperatorIds); + + assertEq(normalized.length, 1); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore + 1); + } + + function test_normalizeFeeDiscounts_OffChainCallPreviewsWithoutChangingState() public { + uint256 snapshot = vm.snapshotState(); + uint256[] memory normalized = feeRegistry.normalizeFeeDiscounts(UintArr(0, 1, 2)); + vm.revertToState(snapshot); + + assertEq(normalized.length, 1); + assertEq(normalized[0], NO_ID); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); + } + + function test_normalizeFeeDiscounts_EmptyArray() public { + assertEq(feeRegistry.normalizeFeeDiscounts(UintArr()).length, 0); + } + + function test_normalizeFeeDiscounts_NoFeeDiscountsToNormalize() public { + uint256[] memory nodeOperatorIds = UintArr(0, 1); + feeRegistry.normalizeFeeDiscounts(nodeOperatorIds); + + assertEq(feeRegistry.normalizeFeeDiscounts(nodeOperatorIds).length, 0); + } +} + +contract CustomFeeRegistrySetDefaultMaxFeeDiscountTest is CustomFeeRegistryBaseTest { + function test_setDefaultMaxFeeDiscount() public { + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.DefaultMaxFeeDiscountSet(6_500); + vm.prank(admin); + feeRegistry.setDefaultMaxFeeDiscount(6_500); + + assertEq(feeRegistry.getDefaultMaxFeeDiscount(), 6_500); + } + + function test_setDefaultMaxFeeDiscount_UsesLastWeightStepAboveLastThreshold() public { + vm.prank(admin); + feeRegistry.setDefaultMaxFeeDiscount(7_750); + + _requestFeeDiscount(7_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_750)); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 25_000); + } + + function test_setDefaultMaxFeeDiscount_RevertWhen_NotAdmin() public { + expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + feeRegistry.setDefaultMaxFeeDiscount(6_500); + } + + function test_setDefaultMaxFeeDiscount_RevertWhen_Zero() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); + vm.prank(admin); + feeRegistry.setDefaultMaxFeeDiscount(0); + } + + function test_setDefaultMaxFeeDiscount_RevertWhen_NotAboveCurrent() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); + vm.prank(admin); + feeRegistry.setDefaultMaxFeeDiscount(MAX_FEE_DISCOUNT); + } + + function test_setDefaultMaxFeeDiscount_RevertWhen_AtOrAboveBaseFee() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); + vm.prank(admin); + feeRegistry.setDefaultMaxFeeDiscount(BASE_FEE); + } + + function test_setDefaultMaxFeeDiscount_RevertWhen_NotGranularityAligned() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); + vm.prank(admin); + feeRegistry.setDefaultMaxFeeDiscount(6_500 + 1); + } +} + +contract CustomFeeRegistrySetFeeModifierTest is CustomFeeRegistryBaseTest { + function test_setFeeModifier_Positive() public { + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeModifierSet(0, 1_250, false); + _setFeeModifier(0, 1_250, false); + + FeeModifier memory feeModifier = feeRegistry.getFeeModifier(0); + assertEq(feeModifier.value, 1_250); + assertFalse(feeModifier.negative); + // The modifier tops the effective fee up to 100% for an operator without a discount. + assertEq(feeRegistry.getEffectiveFee(NO_ID), MAX_BP); + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), MAX_FEE_DISCOUNT); + } + + function test_setFeeModifier_Negative() public { + _setFeeModifier(0, 2_500, true); + + FeeModifier memory feeModifier = feeRegistry.getFeeModifier(0); + assertEq(feeModifier.value, 2_500); + assertTrue(feeModifier.negative); + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), MAX_FEE_DISCOUNT - 2_500); + } + + function test_setFeeModifier_NegativeAtDefaultCeilingLeavesNoDiscount() public { + _setFeeModifier(0, MAX_FEE_DISCOUNT, true); + + // The ceiling is exactly zero: the type grants no discount at all. + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), 0); + assertEq(feeRegistry.getEffectiveFee(NO_ID), BASE_FEE - MAX_FEE_DISCOUNT); + + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _requestFeeDiscount(GRANULARITY); + } + + function test_setFeeModifier_NegativeAtDefaultCeilingSurvivesCeilingRaise() public { + _setFeeModifier(0, MAX_FEE_DISCOUNT, true); + + vm.prank(admin); + feeRegistry.setDefaultMaxFeeDiscount(MAX_FEE_DISCOUNT + GRANULARITY); + + // The default ceiling only ever grows, so the per-type ceiling can only widen from zero. + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), GRANULARITY); + _requestFeeDiscount(GRANULARITY); + assertEq(feeRegistry.getFeeDiscount(NO_ID), GRANULARITY); + } + + function test_setFeeModifier_ZeroWithAnySign() public { + _setFeeModifier(0, 0, true); + assertEq(feeRegistry.getEffectiveFee(NO_ID), BASE_FEE); + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), MAX_FEE_DISCOUNT); + + _setFeeModifier(0, 0, false); + assertEq(feeRegistry.getEffectiveFee(NO_ID), BASE_FEE); + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), MAX_FEE_DISCOUNT); + } + + function test_setFeeModifier_DoesNotNotifyAndDoesNotMoveWeight() public { + _requestFeeDiscount(3_750); + uint256 weightBefore = feeRegistry.getWeightBoostMultiplierBP(NO_ID); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _setFeeModifier(0, 1_250, false); + + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), weightBefore); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_setFeeModifier_SameFeeDiscountSameWeightAcrossTypes() public { + _setFeeModifier(0, 1_250, false); + _accounting.setBondCurve(1, 1); // Add curve 1 without moving NO_ID from curve 0. + _setFeeModifier(1, 2_500, true); + _requestFeeDiscount(3_750); + + uint256 weightOnTypeA = feeRegistry.getWeightBoostMultiplierBP(NO_ID); + assertEq(feeRegistry.getEffectiveFee(NO_ID), 6_250); + + // Moving the operator to another type shifts only the effective fee, never the weight. + _accounting.setBondCurve(NO_ID, 1); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), weightOnTypeA); + assertEq(feeRegistry.getEffectiveFee(NO_ID), 2_500); + } + + function test_setFeeModifier_RevertWhen_NotAdmin() public { + expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + feeRegistry.setFeeModifier(0, 1_250, false); + } + + function test_setFeeModifier_RevertWhen_PositiveAboveMax() public { + // MAX_BP - BASE_FEE = 1_250 is the largest positive modifier. + vm.expectRevert(ICustomFeeRegistry.InvalidFeeModifier.selector); + _setFeeModifier(0, 1_250 + GRANULARITY, false); + } + + function test_setFeeModifier_RevertWhen_NegativeAboveMax() public { + // The default discount ceiling is the largest negative modifier. + vm.expectRevert(ICustomFeeRegistry.InvalidFeeModifier.selector); + _setFeeModifier(0, MAX_FEE_DISCOUNT + GRANULARITY, true); + } + + function test_setFeeModifier_RevertWhen_NotGranularityAligned() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeModifier.selector); + _setFeeModifier(0, 100, false); + } + + function test_setFeeModifier_RevertWhen_CurveDoesNotExist() public { + vm.expectRevert(IBondCurve.InvalidBondCurveId.selector); + _setFeeModifier(1, 0, false); + } +} + +contract CustomFeeRegistrySetStepsTest is CustomFeeRegistryBaseTest, StepwiseWeightBoostBehaviour { + function _setSteps(Step[] memory steps) internal { + vm.prank(admin); + feeRegistry.setSteps(steps); + } + + function _stepwise() internal view override returns (IStepwiseWeightBoost) { + return IStepwiseWeightBoost(address(feeRegistry)); + } + + function _stepwiseAdmin() internal view override returns (address) { + return admin; + } + + function _stepwiseSteps(uint256 count) internal view override returns (Step[] memory steps) { + steps = new Step[](count); + for (uint256 i; i < count; ++i) { + // Fee discounts are FEE_GRANULARITY-aligned and stay below BASE_FEE. + steps[i] = Step({ threshold: uint128(i * GRANULARITY), value: uint128((i + 1) * 100) }); + } + } + + function test_setSteps_ReplacesStepsAndNotifies() public { + // Fee discount thresholds map to weight multiplier increments. + Step[] memory steps = new Step[](2); + steps[0] = Step({ threshold: 0, value: 0 }); + steps[1] = Step({ threshold: 5_000, value: 9_000 }); + + vm.expectEmit(address(feeRegistry)); + emit IStepwiseWeightBoost.StepsSet(steps); + _setSteps(steps); + + Step[] memory stored = feeRegistry.getSteps(); + assertEq(stored.length, 2); + assertEq(stored[0].threshold, 0); + assertEq(stored[0].value, 0); + assertEq(stored[1].threshold, 5_000); + assertEq(stored[1].value, 9_000); + assertEq(metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(), 1); + } + + function test_setSteps_ChangesFeeDiscountWeights() public { + _requestFeeDiscount(3_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 20_000); + + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: 3_750, value: 7_000 }); + _setSteps(steps); + + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 17_000); + } + + function test_setSteps_AllowsMaximumValues() public { + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: uint128(BASE_FEE - GRANULARITY), value: uint128(feeRegistry.MAX_STEP_VALUE()) }); + _setSteps(steps); + + assertEq(feeRegistry.getSteps()[0].threshold, BASE_FEE - GRANULARITY); + } + + function test_setSteps_RevertWhen_FeeDiscountAtBaseFee() public { + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: uint128(BASE_FEE), value: 1 }); + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); + _setSteps(steps); + } + + function test_setSteps_RevertWhen_FeeDiscountNotAligned() public { + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: 1, value: 1 }); + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); + _setSteps(steps); + } +} + +contract CustomFeeRegistrySetFeeDiscountCutCooldownTest is CustomFeeRegistryBaseTest { + function test_setFeeDiscountCutCooldown() public { + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeDiscountCutCooldownSet(30 days); + vm.prank(admin); + feeRegistry.setFeeDiscountCutCooldown(30 days); + + assertEq(feeRegistry.getFeeDiscountCutCooldown(), 30 days); + } + + function test_setFeeDiscountCutCooldown_Max() public { + vm.prank(admin); + feeRegistry.setFeeDiscountCutCooldown(365 days); + + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); + + assertEq(feeRegistry.getFeeDiscountCutCooldown(), 365 days); + assertEq(feeRegistry.getFeeDiscountCutCooldownUntil(NO_ID), block.timestamp + 365 days); + } + + function test_setFeeDiscountCutCooldown_RevertWhen_NotAdmin() public { + expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + feeRegistry.setFeeDiscountCutCooldown(30 days); + } + + function test_setFeeDiscountCutCooldown_RevertWhen_Zero() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscountCutCooldown.selector); + vm.prank(admin); + feeRegistry.setFeeDiscountCutCooldown(0); + } + + function test_setFeeDiscountCutCooldown_RevertWhen_ExceedsMax() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscountCutCooldown.selector); + vm.prank(admin); + feeRegistry.setFeeDiscountCutCooldown(365 days + 1); + } +} + +contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { + function test_getFeeDiscount_UnsetIsZero() public view { + assertEq(feeRegistry.getFeeDiscount(NO_ID), 0); + } + + function test_getPendingFeeDiscountAndCooldownUntil() public { + assertEq(feeRegistry.getPendingFeeDiscount(NO_ID), 0); + assertEq(feeRegistry.getFeeDiscountCutCooldownUntil(NO_ID), 0); + + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); + + assertEq(feeRegistry.getPendingFeeDiscount(NO_ID), 2_750); + assertEq(feeRegistry.getFeeDiscountCutCooldownUntil(NO_ID), block.timestamp + COOLDOWN); + } + + function test_getWeightBoostMultiplierBP_UnsetIsOne() public view { + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP); + } + + function test_getWeightBoostMultiplierBP_StepsAndBands() public { + _requestFeeDiscount(GRANULARITY); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP); + + _requestFeeDiscount(1_250); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 12_000); + + _requestFeeDiscount(1_500); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 12_000); + + _requestFeeDiscount(MAX_FEE_DISCOUNT); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 25_000); + } + + function test_getWeightBoostMultiplierBP_BinarySearchAtMaxSteps() public { + uint256 stepsCount = feeRegistry.MAX_STEPS(); + Step[] memory steps = new Step[](stepsCount); + for (uint256 i; i < stepsCount; ++i) { + steps[i] = Step({ threshold: uint128(i * GRANULARITY), value: uint128(i * 1_000) }); + } + vm.prank(admin); + feeRegistry.setSteps(steps); + + _requestFeeDiscount(4_250); // Reaches step 17. + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP + 17_000); + + vm.prank(admin); + feeRegistry.setDefaultMaxFeeDiscount(BASE_FEE - GRANULARITY); + _requestFeeDiscount(BASE_FEE - GRANULARITY); // Reaches the final step 34. + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP + 34_000); + } + + function test_getEffectiveFee_NoModifier() public { + _requestFeeDiscount(3_750); + assertEq(feeRegistry.getEffectiveFee(NO_ID), 5_000); + } + + function test_getEffectiveFee_ClampsAtZero() public { + _setFeeModifier(0, 2_500, true); + _requestFeeDiscount(3_750); + _setFeeModifier(0, 5_500, true); + + assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); + } + + function test_getEffectiveFee_PendingCutIsExcluded() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); + assertEq(feeRegistry.getEffectiveFee(NO_ID), 5_000); + } + + function test_getMaxFeeDiscount_Default() public view { + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), MAX_FEE_DISCOUNT); + } +} diff --git a/test/unit/ERC20LockBoostProvider.t.sol b/test/unit/ERC20LockBoostProvider.t.sol index 26fef2b84..08e4ca9f1 100644 --- a/test/unit/ERC20LockBoostProvider.t.sol +++ b/test/unit/ERC20LockBoostProvider.t.sol @@ -16,13 +16,14 @@ import { IAragonVotingLockVault } from "src/interfaces/IAragonVotingLockVault.so import { IERC20LockBoostProvider } from "src/interfaces/IERC20LockBoostProvider.sol"; import { IERC20LockVault } from "src/interfaces/IERC20LockVault.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; +import { IStepwiseWeightBoost, Step } from "src/interfaces/IStepwiseWeightBoost.sol"; import { ISnapshotDelegationLockVault } from "src/interfaces/ISnapshotDelegationLockVault.sol"; import { MetaRegistry } from "src/MetaRegistry.sol"; import { NodeOperator, NodeOperatorManagementProperties } from "src/interfaces/IBaseModule.sol"; -import { MAX_WEIGHT_BOOST_BP } from "src/lib/Constants.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; import { StakingRouterMock } from "../helpers/mocks/StakingRouterMock.sol"; +import { StepwiseWeightBoostBehaviour } from "../helpers/StepwiseWeightBoostBehaviour.sol"; import { ERC20Testable } from "../helpers/ERCTestable.sol"; import { Utilities } from "../helpers/Utilities.sol"; import { Fixtures } from "../helpers/Fixtures.sol"; @@ -84,7 +85,7 @@ contract ERC20LockBoostProviderForTest is ERC20LockBoostProvider { } } -contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { +contract ERC20LockBoostProviderBaseTest is Test, Utilities, Fixtures { CuratedMock public module; StakingRouterMock public stakingRouter; MetaRegistry public registry; @@ -98,7 +99,6 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { address public admin; address public groupManager; address public bondCurveWeightManager; - address public lockPeriodManager; address public nodeOperatorOwner; address public receiver; address public votingDelegate; @@ -114,8 +114,8 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { uint256 internal constant LOCK_PERIOD = 30 days; uint128 internal constant STEP_1_AMOUNT = 100 ether; uint128 internal constant STEP_2_AMOUNT = 200 ether; - uint32 internal constant STEP_1_WEIGHT_BOOST_BP = 1_000; - uint32 internal constant STEP_2_WEIGHT_BOOST_BP = 1_500; + uint128 internal constant STEP_1_WEIGHT_BOOST_BP = 1_000; + uint128 internal constant STEP_2_WEIGHT_BOOST_BP = 1_500; uint256 internal constant STEP_1_MULTIPLIER_BP = MAX_BP + STEP_1_WEIGHT_BOOST_BP; uint256 internal constant STEP_2_MULTIPLIER_BP = MAX_BP + STEP_2_WEIGHT_BOOST_BP; bytes32 internal constant SNAPSHOT_ALL_SPACES = bytes32(0); @@ -124,7 +124,6 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { 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"); @@ -145,7 +144,7 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { modules[0] = address(module); stakingRouter.setModules(modules); - registry = new MetaRegistry(address(module), address(0)); + registry = new MetaRegistry(address(module)); module.mock_setMetaRegistry(address(registry)); _enableInitializers(address(registry)); registry.initialize(admin); @@ -170,12 +169,10 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { provider = new ERC20LockBoostProvider(address(module), address(token), address(vaultBeacon), MIN_LOCK_PERIOD); assertEq(address(provider), expectedProvider); _enableInitializers(address(provider)); - provider.initialize(admin, LOCK_PERIOD); + provider.initialize(admin, LOCK_PERIOD, _defaultSteps()); - vm.startPrank(admin); - provider.grantRole(provider.SET_LOCK_PERIOD_ROLE(), lockPeriodManager); + vm.prank(admin); registry.addWeightBoostProvider(provider, IMetaRegistry.WeightBoostProviderMode.MaxPerGroup); - vm.stopPrank(); vm.prank(bondCurveWeightManager); registry.setBondCurveWeight(0, BASE_WEIGHT); @@ -193,9 +190,11 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { function _setDefaultSteps() internal { vm.prank(admin); - provider.setLockBoostSteps( - _steps(STEP_1_AMOUNT, STEP_1_WEIGHT_BOOST_BP, STEP_2_AMOUNT, STEP_2_WEIGHT_BOOST_BP) - ); + provider.setSteps(_defaultSteps()); + } + + function _defaultSteps() internal pure returns (Step[] memory) { + return _steps(STEP_1_AMOUNT, STEP_1_WEIGHT_BOOST_BP, STEP_2_AMOUNT, STEP_2_WEIGHT_BOOST_BP); } function _mintAndApprove(uint256 amount) internal { @@ -237,24 +236,21 @@ contract ERC20LockBoostProviderBaseTest is Utilities, Fixtures { function _steps( uint128 amount0, - uint32 weightBoost0, + uint128 weightBoost0, uint128 amount1, - uint32 weightBoost1 - ) internal pure returns (IERC20LockBoostProvider.LockBoostStep[] memory steps) { - steps = new IERC20LockBoostProvider.LockBoostStep[](2); - steps[0] = IERC20LockBoostProvider.LockBoostStep({ minAmount: amount0, weightBoostBP: weightBoost0 }); - steps[1] = IERC20LockBoostProvider.LockBoostStep({ minAmount: amount1, weightBoostBP: weightBoost1 }); + uint128 weightBoost1 + ) internal pure returns (Step[] memory steps) { + steps = new Step[](2); + steps[0] = Step({ threshold: amount0, value: weightBoost0 }); + steps[1] = Step({ threshold: amount1, value: weightBoost1 }); } - function _steps1( - uint128 amount, - uint32 weightBoost - ) internal pure returns (IERC20LockBoostProvider.LockBoostStep[] memory steps) { - steps = new IERC20LockBoostProvider.LockBoostStep[](1); - steps[0] = IERC20LockBoostProvider.LockBoostStep({ minAmount: amount, weightBoostBP: weightBoost }); + function _steps1(uint128 amount, uint128 weightBoost) internal pure returns (Step[] memory steps) { + steps = new Step[](1); + steps[0] = Step({ threshold: amount, value: weightBoost }); } - function _steps0() internal pure returns (IERC20LockBoostProvider.LockBoostStep[] memory steps) {} + function _steps0() internal pure returns (Step[] memory steps) {} } contract ERC20LockBoostProviderConstructorTest is ERC20LockBoostProviderBaseTest { @@ -268,13 +264,13 @@ contract ERC20LockBoostProviderConstructorTest is ERC20LockBoostProviderBaseTest } function test_constructor_RevertWhen_ZeroAddresses() public { - vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); + vm.expectRevert(IStepwiseWeightBoost.ZeroModuleAddress.selector); new ERC20LockBoostProvider(address(0), address(token), address(vaultBeacon), MIN_LOCK_PERIOD); - vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); + vm.expectRevert(IERC20LockBoostProvider.ZeroTokenAddress.selector); new ERC20LockBoostProvider(address(module), address(0), address(vaultBeacon), MIN_LOCK_PERIOD); - vm.expectRevert(IERC20LockBoostProvider.ZeroAddress.selector); + vm.expectRevert(IERC20LockBoostProvider.ZeroVaultBeaconAddress.selector); new ERC20LockBoostProvider(address(module), address(token), address(0), MIN_LOCK_PERIOD); } @@ -288,10 +284,32 @@ contract ERC20LockBoostProviderConstructorTest is ERC20LockBoostProviderBaseTest } contract ERC20LockBoostProviderInitializeTest is ERC20LockBoostProviderBaseTest { - function test_initialize_SetsAdminAndLockPeriod() public view { + function test_initialize_SetsAdminLockPeriodAndSteps() public view { assertEq(provider.getInitializedVersion(), 1); assertEq(provider.getLockPeriod(), LOCK_PERIOD); assertTrue(provider.hasRole(provider.DEFAULT_ADMIN_ROLE(), admin)); + Step[] memory steps = provider.getSteps(); + assertEq(steps.length, 2); + assertEq(steps[0].threshold, STEP_1_AMOUNT); + assertEq(steps[0].value, STEP_1_WEIGHT_BOOST_BP); + assertEq(steps[1].threshold, STEP_2_AMOUNT); + assertEq(steps[1].value, STEP_2_WEIGHT_BOOST_BP); + } + + function test_initialize_DoesNotNotifyProviderConfigChanged() public { + ERC20LockBoostProvider p = new ERC20LockBoostProvider( + address(module), + address(token), + address(vaultBeacon), + MIN_LOCK_PERIOD + ); + _enableInitializers(address(p)); + + expectNoCall( + address(registry), + abi.encodeWithSelector(IMetaRegistry.notifyWeightBoostProviderConfigChanged.selector) + ); + p.initialize(admin, LOCK_PERIOD, _defaultSteps()); } function test_initialize_RevertWhen_ZeroAdmin() public { @@ -303,8 +321,8 @@ contract ERC20LockBoostProviderInitializeTest is ERC20LockBoostProviderBaseTest ); _enableInitializers(address(p)); - vm.expectRevert(IERC20LockBoostProvider.ZeroAdminAddress.selector); - p.initialize(address(0), LOCK_PERIOD); + vm.expectRevert(IStepwiseWeightBoost.ZeroAdminAddress.selector); + p.initialize(address(0), LOCK_PERIOD, _defaultSteps()); } function test_initialize_RevertWhen_InvalidLockPeriod() public { @@ -317,86 +335,89 @@ contract ERC20LockBoostProviderInitializeTest is ERC20LockBoostProviderBaseTest _enableInitializers(address(p)); vm.expectRevert(IERC20LockBoostProvider.InvalidLockPeriod.selector); - p.initialize(admin, MIN_LOCK_PERIOD - 1); + p.initialize(admin, MIN_LOCK_PERIOD - 1, _defaultSteps()); } function test_initialize_RevertWhen_DoubleCall() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - provider.initialize(admin, LOCK_PERIOD); + provider.initialize(admin, LOCK_PERIOD, _defaultSteps()); } } -contract ERC20LockBoostProviderAdminTest is ERC20LockBoostProviderBaseTest { +contract ERC20LockBoostProviderAdminTest is ERC20LockBoostProviderBaseTest, StepwiseWeightBoostBehaviour { + function _stepwise() internal view override returns (IStepwiseWeightBoost) { + return IStepwiseWeightBoost(address(provider)); + } + + function _stepwiseAdmin() internal view override returns (address) { + return admin; + } + + function _stepwiseSteps(uint256 count) internal view override returns (Step[] memory steps) { + steps = new Step[](count); + for (uint256 i; i < count; ++i) { + // Locked amounts must be nonzero. + steps[i] = Step({ threshold: uint128((i + 1) * 1 ether), value: uint128((i + 1) * 100) }); + } + } + function test_setLockPeriod() public { uint256 newLockPeriod = LOCK_PERIOD + 1 days; vm.expectEmit(address(provider)); emit IERC20LockBoostProvider.LockPeriodSet(newLockPeriod); - vm.prank(lockPeriodManager); + vm.prank(admin); provider.setLockPeriod(newLockPeriod); assertEq(provider.getLockPeriod(), newLockPeriod); } - function test_setLockPeriod_RevertWhen_NoRole() public { - expectRoleRevert(stranger, provider.SET_LOCK_PERIOD_ROLE()); + function test_setLockPeriod_RevertWhen_NotAdmin() public { + expectRoleRevert(stranger, provider.DEFAULT_ADMIN_ROLE()); vm.prank(stranger); provider.setLockPeriod(LOCK_PERIOD + 1 days); } function test_setLockPeriod_RevertWhen_SamePeriod() public { - vm.prank(lockPeriodManager); + vm.prank(admin); vm.expectRevert(IERC20LockBoostProvider.SameLockPeriod.selector); provider.setLockPeriod(LOCK_PERIOD); } function test_setLockPeriod_RevertWhen_InvalidPeriod() public { - vm.prank(lockPeriodManager); + vm.prank(admin); 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_WEIGHT_BOOST_BP, - STEP_2_AMOUNT, - STEP_2_WEIGHT_BOOST_BP - ); + function test_setSteps() public { + Step[] memory steps = _steps(STEP_1_AMOUNT, STEP_1_WEIGHT_BOOST_BP, STEP_2_AMOUNT, STEP_2_WEIGHT_BOOST_BP); vm.expectEmit(address(provider)); - emit IERC20LockBoostProvider.LockBoostStepsSet(steps); + emit IStepwiseWeightBoost.StepsSet(steps); vm.expectCall( address(registry), abi.encodeWithSelector(IMetaRegistry.notifyWeightBoostProviderConfigChanged.selector) ); vm.prank(admin); - provider.setLockBoostSteps(steps); + provider.setSteps(steps); - IERC20LockBoostProvider.LockBoostStep[] memory stored = provider.getLockBoostSteps(); + Step[] memory stored = provider.getSteps(); assertEq(stored.length, 2); - assertEq(stored[0].minAmount, STEP_1_AMOUNT); - assertEq(stored[0].weightBoostBP, STEP_1_WEIGHT_BOOST_BP); - assertEq(stored[1].minAmount, STEP_2_AMOUNT); - assertEq(stored[1].weightBoostBP, STEP_2_WEIGHT_BOOST_BP); - } - - function test_setLockBoostSteps_RevertWhen_EmptySteps() public { - _setDefaultSteps(); - - vm.prank(admin); - vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); - provider.setLockBoostSteps(_steps0()); + assertEq(stored[0].threshold, STEP_1_AMOUNT); + assertEq(stored[0].value, STEP_1_WEIGHT_BOOST_BP); + assertEq(stored[1].threshold, STEP_2_AMOUNT); + assertEq(stored[1].value, STEP_2_WEIGHT_BOOST_BP); } - function test_setLockBoostSteps_DoesNotRefreshExistingWeights() public { + function test_setSteps_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, 2_000)); + provider.setSteps(_steps1(STEP_2_AMOUNT + 1, 2_000)); assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), MAX_BP); assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), _weight(STEP_2_MULTIPLIER_BP)); @@ -405,9 +426,9 @@ contract ERC20LockBoostProviderAdminTest is ERC20LockBoostProviderBaseTest { assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), BASE_WEIGHT); } - function test_setLockBoostSteps_AllowsZeroWeightBoost() public { + function test_setSteps_AllowsZeroWeightBoost() public { vm.prank(admin); - provider.setLockBoostSteps(_steps1(STEP_1_AMOUNT, 0)); + provider.setSteps(_steps1(STEP_1_AMOUNT, 0)); _lock(STEP_1_AMOUNT); @@ -415,42 +436,11 @@ contract ERC20LockBoostProviderAdminTest is ERC20LockBoostProviderBaseTest { assertEq(registry.getNodeOperatorWeight(NODE_OPERATOR_ID), BASE_WEIGHT); } - function test_setLockBoostSteps_RevertWhen_NoRole() public { - expectRoleRevert(stranger, provider.DEFAULT_ADMIN_ROLE()); - vm.prank(stranger); - provider.setLockBoostSteps(_steps1(STEP_1_AMOUNT, STEP_1_WEIGHT_BOOST_BP)); - } - - function test_setLockBoostSteps_RevertWhen_InvalidSteps() public { - vm.startPrank(admin); - - vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); - provider.setLockBoostSteps(_steps1(0, STEP_1_WEIGHT_BOOST_BP)); - - vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); - provider.setLockBoostSteps(_steps1(STEP_1_AMOUNT, uint32(MAX_WEIGHT_BOOST_BP + 1))); - - vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); - provider.setLockBoostSteps( - _steps(STEP_1_AMOUNT, STEP_1_WEIGHT_BOOST_BP, STEP_1_AMOUNT, STEP_2_WEIGHT_BOOST_BP) - ); - - vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); - provider.setLockBoostSteps( - _steps(STEP_1_AMOUNT, STEP_2_WEIGHT_BOOST_BP, STEP_2_AMOUNT, STEP_1_WEIGHT_BOOST_BP) - ); - - vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); - provider.setLockBoostSteps( - _steps(STEP_1_AMOUNT, STEP_1_WEIGHT_BOOST_BP, STEP_2_AMOUNT, STEP_1_WEIGHT_BOOST_BP) - ); - - vm.expectRevert(IERC20LockBoostProvider.InvalidLockBoostSteps.selector); - provider.setLockBoostSteps( - _steps(STEP_1_AMOUNT, STEP_1_WEIGHT_BOOST_BP, STEP_2_AMOUNT, uint32(MAX_WEIGHT_BOOST_BP + 1)) - ); - - vm.stopPrank(); + /// @dev A lock threshold of zero would boost operators that locked nothing. + function test_setSteps_RevertWhen_ZeroThreshold() public { + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); + vm.prank(admin); + provider.setSteps(_steps1(0, STEP_1_WEIGHT_BOOST_BP)); } } @@ -467,7 +457,7 @@ contract ERC20LockBoostProviderLockTest is ERC20LockBoostProviderBaseTest { vm.prank(nodeOperatorOwner); provider.lock(NODE_OPERATOR_ID, amount); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); assertTrue(lockInfo.vault != address(0)); assertEq(provider.getVault(NODE_OPERATOR_ID), lockInfo.vault); assertEq(LidoGovernanceLockVault(lockInfo.vault).VOTING_CONTRACT(), address(voting)); @@ -487,7 +477,7 @@ contract ERC20LockBoostProviderLockTest is ERC20LockBoostProviderBaseTest { vm.warp(2000); _lock(1 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); assertEq(lockInfo.amount, 2 ether); assertEq(lockInfo.lockUntil, 2000 + LOCK_PERIOD); } @@ -554,13 +544,13 @@ contract ERC20LockBoostProviderLockTest is ERC20LockBoostProviderBaseTest { function test_lock_RevertWhen_NotNodeOperatorOwner() public { vm.prank(stranger); - vm.expectRevert(IERC20LockBoostProvider.SenderIsNotNodeOperatorOwner.selector); + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); provider.lock(NODE_OPERATOR_ID, 1 ether); } function test_lock_RevertWhen_NodeOperatorDoesNotExist() public { vm.prank(nodeOperatorOwner); - vm.expectRevert(IERC20LockBoostProvider.NodeOperatorDoesNotExist.selector); + vm.expectRevert(IStepwiseWeightBoost.NodeOperatorDoesNotExist.selector); provider.lock(NODE_OPERATOR_ID + 1, 1 ether); } } @@ -569,7 +559,7 @@ contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { function test_withdraw_AfterLockPeriod() public { _setDefaultSteps(); _lock(STEP_2_AMOUNT); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); vm.warp(lockInfo.lockUntil); vm.expectEmit(address(provider)); @@ -577,7 +567,7 @@ contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { vm.prank(nodeOperatorOwner); provider.withdraw(NODE_OPERATOR_ID, 60 ether, receiver); - lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + lockInfo = provider.getLock(NODE_OPERATOR_ID); assertEq(lockInfo.amount, 140 ether); assertEq(token.balanceOf(receiver), 60 ether); assertEq(provider.getWeightBoostMultiplierBP(NODE_OPERATOR_ID), STEP_1_MULTIPLIER_BP); @@ -586,20 +576,20 @@ contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { function test_withdraw_ClearsLockUntilWhenFullyWithdrawn() public { _lock(10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); vm.warp(lockInfo.lockUntil); vm.prank(nodeOperatorOwner); provider.withdraw(NODE_OPERATOR_ID, 10 ether, receiver); - lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + lockInfo = provider.getLock(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); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); IERC20LockVault vault = IERC20LockVault(lockInfo.vault); SnapshotDelegationMock newSnapshotDelegation = new SnapshotDelegationMock(); LidoGovernanceLockVault newVaultImpl = new LidoGovernanceLockVault( @@ -630,7 +620,7 @@ contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { function test_withdraw_DoesNotRefreshRegistryWhenBoostUnchanged() public { _setDefaultSteps(); _lock(STEP_1_AMOUNT + 10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); vm.warp(lockInfo.lockUntil); expectNoCall( @@ -693,12 +683,12 @@ contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { function test_withdraw_RevertWhen_InvalidInputs() public { _lock(10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); vm.warp(lockInfo.lockUntil); vm.startPrank(nodeOperatorOwner); - vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + vm.expectRevert(IERC20LockVault.ZeroReceiverAddress.selector); provider.withdraw(NODE_OPERATOR_ID, 1 ether, address(0)); vm.expectRevert(IERC20LockBoostProvider.InvalidAmount.selector); @@ -732,7 +722,7 @@ contract ERC20LockBoostProviderWithdrawTest is ERC20LockBoostProviderBaseTest { contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { function test_assignAndUnassignVotingDelegate() public { _lock(10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); vm.prank(nodeOperatorOwner); @@ -748,7 +738,7 @@ contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { function test_assignAndUnassignSnapshotDelegate() public { _lock(10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); ISnapshotDelegationLockVault vault = ISnapshotDelegationLockVault(lockInfo.vault); vm.prank(nodeOperatorOwner); @@ -770,7 +760,7 @@ contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { address newVotingDelegate = nextAddress("NEW_VOTING_DELEGATE"); _lock(10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); vm.startPrank(nodeOperatorOwner); @@ -788,7 +778,7 @@ contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { function test_vote() public { _lock(10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); vm.prank(nodeOperatorOwner); @@ -802,7 +792,7 @@ contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { function test_assignVotingDelegate_AllowsZeroDelegate() public { _lock(10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); vm.prank(nodeOperatorOwner); @@ -813,7 +803,7 @@ contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { function test_assignVotingDelegate_AllowsSameDelegate() public { _lock(10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); IAragonVotingLockVault vault = IAragonVotingLockVault(lockInfo.vault); vm.startPrank(nodeOperatorOwner); @@ -826,7 +816,7 @@ contract ERC20LockBoostProviderVotingTest is ERC20LockBoostProviderBaseTest { function test_assignDelegates_AllowsEmptyVault() public { _lock(10 ether); - IERC20LockBoostProvider.LockInfo memory lockInfo = provider.getNodeOperatorLock(NODE_OPERATOR_ID); + IERC20LockBoostProvider.OperatorLock memory lockInfo = provider.getLock(NODE_OPERATOR_ID); vm.warp(lockInfo.lockUntil); vm.prank(nodeOperatorOwner); @@ -888,13 +878,13 @@ contract ERC20LockVaultTest is Test, Utilities { } function test_constructor_RevertWhen_ZeroAddresses() public { - vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + vm.expectRevert(IERC20LockVault.ZeroTokenAddress.selector); new ERC20LockVault(address(0), provider, module); - vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + vm.expectRevert(IERC20LockVault.ZeroProviderAddress.selector); new ERC20LockVault(address(token), address(0), module); - vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + vm.expectRevert(IERC20LockVault.ZeroModuleAddress.selector); new ERC20LockVault(address(token), provider, address(0)); } @@ -920,7 +910,7 @@ contract ERC20LockVaultTest is Test, Utilities { vault.transferTokens(receiver, 1 ether); vm.prank(provider); - vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + vm.expectRevert(IERC20LockVault.ZeroReceiverAddress.selector); vault.transferTokens(address(0), 1 ether); } } @@ -992,10 +982,10 @@ contract LidoGovernanceLockVaultTest is Test, Utilities { } function test_constructor_RevertWhen_ZeroGovernanceAddresses() public { - vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + vm.expectRevert(IAragonVotingLockVault.ZeroVotingContractAddress.selector); new LidoGovernanceLockVault(address(token), provider, address(module), address(0), address(snapshotDelegation)); - vm.expectRevert(IERC20LockVault.ZeroAddress.selector); + vm.expectRevert(ISnapshotDelegationLockVault.ZeroSnapshotDelegationAddress.selector); new LidoGovernanceLockVault(address(token), provider, address(module), address(voting), address(0)); } diff --git a/test/unit/MetaRegistry.t.sol b/test/unit/MetaRegistry.t.sol index 2b821e931..6165bfa9e 100644 --- a/test/unit/MetaRegistry.t.sol +++ b/test/unit/MetaRegistry.t.sol @@ -26,7 +26,7 @@ import { Utilities } from "../helpers/Utilities.sol"; import { Fixtures } from "../helpers/Fixtures.sol"; contract MetaRegistryForTest is MetaRegistry { - constructor(address module, address additionalBondRegistry) MetaRegistry(module, additionalBondRegistry) {} + constructor(address module) MetaRegistry(module) {} function mock_setModuleAddressInCache(uint256 moduleId, address moduleAddress) external { _storage().moduleAddressCache[moduleId] = moduleAddress; @@ -97,7 +97,7 @@ contract MetaRegistryBaseTest is Test, Utilities, Fixtures { additionalBondRegistry = new AdditionalBondRegistryMock(); - registry = new MetaRegistryForTest(address(module), address(additionalBondRegistry)); + registry = new MetaRegistryForTest(address(module)); _enableInitializers(address(registry)); registry.initialize(admin); @@ -250,16 +250,15 @@ contract MetaRegistryGroupsBaseTest is MetaRegistryBaseTest { contract MetaRegistryConstructorTest is MetaRegistryBaseTest { function test_constructor_SetsImmutables() public { - MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); + MetaRegistry r = new MetaRegistry(address(module)); assertEq(address(r.STAKING_ROUTER()), address(stakingRouter)); assertEq(address(r.MODULE()), address(module)); assertEq(address(r.ACCOUNTING()), address(module.ACCOUNTING())); - assertEq(address(r.ADDITIONAL_BOND_REGISTRY()), address(additionalBondRegistry)); } function test_constructor_RevertWhen_ZeroModule() public { vm.expectRevert(IMetaRegistry.ZeroModuleAddress.selector); - new MetaRegistry(address(0), address(additionalBondRegistry)); + new MetaRegistry(address(0)); } } @@ -269,14 +268,14 @@ contract MetaRegistryInitializeTest is MetaRegistryBaseTest { } function test_initialize_SetsAdmin() public { - MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); + MetaRegistry r = new MetaRegistry(address(module)); _enableInitializers(address(r)); r.initialize(admin); assertTrue(r.hasRole(r.DEFAULT_ADMIN_ROLE(), admin)); } function test_initialize_NoGroupsInitially() public { - MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); + MetaRegistry r = new MetaRegistry(address(module)); _enableInitializers(address(r)); r.initialize(admin); @@ -289,14 +288,14 @@ contract MetaRegistryInitializeTest is MetaRegistryBaseTest { } function test_initialize_RevertWhen_ZeroAdmin() public { - MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); + MetaRegistry r = new MetaRegistry(address(module)); _enableInitializers(address(r)); vm.expectRevert(IMetaRegistry.ZeroAdminAddress.selector); r.initialize(address(0)); } function test_initialize_RevertWhen_DoubleCall() public { - MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); + MetaRegistry r = new MetaRegistry(address(module)); _enableInitializers(address(r)); r.initialize(admin); vm.expectRevert(Initializable.InvalidInitialization.selector); diff --git a/test/unit/NodeOperatorStrikes.t.sol b/test/unit/NodeOperatorStrikes.t.sol index 300495835..32e94b575 100644 --- a/test/unit/NodeOperatorStrikes.t.sol +++ b/test/unit/NodeOperatorStrikes.t.sol @@ -8,16 +8,15 @@ import { Test } from "forge-std/Test.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { NodeOperatorStrikes } from "src/NodeOperatorStrikes.sol"; -import { INodeOperatorStrikes, StrikeInput, Strike, StrikeThreshold } from "src/interfaces/INodeOperatorStrikes.sol"; +import { INodeOperatorStrikes, StrikeInput, Strike } from "src/interfaces/INodeOperatorStrikes.sol"; +import { IStepwiseWeightBoost, Step } from "src/interfaces/IStepwiseWeightBoost.sol"; -import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; -import { MetaRegistryMock } from "../helpers/mocks/MetaRegistryMock.sol"; +import { CuratedProviderFixture } from "../helpers/CuratedProviderFixture.sol"; +import { StepwiseWeightBoostBehaviour } from "../helpers/StepwiseWeightBoostBehaviour.sol"; import { Utilities } from "../helpers/Utilities.sol"; import { Fixtures } from "../helpers/Fixtures.sol"; -contract NodeOperatorStrikesBaseTest is Test, Utilities, Fixtures { - CuratedMock public module; - MetaRegistryMock public metaRegistryMock; +contract NodeOperatorStrikesBaseTest is Test, Utilities, Fixtures, CuratedProviderFixture { NodeOperatorStrikes public strikes; address public admin; @@ -35,15 +34,11 @@ contract NodeOperatorStrikesBaseTest is Test, Utilities, Fixtures { committee = nextAddress("COMMITTEE"); stranger = nextAddress("STRANGER"); - module = new CuratedMock(); - module.mock_setNodeOperatorsCount(3); - - metaRegistryMock = new MetaRegistryMock(); - module.mock_setMetaRegistry(address(metaRegistryMock)); + _deployModuleWithMetaRegistryMock(3); strikes = new NodeOperatorStrikes({ module: address(module) }); _enableInitializers(address(strikes)); - strikes.initialize(admin, _exampleThresholds()); + strikes.initialize(admin, _exampleSteps()); bytes32 committeeRole = strikes.STRIKES_COMMITTEE_ROLE(); vm.prank(admin); @@ -64,17 +59,17 @@ contract NodeOperatorStrikesBaseTest is Test, Utilities, Fixtures { }); } - function _exampleThresholds() internal pure returns (StrikeThreshold[] memory thresholds) { - thresholds = new StrikeThreshold[](4); - thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: 2_500 }); - thresholds[1] = StrikeThreshold({ minCount: 3, reductionBP: 5_000 }); - thresholds[2] = StrikeThreshold({ minCount: 4, reductionBP: 7_500 }); - thresholds[3] = StrikeThreshold({ minCount: 5, reductionBP: 10_000 }); + function _exampleSteps() internal pure returns (Step[] memory steps) { + steps = new Step[](4); + steps[0] = Step({ threshold: 2, value: 2_500 }); + steps[1] = Step({ threshold: 3, value: 5_000 }); + steps[2] = Step({ threshold: 4, value: 7_500 }); + steps[3] = Step({ threshold: 5, value: 10_000 }); } - function _setExampleThresholds() internal { + function _setExampleSteps() internal { vm.prank(admin); - strikes.setStrikeThresholds(_exampleThresholds()); + strikes.setSteps(_exampleSteps()); } function _issue(uint256 nodeOperatorId) internal returns (uint256 strikeId) { @@ -83,7 +78,7 @@ contract NodeOperatorStrikesBaseTest is Test, Utilities, Fixtures { } function _expectNoStrike(uint256 nodeOperatorId, uint256 strikeId) internal { - vm.expectRevert(INodeOperatorStrikes.StrikeNotExist.selector); + vm.expectRevert(INodeOperatorStrikes.StrikeDoesNotExist.selector); strikes.getStrike(nodeOperatorId, strikeId); } } @@ -95,7 +90,7 @@ contract NodeOperatorStrikesConstructorTest is NodeOperatorStrikesBaseTest { } function test_constructor_RevertWhen_ZeroModule() public { - vm.expectRevert(INodeOperatorStrikes.ZeroModuleAddress.selector); + vm.expectRevert(IStepwiseWeightBoost.ZeroModuleAddress.selector); new NodeOperatorStrikes(address(0)); } } @@ -105,50 +100,50 @@ contract NodeOperatorStrikesInitializeTest is NodeOperatorStrikesBaseTest { assertTrue(strikes.hasRole(strikes.DEFAULT_ADMIN_ROLE(), admin)); } - function test_initialize_SetsThresholds() public { + function test_initialize_SetsSteps() public { NodeOperatorStrikes s = new NodeOperatorStrikes(address(module)); _enableInitializers(address(s)); - s.initialize(admin, _exampleThresholds()); + s.initialize(admin, _exampleSteps()); - StrikeThreshold[] memory stored = s.getStrikeThresholds(); + Step[] memory stored = s.getSteps(); assertEq(stored.length, 4); - assertEq(stored[0].minCount, 2); - assertEq(stored[3].reductionBP, 10_000); + assertEq(stored[0].threshold, 2); + assertEq(stored[3].value, 10_000); } function test_initialize_RevertWhen_ZeroAdmin() public { NodeOperatorStrikes s = new NodeOperatorStrikes(address(module)); _enableInitializers(address(s)); - vm.expectRevert(INodeOperatorStrikes.ZeroAdminAddress.selector); - s.initialize(address(0), new StrikeThreshold[](0)); + vm.expectRevert(IStepwiseWeightBoost.ZeroAdminAddress.selector); + s.initialize(address(0), new Step[](0)); } - function test_initialize_RevertWhen_InvalidThresholds() public { + function test_initialize_RevertWhen_InvalidStep() public { NodeOperatorStrikes s = new NodeOperatorStrikes(address(module)); _enableInitializers(address(s)); - StrikeThreshold[] memory bad = new StrikeThreshold[](1); - bad[0] = StrikeThreshold({ minCount: 0, reductionBP: 1_000 }); // minCount 0 is invalid - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + Step[] memory bad = new Step[](1); + bad[0] = Step({ threshold: 0, value: 1_000 }); // threshold 0 is invalid + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); s.initialize(admin, bad); } - function test_initialize_RevertWhen_EmptyThresholds() public { + function test_initialize_RevertWhen_EmptySteps() public { NodeOperatorStrikes s = new NodeOperatorStrikes(address(module)); _enableInitializers(address(s)); - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); - s.initialize(admin, new StrikeThreshold[](0)); + vm.expectRevert(IStepwiseWeightBoost.InvalidStepCount.selector); + s.initialize(admin, new Step[](0)); } function test_initialize_RevertWhen_DoubleCall() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - strikes.initialize(admin, new StrikeThreshold[](0)); + strikes.initialize(admin, new Step[](0)); } } contract NodeOperatorStrikesIssueTest is NodeOperatorStrikesBaseTest { - function test_issueStrike_StoresAndRefreshes() public { + function test_issueStrike_StoresWithoutRefreshBeforeFirstThreshold() public { uint256 expiry = block.timestamp + LIFETIME; vm.expectEmit(true, true, true, true, address(strikes)); @@ -166,6 +161,14 @@ contract NodeOperatorStrikesIssueTest is NodeOperatorStrikesBaseTest { assertEq(s.category, CATEGORY); assertEq(s.description, DESCRIPTION); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 0); + } + + function test_issueStrike_RefreshesWhenStepValueChanges() public { + _issue(NO_ID); + + _issue(NO_ID); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 1); assertEq(metaRegistryMock.lastChangedBoostOperatorId(), NO_ID); } @@ -184,7 +187,7 @@ contract NodeOperatorStrikesIssueTest is NodeOperatorStrikesBaseTest { } function test_issueStrike_RevertWhen_OperatorDoesNotExist() public { - vm.expectRevert(INodeOperatorStrikes.NodeOperatorDoesNotExist.selector); + vm.expectRevert(IStepwiseWeightBoost.NodeOperatorDoesNotExist.selector); vm.prank(committee); strikes.issueStrike(_input(3, CATEGORY, LIFETIME)); // count == 3, so id 3 doesn't exist } @@ -234,6 +237,7 @@ contract NodeOperatorStrikesIssueTest is NodeOperatorStrikesBaseTest { contract NodeOperatorStrikesRemoveTest is NodeOperatorStrikesBaseTest { function test_removeStrike_RemovesAndRefreshes() public { uint256 id = _issue(NO_ID); + _issue(NO_ID); uint256 refreshesBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); vm.expectEmit(true, true, false, false, address(strikes)); @@ -242,11 +246,22 @@ contract NodeOperatorStrikesRemoveTest is NodeOperatorStrikesBaseTest { vm.prank(committee); strikes.removeStrike(NO_ID, id); - assertEq(strikes.getActiveStrikesCount(NO_ID), 0); + assertEq(strikes.getActiveStrikesCount(NO_ID), 1); _expectNoStrike(NO_ID, id); // removed assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), refreshesBefore + 1); } + function test_removeStrike_DoesNotRefreshWhenStepValueUnchanged() public { + uint256 id = _issue(NO_ID); + uint256 refreshesBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + vm.prank(committee); + strikes.removeStrike(NO_ID, id); + + assertEq(strikes.getActiveStrikesCount(NO_ID), 0); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), refreshesBefore); + } + function test_removeStrike_RevertWhen_NotCommittee() public { uint256 id = _issue(NO_ID); bytes32 role = strikes.STRIKES_COMMITTEE_ROLE(); @@ -262,7 +277,7 @@ contract NodeOperatorStrikesRemoveTest is NodeOperatorStrikesBaseTest { } function test_removeStrike_RevertWhen_NonExistent() public { - vm.expectRevert(INodeOperatorStrikes.StrikeNotExist.selector); + vm.expectRevert(INodeOperatorStrikes.StrikeDoesNotExist.selector); vm.prank(committee); strikes.removeStrike(NO_ID, 1); } @@ -272,7 +287,7 @@ contract NodeOperatorStrikesRemoveTest is NodeOperatorStrikesBaseTest { vm.prank(committee); strikes.removeStrike(NO_ID, id); - vm.expectRevert(INodeOperatorStrikes.StrikeNotExist.selector); + vm.expectRevert(INodeOperatorStrikes.StrikeDoesNotExist.selector); vm.prank(committee); strikes.removeStrike(NO_ID, id); } @@ -428,117 +443,80 @@ contract NodeOperatorStrikesRemoveExpiredTest is NodeOperatorStrikesBaseTest { assertEq(strikes.getActiveStrikesCount(NO_ID), 2); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), refreshesBefore); // no refresh } -} -contract NodeOperatorStrikesThresholdsTest is NodeOperatorStrikesBaseTest { - function test_setStrikeThresholds_RoundTrip() public { - StrikeThreshold[] memory thresholds = _exampleThresholds(); - - vm.expectEmit(false, false, false, true, address(strikes)); - emit INodeOperatorStrikes.StrikeThresholdsSet(thresholds); - - vm.prank(admin); - strikes.setStrikeThresholds(thresholds); + function test_removeExpiredStrikes_DoesNotRefreshWhenStepValueUnchanged() public { + _issue(NO_ID); + uint256 refreshesBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + vm.warp(block.timestamp + LIFETIME); - StrikeThreshold[] memory stored = strikes.getStrikeThresholds(); - assertEq(stored.length, 4); - assertEq(stored[0].minCount, 2); - assertEq(stored[0].reductionBP, 2_500); - assertEq(stored[3].minCount, 5); - assertEq(stored[3].reductionBP, 10_000); + vm.prank(stranger); + strikes.removeExpiredStrikes(NO_ID); - // The config change is pushed to MetaRegistry so cached weights get refreshed. - assertEq(metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(), 1); + assertEq(strikes.getActiveStrikesCount(NO_ID), 0); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), refreshesBefore); } +} - function test_setStrikeThresholds_Replaces() public { - _setExampleThresholds(); - - StrikeThreshold[] memory next = new StrikeThreshold[](1); - next[0] = StrikeThreshold({ minCount: 1, reductionBP: 1_000 }); - vm.prank(admin); - strikes.setStrikeThresholds(next); - - StrikeThreshold[] memory stored = strikes.getStrikeThresholds(); - assertEq(stored.length, 1); - assertEq(stored[0].minCount, 1); - assertEq(stored[0].reductionBP, 1_000); +contract NodeOperatorStrikesStepsTest is NodeOperatorStrikesBaseTest, StepwiseWeightBoostBehaviour { + function _stepwise() internal view override returns (IStepwiseWeightBoost) { + return IStepwiseWeightBoost(address(strikes)); } - function test_setStrikeThresholds_RevertWhen_NotAdmin() public { - bytes32 adminRole = strikes.DEFAULT_ADMIN_ROLE(); - expectRoleRevert(stranger, adminRole); - vm.prank(stranger); - strikes.setStrikeThresholds(_exampleThresholds()); + function _stepwiseAdmin() internal view override returns (address) { + return admin; } - function test_setStrikeThresholds_RevertWhen_Empty() public { - StrikeThreshold[] memory thresholds = new StrikeThreshold[](0); - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); - vm.prank(admin); - strikes.setStrikeThresholds(thresholds); + function _stepwiseSteps(uint256 count) internal view override returns (Step[] memory steps) { + steps = new Step[](count); + for (uint256 i; i < count; ++i) { + // Strike counts start at one; reductions stay within MAX_BP. + steps[i] = Step({ threshold: uint128(i + 1), value: uint128((i + 1) * 100) }); + } } - function test_setStrikeThresholds_RevertWhen_FirstMinCountZero() public { - StrikeThreshold[] memory thresholds = new StrikeThreshold[](1); - thresholds[0] = StrikeThreshold({ minCount: 0, reductionBP: 1_000 }); - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); - vm.prank(admin); - strikes.setStrikeThresholds(thresholds); - } + function test_setSteps_RoundTrip() public { + Step[] memory steps = _exampleSteps(); - function test_setStrikeThresholds_RevertWhen_FirstReductionZero() public { - StrikeThreshold[] memory thresholds = new StrikeThreshold[](1); - thresholds[0] = StrikeThreshold({ minCount: 1, reductionBP: 0 }); - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); - vm.prank(admin); - strikes.setStrikeThresholds(thresholds); - } + vm.expectEmit(false, false, false, true, address(strikes)); + emit IStepwiseWeightBoost.StepsSet(steps); - function test_setStrikeThresholds_RevertWhen_MinCountNotAscending() public { - StrikeThreshold[] memory thresholds = new StrikeThreshold[](2); - thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: 2_500 }); - thresholds[1] = StrikeThreshold({ minCount: 2, reductionBP: 5_000 }); - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); vm.prank(admin); - strikes.setStrikeThresholds(thresholds); - } + strikes.setSteps(steps); - function test_setStrikeThresholds_RevertWhen_ReductionDecreasing() public { - StrikeThreshold[] memory thresholds = new StrikeThreshold[](2); - thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: 5_000 }); - thresholds[1] = StrikeThreshold({ minCount: 3, reductionBP: 2_500 }); - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); - vm.prank(admin); - strikes.setStrikeThresholds(thresholds); + Step[] memory stored = strikes.getSteps(); + assertEq(stored.length, 4); + assertEq(stored[0].threshold, 2); + assertEq(stored[0].value, 2_500); + assertEq(stored[3].threshold, 5); + assertEq(stored[3].value, 10_000); + + // The config change is pushed to MetaRegistry so cached weights get refreshed. + assertEq(metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(), 1); } - function test_setStrikeThresholds_RevertWhen_ReductionEqual() public { - StrikeThreshold[] memory thresholds = new StrikeThreshold[](2); - thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: 2_500 }); - thresholds[1] = StrikeThreshold({ minCount: 3, reductionBP: 2_500 }); // equal -> redundant band - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + function test_setSteps_RevertWhen_FirstThresholdZero() public { + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: 0, value: 1_000 }); + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); vm.prank(admin); - strikes.setStrikeThresholds(thresholds); + strikes.setSteps(steps); } - function test_setStrikeThresholds_RevertWhen_ReductionAboveMaxBp() public { - StrikeThreshold[] memory thresholds = new StrikeThreshold[](1); - thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: uint128(MAX_BP + 1) }); - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + function test_setSteps_RevertWhen_FirstValueZero() public { + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: 1, value: 0 }); + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); vm.prank(admin); - strikes.setStrikeThresholds(thresholds); + strikes.setSteps(steps); } - function test_setStrikeThresholds_RevertWhen_TooMany() public { - uint256 n = strikes.MAX_THRESHOLDS() + 1; - StrikeThreshold[] memory thresholds = new StrikeThreshold[](n); - for (uint256 i; i < n; ++i) { - thresholds[i] = StrikeThreshold({ minCount: uint128(i + 1), reductionBP: 0 }); - } - vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + /// @dev A reduction cannot exceed 100%, so this provider caps values below MAX_STEP_VALUE. + function test_setSteps_RevertWhen_ValueAboveMaxBp() public { + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: 2, value: uint128(MAX_BP + 1) }); + vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); vm.prank(admin); - strikes.setStrikeThresholds(thresholds); + strikes.setSteps(steps); } }