From 0d2e5cb0e5f73714019227ef08872ba6aef5a178 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Thu, 16 Jul 2026 14:37:47 +0200 Subject: [PATCH 01/11] feat: custom fees --- src/CustomFeeRegistry.sol | 252 +++++++++++ src/interfaces/ICustomFeeRegistry.sol | 190 +++++++++ test/unit/CustomFeeRegistry.t.sol | 591 ++++++++++++++++++++++++++ 3 files changed, 1033 insertions(+) create mode 100644 src/CustomFeeRegistry.sol create mode 100644 src/interfaces/ICustomFeeRegistry.sol create mode 100644 test/unit/CustomFeeRegistry.t.sol diff --git a/src/CustomFeeRegistry.sol b/src/CustomFeeRegistry.sol new file mode 100644 index 000000000..d8f4f6e11 --- /dev/null +++ b/src/CustomFeeRegistry.sol @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import { IAccounting } from "./interfaces/IAccounting.sol"; +import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; +import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; +import { ICustomFeeRegistry, OperatorFee, TypeBonus } from "./interfaces/ICustomFeeRegistry.sol"; +import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; +import { MAX_BP } from "./lib/Constants.sol"; + +/// @notice Per-operator custom fees and the allocation weight boost derived from them. See +/// ICustomFeeRegistry for the model. +contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEnumerableUpgradeable { + using SafeCast for uint256; + + /// @custom:storage-location erc7201:CustomFeeRegistry + struct CustomFeeRegistryStorage { + uint256 defaultMinFee; + uint256 feeIncreaseCooldown; + mapping(uint256 curveId => TypeBonus) typeBonus; + mapping(uint256 nodeOperatorId => OperatorFee) fees; + } + + // All fees are basis points of the operator's own rewards. + // A custom fee moves in these increments: 2.5% of the operator's rewards, which is 0.1% + // of the total staking rewards at a 4% module reward share. + uint256 public constant FEE_STEP = 250; // 2.5% + // The starting fee of every operator; a custom fee can only go below it. + uint256 public constant DEFAULT_MAX_FEE = 35 * FEE_STEP; // 87.5% + // Slope of the weight line: multiplier basis points per FEE_STEP below DEFAULT_MAX_FEE. + uint256 public constant WEIGHT_BOOST_PER_STEP = 400; + + ICuratedModule public immutable MODULE; + IAccounting public immutable ACCOUNTING; + IMetaRegistry public immutable META_REGISTRY; + + // 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) { + MODULE = ICuratedModule(module); + ACCOUNTING = IAccounting(MODULE.ACCOUNTING()); + META_REGISTRY = IMetaRegistry(MODULE.META_REGISTRY()); + + _disableInitializers(); + } + + /// @inheritdoc ICustomFeeRegistry + function initialize(address admin, uint256 defaultMinFee, uint256 feeIncreaseCooldown) external initializer { + if (admin == address(0)) revert ZeroAdminAddress(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _setDefaultMinFee(defaultMinFee, DEFAULT_MAX_FEE); + _setFeeIncreaseCooldown(feeIncreaseCooldown); + } + + /// @inheritdoc ICustomFeeRegistry + function requestFee(uint256 nodeOperatorId, uint256 fee) external { + _checkOperatorOwner(nodeOperatorId); + + uint256 minFee = _getMinFee(nodeOperatorId); + if (fee < minFee || fee > DEFAULT_MAX_FEE || fee % FEE_STEP != 0) revert InvalidFee(); + + uint256 curFee = _getFee(nodeOperatorId); + if (fee == curFee) revert SameFee(); + + if (fee < curFee) { + // A decrease also cancels any pending increase along with its cooldown. + _setFee(nodeOperatorId, fee); + return; + } + + _requestFeeIncrease(nodeOperatorId, fee); + } + + /// @inheritdoc ICustomFeeRegistry + function applyFeeIncrease(uint256 nodeOperatorId) external { + _checkOperatorOwner(nodeOperatorId); + + CustomFeeRegistryStorage storage $ = _storage(); + OperatorFee storage f = $.fees[nodeOperatorId]; + if (f.cooldownUntil == 0) revert NoFeeIncreaseCooldown(); + if (f.cooldownUntil > block.timestamp) revert FeeIncreaseCooldownNotElapsed(); + + uint16 fee = f.pendingFeeIncrease; + $.fees[nodeOperatorId] = OperatorFee({ currentFee: fee, pendingFeeIncrease: 0, cooldownUntil: 0 }); + emit FeeIncreaseApplied(nodeOperatorId, fee); + // No weight notification: the weight has followed the pending fee since the request. + } + + /// @inheritdoc ICustomFeeRegistry + function restoreFeeToMin(uint256 nodeOperatorId) external { + if (_storage().fees[nodeOperatorId].cooldownUntil != 0) revert FeeIncreaseCooldownActive(); + + uint256 minFee = _getMinFee(nodeOperatorId); + // A fee below the type's minimum can only be left over from a tightened type bonus. + if (_getFee(nodeOperatorId) >= minFee) revert FeeNotBelowMinFee(); + + _setFee(nodeOperatorId, minFee); + } + + /// @inheritdoc ICustomFeeRegistry + function setDefaultMinFee(uint256 defaultMinFee) external onlyRole(DEFAULT_ADMIN_ROLE) { + // Only downwards: fees already set stay valid. + _setDefaultMinFee(defaultMinFee, _storage().defaultMinFee); + } + + /// @inheritdoc ICustomFeeRegistry + function setTypeBonus(uint256 curveId, uint256 value, bool negative) external onlyRole(DEFAULT_ADMIN_ROLE) { + CustomFeeRegistryStorage storage $ = _storage(); + // Keeps the effective fee within MAX_BP and the per-type minimum within DEFAULT_MAX_FEE. + uint256 maxValue = negative ? DEFAULT_MAX_FEE - $.defaultMinFee : MAX_BP - DEFAULT_MAX_FEE; + if (value > maxValue || value % FEE_STEP != 0) revert InvalidTypeBonus(); + + $.typeBonus[curveId] = TypeBonus({ value: value.toUint248(), negative: negative }); + emit TypeBonusSet(curveId, value, negative); + // No weight notification: the bonus shifts only the effective fee. + } + + /// @inheritdoc ICustomFeeRegistry + function setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setFeeIncreaseCooldown(feeIncreaseCooldown); + } + + /// @inheritdoc ICustomFeeRegistry + function getDefaultMinFee() external view returns (uint256) { + return _storage().defaultMinFee; + } + + /// @inheritdoc ICustomFeeRegistry + function getFeeIncreaseCooldown() external view returns (uint256) { + return _storage().feeIncreaseCooldown; + } + + /// @inheritdoc ICustomFeeRegistry + function getTypeBonus(uint256 curveId) external view returns (TypeBonus memory) { + return _storage().typeBonus[curveId]; + } + + /// @inheritdoc ICustomFeeRegistry + function getFee(uint256 nodeOperatorId) external view returns (uint256) { + return _getFee(nodeOperatorId); + } + + /// @inheritdoc ICustomFeeRegistry + function getMinFee(uint256 nodeOperatorId) external view returns (uint256) { + return _getMinFee(nodeOperatorId); + } + + /// @inheritdoc IWeightBoostProvider + function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { + OperatorFee storage f = _storage().fees[nodeOperatorId]; + // During a pending increase the weight already follows the higher pending fee. + uint256 fee = f.pendingFeeIncrease > 0 ? f.pendingFeeIncrease : _getFee(nodeOperatorId); + // The multiplier is 1x for an operator at DEFAULT_MAX_FEE and grows by + // WEIGHT_BOOST_PER_STEP for every step below it. Fees are multiples of FEE_STEP, + // so the division has no remainder. + multiplierBP = MAX_BP + ((DEFAULT_MAX_FEE - fee) / FEE_STEP) * WEIGHT_BOOST_PER_STEP; + } + + /// @inheritdoc ICustomFeeRegistry + function getEffectiveFee(uint256 nodeOperatorId) external view returns (uint256) { + uint256 fee = _getFee(nodeOperatorId); + TypeBonus storage bonus = _storage().typeBonus[ACCOUNTING.getBondCurveId(nodeOperatorId)]; + if (bonus.negative) { + // A tightened bonus can leave the fee below its value. + return fee > bonus.value ? fee - bonus.value : 0; + } + + // The bonus bounds keep the sum within MAX_BP. + return fee + bonus.value; + } + + /// @dev Sets the custom fee immediately, dropping any pending increase, and notifies the + /// weight change. + function _setFee(uint256 nodeOperatorId, uint256 fee) internal { + _storage().fees[nodeOperatorId] = OperatorFee({ + currentFee: fee.toUint16(), + pendingFeeIncrease: 0, + cooldownUntil: 0 + }); + emit FeeSet(nodeOperatorId, fee); + META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + } + + /// @dev Locks in a pending fee increase with its cooldown and notifies the weight change; the + /// fee itself applies via `applyFeeIncrease` once the cooldown elapses. A repeated + /// increase overwrites the pending one and restarts the cooldown. + function _requestFeeIncrease(uint256 nodeOperatorId, uint256 fee) internal { + CustomFeeRegistryStorage storage $ = _storage(); + OperatorFee storage f = $.fees[nodeOperatorId]; + // Unreachable for an unset operator, so `f.currentFee` is never zero here. + uint256 cooldownUntil = block.timestamp + $.feeIncreaseCooldown; + $.fees[nodeOperatorId] = OperatorFee({ + currentFee: f.currentFee, + pendingFeeIncrease: fee.toUint16(), + cooldownUntil: cooldownUntil.toUint64() + }); + emit FeeIncreaseRequested(nodeOperatorId, fee, cooldownUntil); + META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + } + + /// @dev Never zero: a zero stored custom fee is the "never set" marker. + function _setDefaultMinFee(uint256 defaultMinFee, uint256 maxAllowed) internal { + if (defaultMinFee == 0 || defaultMinFee >= maxAllowed || defaultMinFee % FEE_STEP != 0) { + revert InvalidDefaultMinFee(); + } + _storage().defaultMinFee = defaultMinFee; + emit DefaultMinFeeSet(defaultMinFee); + } + + function _setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) internal { + if (feeIncreaseCooldown == 0) revert InvalidFeeIncreaseCooldown(); + _storage().feeIncreaseCooldown = feeIncreaseCooldown; + emit FeeIncreaseCooldownSet(feeIncreaseCooldown); + } + + /// @dev Per-type minimum: a negative bonus raises it so the effective fee stays above the + /// default min fee. + function _getMinFee(uint256 nodeOperatorId) internal view returns (uint256) { + CustomFeeRegistryStorage storage $ = _storage(); + TypeBonus storage bonus = $.typeBonus[ACCOUNTING.getBondCurveId(nodeOperatorId)]; + return bonus.negative ? $.defaultMinFee + bonus.value : $.defaultMinFee; + } + + /// @dev Zero means "never set" and reads as DEFAULT_MAX_FEE; a real fee is never zero (defaultMinFee > 0). + function _getFee(uint256 nodeOperatorId) internal view returns (uint256) { + uint256 fee = _storage().fees[nodeOperatorId].currentFee; + return fee == 0 ? DEFAULT_MAX_FEE : fee; + } + + // TODO: Have the same in many places. Move to lib + function _checkOperatorOwner(uint256 nodeOperatorId) internal view { + if (msg.sender != MODULE.getNodeOperatorOwner(nodeOperatorId)) { + revert SenderIsNotOperatorOwner(); + } + } + + function _storage() internal pure returns (CustomFeeRegistryStorage storage $) { + assembly ("memory-safe") { + // keccak256(abi.encode(uint256(keccak256("CustomFeeRegistry")) - 1)) & ~bytes32(uint256(0xff)) + $.slot := CUSTOM_FEE_REGISTRY_STORAGE_LOCATION + } + } +} diff --git a/src/interfaces/ICustomFeeRegistry.sol b/src/interfaces/ICustomFeeRegistry.sol new file mode 100644 index 000000000..cbe2f49d5 --- /dev/null +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +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 Custom fee state of a Node Operator. `currentFee == 0` means "never set" and reads as +/// `DEFAULT_MAX_FEE`. `cooldownUntil == 0` means no pending increase. Packed into a single slot. +struct OperatorFee { + uint16 currentFee; + uint16 pendingFeeIncrease; + uint64 cooldownUntil; +} + +/// @dev Sign-magnitude fee bonus of a Node Operator type: `value` basis points are subtracted from +/// the custom fee when `negative` is true, added otherwise. Packed into a single slot. +struct TypeBonus { + uint248 value; + bool negative; +} + +/* + * ── Fee scale and per-type ranges ───────────────────────────────────────────── + * + * All fees are basis points (BP) of the operator's own rewards; the lower + * axis shows the same fee as a share of the total staking rewards at a 4% + * module share. One character is one step of 250 BP. + * An operator picks a custom fee between defaultMinFee and DEFAULT_MAX_FEE; + * the lower the fee, the higher the allocation weight. A type bonus shifts + * only the effective fee. + * ● custom (set by the operator) ○ effective (billed by the oracle) + * + * portion BP 0 2500 5000 6250 8750 10000 + * ├─────────┼─────────┼────┼─────────┼────┤ + * total at 4% 0% 1% 2% 2.5% 3.5% 4% + * └ defaultMinFee └ DEFAULT_MAX_FEE + * + * type A — bonus +1250, effective = custom + 1250: + * custom ●────────────────────────● + * effective ○────────────────────────○ + * + * type B — bonus -2500, effective = custom - 2500; the minimum rises to 5000: + * custom ●──────────────● + * effective ○──────────────○ + * + * A and B both pick custom = 5000: equal weights, different effective fees: + * custom A = B ● + * effective A ○ + * effective B ○ + * + * ── Fee increase timeline and oracle frames ─────────────────────────────────── + * + * Z is the fee-increase cooldown. The oracle reads the fee at the refSlot. + * Keeping Z >= frame + margin (a governance invariant) guarantees at least one + * report at the old fee while the weight is already low. A decrease needs + * no timeline: it applies at once and cancels any pending increase. + * + * requestFee(6000) + * │ applyFeeIncrease() + * │ │ + * 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: a decrease cancels it; a new increase overwrites it and restarts + * the cooldown. + * * the report the cooldown guarantees: the weight is already low, yet frame k + * is still billed at the old fee. + * ** the oracle reads the fee at this frame's refSlot, so a change made + * mid-frame takes effect for the whole frame. + */ +/// @notice Per-operator custom fees and the allocation weight boost derived from them: the lower +/// the custom fee, the higher the operator's allocation weight. The effective fee +/// (custom + type bonus) is what the fee oracle bills. See the diagrams above. +interface ICustomFeeRegistry is IWeightBoostProvider { + event FeeSet(uint256 indexed nodeOperatorId, uint256 fee); + event FeeIncreaseRequested(uint256 indexed nodeOperatorId, uint256 pendingFeeIncrease, uint256 cooldownUntil); + event FeeIncreaseApplied(uint256 indexed nodeOperatorId, uint256 fee); + event DefaultMinFeeSet(uint256 defaultMinFee); + event TypeBonusSet(uint256 indexed curveId, uint256 value, bool negative); + event FeeIncreaseCooldownSet(uint256 feeIncreaseCooldown); + + error ZeroAdminAddress(); + error SenderIsNotOperatorOwner(); + error InvalidFee(); + error SameFee(); + error FeeNotBelowMinFee(); + error FeeIncreaseCooldownActive(); + error NoFeeIncreaseCooldown(); + error FeeIncreaseCooldownNotElapsed(); + error InvalidDefaultMinFee(); + error InvalidTypeBonus(); + error InvalidFeeIncreaseCooldown(); + + /// @notice Curated module address. + function MODULE() external view returns (ICuratedModule); + + /// @notice Accounting contract holding bond curves; an operator's type is its curve id. + function ACCOUNTING() external view returns (IAccounting); + + /// @notice MetaRegistry notified via `notifyWeightBoostChanged` on weight changes. + function META_REGISTRY() external view returns (IMetaRegistry); + + /// @notice The starting fee of every operator, in basis points; a custom fee can only go + /// below it. + function DEFAULT_MAX_FEE() external view returns (uint256); + + /// @notice Slope of the weight line: multiplier basis points per FEE_STEP below DEFAULT_MAX_FEE. + function WEIGHT_BOOST_PER_STEP() external view returns (uint256); + + /// @notice Custom fee granularity in basis points. + function FEE_STEP() external view returns (uint256); + + /// @notice Initialize the registry. + /// @param admin Address to receive DEFAULT_ADMIN_ROLE. + /// @param defaultMinFee Initial minimum custom fee: a non-zero multiple of FEE_STEP below + /// DEFAULT_MAX_FEE. + /// @param feeIncreaseCooldown Fee increase cooldown in seconds, non-zero. + function initialize(address admin, uint256 defaultMinFee, uint256 feeIncreaseCooldown) external; + + /// @notice Request a custom fee. Only the Node Operator owner. A decrease applies immediately + /// and cancels any pending increase; an increase drops the weight at once but applies + /// only via `applyFeeIncrease` after the cooldown. A repeated increase overwrites the + /// pending one and restarts the cooldown. + /// @param nodeOperatorId ID of the Node Operator. + /// @param fee Fee in basis points: a multiple of FEE_STEP within + /// [getMinFee(id), DEFAULT_MAX_FEE]. + function requestFee(uint256 nodeOperatorId, uint256 fee) external; + + /// @notice Apply a pending fee increase after its cooldown. Only the Node Operator owner. + /// The weight already follows the pending fee. + /// @param nodeOperatorId ID of the Node Operator. + function applyFeeIncrease(uint256 nodeOperatorId) external; + + /// @notice Permissionlessly raise a custom fee left below the type's minimum (after a bonus + /// change) exactly to that minimum. Immediate; reverts while an increase is pending. + /// @param nodeOperatorId ID of the Node Operator. + function restoreFeeToMin(uint256 nodeOperatorId) external; + + /// @notice Lower the default minimum custom fee. Only downwards and never zero. + /// @param defaultMinFee New minimum in basis points, a non-zero multiple of FEE_STEP. + function setDefaultMinFee(uint256 defaultMinFee) external; + + /// @notice Set the fee bonus of a Node Operator type (bond curve). Shifts only the effective + /// fee, never the weight; a negative bonus raises the type's minimum custom fee. + /// @param curveId Bond curve ID of the type. + /// @param value Magnitude in basis points, a multiple of FEE_STEP: at most + /// MAX_BP - DEFAULT_MAX_FEE when positive, DEFAULT_MAX_FEE - defaultMinFee when negative. + /// @param negative Whether the bonus is subtracted from the custom fee. + function setTypeBonus(uint256 curveId, uint256 value, bool negative) external; + + /// @notice Set the fee increase cooldown. + /// @dev Governance invariant, not enforced on-chain: keep it >= one oracle frame + margin; + /// raise it when frames are lengthened. + /// @param feeIncreaseCooldown Cooldown in seconds, non-zero. + function setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) external; + + /// @notice Default minimum custom fee in basis points. + function getDefaultMinFee() external view returns (uint256); + + /// @notice Fee increase cooldown in seconds. + function getFeeIncreaseCooldown() external view returns (uint256); + + /// @notice Fee bonus of a Node Operator type. + /// @param curveId Bond curve ID of the type. + function getTypeBonus(uint256 curveId) external view returns (TypeBonus memory); + + /// @notice Custom fee of the Node Operator; DEFAULT_MAX_FEE if never set. While an increase + /// is pending, the old fee is returned until `applyFeeIncrease`. + /// @param nodeOperatorId ID of the Node Operator. + function getFee(uint256 nodeOperatorId) external view returns (uint256); + + /// @notice Minimum custom fee of the Node Operator: the default minimum raised by the type's + /// negative bonus. + /// @param nodeOperatorId ID of the Node Operator. + function getMinFee(uint256 nodeOperatorId) external view returns (uint256); + + /// @notice Effective fee of the Node Operator: custom fee + type bonus, clamped at zero. + /// The reward share the fee oracle bills. + /// @param nodeOperatorId ID of the Node Operator. + function getEffectiveFee(uint256 nodeOperatorId) external view returns (uint256); +} diff --git a/test/unit/CustomFeeRegistry.t.sol b/test/unit/CustomFeeRegistry.t.sol new file mode 100644 index 000000000..78c6c8d03 --- /dev/null +++ b/test/unit/CustomFeeRegistry.t.sol @@ -0,0 +1,591 @@ +// 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, TypeBonus } from "src/interfaces/ICustomFeeRegistry.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 { Utilities } from "../helpers/Utilities.sol"; +import { Fixtures } from "../helpers/Fixtures.sol"; + +contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures { + CuratedMock public module; + CustomFeeRegistry public feeRegistry; + MetaRegistryMock public metaRegistryMock; + AccountingMock internal acct; + + address public admin; + address public nodeOperatorOwner; + address public stranger; + + uint256 internal constant MAX_BP = 10_000; + uint256 internal constant STEP = 250; + uint256 internal constant MAX_FEE = 8_750; + uint256 internal constant SLOPE = 400; + uint256 internal constant MIN_FEE = 2_500; + 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"); + + 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)); + + feeRegistry = new CustomFeeRegistry(address(module)); + _enableInitializers(address(feeRegistry)); + feeRegistry.initialize(admin, MIN_FEE, COOLDOWN); + + acct = AccountingMock(address(module.ACCOUNTING())); + } + + function _weight(uint256 fee) internal pure returns (uint256) { + return MAX_BP + ((MAX_FEE - fee) / STEP) * SLOPE; + } + + function _requestFee(uint256 fee) internal { + vm.prank(nodeOperatorOwner); + feeRegistry.requestFee(NO_ID, fee); + } + + function _setTypeBonus(uint256 curveId, uint256 value, bool negative) internal { + vm.prank(admin); + feeRegistry.setTypeBonus(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_STEP(), STEP); + assertEq(feeRegistry.DEFAULT_MAX_FEE(), MAX_FEE); + assertEq(feeRegistry.WEIGHT_BOOST_PER_STEP(), SLOPE); + } +} + +contract CustomFeeRegistryInitializeTest is CustomFeeRegistryBaseTest { + function test_initialize() public view { + assertTrue(feeRegistry.hasRole(feeRegistry.DEFAULT_ADMIN_ROLE(), admin)); + assertEq(feeRegistry.getDefaultMinFee(), MIN_FEE); + assertEq(feeRegistry.getFeeIncreaseCooldown(), COOLDOWN); + } + + function test_initialize_EmitsEvents() public { + CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); + _enableInitializers(address(tp)); + vm.expectEmit(address(tp)); + emit ICustomFeeRegistry.DefaultMinFeeSet(MIN_FEE); + vm.expectEmit(address(tp)); + emit ICustomFeeRegistry.FeeIncreaseCooldownSet(COOLDOWN); + tp.initialize(admin, MIN_FEE, COOLDOWN); + } + + function test_initialize_RevertWhen_ZeroAdmin() public { + CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); + _enableInitializers(address(tp)); + vm.expectRevert(ICustomFeeRegistry.ZeroAdminAddress.selector); + tp.initialize(address(0), MIN_FEE, COOLDOWN); + } + + function test_initialize_RevertWhen_DoubleCall() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + feeRegistry.initialize(admin, MIN_FEE, COOLDOWN); + } + + function test_initialize_RevertWhen_ZeroMinFee() public { + CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); + _enableInitializers(address(tp)); + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + tp.initialize(admin, 0, COOLDOWN); + } + + function test_initialize_RevertWhen_MinFeeAtOrAboveMax() public { + CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); + _enableInitializers(address(tp)); + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + tp.initialize(admin, MAX_FEE, COOLDOWN); + } + + function test_initialize_RevertWhen_MinFeeNotStepAligned() public { + CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); + _enableInitializers(address(tp)); + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + tp.initialize(admin, MIN_FEE + 1, COOLDOWN); + } + + function test_initialize_RevertWhen_ZeroCooldown() public { + CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); + _enableInitializers(address(tp)); + vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); + tp.initialize(admin, MIN_FEE, 0); + } +} + +contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { + function test_requestFee_Decrease_AppliesImmediately() public { + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeSet(NO_ID, 5_000); + _requestFee(5_000); + + assertEq(feeRegistry.getFee(NO_ID), 5_000); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(5_000)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 1); + assertEq(metaRegistryMock.lastChangedBoostOperatorId(), NO_ID); + } + + function test_requestFee_Decrease_ToMinFee() public { + _requestFee(MIN_FEE); + assertEq(feeRegistry.getFee(NO_ID), MIN_FEE); + // Exactly 2x at the planned minimum. + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 2 * MAX_BP); + } + + function test_requestFee_Increase_SetsPending() public { + _requestFee(5_000); + + uint256 cooldownUntil = block.timestamp + COOLDOWN; + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeIncreaseRequested(NO_ID, 6_000, cooldownUntil); + _requestFee(6_000); + + // The fee itself is unchanged, but the weight already follows the pending increase. + assertEq(feeRegistry.getFee(NO_ID), 5_000); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_000)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 2); + } + + function test_requestFee_Increase_OverwritesPendingAndRestartsCooldown() public { + _requestFee(5_000); + _requestFee(6_000); + + vm.warp(block.timestamp + 1 days); + uint256 cooldownUntil = block.timestamp + COOLDOWN; + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeIncreaseRequested(NO_ID, 7_000, cooldownUntil); + _requestFee(7_000); + + assertEq(feeRegistry.getFee(NO_ID), 5_000); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_000)); + + // The old deadline is void: the increase applies only at the restarted one. + vm.warp(cooldownUntil - 1 days); + vm.expectRevert(ICustomFeeRegistry.FeeIncreaseCooldownNotElapsed.selector); + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + + vm.warp(cooldownUntil); + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + assertEq(feeRegistry.getFee(NO_ID), 7_000); + } + + function test_requestFee_Increase_LoweringPendingRaisesWeight() public { + _requestFee(5_000); + _requestFee(7_000); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_000)); + + _requestFee(6_000); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_000)); + assertEq(feeRegistry.getFee(NO_ID), 5_000); + } + + function test_requestFee_Decrease_CancelsPending() public { + _requestFee(5_000); + _requestFee(6_000); + + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeSet(NO_ID, 4_000); + _requestFee(4_000); + + assertEq(feeRegistry.getFee(NO_ID), 4_000); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(4_000)); + + vm.warp(block.timestamp + COOLDOWN + 1); + vm.expectRevert(ICustomFeeRegistry.NoFeeIncreaseCooldown.selector); + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + } + + function test_requestFee_RevertWhen_NotOwner() public { + vm.expectRevert(ICustomFeeRegistry.SenderIsNotOperatorOwner.selector); + vm.prank(stranger); + feeRegistry.requestFee(NO_ID, 5_000); + } + + function test_requestFee_RevertWhen_BelowMinFee() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); + _requestFee(MIN_FEE - STEP); + } + + function test_requestFee_RevertWhen_AboveMaxFee() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); + _requestFee(MAX_FEE + STEP); + } + + function test_requestFee_RevertWhen_NotStepAligned() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); + _requestFee(5_000 + 1); + } + + function test_requestFee_RevertWhen_BelowTypeMinimum() public { + _setTypeBonus(0, 2_500, true); + // The negative bonus raises the operator's minimum to 5_000. + vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); + _requestFee(4_750); + } + + function test_requestFee_RevertWhen_SameFee_Unset() public { + vm.expectRevert(ICustomFeeRegistry.SameFee.selector); + _requestFee(MAX_FEE); + } + + function test_requestFee_RevertWhen_SameFee_Set() public { + _requestFee(5_000); + vm.expectRevert(ICustomFeeRegistry.SameFee.selector); + _requestFee(5_000); + } + + function test_requestFee_RevertWhen_SameFeeWithPending() public { + _requestFee(5_000); + _requestFee(6_000); + // Cancelling to the very same fee is not a thing: only a decrease cancels. + vm.expectRevert(ICustomFeeRegistry.SameFee.selector); + _requestFee(5_000); + } +} + +contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { + uint256 internal cooldownUntil; + + function setUp() public override { + super.setUp(); + _requestFee(5_000); + _requestFee(6_000); + cooldownUntil = block.timestamp + COOLDOWN; + } + + function test_applyFeeIncrease() public { + vm.warp(cooldownUntil + 1); + + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeIncreaseApplied(NO_ID, 6_000); + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + + assertEq(feeRegistry.getFee(NO_ID), 6_000); + // The weight followed the pending fee already, so no new notification. + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_000)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_applyFeeIncrease_AtExactDeadline() public { + vm.warp(cooldownUntil); + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + assertEq(feeRegistry.getFee(NO_ID), 6_000); + } + + function test_applyFeeIncrease_CooldownChangeDoesNotAffectPending() public { + vm.prank(admin); + feeRegistry.setFeeIncreaseCooldown(COOLDOWN * 2); + + vm.warp(cooldownUntil); + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + assertEq(feeRegistry.getFee(NO_ID), 6_000); + } + + function test_applyFeeIncrease_RevertWhen_NotOwner() public { + vm.warp(cooldownUntil + 1); + vm.expectRevert(ICustomFeeRegistry.SenderIsNotOperatorOwner.selector); + vm.prank(stranger); + feeRegistry.applyFeeIncrease(NO_ID); + } + + function test_applyFeeIncrease_RevertWhen_NotElapsed() public { + vm.warp(cooldownUntil - 1); + vm.expectRevert(ICustomFeeRegistry.FeeIncreaseCooldownNotElapsed.selector); + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + } + + function test_applyFeeIncrease_RevertWhen_NoPending() public { + vm.warp(cooldownUntil + 1); + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + + vm.expectRevert(ICustomFeeRegistry.NoFeeIncreaseCooldown.selector); + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + } +} + +contract CustomFeeRegistryRestoreFeeToMinTest is CustomFeeRegistryBaseTest { + function setUp() public override { + super.setUp(); + // The operator settles at the type minimum, then the DAO tightens the bonus. + _setTypeBonus(0, 2_500, true); + _requestFee(5_000); + _setTypeBonus(0, 5_000, true); + // The minimum is now 7_500 and the operator at 5_000 sits below it. + } + + function test_restoreFeeToMin() public { + assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); // max(0, 5_000 - 5_000) + + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeSet(NO_ID, 7_500); + vm.prank(stranger); // permissionless + feeRegistry.restoreFeeToMin(NO_ID); + + assertEq(feeRegistry.getFee(NO_ID), 7_500); + // The effective fee is back at the default minimum. + assertEq(feeRegistry.getEffectiveFee(NO_ID), MIN_FEE); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_500)); + } + + function test_restoreFeeToMin_RevertWhen_FeeNotBelowMinFee() public { + vm.prank(stranger); + feeRegistry.restoreFeeToMin(NO_ID); + + vm.expectRevert(ICustomFeeRegistry.FeeNotBelowMinFee.selector); + vm.prank(stranger); + feeRegistry.restoreFeeToMin(NO_ID); + } + + function test_restoreFeeToMin_RevertWhen_UnsetOperator() public { + // An unset operator reads as DEFAULT_MAX_FEE and can never be below the minimum. + vm.expectRevert(ICustomFeeRegistry.FeeNotBelowMinFee.selector); + vm.prank(stranger); + feeRegistry.restoreFeeToMin(1); + } + + function test_restoreFeeToMin_RevertWhen_IncreasePending() public { + vm.prank(nodeOperatorOwner); + feeRegistry.requestFee(NO_ID, 8_000); + + vm.expectRevert(ICustomFeeRegistry.FeeIncreaseCooldownActive.selector); + vm.prank(stranger); + feeRegistry.restoreFeeToMin(NO_ID); + } +} + +contract CustomFeeRegistrySetDefaultMinFeeTest is CustomFeeRegistryBaseTest { + function test_setDefaultMinFee() public { + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.DefaultMinFeeSet(2_000); + vm.prank(admin); + feeRegistry.setDefaultMinFee(2_000); + + assertEq(feeRegistry.getDefaultMinFee(), 2_000); + } + + function test_setDefaultMinFee_OpensWeightsAboveTwo() public { + vm.prank(admin); + feeRegistry.setDefaultMinFee(1_000); + + // The weight line does not move: fees below the initial minimum extrapolate above 2x. + _requestFee(1_000); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_000)); + assertGt(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 2 * MAX_BP); + } + + function test_setDefaultMinFee_RevertWhen_NotAdmin() public { + vm.expectRevert(); + vm.prank(stranger); + feeRegistry.setDefaultMinFee(2_000); + } + + function test_setDefaultMinFee_RevertWhen_Zero() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + vm.prank(admin); + feeRegistry.setDefaultMinFee(0); + } + + function test_setDefaultMinFee_RevertWhen_NotBelowCurrent() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + vm.prank(admin); + feeRegistry.setDefaultMinFee(MIN_FEE); + } + + function test_setDefaultMinFee_RevertWhen_NotStepAligned() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + vm.prank(admin); + feeRegistry.setDefaultMinFee(2_000 + 1); + } +} + +contract CustomFeeRegistrySetTypeBonusTest is CustomFeeRegistryBaseTest { + function test_setTypeBonus_Positive() public { + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.TypeBonusSet(0, 1_250, false); + _setTypeBonus(0, 1_250, false); + + TypeBonus memory bonus = feeRegistry.getTypeBonus(0); + assertEq(bonus.value, 1_250); + assertFalse(bonus.negative); + // The bonus tops the effective fee up to 100% for an unset operator. + assertEq(feeRegistry.getEffectiveFee(NO_ID), MAX_BP); + assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); + } + + function test_setTypeBonus_Negative() public { + _setTypeBonus(0, 2_500, true); + + TypeBonus memory bonus = feeRegistry.getTypeBonus(0); + assertEq(bonus.value, 2_500); + assertTrue(bonus.negative); + assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE + 2_500); + } + + function test_setTypeBonus_ZeroWithAnySign() public { + _setTypeBonus(0, 0, true); + assertEq(feeRegistry.getEffectiveFee(NO_ID), MAX_FEE); + assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); + + _setTypeBonus(0, 0, false); + assertEq(feeRegistry.getEffectiveFee(NO_ID), MAX_FEE); + assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); + } + + function test_setTypeBonus_DoesNotNotifyAndDoesNotMoveWeight() public { + _requestFee(5_000); + uint256 weightBefore = feeRegistry.getWeightBoostMultiplierBP(NO_ID); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _setTypeBonus(0, 1_250, false); + + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), weightBefore); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_setTypeBonus_SameFeeSameWeightAcrossTypes() public { + _setTypeBonus(0, 1_250, false); + _setTypeBonus(1, 2_500, true); + _requestFee(5_000); + + 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. + acct.setBondCurve(NO_ID, 1); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), weightOnTypeA); + assertEq(feeRegistry.getEffectiveFee(NO_ID), 2_500); + } + + function test_setTypeBonus_RevertWhen_NotAdmin() public { + vm.expectRevert(); + vm.prank(stranger); + feeRegistry.setTypeBonus(0, 1_250, false); + } + + function test_setTypeBonus_RevertWhen_PositiveAboveMax() public { + // MAX_BP - DEFAULT_MAX_FEE = 1_250 is the largest positive bonus. + vm.expectRevert(ICustomFeeRegistry.InvalidTypeBonus.selector); + _setTypeBonus(0, 1_250 + STEP, false); + } + + function test_setTypeBonus_RevertWhen_NegativeAboveMax() public { + // DEFAULT_MAX_FEE - defaultMinFee = 6_250 is the largest negative bonus. + vm.expectRevert(ICustomFeeRegistry.InvalidTypeBonus.selector); + _setTypeBonus(0, 6_250 + STEP, true); + } + + function test_setTypeBonus_RevertWhen_NotStepAligned() public { + vm.expectRevert(ICustomFeeRegistry.InvalidTypeBonus.selector); + _setTypeBonus(0, 100, false); + } +} + +contract CustomFeeRegistrySetFeeIncreaseCooldownTest is CustomFeeRegistryBaseTest { + function test_setFeeIncreaseCooldown() public { + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeIncreaseCooldownSet(30 days); + vm.prank(admin); + feeRegistry.setFeeIncreaseCooldown(30 days); + + assertEq(feeRegistry.getFeeIncreaseCooldown(), 30 days); + } + + function test_setFeeIncreaseCooldown_RevertWhen_NotAdmin() public { + vm.expectRevert(); + vm.prank(stranger); + feeRegistry.setFeeIncreaseCooldown(30 days); + } + + function test_setFeeIncreaseCooldown_RevertWhen_Zero() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); + vm.prank(admin); + feeRegistry.setFeeIncreaseCooldown(0); + } +} + +contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { + function test_getFee_UnsetReadsAsDefaultMax() public view { + assertEq(feeRegistry.getFee(NO_ID), MAX_FEE); + } + + function test_getWeightBoostMultiplierBP_UnsetIsOne() public view { + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP); + } + + function test_getWeightBoostMultiplierBP_Linearity() public { + _requestFee(MAX_FEE - STEP); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP + SLOPE); + + _requestFee(MIN_FEE); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 2 * MAX_BP); + } + + function test_getEffectiveFee_NoBonus() public { + _requestFee(5_000); + assertEq(feeRegistry.getEffectiveFee(NO_ID), 5_000); + } + + function test_getEffectiveFee_ClampsAtZero() public { + _setTypeBonus(0, 2_500, true); + _requestFee(5_000); + _setTypeBonus(0, 5_500, true); + + // max(0, 5_000 - 5_500) + assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); + } + + function test_getEffectiveFee_PendingIncreaseIsExcluded() public { + _requestFee(5_000); + _requestFee(6_000); + assertEq(feeRegistry.getEffectiveFee(NO_ID), 5_000); + } + + function test_getMinFee_Default() public view { + assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); + } +} From 2284996c5c3085aae23bc41b6ff00c19cf62a2cb Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Fri, 17 Jul 2026 11:34:23 +0200 Subject: [PATCH 02/11] chore: docs --- src/interfaces/ICustomFeeRegistry.sol | 70 ++++++++++++++++++--------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/src/interfaces/ICustomFeeRegistry.sol b/src/interfaces/ICustomFeeRegistry.sol index cbe2f49d5..128606b9e 100644 --- a/src/interfaces/ICustomFeeRegistry.sol +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -24,51 +24,73 @@ struct TypeBonus { } /* - * ── Fee scale and per-type ranges ───────────────────────────────────────────── + * ── Fee scale and per-type ranges ────────────────────────────────────────────── * * All fees are basis points (BP) of the operator's own rewards; the lower * axis shows the same fee as a share of the total staking rewards at a 4% - * module share. One character is one step of 250 BP. + * module share. One character is 125 BP (half a fee step). * An operator picks a custom fee between defaultMinFee and DEFAULT_MAX_FEE; * the lower the fee, the higher the allocation weight. A type bonus shifts * only the effective fee. * ● custom (set by the operator) ○ effective (billed by the oracle) * - * portion BP 0 2500 5000 6250 8750 10000 - * ├─────────┼─────────┼────┼─────────┼────┤ - * total at 4% 0% 1% 2% 2.5% 3.5% 4% - * └ defaultMinFee └ DEFAULT_MAX_FEE + * portion BP 0 2500 5000 6250 8750 10000 + * ├───────────────────┼───────────────────┼─────────┼───────────────────┼─────────┤ + * total at 4% 0% 1% 2% 2.5% 3.5% 4% + * └ defaultMinFee └ DEFAULT_MAX_FEE * * type A — bonus +1250, effective = custom + 1250: - * custom ●────────────────────────● - * effective ○────────────────────────○ + * custom ●─────────────────────────────────────────────────● + * effective ○─────────────────────────────────────────────────○ * * type B — bonus -2500, effective = custom - 2500; the minimum rises to 5000: - * custom ●──────────────● - * effective ○──────────────○ + * custom ●─────────────────────────────● + * effective ○─────────────────────────────○ * * A and B both pick custom = 5000: equal weights, different effective fees: - * custom A = B ● - * effective A ○ - * effective B ○ + * custom A = B ● + * effective A ○ + * effective B ○ * - * ── Fee increase timeline and oracle frames ─────────────────────────────────── + * ── Custom fee to allocation weight ──────────────────────────────────────────── + * + * The weight multiplier depends on the fee only, never on a type bonus — on + * the custom fee, or the pending target while an increase is pending (see the + * timeline below). Two things are fixed: 1x at DEFAULT_MAX_FEE and a straight + * slope of WEIGHT_BOOST_PER_STEP (400 BP) per FEE_STEP (250 BP) of discount. + * The minimum is a parameter: the planned defaultMinFee of 2500 happens to + * land at 2x, and governance may lower it further down the same line, no cap. + * + * multiplier + * 2.4x ┤╲ custom 0: hard floor, defaultMinFee > 0 + * 2.2x ┤ ╲ below the min — only if DAO lowers it + * 2.0x ┤ ● 2500 (planned defaultMinFee): 2x + * 1.8x ┤ ╲ + * 1.6x ┤ ╲ + * 1.4x ┤ ╲ + * 1.2x ┤ ╲ + * 1.0x ┤ ● 8750 (DEFAULT_MAX_FEE, unset): 1x + * └┬ ┬ ┬─► custom fee, portion BP + * 0 2500 8750 + * ├────────reachable now────────┤ + * + * ── Fee increase timeline and oracle frames ──────────────────────────────────── * * Z is the fee-increase cooldown. The oracle reads the fee at the refSlot. * Keeping Z >= frame + margin (a governance invariant) guarantees at least one * report at the old fee while the weight is already low. A decrease needs * no timeline: it applies at once and cancels any pending increase. * - * requestFee(6000) - * │ applyFeeIncrease() - * │ │ - * 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 + * requestFee(6000) + * │ applyFeeIncrease() + * │ │ + * 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: a decrease cancels it; a new increase overwrites it and restarts * the cooldown. From 921cbd0bbc7740ba40b4daaa4f3e4bd516f5f38f Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 28 Jul 2026 17:54:00 +0200 Subject: [PATCH 03/11] feat: changes, tests, deploy script --- script/curated/DeployBase.s.sol | 51 +++ script/curated/DeployHoodi.s.sol | 7 +- script/curated/DeployLocalDevNet.s.sol | 6 +- script/curated/DeployMainnet.s.sol | 7 +- src/CustomFeeRegistry.sol | 172 +++++++---- src/interfaces/ICustomFeeRegistry.sol | 130 +++++--- .../deployment/PostDeploymentCurated.t.sol | 63 +++- test/helpers/Fixtures.sol | 20 ++ test/unit/CustomFeeRegistry.t.sol | 292 ++++++++++++++---- 9 files changed, 576 insertions(+), 172 deletions(-) diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 338dc712d..9421c60b1 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -21,6 +21,7 @@ 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 { CustomFeeRegistry } from "../../src/CustomFeeRegistry.sol"; import { BoostStep } from "../../src/interfaces/IAdditionalBondRegistry.sol"; import { ERC20LockBoostProvider } from "../../src/ERC20LockBoostProvider.sol"; import { LidoGovernanceLockVault } from "../../src/LidoGovernanceLockVault.sol"; @@ -84,6 +85,18 @@ struct ERC20LockBoostProviderConfig { IERC20LockBoostProvider.LockBoostStep[] lockBoostSteps; } +struct CurveTypeBonusConfig { + uint256 curveId; + uint256 value; + bool negative; +} + +struct CustomFeeRegistryConfig { + uint256 defaultMinFee; + uint256 feeIncreaseCooldown; + CurveTypeBonusConfig[] typeBonuses; +} + struct CuratedDeployParams { // Lido addresses address lidoLocatorAddress; @@ -154,6 +167,8 @@ struct CuratedDeployParams { StrikeThreshold[] strikesThresholds; // LDO lock boost provider ERC20LockBoostProviderConfig ldoLockBoostProviderConfig; + // CustomFeeRegistry + CustomFeeRegistryConfig customFeeRegistryConfig; } abstract contract DeployBase is Script { @@ -182,6 +197,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 +290,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(), @@ -421,6 +439,24 @@ abstract contract DeployBase is Script { ldoLockBoostProviderProxy.proxy__changeAdmin(config.proxyAdmin); } + customFeeRegistryImpl = new CustomFeeRegistry({ module: address(curatedModule) }); + + { + OssifiableProxy customFeeRegistryProxy = OssifiableProxy(payable(address(customFeeRegistry))); + customFeeRegistryProxy.proxy__upgradeToAndCall( + address(customFeeRegistryImpl), + abi.encodeCall( + CustomFeeRegistry.initialize, + ( + deployer, + config.customFeeRegistryConfig.defaultMinFee, + config.customFeeRegistryConfig.feeIncreaseCooldown + ) + ) + ); + customFeeRegistryProxy.proxy__changeAdmin(config.proxyAdmin); + } + accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), address(additionalBondRegistry)); metaRegistry.addWeightBoostProvider( @@ -431,6 +467,10 @@ abstract contract DeployBase is Script { IWeightBoostProvider(address(nodeOperatorStrikes)), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator ); + metaRegistry.addWeightBoostProvider( + IWeightBoostProvider(address(customFeeRegistry)), + IMetaRegistry.WeightBoostProviderMode.PerNodeOperator + ); nodeOperatorStrikes.grantRole(nodeOperatorStrikes.STRIKES_COMMITTEE_ROLE(), config.strikesCommittee); metaRegistry.addWeightBoostProvider( IWeightBoostProvider(address(ldoLockBoostProvider)), @@ -503,6 +543,12 @@ abstract contract DeployBase is Script { parametersRegistry.setMaxElWithdrawalRequestFee(curveId, params.maxElWithdrawalRequestFee.value); } } + + for (uint256 i; i < config.customFeeRegistryConfig.typeBonuses.length; ++i) { + CurveTypeBonusConfig storage bonus = config.customFeeRegistryConfig.typeBonuses[i]; + customFeeRegistry.setTypeBonus(bonus.curveId, bonus.value, bonus.negative); + } + accounting.revokeRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); metaRegistry.revokeRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); @@ -644,6 +690,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 +725,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)); @@ -780,6 +830,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..98c899082 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig } from "./DeployBase.s.sol"; import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; @@ -223,6 +223,11 @@ contract DeployHoodi is DeployBase { _addLDOLockBoostStep(100_000 ether, 1_000); _addLDOLockBoostStep(200_000 ether, 1_500); + // CustomFeeRegistry + // TODO: finalize custom fee parameters and per-type bonuses. + config.customFeeRegistryConfig.defaultMinFee = 2_500; + config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + _setUp(); } } diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index d07272d30..7b82d0c56 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig } from "./DeployBase.s.sol"; import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; import { BaseOracle } from "../../src/lib/base-oracle/BaseOracle.sol"; @@ -210,6 +210,10 @@ contract DeployLocalDevNet is DeployBase { _addLDOLockBoostStep(100_000 ether, 1_000); _addLDOLockBoostStep(200_000 ether, 1_500); + // CustomFeeRegistry + config.customFeeRegistryConfig.defaultMinFee = 2_500; + config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + _setUp(); } diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index f5eda7734..de4beeac3 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig } from "./DeployBase.s.sol"; import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; @@ -222,6 +222,11 @@ contract DeployMainnet is DeployBase { _addLDOLockBoostStep(100_000 ether, 1_000); _addLDOLockBoostStep(200_000 ether, 1_500); + // CustomFeeRegistry + // TODO: finalize custom fee parameters and per-type bonuses. + config.customFeeRegistryConfig.defaultMinFee = 2_500; + config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + _setUp(); } } diff --git a/src/CustomFeeRegistry.sol b/src/CustomFeeRegistry.sol index d8f4f6e11..4ca11c091 100644 --- a/src/CustomFeeRegistry.sol +++ b/src/CustomFeeRegistry.sol @@ -8,6 +8,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { IAccounting } from "./interfaces/IAccounting.sol"; +import { IBondCurve } from "./interfaces/IBondCurve.sol"; import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; import { ICustomFeeRegistry, OperatorFee, TypeBonus } from "./interfaces/ICustomFeeRegistry.sol"; @@ -31,7 +32,7 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn // A custom fee moves in these increments: 2.5% of the operator's rewards, which is 0.1% // of the total staking rewards at a 4% module reward share. uint256 public constant FEE_STEP = 250; // 2.5% - // The starting fee of every operator; a custom fee can only go below it. + // The fee of an unset operator and the inclusive upper bound for custom fees. uint256 public constant DEFAULT_MAX_FEE = 35 * FEE_STEP; // 87.5% // Slope of the weight line: multiplier basis points per FEE_STEP below DEFAULT_MAX_FEE. uint256 public constant WEIGHT_BOOST_PER_STEP = 400; @@ -62,48 +63,61 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn } /// @inheritdoc ICustomFeeRegistry - function requestFee(uint256 nodeOperatorId, uint256 fee) external { + function requestFee(uint256 nodeOperatorId, uint256 requestedFee) external { _checkOperatorOwner(nodeOperatorId); - uint256 minFee = _getMinFee(nodeOperatorId); - if (fee < minFee || fee > DEFAULT_MAX_FEE || fee % FEE_STEP != 0) revert InvalidFee(); + OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; + uint256 currentFee = _getCurrentFee(nodeOperatorId); - uint256 curFee = _getFee(nodeOperatorId); - if (fee == curFee) revert SameFee(); + // Cancellation remains available if a curve or bonus change raised the minimum. + if (requestedFee == currentFee) { + if (operatorFee.cooldownUntil == 0) revert SameFee(); - if (fee < curFee) { - // A decrease also cancels any pending increase along with its cooldown. - _setFee(nodeOperatorId, fee); + operatorFee.pendingFeeIncrease = 0; + operatorFee.cooldownUntil = 0; + emit FeeIncreaseCancelled(nodeOperatorId); + META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); return; } - _requestFeeIncrease(nodeOperatorId, fee); + _validateFee(nodeOperatorId, requestedFee); + + if (requestedFee < currentFee) { + _setCurrentFee(nodeOperatorId, requestedFee); + } else { + _scheduleFeeIncrease(nodeOperatorId, requestedFee); + } } /// @inheritdoc ICustomFeeRegistry function applyFeeIncrease(uint256 nodeOperatorId) external { _checkOperatorOwner(nodeOperatorId); - CustomFeeRegistryStorage storage $ = _storage(); - OperatorFee storage f = $.fees[nodeOperatorId]; - if (f.cooldownUntil == 0) revert NoFeeIncreaseCooldown(); - if (f.cooldownUntil > block.timestamp) revert FeeIncreaseCooldownNotElapsed(); + OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; + if (operatorFee.cooldownUntil == 0) revert NoFeeIncreaseCooldown(); + if (operatorFee.cooldownUntil > block.timestamp) revert FeeIncreaseCooldownNotElapsed(); - uint16 fee = f.pendingFeeIncrease; - $.fees[nodeOperatorId] = OperatorFee({ currentFee: fee, pendingFeeIncrease: 0, cooldownUntil: 0 }); - emit FeeIncreaseApplied(nodeOperatorId, fee); - // No weight notification: the weight has followed the pending fee since the request. + uint16 pendingFee = operatorFee.pendingFeeIncrease; + // The fee may have become invalid during the cooldown after a curve or bonus change. + _validateFee(nodeOperatorId, pendingFee); + + operatorFee.currentFee = pendingFee; + operatorFee.pendingFeeIncrease = 0; + operatorFee.cooldownUntil = 0; + emit FeeIncreaseApplied(nodeOperatorId, pendingFee); + // No notification: the allocation weight changed when the increase was requested. } /// @inheritdoc ICustomFeeRegistry - function restoreFeeToMin(uint256 nodeOperatorId) external { - if (_storage().fees[nodeOperatorId].cooldownUntil != 0) revert FeeIncreaseCooldownActive(); - - uint256 minFee = _getMinFee(nodeOperatorId); - // A fee below the type's minimum can only be left over from a tightened type bonus. - if (_getFee(nodeOperatorId) >= minFee) revert FeeNotBelowMinFee(); + function normalizeFee(uint256 nodeOperatorId) external { + if (!_normalizeFee(nodeOperatorId)) revert FeeNotBelowMinFee(); + } - _setFee(nodeOperatorId, minFee); + /// @inheritdoc ICustomFeeRegistry + function normalizeFees(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount) { + for (uint256 i; i < nodeOperatorIds.length; ++i) { + if (_normalizeFee(nodeOperatorIds[i])) ++normalizedCount; + } } /// @inheritdoc ICustomFeeRegistry @@ -114,6 +128,8 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn /// @inheritdoc ICustomFeeRegistry function setTypeBonus(uint256 curveId, uint256 value, bool negative) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (curveId >= ACCOUNTING.getCurvesCount()) revert IBondCurve.InvalidBondCurveId(); + CustomFeeRegistryStorage storage $ = _storage(); // Keeps the effective fee within MAX_BP and the per-type minimum within DEFAULT_MAX_FEE. uint256 maxValue = negative ? DEFAULT_MAX_FEE - $.defaultMinFee : MAX_BP - DEFAULT_MAX_FEE; @@ -146,7 +162,17 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn /// @inheritdoc ICustomFeeRegistry function getFee(uint256 nodeOperatorId) external view returns (uint256) { - return _getFee(nodeOperatorId); + return _getCurrentFee(nodeOperatorId); + } + + /// @inheritdoc ICustomFeeRegistry + function getPendingFeeIncrease(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().fees[nodeOperatorId].pendingFeeIncrease; + } + + /// @inheritdoc ICustomFeeRegistry + function getFeeIncreaseCooldownUntil(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().fees[nodeOperatorId].cooldownUntil; } /// @inheritdoc ICustomFeeRegistry @@ -156,60 +182,60 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn /// @inheritdoc IWeightBoostProvider function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { - OperatorFee storage f = _storage().fees[nodeOperatorId]; - // During a pending increase the weight already follows the higher pending fee. - uint256 fee = f.pendingFeeIncrease > 0 ? f.pendingFeeIncrease : _getFee(nodeOperatorId); + OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; + // A pending increase affects allocation weight before it becomes the current fee. + uint256 fee = operatorFee.cooldownUntil != 0 ? operatorFee.pendingFeeIncrease : _getCurrentFee(nodeOperatorId); // The multiplier is 1x for an operator at DEFAULT_MAX_FEE and grows by // WEIGHT_BOOST_PER_STEP for every step below it. Fees are multiples of FEE_STEP, // so the division has no remainder. - multiplierBP = MAX_BP + ((DEFAULT_MAX_FEE - fee) / FEE_STEP) * WEIGHT_BOOST_PER_STEP; + multiplierBP = MAX_BP + ((DEFAULT_MAX_FEE - fee) * WEIGHT_BOOST_PER_STEP) / FEE_STEP; } /// @inheritdoc ICustomFeeRegistry function getEffectiveFee(uint256 nodeOperatorId) external view returns (uint256) { - uint256 fee = _getFee(nodeOperatorId); + uint256 currentFee = _getCurrentFee(nodeOperatorId); TypeBonus storage bonus = _storage().typeBonus[ACCOUNTING.getBondCurveId(nodeOperatorId)]; if (bonus.negative) { - // A tightened bonus can leave the fee below its value. - return fee > bonus.value ? fee - bonus.value : 0; + // A curve or bonus change can leave the current fee below the negative bonus. + return currentFee > bonus.value ? currentFee - bonus.value : 0; } // The bonus bounds keep the sum within MAX_BP. - return fee + bonus.value; - } - - /// @dev Sets the custom fee immediately, dropping any pending increase, and notifies the - /// weight change. - function _setFee(uint256 nodeOperatorId, uint256 fee) internal { - _storage().fees[nodeOperatorId] = OperatorFee({ - currentFee: fee.toUint16(), - pendingFeeIncrease: 0, - cooldownUntil: 0 - }); - emit FeeSet(nodeOperatorId, fee); - META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + return currentFee + bonus.value; } - /// @dev Locks in a pending fee increase with its cooldown and notifies the weight change; the - /// fee itself applies via `applyFeeIncrease` once the cooldown elapses. A repeated - /// increase overwrites the pending one and restarts the cooldown. - function _requestFeeIncrease(uint256 nodeOperatorId, uint256 fee) internal { + /// @dev Sets the custom fee immediately, drops any pending increase, and notifies if the + /// allocation weight changes. + function _setCurrentFee(uint256 nodeOperatorId, uint256 newCurrentFee) internal { + OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; + bool hadPendingIncrease = operatorFee.cooldownUntil != 0; + // A pending increase, if any, is the fee currently affecting allocation weight. + uint256 previousFee = hadPendingIncrease ? operatorFee.pendingFeeIncrease : _getCurrentFee(nodeOperatorId); + + operatorFee.currentFee = newCurrentFee.toUint16(); + operatorFee.pendingFeeIncrease = 0; + operatorFee.cooldownUntil = 0; + if (hadPendingIncrease) emit FeeIncreaseCancelled(nodeOperatorId); + emit FeeSet(nodeOperatorId, newCurrentFee); + if (previousFee != newCurrentFee) META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + } + + /// @dev Stores or replaces a pending increase, restarts its cooldown, and notifies MetaRegistry. + /// Notification is sent even if the pending target and multiplier are unchanged. + function _scheduleFeeIncrease(uint256 nodeOperatorId, uint256 pendingFee) internal { CustomFeeRegistryStorage storage $ = _storage(); - OperatorFee storage f = $.fees[nodeOperatorId]; - // Unreachable for an unset operator, so `f.currentFee` is never zero here. + OperatorFee storage operatorFee = $.fees[nodeOperatorId]; uint256 cooldownUntil = block.timestamp + $.feeIncreaseCooldown; - $.fees[nodeOperatorId] = OperatorFee({ - currentFee: f.currentFee, - pendingFeeIncrease: fee.toUint16(), - cooldownUntil: cooldownUntil.toUint64() - }); - emit FeeIncreaseRequested(nodeOperatorId, fee, cooldownUntil); + if (cooldownUntil > type(uint64).max) revert InvalidFeeIncreaseCooldown(); + operatorFee.pendingFeeIncrease = pendingFee.toUint16(); + operatorFee.cooldownUntil = cooldownUntil.toUint64(); + emit FeeIncreaseRequested(nodeOperatorId, pendingFee, cooldownUntil); META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); } - /// @dev Never zero: a zero stored custom fee is the "never set" marker. - function _setDefaultMinFee(uint256 defaultMinFee, uint256 maxAllowed) internal { - if (defaultMinFee == 0 || defaultMinFee >= maxAllowed || defaultMinFee % FEE_STEP != 0) { + /// @dev The minimum must remain non-zero because zero currentFee is the unset marker. + function _setDefaultMinFee(uint256 defaultMinFee, uint256 maxExclusive) internal { + if (defaultMinFee == 0 || defaultMinFee >= maxExclusive || defaultMinFee % FEE_STEP != 0) { revert InvalidDefaultMinFee(); } _storage().defaultMinFee = defaultMinFee; @@ -217,13 +243,28 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn } function _setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) internal { - if (feeIncreaseCooldown == 0) revert InvalidFeeIncreaseCooldown(); + if (feeIncreaseCooldown == 0 || feeIncreaseCooldown > type(uint64).max) { + revert InvalidFeeIncreaseCooldown(); + } _storage().feeIncreaseCooldown = feeIncreaseCooldown; emit FeeIncreaseCooldownSet(feeIncreaseCooldown); } - /// @dev Per-type minimum: a negative bonus raises it so the effective fee stays above the - /// default min fee. + /// @dev Normalizes the fee if a curve or bonus change left it below the current minimum. + function _normalizeFee(uint256 nodeOperatorId) internal returns (bool normalized) { + uint256 minFee = _getMinFee(nodeOperatorId); + if (_getCurrentFee(nodeOperatorId) >= minFee) return false; + + _setCurrentFee(nodeOperatorId, minFee); + return true; + } + + function _validateFee(uint256 nodeOperatorId, uint256 fee) internal view { + if (fee < _getMinFee(nodeOperatorId) || fee > DEFAULT_MAX_FEE || fee % FEE_STEP != 0) revert InvalidFee(); + } + + /// @dev A negative bonus raises the per-type minimum so a valid current fee has an effective + /// fee of at least defaultMinFee. Existing fees may remain below it until normalized. function _getMinFee(uint256 nodeOperatorId) internal view returns (uint256) { CustomFeeRegistryStorage storage $ = _storage(); TypeBonus storage bonus = $.typeBonus[ACCOUNTING.getBondCurveId(nodeOperatorId)]; @@ -231,12 +272,11 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn } /// @dev Zero means "never set" and reads as DEFAULT_MAX_FEE; a real fee is never zero (defaultMinFee > 0). - function _getFee(uint256 nodeOperatorId) internal view returns (uint256) { + function _getCurrentFee(uint256 nodeOperatorId) internal view returns (uint256) { uint256 fee = _storage().fees[nodeOperatorId].currentFee; return fee == 0 ? DEFAULT_MAX_FEE : fee; } - // TODO: Have the same in many places. Move to lib function _checkOperatorOwner(uint256 nodeOperatorId) internal view { if (msg.sender != MODULE.getNodeOperatorOwner(nodeOperatorId)) { revert SenderIsNotOperatorOwner(); diff --git a/src/interfaces/ICustomFeeRegistry.sol b/src/interfaces/ICustomFeeRegistry.sol index 128606b9e..127d22b20 100644 --- a/src/interfaces/ICustomFeeRegistry.sol +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -29,10 +29,11 @@ struct TypeBonus { * All fees are basis points (BP) of the operator's own rewards; the lower * axis shows the same fee as a share of the total staking rewards at a 4% * module share. One character is 125 BP (half a fee step). - * An operator picks a custom fee between defaultMinFee and DEFAULT_MAX_FEE; - * the lower the fee, the higher the allocation weight. A type bonus shifts - * only the effective fee. - * ● custom (set by the operator) ○ effective (billed by the oracle) + * An operator picks a custom fee between its current getMinFee(id) and + * DEFAULT_MAX_FEE; a negative type bonus raises that lower bound above + * defaultMinFee. The lower the custom fee, the higher the allocation weight. + * A type bonus shifts only the effective fee. + * ● custom (set by the operator) ○ effective (exposed for fee reporting) * * portion BP 0 2500 5000 6250 8750 10000 * ├───────────────────┼───────────────────┼─────────┼───────────────────┼─────────┤ @@ -58,13 +59,15 @@ struct TypeBonus { * the custom fee, or the pending target while an increase is pending (see the * timeline below). Two things are fixed: 1x at DEFAULT_MAX_FEE and a straight * slope of WEIGHT_BOOST_PER_STEP (400 BP) per FEE_STEP (250 BP) of discount. - * The minimum is a parameter: the planned defaultMinFee of 2500 happens to - * land at 2x, and governance may lower it further down the same line, no cap. + * The minimum is a parameter. For the illustrated defaultMinFee of 2500, the + * multiplier at the minimum is 2x. Governance may lower the minimum along the + * same line. Since it must remain a non-zero multiple of FEE_STEP, the lowest + * reachable fee is 250 and the highest reachable multiplier is 2.36x. * * multiplier - * 2.4x ┤╲ custom 0: hard floor, defaultMinFee > 0 - * 2.2x ┤ ╲ below the min — only if DAO lowers it - * 2.0x ┤ ● 2500 (planned defaultMinFee): 2x + * 2.4x ┤╲ custom 0: unreachable + * 2.2x ┤ ╲ below 2500 — only if DAO lowers the minimum + * 2.0x ┤ ● 2500 (illustrated defaultMinFee): 2x * 1.8x ┤ ╲ * 1.6x ┤ ╲ * 1.4x ┤ ╲ @@ -72,14 +75,16 @@ struct TypeBonus { * 1.0x ┤ ● 8750 (DEFAULT_MAX_FEE, unset): 1x * └┬ ┬ ┬─► custom fee, portion BP * 0 2500 8750 - * ├────────reachable now────────┤ + * ├────reachable when defaultMinFee = 2500────┤ * * ── Fee increase timeline and oracle frames ──────────────────────────────────── * - * Z is the fee-increase cooldown. The oracle reads the fee at the refSlot. - * Keeping Z >= frame + margin (a governance invariant) guarantees at least one - * report at the old fee while the weight is already low. A decrease needs - * no timeline: it applies at once and cancels any pending increase. + * Z is the fee-increase cooldown. Off-chain fee-report construction 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 + * increase. A decrease applies at once and cancels any pending increase. * * requestFee(6000) * │ applyFeeIncrease() @@ -94,32 +99,49 @@ struct TypeBonus { * * pending: a decrease cancels it; a new increase overwrites it and restarts * the cooldown. - * * the report the cooldown guarantees: the weight is already low, yet frame k - * is still billed at the old fee. - * ** the oracle reads the fee at this frame's refSlot, so a change made - * mid-frame takes effect for the whole frame. + * * 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 custom fees and the allocation weight boost derived from them: the lower /// the custom fee, the higher the operator's allocation weight. The effective fee -/// (custom + type bonus) is what the fee oracle bills. See the diagrams above. +/// (custom + type bonus) is exposed for off-chain fee-report construction. The registry +/// itself does not enforce report timing or construction. See the diagrams above. interface ICustomFeeRegistry is IWeightBoostProvider { + /// @notice Emitted when a decrease or normalization sets the current fee immediately. event FeeSet(uint256 indexed nodeOperatorId, uint256 fee); + /// @notice Emitted when a pending increase is created or replaced. event FeeIncreaseRequested(uint256 indexed nodeOperatorId, uint256 pendingFeeIncrease, uint256 cooldownUntil); + /// @notice Emitted when a pending increase becomes the current fee and pending state is cleared. event FeeIncreaseApplied(uint256 indexed nodeOperatorId, uint256 fee); + /// @notice Emitted when a pending increase is cancelled by a fee request or normalization. + event FeeIncreaseCancelled(uint256 indexed nodeOperatorId); + /// @notice Emitted during initialization and whenever the default minimum is lowered. event DefaultMinFeeSet(uint256 defaultMinFee); + /// @notice Emitted when the sign and magnitude of an existing curve's type bonus are stored. event TypeBonusSet(uint256 indexed curveId, uint256 value, bool negative); + /// @notice Emitted when the cooldown for future requests is set; existing deadlines are unchanged. event FeeIncreaseCooldownSet(uint256 feeIncreaseCooldown); + /// @notice The initializer admin is the zero address. error ZeroAdminAddress(); + /// @notice The caller is not the Node Operator owner. error SenderIsNotOperatorOwner(); + /// @notice A requested or pending fee is outside its current valid range or is not step-aligned. error InvalidFee(); + /// @notice The requested fee equals the current fee and there is no pending increase to cancel. error SameFee(); + /// @notice Strict normalization was requested while the current fee was already valid. error FeeNotBelowMinFee(); - error FeeIncreaseCooldownActive(); + /// @notice The Node Operator has no pending fee increase. error NoFeeIncreaseCooldown(); + /// @notice The pending increase cannot be applied before its stored deadline. error FeeIncreaseCooldownNotElapsed(); + /// @notice The default minimum is zero, not step-aligned, or not below its required upper bound. error InvalidDefaultMinFee(); + /// @notice The type bonus exceeds its sign-dependent bound or is not step-aligned. error InvalidTypeBonus(); + /// @notice The duration or resulting absolute deadline cannot be represented by uint64. error InvalidFeeIncreaseCooldown(); /// @notice Curated module address. @@ -128,11 +150,11 @@ interface ICustomFeeRegistry is IWeightBoostProvider { /// @notice Accounting contract holding bond curves; an operator's type is its curve id. function ACCOUNTING() external view returns (IAccounting); - /// @notice MetaRegistry notified via `notifyWeightBoostChanged` on weight changes. + /// @notice MetaRegistry notified when fee operations may require an allocation-weight refresh. function META_REGISTRY() external view returns (IMetaRegistry); - /// @notice The starting fee of every operator, in basis points; a custom fee can only go - /// below it. + /// @notice Fee returned for an operator whose fee has never been set, and the inclusive upper + /// bound for custom fees, in basis points. function DEFAULT_MAX_FEE() external view returns (uint256); /// @notice Slope of the weight line: multiplier basis points per FEE_STEP below DEFAULT_MAX_FEE. @@ -145,44 +167,60 @@ interface ICustomFeeRegistry is IWeightBoostProvider { /// @param admin Address to receive DEFAULT_ADMIN_ROLE. /// @param defaultMinFee Initial minimum custom fee: a non-zero multiple of FEE_STEP below /// DEFAULT_MAX_FEE. - /// @param feeIncreaseCooldown Fee increase cooldown in seconds, non-zero. + /// @param feeIncreaseCooldown Stored cooldown duration in seconds, in [1, type(uint64).max]. + /// A later increase request also requires its absolute deadline to fit uint64. function initialize(address admin, uint256 defaultMinFee, uint256 feeIncreaseCooldown) external; /// @notice Request a custom fee. Only the Node Operator owner. A decrease applies immediately - /// and cancels any pending increase; an increase drops the weight at once but applies - /// only via `applyFeeIncrease` after the cooldown. A repeated increase overwrites the - /// pending one and restarts the cooldown. + /// and cancels any pending increase. A request above the current fee creates or replaces + /// a pending increase: allocation weight immediately follows the requested target, while + /// the current and effective fees change only after `applyFeeIncrease`. Every replacement + /// restarts the cooldown. Requesting the current fee cancels a pending increase; without + /// a pending increase it reverts. /// @param nodeOperatorId ID of the Node Operator. - /// @param fee Fee in basis points: a multiple of FEE_STEP within - /// [getMinFee(id), DEFAULT_MAX_FEE]. + /// @param fee Fee in basis points. To set or schedule it, the value must be a multiple of + /// FEE_STEP within [getMinFee(nodeOperatorId), DEFAULT_MAX_FEE]. As an exception, + /// requesting the current fee cancels a pending increase even if the current fee has + /// fallen below the minimum. A new deadline must fit uint64. function requestFee(uint256 nodeOperatorId, uint256 fee) external; /// @notice Apply a pending fee increase after its cooldown. Only the Node Operator owner. - /// The weight already follows the pending fee. + /// Allocation weight already follows the pending fee. The fee is validated again + /// against the current range because a curve or bonus may have changed during cooldown. /// @param nodeOperatorId ID of the Node Operator. function applyFeeIncrease(uint256 nodeOperatorId) external; - /// @notice Permissionlessly raise a custom fee left below the type's minimum (after a bonus - /// change) exactly to that minimum. Immediate; reverts while an increase is pending. + /// @notice Permissionlessly normalize a custom fee left below the current minimum after a + /// curve or bonus change. Sets it to the minimum and cancels any pending increase. /// @param nodeOperatorId ID of the Node Operator. - function restoreFeeToMin(uint256 nodeOperatorId) external; + function normalizeFee(uint256 nodeOperatorId) external; - /// @notice Lower the default minimum custom fee. Only downwards and never zero. + /// @notice Permissionlessly normalize multiple custom fees. Operators whose fees are already + /// valid are skipped. Duplicate IDs are allowed and normalized at most once. + /// @param nodeOperatorIds IDs of the Node Operators. + /// @return normalizedCount Number of fees normalized. + function normalizeFees(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount); + + /// @notice Lower the default minimum custom fee. Only DEFAULT_ADMIN_ROLE; only downwards and + /// never zero. /// @param defaultMinFee New minimum in basis points, a non-zero multiple of FEE_STEP. function setDefaultMinFee(uint256 defaultMinFee) external; - /// @notice Set the fee bonus of a Node Operator type (bond curve). Shifts only the effective - /// fee, never the weight; a negative bonus raises the type's minimum custom fee. + /// @notice Set the fee bonus of an existing Node Operator type (bond curve). Only + /// DEFAULT_ADMIN_ROLE. Shifts only the effective fee, never the weight; a negative + /// bonus raises the type's minimum custom fee. Reverts for a nonexistent curve. /// @param curveId Bond curve ID of the type. /// @param value Magnitude in basis points, a multiple of FEE_STEP: at most /// MAX_BP - DEFAULT_MAX_FEE when positive, DEFAULT_MAX_FEE - defaultMinFee when negative. /// @param negative Whether the bonus is subtracted from the custom fee. function setTypeBonus(uint256 curveId, uint256 value, bool negative) external; - /// @notice Set the fee increase cooldown. + /// @notice Set the cooldown used by future fee-increase requests. Only DEFAULT_ADMIN_ROLE; + /// existing pending deadlines are unchanged. /// @dev Governance invariant, not enforced on-chain: keep it >= one oracle frame + margin; /// raise it when frames are lengthened. - /// @param feeIncreaseCooldown Cooldown in seconds, non-zero. + /// @param feeIncreaseCooldown Stored duration in seconds, in [1, type(uint64).max]. A later + /// increase request also requires its absolute deadline to fit uint64. function setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) external; /// @notice Default minimum custom fee in basis points. @@ -200,13 +238,25 @@ interface ICustomFeeRegistry is IWeightBoostProvider { /// @param nodeOperatorId ID of the Node Operator. function getFee(uint256 nodeOperatorId) external view returns (uint256); + /// @notice Stored pending increase target, or zero if none. It remains pending after the + /// deadline until applied, cancelled, overwritten, or cleared by normalization. + /// @param nodeOperatorId ID of the Node Operator. + function getPendingFeeIncrease(uint256 nodeOperatorId) external view returns (uint256); + + /// @notice Earliest timestamp at which the pending increase passes its time check, or zero if + /// none. The deadline does not clear automatically, and application remains subject to + /// current fee validation. + /// @param nodeOperatorId ID of the Node Operator. + function getFeeIncreaseCooldownUntil(uint256 nodeOperatorId) external view returns (uint256); + /// @notice Minimum custom fee of the Node Operator: the default minimum raised by the type's /// negative bonus. /// @param nodeOperatorId ID of the Node Operator. function getMinFee(uint256 nodeOperatorId) external view returns (uint256); - /// @notice Effective fee of the Node Operator: custom fee + type bonus, clamped at zero. - /// The reward share the fee oracle bills. + /// @notice Effective fee in basis points, computed from the current custom fee and type bonus. + /// A pending increase is excluded until applied. Negative results are clamped at zero. + /// Exposed for off-chain fee-report construction. /// @param nodeOperatorId ID of the Node Operator. function getEffectiveFee(uint256 nodeOperatorId) external view returns (uint256); } diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index f69ba2c2a..08673c079 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -13,6 +13,7 @@ import { IERC20LockBoostProvider } from "src/interfaces/IERC20LockBoostProvider. import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; import { BoostStep } from "src/interfaces/IAdditionalBondRegistry.sol"; import { StrikeThreshold } from "src/interfaces/INodeOperatorStrikes.sol"; +import { TypeBonus } from "src/interfaces/ICustomFeeRegistry.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; import { IParametersRegistry } from "src/interfaces/IParametersRegistry.sol"; import { OssifiableProxy } from "src/lib/proxy/OssifiableProxy.sol"; @@ -24,12 +25,14 @@ import { ProxySlotUtils } from "../../helpers/ProxySlotUtils.sol"; contract DeploymentBaseTest is Test, Utilities, DeploymentFixtures { CuratedDeployParams internal deployParams; CuratedGateConfig[] internal deployGateConfigs; + uint256 internal adminsCount; 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); @@ -138,13 +141,14 @@ 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( address(additionalBondRegistry), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator ); _assertWeightBoostProvider(address(nodeOperatorStrikes), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator); _assertWeightBoostProvider(address(ldoLockBoostProvider), IMetaRegistry.WeightBoostProviderMode.MaxPerGroup); + _assertWeightBoostProvider(address(customFeeRegistry), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator); } } @@ -405,6 +409,63 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { } } +contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { + function test_state_onlyFull() public view { + assertEq(customFeeRegistry.getDefaultMinFee(), deployParams.customFeeRegistryConfig.defaultMinFee); + assertEq(customFeeRegistry.getFeeIncreaseCooldown(), deployParams.customFeeRegistryConfig.feeIncreaseCooldown); + + for (uint256 i; i < deployParams.customFeeRegistryConfig.typeBonuses.length; ++i) { + TypeBonus memory actual = customFeeRegistry.getTypeBonus( + deployParams.customFeeRegistryConfig.typeBonuses[i].curveId + ); + assertEq(actual.value, deployParams.customFeeRegistryConfig.typeBonuses[i].value); + assertEq(actual.negative, deployParams.customFeeRegistryConfig.typeBonuses[i].negative); + } + } + + function test_immutables_onlyFull() public view { + assertEq(address(customFeeRegistry.MODULE()), address(curatedModule), "custom fee registry module"); + assertEq(address(customFeeRegistry.ACCOUNTING()), address(accounting), "custom fee registry accounting"); + assertEq( + address(customFeeRegistry.META_REGISTRY()), + address(metaRegistry), + "custom fee registry meta registry" + ); + assertEq(customFeeRegistry.FEE_STEP(), 250, "custom fee step"); + assertEq(customFeeRegistry.DEFAULT_MAX_FEE(), 8_750, "custom fee default max"); + assertEq(customFeeRegistry.WEIGHT_BOOST_PER_STEP(), 400, "custom fee weight boost per step"); + } + + function test_roles_onlyFull() public view { + _checkAdminRole(address(customFeeRegistry), deployParams.aragonAgent, deployParams.secondAdminAddress); + } + + function test_initialization_onlyFull() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + customFeeRegistry.initialize(deployParams.aragonAgent, 2_500, 15 days); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + customFeeRegistryImpl.initialize(deployParams.aragonAgent, 2_500, 15 days); + } + + function test_proxy_onlyFull() public view { + OssifiableProxy proxy = OssifiableProxy(payable(address(customFeeRegistry))); + assertEq(proxy.proxy__getImplementation(), address(customFeeRegistryImpl), "custom fee proxy getter impl"); + assertEq( + ProxySlotUtils.getImplementation(address(customFeeRegistry)), + address(customFeeRegistryImpl), + "custom fee proxy slot impl" + ); + assertEq(proxy.proxy__getAdmin(), address(deployParams.proxyAdmin), "custom fee proxy getter admin"); + assertEq( + ProxySlotUtils.getAdmin(address(customFeeRegistry)), + address(deployParams.proxyAdmin), + "custom fee proxy slot admin" + ); + assertFalse(proxy.proxy__getIsOssified(), "custom fee proxy ossified"); + } +} + contract CuratedGatesDeploymentTest is DeploymentBaseTest { function _expectedCurveId(uint256 gateIndex) internal view returns (uint256 curveId) { uint256 nextCustomCurveId = 1; diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index 6caaad508..71ae72f23 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"); } @@ -608,6 +617,13 @@ contract DeploymentHelpers is Test { for (uint256 i; i < src.ldoLockBoostProviderConfig.lockBoostSteps.length; ++i) { dst.ldoLockBoostProviderConfig.lockBoostSteps.push(src.ldoLockBoostProviderConfig.lockBoostSteps[i]); } + + // CustomFeeRegistry + dst.customFeeRegistryConfig.defaultMinFee = src.customFeeRegistryConfig.defaultMinFee; + dst.customFeeRegistryConfig.feeIncreaseCooldown = src.customFeeRegistryConfig.feeIncreaseCooldown; + for (uint256 i; i < src.customFeeRegistryConfig.typeBonuses.length; ++i) { + dst.customFeeRegistryConfig.typeBonuses.push(src.customFeeRegistryConfig.typeBonuses[i]); + } } function parseCommonDeployParams(string memory config) internal view returns (CommonDeployParams memory params) { @@ -865,6 +881,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 +1003,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/unit/CustomFeeRegistry.t.sol b/test/unit/CustomFeeRegistry.t.sol index 78c6c8d03..0a4668657 100644 --- a/test/unit/CustomFeeRegistry.t.sol +++ b/test/unit/CustomFeeRegistry.t.sol @@ -9,6 +9,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { CustomFeeRegistry } from "src/CustomFeeRegistry.sol"; import { ICustomFeeRegistry, TypeBonus } from "src/interfaces/ICustomFeeRegistry.sol"; +import { IBondCurve } from "src/interfaces/IBondCurve.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; import { AccountingMock } from "../helpers/mocks/AccountingMock.sol"; @@ -21,7 +22,7 @@ contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures { CuratedMock public module; CustomFeeRegistry public feeRegistry; MetaRegistryMock public metaRegistryMock; - AccountingMock internal acct; + AccountingMock internal _accounting; address public admin; address public nodeOperatorOwner; @@ -58,7 +59,7 @@ contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures { _enableInitializers(address(feeRegistry)); feeRegistry.initialize(admin, MIN_FEE, COOLDOWN); - acct = AccountingMock(address(module.ACCOUNTING())); + _accounting = AccountingMock(address(module.ACCOUNTING())); } function _weight(uint256 fee) internal pure returns (uint256) { @@ -70,6 +71,16 @@ contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures { feeRegistry.requestFee(NO_ID, fee); } + function _applyFeeIncrease() internal { + vm.prank(nodeOperatorOwner); + feeRegistry.applyFeeIncrease(NO_ID); + } + + function _assertNoPendingFeeIncrease() internal view { + assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 0); + assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), 0); + } + function _setTypeBonus(uint256 curveId, uint256 value, bool negative) internal { vm.prank(admin); feeRegistry.setTypeBonus(curveId, value, negative); @@ -91,6 +102,11 @@ contract CustomFeeRegistryConstructorTest is CustomFeeRegistryBaseTest { } 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.getDefaultMinFee(), MIN_FEE); @@ -98,20 +114,18 @@ contract CustomFeeRegistryInitializeTest is CustomFeeRegistryBaseTest { } function test_initialize_EmitsEvents() public { - CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); - _enableInitializers(address(tp)); - vm.expectEmit(address(tp)); + CustomFeeRegistry registry = _newRegistry(); + vm.expectEmit(address(registry)); emit ICustomFeeRegistry.DefaultMinFeeSet(MIN_FEE); - vm.expectEmit(address(tp)); + vm.expectEmit(address(registry)); emit ICustomFeeRegistry.FeeIncreaseCooldownSet(COOLDOWN); - tp.initialize(admin, MIN_FEE, COOLDOWN); + registry.initialize(admin, MIN_FEE, COOLDOWN); } function test_initialize_RevertWhen_ZeroAdmin() public { - CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); - _enableInitializers(address(tp)); + CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.ZeroAdminAddress.selector); - tp.initialize(address(0), MIN_FEE, COOLDOWN); + registry.initialize(address(0), MIN_FEE, COOLDOWN); } function test_initialize_RevertWhen_DoubleCall() public { @@ -120,31 +134,33 @@ contract CustomFeeRegistryInitializeTest is CustomFeeRegistryBaseTest { } function test_initialize_RevertWhen_ZeroMinFee() public { - CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); - _enableInitializers(address(tp)); + CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); - tp.initialize(admin, 0, COOLDOWN); + registry.initialize(admin, 0, COOLDOWN); } function test_initialize_RevertWhen_MinFeeAtOrAboveMax() public { - CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); - _enableInitializers(address(tp)); + CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); - tp.initialize(admin, MAX_FEE, COOLDOWN); + registry.initialize(admin, MAX_FEE, COOLDOWN); } function test_initialize_RevertWhen_MinFeeNotStepAligned() public { - CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); - _enableInitializers(address(tp)); + CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); - tp.initialize(admin, MIN_FEE + 1, COOLDOWN); + registry.initialize(admin, MIN_FEE + 1, COOLDOWN); } function test_initialize_RevertWhen_ZeroCooldown() public { - CustomFeeRegistry tp = new CustomFeeRegistry(address(module)); - _enableInitializers(address(tp)); + CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); - tp.initialize(admin, MIN_FEE, 0); + registry.initialize(admin, MIN_FEE, 0); + } + + function test_initialize_RevertWhen_CooldownExceedsUint64() public { + CustomFeeRegistry registry = _newRegistry(); + vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); + registry.initialize(admin, MIN_FEE, uint256(type(uint64).max) + 1); } } @@ -163,7 +179,6 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { function test_requestFee_Decrease_ToMinFee() public { _requestFee(MIN_FEE); assertEq(feeRegistry.getFee(NO_ID), MIN_FEE); - // Exactly 2x at the planned minimum. assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 2 * MAX_BP); } @@ -197,12 +212,10 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { // The old deadline is void: the increase applies only at the restarted one. vm.warp(cooldownUntil - 1 days); vm.expectRevert(ICustomFeeRegistry.FeeIncreaseCooldownNotElapsed.selector); - vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeIncrease(); vm.warp(cooldownUntil); - vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeIncrease(); assertEq(feeRegistry.getFee(NO_ID), 7_000); } @@ -220,6 +233,8 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { _requestFee(5_000); _requestFee(6_000); + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeIncreaseCancelled(NO_ID); vm.expectEmit(address(feeRegistry)); emit ICustomFeeRegistry.FeeSet(NO_ID, 4_000); _requestFee(4_000); @@ -229,8 +244,7 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { vm.warp(block.timestamp + COOLDOWN + 1); vm.expectRevert(ICustomFeeRegistry.NoFeeIncreaseCooldown.selector); - vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeIncrease(); } function test_requestFee_RevertWhen_NotOwner() public { @@ -272,12 +286,40 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { _requestFee(5_000); } - function test_requestFee_RevertWhen_SameFeeWithPending() public { + function test_requestFee_SameFeeCancelsPending() public { _requestFee(5_000); _requestFee(6_000); - // Cancelling to the very same fee is not a thing: only a decrease cancels. - vm.expectRevert(ICustomFeeRegistry.SameFee.selector); + + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeIncreaseCancelled(NO_ID); + _requestFee(5_000); + + assertEq(feeRegistry.getFee(NO_ID), 5_000); + _assertNoPendingFeeIncrease(); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(5_000)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore + 1); + } + + function test_requestFee_SameFeeCancelsPendingAfterCooldown() public { + _requestFee(5_000); + _requestFee(6_000); + vm.warp(block.timestamp + COOLDOWN + 1); + _requestFee(5_000); + + _assertNoPendingFeeIncrease(); + } + + function test_requestFee_SameFeeCancelsPendingWhenCurrentFeeBelowNewMin() public { + _requestFee(5_000); + _requestFee(6_000); + _setTypeBonus(0, 4_000, true); // New minimum is 6_500. + + _requestFee(5_000); + + _assertNoPendingFeeIncrease(); + assertEq(feeRegistry.getFee(NO_ID), 5_000); } } @@ -297,8 +339,7 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); vm.expectEmit(address(feeRegistry)); emit ICustomFeeRegistry.FeeIncreaseApplied(NO_ID, 6_000); - vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeIncrease(); assertEq(feeRegistry.getFee(NO_ID), 6_000); // The weight followed the pending fee already, so no new notification. @@ -308,8 +349,7 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { function test_applyFeeIncrease_AtExactDeadline() public { vm.warp(cooldownUntil); - vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeIncrease(); assertEq(feeRegistry.getFee(NO_ID), 6_000); } @@ -318,8 +358,7 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { feeRegistry.setFeeIncreaseCooldown(COOLDOWN * 2); vm.warp(cooldownUntil); - vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeIncrease(); assertEq(feeRegistry.getFee(NO_ID), 6_000); } @@ -333,22 +372,46 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { function test_applyFeeIncrease_RevertWhen_NotElapsed() public { vm.warp(cooldownUntil - 1); vm.expectRevert(ICustomFeeRegistry.FeeIncreaseCooldownNotElapsed.selector); - vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeIncrease(); } function test_applyFeeIncrease_RevertWhen_NoPending() public { vm.warp(cooldownUntil + 1); - vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeIncrease(); vm.expectRevert(ICustomFeeRegistry.NoFeeIncreaseCooldown.selector); - vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeIncrease(); + } + + function test_applyFeeIncrease_RevertWhen_PendingFeeBelowCurrentMin() public { + _setTypeBonus(0, 4_000, true); // New minimum is 6_500, pending fee is 6_000. + vm.warp(cooldownUntil); + + vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); + _applyFeeIncrease(); + + assertEq(feeRegistry.getFee(NO_ID), 5_000); + assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 6_000); + assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), cooldownUntil); + } + + function test_applyFeeIncrease_InvalidPendingCanBeNormalizedPermissionlessly() public { + _setTypeBonus(0, 4_000, true); // New minimum is 6_500, pending fee is 6_000. + vm.warp(cooldownUntil); + + vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); + _applyFeeIncrease(); + + vm.prank(stranger); + feeRegistry.normalizeFee(NO_ID); + + assertEq(feeRegistry.getFee(NO_ID), 6_500); + _assertNoPendingFeeIncrease(); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_500)); } } -contract CustomFeeRegistryRestoreFeeToMinTest is CustomFeeRegistryBaseTest { +contract CustomFeeRegistryNormalizeFeeTest is CustomFeeRegistryBaseTest { function setUp() public override { super.setUp(); // The operator settles at the type minimum, then the DAO tightens the bonus. @@ -358,13 +421,13 @@ contract CustomFeeRegistryRestoreFeeToMinTest is CustomFeeRegistryBaseTest { // The minimum is now 7_500 and the operator at 5_000 sits below it. } - function test_restoreFeeToMin() public { + function test_normalizeFee() public { assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); // max(0, 5_000 - 5_000) vm.expectEmit(address(feeRegistry)); emit ICustomFeeRegistry.FeeSet(NO_ID, 7_500); vm.prank(stranger); // permissionless - feeRegistry.restoreFeeToMin(NO_ID); + feeRegistry.normalizeFee(NO_ID); assertEq(feeRegistry.getFee(NO_ID), 7_500); // The effective fee is back at the default minimum. @@ -372,29 +435,104 @@ contract CustomFeeRegistryRestoreFeeToMinTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_500)); } - function test_restoreFeeToMin_RevertWhen_FeeNotBelowMinFee() public { + function test_normalizeFee_RevertWhen_FeeNotBelowMinFee() public { vm.prank(stranger); - feeRegistry.restoreFeeToMin(NO_ID); + feeRegistry.normalizeFee(NO_ID); vm.expectRevert(ICustomFeeRegistry.FeeNotBelowMinFee.selector); vm.prank(stranger); - feeRegistry.restoreFeeToMin(NO_ID); + feeRegistry.normalizeFee(NO_ID); } - function test_restoreFeeToMin_RevertWhen_UnsetOperator() public { + function test_normalizeFee_RevertWhen_UnsetOperator() public { // An unset operator reads as DEFAULT_MAX_FEE and can never be below the minimum. vm.expectRevert(ICustomFeeRegistry.FeeNotBelowMinFee.selector); vm.prank(stranger); - feeRegistry.restoreFeeToMin(1); + feeRegistry.normalizeFee(1); } - function test_restoreFeeToMin_RevertWhen_IncreasePending() public { - vm.prank(nodeOperatorOwner); - feeRegistry.requestFee(NO_ID, 8_000); + function test_normalizeFee_CancelsPendingIncrease() public { + _requestFee(8_000); - vm.expectRevert(ICustomFeeRegistry.FeeIncreaseCooldownActive.selector); + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeIncreaseCancelled(NO_ID); + vm.expectEmit(address(feeRegistry)); + emit ICustomFeeRegistry.FeeSet(NO_ID, 7_500); vm.prank(stranger); - feeRegistry.restoreFeeToMin(NO_ID); + feeRegistry.normalizeFee(NO_ID); + + assertEq(feeRegistry.getFee(NO_ID), 7_500); + _assertNoPendingFeeIncrease(); + } + + function test_normalizeFee_CancelsExpiredPendingIncrease() public { + _requestFee(8_000); + vm.warp(block.timestamp + COOLDOWN + 1); + + vm.prank(stranger); + feeRegistry.normalizeFee(NO_ID); + + assertEq(feeRegistry.getFee(NO_ID), 7_500); + _assertNoPendingFeeIncrease(); + } + + function test_normalizeFee_DoesNotNotifyWhenPendingFeeEqualsMin() public { + _requestFee(7_500); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + vm.prank(stranger); + feeRegistry.normalizeFee(NO_ID); + + assertEq(feeRegistry.getFee(NO_ID), 7_500); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } +} + +contract CustomFeeRegistryNormalizeFeesTest is CustomFeeRegistryBaseTest { + function setUp() public override { + super.setUp(); + + // Operator 0 is left below the new minimum; the other operators remain unset and valid. + _setTypeBonus(0, 2_500, true); + _requestFee(5_000); + _setTypeBonus(0, 5_000, true); + } + + function test_normalizeFees() public { + uint256 normalizedCount = feeRegistry.normalizeFees(UintArr(0, 1, 2)); + + assertEq(normalizedCount, 1); + assertEq(feeRegistry.getFee(0), 7_500); + assertEq(feeRegistry.getFee(1), MAX_FEE); + assertEq(feeRegistry.getFee(2), MAX_FEE); + } + + function test_normalizeFees_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 normalizedCount = feeRegistry.normalizeFees(nodeOperatorIds); + + assertEq(normalizedCount, 1); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore + 1); + } + + function test_normalizeFees_EmptyArray() public { + uint256 normalizedCount = feeRegistry.normalizeFees(UintArr()); + assertEq(normalizedCount, 0); + } + + function test_normalizeFees_NoFeesToNormalize() public { + uint256[] memory nodeOperatorIds = UintArr(0, 1); + feeRegistry.normalizeFees(nodeOperatorIds); + + uint256 normalizedCount = feeRegistry.normalizeFees(nodeOperatorIds); + + assertEq(normalizedCount, 0); } } @@ -419,7 +557,7 @@ contract CustomFeeRegistrySetDefaultMinFeeTest is CustomFeeRegistryBaseTest { } function test_setDefaultMinFee_RevertWhen_NotAdmin() public { - vm.expectRevert(); + expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); vm.prank(stranger); feeRegistry.setDefaultMinFee(2_000); } @@ -489,6 +627,7 @@ contract CustomFeeRegistrySetTypeBonusTest is CustomFeeRegistryBaseTest { function test_setTypeBonus_SameFeeSameWeightAcrossTypes() public { _setTypeBonus(0, 1_250, false); + _accounting.setBondCurve(1, 1); // Add curve 1 without moving NO_ID from curve 0. _setTypeBonus(1, 2_500, true); _requestFee(5_000); @@ -496,13 +635,13 @@ contract CustomFeeRegistrySetTypeBonusTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.getEffectiveFee(NO_ID), 6_250); // Moving the operator to another type shifts only the effective fee, never the weight. - acct.setBondCurve(NO_ID, 1); + _accounting.setBondCurve(NO_ID, 1); assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), weightOnTypeA); assertEq(feeRegistry.getEffectiveFee(NO_ID), 2_500); } function test_setTypeBonus_RevertWhen_NotAdmin() public { - vm.expectRevert(); + expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); vm.prank(stranger); feeRegistry.setTypeBonus(0, 1_250, false); } @@ -523,6 +662,11 @@ contract CustomFeeRegistrySetTypeBonusTest is CustomFeeRegistryBaseTest { vm.expectRevert(ICustomFeeRegistry.InvalidTypeBonus.selector); _setTypeBonus(0, 100, false); } + + function test_setTypeBonus_RevertWhen_CurveDoesNotExist() public { + vm.expectRevert(IBondCurve.InvalidBondCurveId.selector); + _setTypeBonus(1, 0, false); + } } contract CustomFeeRegistrySetFeeIncreaseCooldownTest is CustomFeeRegistryBaseTest { @@ -536,7 +680,7 @@ contract CustomFeeRegistrySetFeeIncreaseCooldownTest is CustomFeeRegistryBaseTes } function test_setFeeIncreaseCooldown_RevertWhen_NotAdmin() public { - vm.expectRevert(); + expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); vm.prank(stranger); feeRegistry.setFeeIncreaseCooldown(30 days); } @@ -546,6 +690,20 @@ contract CustomFeeRegistrySetFeeIncreaseCooldownTest is CustomFeeRegistryBaseTes vm.prank(admin); feeRegistry.setFeeIncreaseCooldown(0); } + + function test_setFeeIncreaseCooldown_RevertWhen_ExceedsUint64() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); + vm.prank(admin); + feeRegistry.setFeeIncreaseCooldown(uint256(type(uint64).max) + 1); + } + + function test_requestFeeIncrease_RevertWhen_DeadlineExceedsUint64() public { + _requestFee(5_000); + vm.warp(uint256(type(uint64).max) - COOLDOWN + 1); + + vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); + _requestFee(6_000); + } } contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { @@ -553,6 +711,17 @@ contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.getFee(NO_ID), MAX_FEE); } + function test_getPendingFeeIncreaseAndCooldownUntil() public { + assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 0); + assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), 0); + + _requestFee(5_000); + _requestFee(6_000); + + assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 6_000); + assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), block.timestamp + COOLDOWN); + } + function test_getWeightBoostMultiplierBP_UnsetIsOne() public view { assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP); } @@ -575,7 +744,6 @@ contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { _requestFee(5_000); _setTypeBonus(0, 5_500, true); - // max(0, 5_000 - 5_500) assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); } From bbc5cd900e24b37cad3f80332de78ed696a75141 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Wed, 29 Jul 2026 10:39:23 +0200 Subject: [PATCH 04/11] fix: image --- src/interfaces/ICustomFeeRegistry.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/ICustomFeeRegistry.sol b/src/interfaces/ICustomFeeRegistry.sol index 127d22b20..1d5e18531 100644 --- a/src/interfaces/ICustomFeeRegistry.sol +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -75,7 +75,7 @@ struct TypeBonus { * 1.0x ┤ ● 8750 (DEFAULT_MAX_FEE, unset): 1x * └┬ ┬ ┬─► custom fee, portion BP * 0 2500 8750 - * ├────reachable when defaultMinFee = 2500────┤ + * ├─── reachable at min 2500 ───┤ * * ── Fee increase timeline and oracle frames ──────────────────────────────────── * From 0715a49194ee5c9aa3cfe11a3d58460317705a8a Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Wed, 29 Jul 2026 14:42:17 +0200 Subject: [PATCH 05/11] chore: temp value for type bonus --- script/curated/DeployBase.s.sol | 2 ++ script/curated/DeployHoodi.s.sol | 9 +++++++-- script/curated/DeployLocalDevNet.s.sol | 7 ++++++- script/curated/DeployMainnet.s.sol | 9 +++++++-- test/fork/deployment/PostDeploymentCurated.t.sol | 7 +++++++ 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 9421c60b1..4481a315a 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -49,6 +49,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; @@ -135,6 +136,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; diff --git a/script/curated/DeployHoodi.s.sol b/script/curated/DeployHoodi.s.sol index 98c899082..01472b664 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig, CurveTypeBonusConfig } from "./DeployBase.s.sol"; import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; @@ -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; @@ -224,9 +225,13 @@ contract DeployHoodi is DeployBase { _addLDOLockBoostStep(200_000 ether, 1_500); // CustomFeeRegistry - // TODO: finalize custom fee parameters and per-type bonuses. + // TODO: finalize custom fee parameters. config.customFeeRegistryConfig.defaultMinFee = 2_500; config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + // Professional Operator uses the default bond curve (curve 0): 8_750 - 2_500 = 6_250. + config.customFeeRegistryConfig.typeBonuses.push( + CurveTypeBonusConfig({ curveId: 0, value: 2_500, negative: true }) + ); _setUp(); } diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index 7b82d0c56..e0ae17a03 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig, CurveTypeBonusConfig } from "./DeployBase.s.sol"; import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; import { BaseOracle } from "../../src/lib/base-oracle/BaseOracle.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; @@ -213,6 +214,10 @@ contract DeployLocalDevNet is DeployBase { // CustomFeeRegistry config.customFeeRegistryConfig.defaultMinFee = 2_500; config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + // Professional Operator uses the default bond curve (curve 0): 8_750 - 2_500 = 6_250. + config.customFeeRegistryConfig.typeBonuses.push( + CurveTypeBonusConfig({ curveId: 0, value: 2_500, negative: true }) + ); _setUp(); } diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index de4beeac3..e32c0b93d 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig, CurveTypeBonusConfig } from "./DeployBase.s.sol"; import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; @@ -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; @@ -223,9 +224,13 @@ contract DeployMainnet is DeployBase { _addLDOLockBoostStep(200_000 ether, 1_500); // CustomFeeRegistry - // TODO: finalize custom fee parameters and per-type bonuses. + // TODO: finalize custom fee parameters. config.customFeeRegistryConfig.defaultMinFee = 2_500; config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + // Professional Operator uses the default bond curve (curve 0): 8_750 - 2_500 = 6_250. + config.customFeeRegistryConfig.typeBonuses.push( + CurveTypeBonusConfig({ curveId: 0, value: 2_500, negative: true }) + ); _setUp(); } diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index 08673c079..b54a23752 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -421,6 +421,13 @@ contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { assertEq(actual.value, deployParams.customFeeRegistryConfig.typeBonuses[i].value); assertEq(actual.negative, deployParams.customFeeRegistryConfig.typeBonuses[i].negative); } + + uint256 defaultFee = customFeeRegistry.DEFAULT_MAX_FEE(); + for (uint256 curveId; curveId < accounting.getCurvesCount(); ++curveId) { + TypeBonus memory bonus = customFeeRegistry.getTypeBonus(curveId); + uint256 effectiveFee = bonus.negative ? defaultFee - bonus.value : defaultFee + bonus.value; + assertEq(effectiveFee, curveId == 0 ? 6_250 : 8_750, "unexpected initial effective fee"); + } } function test_immutables_onlyFull() public view { From 82c4cc59893559314288e33d1a3e5e34412ab788 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 4 Aug 2026 16:01:43 +0200 Subject: [PATCH 06/11] fix: rename bonus, add cancel --- script/curated/DeployBase.s.sol | 10 +- script/curated/DeployHoodi.s.sol | 6 +- script/curated/DeployLocalDevNet.s.sol | 6 +- script/curated/DeployMainnet.s.sol | 6 +- src/CustomFeeRegistry.sol | 70 +++---- src/interfaces/ICustomFeeRegistry.sol | 80 ++++---- .../deployment/PostDeploymentCurated.t.sol | 19 +- test/helpers/Fixtures.sol | 4 +- test/unit/CustomFeeRegistry.t.sol | 175 ++++++++++-------- 9 files changed, 206 insertions(+), 170 deletions(-) diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 4481a315a..9951a8cfa 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -86,7 +86,7 @@ struct ERC20LockBoostProviderConfig { IERC20LockBoostProvider.LockBoostStep[] lockBoostSteps; } -struct CurveTypeBonusConfig { +struct CurveFeeModifierConfig { uint256 curveId; uint256 value; bool negative; @@ -95,7 +95,7 @@ struct CurveTypeBonusConfig { struct CustomFeeRegistryConfig { uint256 defaultMinFee; uint256 feeIncreaseCooldown; - CurveTypeBonusConfig[] typeBonuses; + CurveFeeModifierConfig[] feeModifiers; } struct CuratedDeployParams { @@ -546,9 +546,9 @@ abstract contract DeployBase is Script { } } - for (uint256 i; i < config.customFeeRegistryConfig.typeBonuses.length; ++i) { - CurveTypeBonusConfig storage bonus = config.customFeeRegistryConfig.typeBonuses[i]; - customFeeRegistry.setTypeBonus(bonus.curveId, bonus.value, bonus.negative); + 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)); diff --git a/script/curated/DeployHoodi.s.sol b/script/curated/DeployHoodi.s.sol index 01472b664..834466d60 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig, CurveTypeBonusConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig, CurveFeeModifierConfig } from "./DeployBase.s.sol"; import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; @@ -229,8 +229,8 @@ contract DeployHoodi is DeployBase { config.customFeeRegistryConfig.defaultMinFee = 2_500; config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; // Professional Operator uses the default bond curve (curve 0): 8_750 - 2_500 = 6_250. - config.customFeeRegistryConfig.typeBonuses.push( - CurveTypeBonusConfig({ curveId: 0, value: 2_500, negative: true }) + 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 e0ae17a03..a25deca2f 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig, CurveTypeBonusConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig, CurveFeeModifierConfig } from "./DeployBase.s.sol"; import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; import { BaseOracle } from "../../src/lib/base-oracle/BaseOracle.sol"; @@ -215,8 +215,8 @@ contract DeployLocalDevNet is DeployBase { config.customFeeRegistryConfig.defaultMinFee = 2_500; config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; // Professional Operator uses the default bond curve (curve 0): 8_750 - 2_500 = 6_250. - config.customFeeRegistryConfig.typeBonuses.push( - CurveTypeBonusConfig({ curveId: 0, value: 2_500, negative: true }) + 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 e32c0b93d..7d41c68bd 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig, CurveTypeBonusConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig, CurveFeeModifierConfig } from "./DeployBase.s.sol"; import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; @@ -228,8 +228,8 @@ contract DeployMainnet is DeployBase { config.customFeeRegistryConfig.defaultMinFee = 2_500; config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; // Professional Operator uses the default bond curve (curve 0): 8_750 - 2_500 = 6_250. - config.customFeeRegistryConfig.typeBonuses.push( - CurveTypeBonusConfig({ curveId: 0, value: 2_500, negative: true }) + config.customFeeRegistryConfig.feeModifiers.push( + CurveFeeModifierConfig({ curveId: 0, value: 2_500, negative: true }) ); _setUp(); diff --git a/src/CustomFeeRegistry.sol b/src/CustomFeeRegistry.sol index 4ca11c091..c7154484f 100644 --- a/src/CustomFeeRegistry.sol +++ b/src/CustomFeeRegistry.sol @@ -11,7 +11,7 @@ import { IAccounting } from "./interfaces/IAccounting.sol"; import { IBondCurve } from "./interfaces/IBondCurve.sol"; import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; -import { ICustomFeeRegistry, OperatorFee, TypeBonus } from "./interfaces/ICustomFeeRegistry.sol"; +import { ICustomFeeRegistry, OperatorFee, FeeModifier } from "./interfaces/ICustomFeeRegistry.sol"; import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; import { MAX_BP } from "./lib/Constants.sol"; @@ -24,7 +24,7 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn struct CustomFeeRegistryStorage { uint256 defaultMinFee; uint256 feeIncreaseCooldown; - mapping(uint256 curveId => TypeBonus) typeBonus; + mapping(uint256 curveId => FeeModifier) feeModifier; mapping(uint256 nodeOperatorId => OperatorFee) fees; } @@ -36,6 +36,7 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn uint256 public constant DEFAULT_MAX_FEE = 35 * FEE_STEP; // 87.5% // Slope of the weight line: multiplier basis points per FEE_STEP below DEFAULT_MAX_FEE. uint256 public constant WEIGHT_BOOST_PER_STEP = 400; + uint256 public constant MAX_FEE_INCREASE_COOLDOWN = type(uint32).max; ICuratedModule public immutable MODULE; IAccounting public immutable ACCOUNTING; @@ -66,19 +67,8 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn function requestFee(uint256 nodeOperatorId, uint256 requestedFee) external { _checkOperatorOwner(nodeOperatorId); - OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; uint256 currentFee = _getCurrentFee(nodeOperatorId); - - // Cancellation remains available if a curve or bonus change raised the minimum. - if (requestedFee == currentFee) { - if (operatorFee.cooldownUntil == 0) revert SameFee(); - - operatorFee.pendingFeeIncrease = 0; - operatorFee.cooldownUntil = 0; - emit FeeIncreaseCancelled(nodeOperatorId); - META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); - return; - } + if (requestedFee == currentFee) revert SameFee(); _validateFee(nodeOperatorId, requestedFee); @@ -89,6 +79,19 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn } } + /// @inheritdoc ICustomFeeRegistry + function cancelFeeIncrease(uint256 nodeOperatorId) external { + _checkOperatorOwner(nodeOperatorId); + + OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; + if (operatorFee.cooldownUntil == 0) revert NoFeeIncreaseCooldown(); + + operatorFee.pendingFeeIncrease = 0; + operatorFee.cooldownUntil = 0; + emit FeeIncreaseCancelled(nodeOperatorId); + META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + } + /// @inheritdoc ICustomFeeRegistry function applyFeeIncrease(uint256 nodeOperatorId) external { _checkOperatorOwner(nodeOperatorId); @@ -98,7 +101,7 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn if (operatorFee.cooldownUntil > block.timestamp) revert FeeIncreaseCooldownNotElapsed(); uint16 pendingFee = operatorFee.pendingFeeIncrease; - // The fee may have become invalid during the cooldown after a curve or bonus change. + // The fee may have become invalid during the cooldown after a curve or modifier change. _validateFee(nodeOperatorId, pendingFee); operatorFee.currentFee = pendingFee; @@ -127,17 +130,17 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn } /// @inheritdoc ICustomFeeRegistry - function setTypeBonus(uint256 curveId, uint256 value, bool negative) external onlyRole(DEFAULT_ADMIN_ROLE) { + function setFeeModifier(uint256 curveId, uint256 value, bool negative) external onlyRole(DEFAULT_ADMIN_ROLE) { if (curveId >= ACCOUNTING.getCurvesCount()) revert IBondCurve.InvalidBondCurveId(); CustomFeeRegistryStorage storage $ = _storage(); // Keeps the effective fee within MAX_BP and the per-type minimum within DEFAULT_MAX_FEE. uint256 maxValue = negative ? DEFAULT_MAX_FEE - $.defaultMinFee : MAX_BP - DEFAULT_MAX_FEE; - if (value > maxValue || value % FEE_STEP != 0) revert InvalidTypeBonus(); + if (value > maxValue || value % FEE_STEP != 0) revert InvalidFeeModifier(); - $.typeBonus[curveId] = TypeBonus({ value: value.toUint248(), negative: negative }); - emit TypeBonusSet(curveId, value, negative); - // No weight notification: the bonus shifts only the effective fee. + $.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 @@ -156,8 +159,8 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn } /// @inheritdoc ICustomFeeRegistry - function getTypeBonus(uint256 curveId) external view returns (TypeBonus memory) { - return _storage().typeBonus[curveId]; + function getFeeModifier(uint256 curveId) external view returns (FeeModifier memory) { + return _storage().feeModifier[curveId]; } /// @inheritdoc ICustomFeeRegistry @@ -194,14 +197,14 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn /// @inheritdoc ICustomFeeRegistry function getEffectiveFee(uint256 nodeOperatorId) external view returns (uint256) { uint256 currentFee = _getCurrentFee(nodeOperatorId); - TypeBonus storage bonus = _storage().typeBonus[ACCOUNTING.getBondCurveId(nodeOperatorId)]; - if (bonus.negative) { - // A curve or bonus change can leave the current fee below the negative bonus. - return currentFee > bonus.value ? currentFee - bonus.value : 0; + FeeModifier storage feeModifier = _storage().feeModifier[ACCOUNTING.getBondCurveId(nodeOperatorId)]; + if (feeModifier.negative) { + // A curve or modifier change can leave the current fee below the negative modifier. + return currentFee > feeModifier.value ? currentFee - feeModifier.value : 0; } - // The bonus bounds keep the sum within MAX_BP. - return currentFee + bonus.value; + // The modifier bounds keep the sum within MAX_BP. + return currentFee + feeModifier.value; } /// @dev Sets the custom fee immediately, drops any pending increase, and notifies if the @@ -226,7 +229,6 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn CustomFeeRegistryStorage storage $ = _storage(); OperatorFee storage operatorFee = $.fees[nodeOperatorId]; uint256 cooldownUntil = block.timestamp + $.feeIncreaseCooldown; - if (cooldownUntil > type(uint64).max) revert InvalidFeeIncreaseCooldown(); operatorFee.pendingFeeIncrease = pendingFee.toUint16(); operatorFee.cooldownUntil = cooldownUntil.toUint64(); emit FeeIncreaseRequested(nodeOperatorId, pendingFee, cooldownUntil); @@ -243,14 +245,14 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn } function _setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) internal { - if (feeIncreaseCooldown == 0 || feeIncreaseCooldown > type(uint64).max) { + if (feeIncreaseCooldown == 0 || feeIncreaseCooldown > MAX_FEE_INCREASE_COOLDOWN) { revert InvalidFeeIncreaseCooldown(); } _storage().feeIncreaseCooldown = feeIncreaseCooldown; emit FeeIncreaseCooldownSet(feeIncreaseCooldown); } - /// @dev Normalizes the fee if a curve or bonus change left it below the current minimum. + /// @dev Normalizes the fee if a curve or modifier change left it below the current minimum. function _normalizeFee(uint256 nodeOperatorId) internal returns (bool normalized) { uint256 minFee = _getMinFee(nodeOperatorId); if (_getCurrentFee(nodeOperatorId) >= minFee) return false; @@ -263,12 +265,12 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn if (fee < _getMinFee(nodeOperatorId) || fee > DEFAULT_MAX_FEE || fee % FEE_STEP != 0) revert InvalidFee(); } - /// @dev A negative bonus raises the per-type minimum so a valid current fee has an effective + /// @dev A negative modifier raises the per-type minimum so a valid current fee has an effective /// fee of at least defaultMinFee. Existing fees may remain below it until normalized. function _getMinFee(uint256 nodeOperatorId) internal view returns (uint256) { CustomFeeRegistryStorage storage $ = _storage(); - TypeBonus storage bonus = $.typeBonus[ACCOUNTING.getBondCurveId(nodeOperatorId)]; - return bonus.negative ? $.defaultMinFee + bonus.value : $.defaultMinFee; + FeeModifier storage feeModifier = $.feeModifier[ACCOUNTING.getBondCurveId(nodeOperatorId)]; + return feeModifier.negative ? $.defaultMinFee + feeModifier.value : $.defaultMinFee; } /// @dev Zero means "never set" and reads as DEFAULT_MAX_FEE; a real fee is never zero (defaultMinFee > 0). diff --git a/src/interfaces/ICustomFeeRegistry.sol b/src/interfaces/ICustomFeeRegistry.sol index 1d5e18531..6d4bebd5f 100644 --- a/src/interfaces/ICustomFeeRegistry.sol +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -16,9 +16,9 @@ struct OperatorFee { uint64 cooldownUntil; } -/// @dev Sign-magnitude fee bonus of a Node Operator type: `value` basis points are subtracted from +/// @dev Sign-magnitude fee modifier of a Node Operator type: `value` basis points are subtracted from /// the custom fee when `negative` is true, added otherwise. Packed into a single slot. -struct TypeBonus { +struct FeeModifier { uint248 value; bool negative; } @@ -30,9 +30,9 @@ struct TypeBonus { * axis shows the same fee as a share of the total staking rewards at a 4% * module share. One character is 125 BP (half a fee step). * An operator picks a custom fee between its current getMinFee(id) and - * DEFAULT_MAX_FEE; a negative type bonus raises that lower bound above + * DEFAULT_MAX_FEE; a negative fee modifier raises that lower bound above * defaultMinFee. The lower the custom fee, the higher the allocation weight. - * A type bonus shifts only the effective fee. + * A fee modifier shifts only the effective fee. * ● custom (set by the operator) ○ effective (exposed for fee reporting) * * portion BP 0 2500 5000 6250 8750 10000 @@ -40,11 +40,11 @@ struct TypeBonus { * total at 4% 0% 1% 2% 2.5% 3.5% 4% * └ defaultMinFee └ DEFAULT_MAX_FEE * - * type A — bonus +1250, effective = custom + 1250: + * type A — modifier +1250, effective = custom + 1250: * custom ●─────────────────────────────────────────────────● * effective ○─────────────────────────────────────────────────○ * - * type B — bonus -2500, effective = custom - 2500; the minimum rises to 5000: + * type B — modifier -2500, effective = custom - 2500; the minimum rises to 5000: * custom ●─────────────────────────────● * effective ○─────────────────────────────○ * @@ -55,7 +55,7 @@ struct TypeBonus { * * ── Custom fee to allocation weight ──────────────────────────────────────────── * - * The weight multiplier depends on the fee only, never on a type bonus — on + * The weight multiplier depends on the fee only, never on a fee modifier — on * the custom fee, or the pending target while an increase is pending (see the * timeline below). Two things are fixed: 1x at DEFAULT_MAX_FEE and a straight * slope of WEIGHT_BOOST_PER_STEP (400 BP) per FEE_STEP (250 BP) of discount. @@ -97,16 +97,16 @@ struct TypeBonus { * oracle samples ▲ old ▲ new ▲ new * frame billed old * new ** new * - * pending: a decrease cancels it; a new increase overwrites it and restarts - * the cooldown. + * pending: an explicit cancellation or a decrease clears it; a new increase + * 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 custom fees and the allocation weight boost derived from them: the lower /// the custom fee, the higher the operator's allocation weight. The effective fee -/// (custom + type bonus) is exposed for off-chain fee-report construction. The registry -/// itself does not enforce report timing or construction. See the diagrams above. +/// (custom fee adjusted by the fee modifier) is exposed for off-chain fee-report construction. +/// The registry itself does not enforce report timing or construction. See the diagrams above. interface ICustomFeeRegistry is IWeightBoostProvider { /// @notice Emitted when a decrease or normalization sets the current fee immediately. event FeeSet(uint256 indexed nodeOperatorId, uint256 fee); @@ -114,12 +114,12 @@ interface ICustomFeeRegistry is IWeightBoostProvider { event FeeIncreaseRequested(uint256 indexed nodeOperatorId, uint256 pendingFeeIncrease, uint256 cooldownUntil); /// @notice Emitted when a pending increase becomes the current fee and pending state is cleared. event FeeIncreaseApplied(uint256 indexed nodeOperatorId, uint256 fee); - /// @notice Emitted when a pending increase is cancelled by a fee request or normalization. + /// @notice Emitted when a pending increase is cancelled explicitly, by a decrease, or by normalization. event FeeIncreaseCancelled(uint256 indexed nodeOperatorId); /// @notice Emitted during initialization and whenever the default minimum is lowered. event DefaultMinFeeSet(uint256 defaultMinFee); - /// @notice Emitted when the sign and magnitude of an existing curve's type bonus are stored. - event TypeBonusSet(uint256 indexed curveId, uint256 value, bool negative); + /// @notice Emitted when the sign and magnitude of an existing curve's fee modifier are stored. + event FeeModifierSet(uint256 indexed curveId, uint256 value, bool negative); /// @notice Emitted when the cooldown for future requests is set; existing deadlines are unchanged. event FeeIncreaseCooldownSet(uint256 feeIncreaseCooldown); @@ -129,7 +129,7 @@ interface ICustomFeeRegistry is IWeightBoostProvider { error SenderIsNotOperatorOwner(); /// @notice A requested or pending fee is outside its current valid range or is not step-aligned. error InvalidFee(); - /// @notice The requested fee equals the current fee and there is no pending increase to cancel. + /// @notice The requested fee equals the current fee. error SameFee(); /// @notice Strict normalization was requested while the current fee was already valid. error FeeNotBelowMinFee(); @@ -139,9 +139,9 @@ interface ICustomFeeRegistry is IWeightBoostProvider { error FeeIncreaseCooldownNotElapsed(); /// @notice The default minimum is zero, not step-aligned, or not below its required upper bound. error InvalidDefaultMinFee(); - /// @notice The type bonus exceeds its sign-dependent bound or is not step-aligned. - error InvalidTypeBonus(); - /// @notice The duration or resulting absolute deadline cannot be represented by uint64. + /// @notice The fee modifier exceeds its sign-dependent bound or is not step-aligned. + error InvalidFeeModifier(); + /// @notice The cooldown duration is zero or exceeds MAX_FEE_INCREASE_COOLDOWN. error InvalidFeeIncreaseCooldown(); /// @notice Curated module address. @@ -163,35 +163,40 @@ interface ICustomFeeRegistry is IWeightBoostProvider { /// @notice Custom fee granularity in basis points. function FEE_STEP() external view returns (uint256); + /// @notice Maximum configurable fee-increase cooldown in seconds. + function MAX_FEE_INCREASE_COOLDOWN() external view returns (uint256); + /// @notice Initialize the registry. /// @param admin Address to receive DEFAULT_ADMIN_ROLE. /// @param defaultMinFee Initial minimum custom fee: a non-zero multiple of FEE_STEP below /// DEFAULT_MAX_FEE. - /// @param feeIncreaseCooldown Stored cooldown duration in seconds, in [1, type(uint64).max]. - /// A later increase request also requires its absolute deadline to fit uint64. + /// @param feeIncreaseCooldown Stored cooldown duration in seconds, in + /// [1, MAX_FEE_INCREASE_COOLDOWN]. function initialize(address admin, uint256 defaultMinFee, uint256 feeIncreaseCooldown) external; /// @notice Request a custom fee. Only the Node Operator owner. A decrease applies immediately /// and cancels any pending increase. A request above the current fee creates or replaces /// a pending increase: allocation weight immediately follows the requested target, while /// the current and effective fees change only after `applyFeeIncrease`. Every replacement - /// restarts the cooldown. Requesting the current fee cancels a pending increase; without - /// a pending increase it reverts. + /// restarts the cooldown. Requesting the current fee reverts. /// @param nodeOperatorId ID of the Node Operator. - /// @param fee Fee in basis points. To set or schedule it, the value must be a multiple of - /// FEE_STEP within [getMinFee(nodeOperatorId), DEFAULT_MAX_FEE]. As an exception, - /// requesting the current fee cancels a pending increase even if the current fee has - /// fallen below the minimum. A new deadline must fit uint64. + /// @param fee Fee in basis points, a multiple of FEE_STEP within + /// [getMinFee(nodeOperatorId), DEFAULT_MAX_FEE]. function requestFee(uint256 nodeOperatorId, uint256 fee) external; + /// @notice Cancel a pending fee increase. Only the Node Operator owner. Restores allocation + /// weight to the current fee and reverts if no increase is pending. + /// @param nodeOperatorId ID of the Node Operator. + function cancelFeeIncrease(uint256 nodeOperatorId) external; + /// @notice Apply a pending fee increase after its cooldown. Only the Node Operator owner. /// Allocation weight already follows the pending fee. The fee is validated again - /// against the current range because a curve or bonus may have changed during cooldown. + /// against the current range because a curve or modifier may have changed during cooldown. /// @param nodeOperatorId ID of the Node Operator. function applyFeeIncrease(uint256 nodeOperatorId) external; /// @notice Permissionlessly normalize a custom fee left below the current minimum after a - /// curve or bonus change. Sets it to the minimum and cancels any pending increase. + /// curve or modifier change. Sets it to the minimum and cancels any pending increase. /// @param nodeOperatorId ID of the Node Operator. function normalizeFee(uint256 nodeOperatorId) external; @@ -206,21 +211,20 @@ interface ICustomFeeRegistry is IWeightBoostProvider { /// @param defaultMinFee New minimum in basis points, a non-zero multiple of FEE_STEP. function setDefaultMinFee(uint256 defaultMinFee) external; - /// @notice Set the fee bonus of an existing Node Operator type (bond curve). Only + /// @notice Set the fee modifier of an existing Node Operator type (bond curve). Only /// DEFAULT_ADMIN_ROLE. Shifts only the effective fee, never the weight; a negative - /// bonus raises the type's minimum custom fee. Reverts for a nonexistent curve. + /// modifier raises the type's minimum custom fee. Reverts for a nonexistent curve. /// @param curveId Bond curve ID of the type. /// @param value Magnitude in basis points, a multiple of FEE_STEP: at most /// MAX_BP - DEFAULT_MAX_FEE when positive, DEFAULT_MAX_FEE - defaultMinFee when negative. - /// @param negative Whether the bonus is subtracted from the custom fee. - function setTypeBonus(uint256 curveId, uint256 value, bool negative) external; + /// @param negative Whether the modifier is subtracted from the custom fee. + function setFeeModifier(uint256 curveId, uint256 value, bool negative) external; /// @notice Set the cooldown used by future fee-increase requests. Only DEFAULT_ADMIN_ROLE; /// existing pending deadlines are unchanged. /// @dev Governance invariant, not enforced on-chain: keep it >= one oracle frame + margin; /// raise it when frames are lengthened. - /// @param feeIncreaseCooldown Stored duration in seconds, in [1, type(uint64).max]. A later - /// increase request also requires its absolute deadline to fit uint64. + /// @param feeIncreaseCooldown Stored duration in seconds, in [1, MAX_FEE_INCREASE_COOLDOWN]. function setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) external; /// @notice Default minimum custom fee in basis points. @@ -229,9 +233,9 @@ interface ICustomFeeRegistry is IWeightBoostProvider { /// @notice Fee increase cooldown in seconds. function getFeeIncreaseCooldown() external view returns (uint256); - /// @notice Fee bonus of a Node Operator type. + /// @notice Fee modifier of a Node Operator type. /// @param curveId Bond curve ID of the type. - function getTypeBonus(uint256 curveId) external view returns (TypeBonus memory); + function getFeeModifier(uint256 curveId) external view returns (FeeModifier memory); /// @notice Custom fee of the Node Operator; DEFAULT_MAX_FEE if never set. While an increase /// is pending, the old fee is returned until `applyFeeIncrease`. @@ -250,11 +254,11 @@ interface ICustomFeeRegistry is IWeightBoostProvider { function getFeeIncreaseCooldownUntil(uint256 nodeOperatorId) external view returns (uint256); /// @notice Minimum custom fee of the Node Operator: the default minimum raised by the type's - /// negative bonus. + /// negative modifier. /// @param nodeOperatorId ID of the Node Operator. function getMinFee(uint256 nodeOperatorId) external view returns (uint256); - /// @notice Effective fee in basis points, computed from the current custom fee and type bonus. + /// @notice Effective fee in basis points, computed from the current custom fee and fee modifier. /// A pending increase is excluded until applied. Negative results are clamped at zero. /// Exposed for off-chain fee-report construction. /// @param nodeOperatorId ID of the Node Operator. diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index b54a23752..e77f05d72 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -13,7 +13,7 @@ import { IERC20LockBoostProvider } from "src/interfaces/IERC20LockBoostProvider. import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; import { BoostStep } from "src/interfaces/IAdditionalBondRegistry.sol"; import { StrikeThreshold } from "src/interfaces/INodeOperatorStrikes.sol"; -import { TypeBonus } from "src/interfaces/ICustomFeeRegistry.sol"; +import { FeeModifier } from "src/interfaces/ICustomFeeRegistry.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; import { IParametersRegistry } from "src/interfaces/IParametersRegistry.sol"; import { OssifiableProxy } from "src/lib/proxy/OssifiableProxy.sol"; @@ -414,18 +414,20 @@ contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { assertEq(customFeeRegistry.getDefaultMinFee(), deployParams.customFeeRegistryConfig.defaultMinFee); assertEq(customFeeRegistry.getFeeIncreaseCooldown(), deployParams.customFeeRegistryConfig.feeIncreaseCooldown); - for (uint256 i; i < deployParams.customFeeRegistryConfig.typeBonuses.length; ++i) { - TypeBonus memory actual = customFeeRegistry.getTypeBonus( - deployParams.customFeeRegistryConfig.typeBonuses[i].curveId + for (uint256 i; i < deployParams.customFeeRegistryConfig.feeModifiers.length; ++i) { + FeeModifier memory actual = customFeeRegistry.getFeeModifier( + deployParams.customFeeRegistryConfig.feeModifiers[i].curveId ); - assertEq(actual.value, deployParams.customFeeRegistryConfig.typeBonuses[i].value); - assertEq(actual.negative, deployParams.customFeeRegistryConfig.typeBonuses[i].negative); + assertEq(actual.value, deployParams.customFeeRegistryConfig.feeModifiers[i].value); + assertEq(actual.negative, deployParams.customFeeRegistryConfig.feeModifiers[i].negative); } uint256 defaultFee = customFeeRegistry.DEFAULT_MAX_FEE(); for (uint256 curveId; curveId < accounting.getCurvesCount(); ++curveId) { - TypeBonus memory bonus = customFeeRegistry.getTypeBonus(curveId); - uint256 effectiveFee = bonus.negative ? defaultFee - bonus.value : defaultFee + bonus.value; + 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"); } } @@ -441,6 +443,7 @@ contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { assertEq(customFeeRegistry.FEE_STEP(), 250, "custom fee step"); assertEq(customFeeRegistry.DEFAULT_MAX_FEE(), 8_750, "custom fee default max"); assertEq(customFeeRegistry.WEIGHT_BOOST_PER_STEP(), 400, "custom fee weight boost per step"); + assertEq(customFeeRegistry.MAX_FEE_INCREASE_COOLDOWN(), type(uint32).max, "custom fee max increase cooldown"); } function test_roles_onlyFull() public view { diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index 71ae72f23..36e7f56fe 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -621,8 +621,8 @@ contract DeploymentHelpers is Test { // CustomFeeRegistry dst.customFeeRegistryConfig.defaultMinFee = src.customFeeRegistryConfig.defaultMinFee; dst.customFeeRegistryConfig.feeIncreaseCooldown = src.customFeeRegistryConfig.feeIncreaseCooldown; - for (uint256 i; i < src.customFeeRegistryConfig.typeBonuses.length; ++i) { - dst.customFeeRegistryConfig.typeBonuses.push(src.customFeeRegistryConfig.typeBonuses[i]); + for (uint256 i; i < src.customFeeRegistryConfig.feeModifiers.length; ++i) { + dst.customFeeRegistryConfig.feeModifiers.push(src.customFeeRegistryConfig.feeModifiers[i]); } } diff --git a/test/unit/CustomFeeRegistry.t.sol b/test/unit/CustomFeeRegistry.t.sol index 0a4668657..ad9bcbf77 100644 --- a/test/unit/CustomFeeRegistry.t.sol +++ b/test/unit/CustomFeeRegistry.t.sol @@ -8,7 +8,7 @@ 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, TypeBonus } from "src/interfaces/ICustomFeeRegistry.sol"; +import { ICustomFeeRegistry, FeeModifier } from "src/interfaces/ICustomFeeRegistry.sol"; import { IBondCurve } from "src/interfaces/IBondCurve.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; @@ -76,14 +76,19 @@ contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures { feeRegistry.applyFeeIncrease(NO_ID); } + function _cancelFeeIncrease() internal { + vm.prank(nodeOperatorOwner); + feeRegistry.cancelFeeIncrease(NO_ID); + } + function _assertNoPendingFeeIncrease() internal view { assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 0); assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), 0); } - function _setTypeBonus(uint256 curveId, uint256 value, bool negative) internal { + function _setFeeModifier(uint256 curveId, uint256 value, bool negative) internal { vm.prank(admin); - feeRegistry.setTypeBonus(curveId, value, negative); + feeRegistry.setFeeModifier(curveId, value, negative); } } @@ -98,6 +103,7 @@ contract CustomFeeRegistryConstructorTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.FEE_STEP(), STEP); assertEq(feeRegistry.DEFAULT_MAX_FEE(), MAX_FEE); assertEq(feeRegistry.WEIGHT_BOOST_PER_STEP(), SLOPE); + assertEq(feeRegistry.MAX_FEE_INCREASE_COOLDOWN(), type(uint32).max); } } @@ -157,10 +163,10 @@ contract CustomFeeRegistryInitializeTest is CustomFeeRegistryBaseTest { registry.initialize(admin, MIN_FEE, 0); } - function test_initialize_RevertWhen_CooldownExceedsUint64() public { + function test_initialize_RevertWhen_CooldownExceedsMax() public { CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); - registry.initialize(admin, MIN_FEE, uint256(type(uint64).max) + 1); + registry.initialize(admin, MIN_FEE, uint256(type(uint32).max) + 1); } } @@ -269,8 +275,8 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { } function test_requestFee_RevertWhen_BelowTypeMinimum() public { - _setTypeBonus(0, 2_500, true); - // The negative bonus raises the operator's minimum to 5_000. + _setFeeModifier(0, 2_500, true); + // The negative modifier raises the operator's minimum to 5_000. vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); _requestFee(4_750); } @@ -286,14 +292,27 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { _requestFee(5_000); } - function test_requestFee_SameFeeCancelsPending() public { + function test_requestFee_RevertWhen_SameFeeWithPendingIncrease() public { _requestFee(5_000); _requestFee(6_000); + vm.expectRevert(ICustomFeeRegistry.SameFee.selector); + _requestFee(5_000); + } +} + +contract CustomFeeRegistryCancelFeeIncreaseTest is CustomFeeRegistryBaseTest { + function setUp() public override { + super.setUp(); + _requestFee(5_000); + _requestFee(6_000); + } + + function test_cancelFeeIncrease() public { uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); vm.expectEmit(address(feeRegistry)); emit ICustomFeeRegistry.FeeIncreaseCancelled(NO_ID); - _requestFee(5_000); + _cancelFeeIncrease(); assertEq(feeRegistry.getFee(NO_ID), 5_000); _assertNoPendingFeeIncrease(); @@ -301,26 +320,31 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore + 1); } - function test_requestFee_SameFeeCancelsPendingAfterCooldown() public { - _requestFee(5_000); - _requestFee(6_000); + function test_cancelFeeIncrease_AfterCooldown() public { vm.warp(block.timestamp + COOLDOWN + 1); - - _requestFee(5_000); - + _cancelFeeIncrease(); _assertNoPendingFeeIncrease(); } - function test_requestFee_SameFeeCancelsPendingWhenCurrentFeeBelowNewMin() public { - _requestFee(5_000); - _requestFee(6_000); - _setTypeBonus(0, 4_000, true); // New minimum is 6_500. - - _requestFee(5_000); + function test_cancelFeeIncrease_WhenCurrentFeeBelowNewMin() public { + _setFeeModifier(0, 4_000, true); // New minimum is 6_500. + _cancelFeeIncrease(); _assertNoPendingFeeIncrease(); assertEq(feeRegistry.getFee(NO_ID), 5_000); } + + function test_cancelFeeIncrease_RevertWhen_NotOwner() public { + vm.expectRevert(ICustomFeeRegistry.SenderIsNotOperatorOwner.selector); + vm.prank(stranger); + feeRegistry.cancelFeeIncrease(NO_ID); + } + + function test_cancelFeeIncrease_RevertWhen_NoPendingIncrease() public { + _cancelFeeIncrease(); + vm.expectRevert(ICustomFeeRegistry.NoFeeIncreaseCooldown.selector); + _cancelFeeIncrease(); + } } contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { @@ -384,7 +408,7 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { } function test_applyFeeIncrease_RevertWhen_PendingFeeBelowCurrentMin() public { - _setTypeBonus(0, 4_000, true); // New minimum is 6_500, pending fee is 6_000. + _setFeeModifier(0, 4_000, true); // New minimum is 6_500, pending fee is 6_000. vm.warp(cooldownUntil); vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); @@ -396,7 +420,7 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { } function test_applyFeeIncrease_InvalidPendingCanBeNormalizedPermissionlessly() public { - _setTypeBonus(0, 4_000, true); // New minimum is 6_500, pending fee is 6_000. + _setFeeModifier(0, 4_000, true); // New minimum is 6_500, pending fee is 6_000. vm.warp(cooldownUntil); vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); @@ -414,10 +438,10 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { contract CustomFeeRegistryNormalizeFeeTest is CustomFeeRegistryBaseTest { function setUp() public override { super.setUp(); - // The operator settles at the type minimum, then the DAO tightens the bonus. - _setTypeBonus(0, 2_500, true); + // The operator settles at the type minimum, then the DAO tightens the modifier. + _setFeeModifier(0, 2_500, true); _requestFee(5_000); - _setTypeBonus(0, 5_000, true); + _setFeeModifier(0, 5_000, true); // The minimum is now 7_500 and the operator at 5_000 sits below it. } @@ -493,9 +517,9 @@ contract CustomFeeRegistryNormalizeFeesTest is CustomFeeRegistryBaseTest { super.setUp(); // Operator 0 is left below the new minimum; the other operators remain unset and valid. - _setTypeBonus(0, 2_500, true); + _setFeeModifier(0, 2_500, true); _requestFee(5_000); - _setTypeBonus(0, 5_000, true); + _setFeeModifier(0, 5_000, true); } function test_normalizeFees() public { @@ -581,54 +605,54 @@ contract CustomFeeRegistrySetDefaultMinFeeTest is CustomFeeRegistryBaseTest { } } -contract CustomFeeRegistrySetTypeBonusTest is CustomFeeRegistryBaseTest { - function test_setTypeBonus_Positive() public { +contract CustomFeeRegistrySetFeeModifierTest is CustomFeeRegistryBaseTest { + function test_setFeeModifier_Positive() public { vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.TypeBonusSet(0, 1_250, false); - _setTypeBonus(0, 1_250, false); + emit ICustomFeeRegistry.FeeModifierSet(0, 1_250, false); + _setFeeModifier(0, 1_250, false); - TypeBonus memory bonus = feeRegistry.getTypeBonus(0); - assertEq(bonus.value, 1_250); - assertFalse(bonus.negative); - // The bonus tops the effective fee up to 100% for an unset operator. + 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 unset operator. assertEq(feeRegistry.getEffectiveFee(NO_ID), MAX_BP); assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); } - function test_setTypeBonus_Negative() public { - _setTypeBonus(0, 2_500, true); + function test_setFeeModifier_Negative() public { + _setFeeModifier(0, 2_500, true); - TypeBonus memory bonus = feeRegistry.getTypeBonus(0); - assertEq(bonus.value, 2_500); - assertTrue(bonus.negative); + FeeModifier memory feeModifier = feeRegistry.getFeeModifier(0); + assertEq(feeModifier.value, 2_500); + assertTrue(feeModifier.negative); assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE + 2_500); } - function test_setTypeBonus_ZeroWithAnySign() public { - _setTypeBonus(0, 0, true); + function test_setFeeModifier_ZeroWithAnySign() public { + _setFeeModifier(0, 0, true); assertEq(feeRegistry.getEffectiveFee(NO_ID), MAX_FEE); assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); - _setTypeBonus(0, 0, false); + _setFeeModifier(0, 0, false); assertEq(feeRegistry.getEffectiveFee(NO_ID), MAX_FEE); assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); } - function test_setTypeBonus_DoesNotNotifyAndDoesNotMoveWeight() public { + function test_setFeeModifier_DoesNotNotifyAndDoesNotMoveWeight() public { _requestFee(5_000); uint256 weightBefore = feeRegistry.getWeightBoostMultiplierBP(NO_ID); uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); - _setTypeBonus(0, 1_250, false); + _setFeeModifier(0, 1_250, false); assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), weightBefore); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } - function test_setTypeBonus_SameFeeSameWeightAcrossTypes() public { - _setTypeBonus(0, 1_250, false); + function test_setFeeModifier_SameFeeSameWeightAcrossTypes() public { + _setFeeModifier(0, 1_250, false); _accounting.setBondCurve(1, 1); // Add curve 1 without moving NO_ID from curve 0. - _setTypeBonus(1, 2_500, true); + _setFeeModifier(1, 2_500, true); _requestFee(5_000); uint256 weightOnTypeA = feeRegistry.getWeightBoostMultiplierBP(NO_ID); @@ -640,32 +664,32 @@ contract CustomFeeRegistrySetTypeBonusTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.getEffectiveFee(NO_ID), 2_500); } - function test_setTypeBonus_RevertWhen_NotAdmin() public { + function test_setFeeModifier_RevertWhen_NotAdmin() public { expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); vm.prank(stranger); - feeRegistry.setTypeBonus(0, 1_250, false); + feeRegistry.setFeeModifier(0, 1_250, false); } - function test_setTypeBonus_RevertWhen_PositiveAboveMax() public { - // MAX_BP - DEFAULT_MAX_FEE = 1_250 is the largest positive bonus. - vm.expectRevert(ICustomFeeRegistry.InvalidTypeBonus.selector); - _setTypeBonus(0, 1_250 + STEP, false); + function test_setFeeModifier_RevertWhen_PositiveAboveMax() public { + // MAX_BP - DEFAULT_MAX_FEE = 1_250 is the largest positive modifier. + vm.expectRevert(ICustomFeeRegistry.InvalidFeeModifier.selector); + _setFeeModifier(0, 1_250 + STEP, false); } - function test_setTypeBonus_RevertWhen_NegativeAboveMax() public { - // DEFAULT_MAX_FEE - defaultMinFee = 6_250 is the largest negative bonus. - vm.expectRevert(ICustomFeeRegistry.InvalidTypeBonus.selector); - _setTypeBonus(0, 6_250 + STEP, true); + function test_setFeeModifier_RevertWhen_NegativeAboveMax() public { + // DEFAULT_MAX_FEE - defaultMinFee = 6_250 is the largest negative modifier. + vm.expectRevert(ICustomFeeRegistry.InvalidFeeModifier.selector); + _setFeeModifier(0, 6_250 + STEP, true); } - function test_setTypeBonus_RevertWhen_NotStepAligned() public { - vm.expectRevert(ICustomFeeRegistry.InvalidTypeBonus.selector); - _setTypeBonus(0, 100, false); + function test_setFeeModifier_RevertWhen_NotStepAligned() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeModifier.selector); + _setFeeModifier(0, 100, false); } - function test_setTypeBonus_RevertWhen_CurveDoesNotExist() public { + function test_setFeeModifier_RevertWhen_CurveDoesNotExist() public { vm.expectRevert(IBondCurve.InvalidBondCurveId.selector); - _setTypeBonus(1, 0, false); + _setFeeModifier(1, 0, false); } } @@ -691,18 +715,21 @@ contract CustomFeeRegistrySetFeeIncreaseCooldownTest is CustomFeeRegistryBaseTes feeRegistry.setFeeIncreaseCooldown(0); } - function test_setFeeIncreaseCooldown_RevertWhen_ExceedsUint64() public { - vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); + function test_setFeeIncreaseCooldown_Max() public { vm.prank(admin); - feeRegistry.setFeeIncreaseCooldown(uint256(type(uint64).max) + 1); - } + feeRegistry.setFeeIncreaseCooldown(type(uint32).max); - function test_requestFeeIncrease_RevertWhen_DeadlineExceedsUint64() public { _requestFee(5_000); - vm.warp(uint256(type(uint64).max) - COOLDOWN + 1); + _requestFee(6_000); + assertEq(feeRegistry.getFeeIncreaseCooldown(), type(uint32).max); + assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), block.timestamp + type(uint32).max); + } + + function test_setFeeIncreaseCooldown_RevertWhen_ExceedsMax() public { vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); - _requestFee(6_000); + vm.prank(admin); + feeRegistry.setFeeIncreaseCooldown(uint256(type(uint32).max) + 1); } } @@ -734,15 +761,15 @@ contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 2 * MAX_BP); } - function test_getEffectiveFee_NoBonus() public { + function test_getEffectiveFee_NoModifier() public { _requestFee(5_000); assertEq(feeRegistry.getEffectiveFee(NO_ID), 5_000); } function test_getEffectiveFee_ClampsAtZero() public { - _setTypeBonus(0, 2_500, true); + _setFeeModifier(0, 2_500, true); _requestFee(5_000); - _setTypeBonus(0, 5_500, true); + _setFeeModifier(0, 5_500, true); assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); } From e9cb35774b07785dac687bfc415bb8819b155111 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Thu, 6 Aug 2026 15:18:51 +0200 Subject: [PATCH 07/11] fix: review, refactoring --- script/curated/DeployBase.s.sol | 158 +++--- script/curated/DeployHoodi.s.sol | 19 +- script/curated/DeployLocalDevNet.s.sol | 19 +- script/curated/DeployMainnet.s.sol | 19 +- script/utils/Common.sol | 12 - src/AdditionalBondRegistry.sol | 192 ++++---- src/CustomFeeRegistry.sol | 112 ++--- src/ERC20LockBoostProvider.sol | 134 ++---- src/ERC20LockVault.sol | 6 +- src/LidoGovernanceLockVault.sol | 6 +- src/MetaRegistry.sol | 61 +-- src/NodeOperatorStrikes.sol | 108 +---- src/abstract/StepwiseWeightBoost.sol | 135 ++++++ src/interfaces/IAdditionalBondRegistry.sol | 105 ++-- src/interfaces/IAragonVotingLockVault.sol | 2 + src/interfaces/ICustomFeeRegistry.sol | 85 +--- src/interfaces/IERC20LockBoostProvider.sol | 83 ++-- src/interfaces/IERC20LockVault.sol | 5 +- src/interfaces/IMetaRegistry.sol | 57 +-- src/interfaces/INodeOperatorStrikes.sol | 59 +-- .../ISnapshotDelegationLockVault.sol | 2 + src/interfaces/IStepwiseWeightBoost.sol | 52 ++ src/interfaces/IWeightBoostProvider.sol | 4 +- src/lib/Constants.sol | 3 - .../deployment/PostDeploymentCurated.t.sol | 330 ++++++------- test/helpers/CuratedProviderFixture.sol | 36 ++ test/helpers/Fixtures.sol | 10 +- test/helpers/StepwiseWeightBoostBehaviour.sol | 126 +++++ test/unit/AdditionalBondRegistry.t.sol | 454 +++++++++++------- test/unit/CustomFeeRegistry.t.sol | 322 +++++++++---- test/unit/ERC20LockBoostProvider.t.sol | 246 +++++----- test/unit/MetaRegistry.t.sol | 17 +- test/unit/NodeOperatorStrikes.t.sol | 236 +++++---- 33 files changed, 1750 insertions(+), 1465 deletions(-) create mode 100644 src/abstract/StepwiseWeightBoost.sol create mode 100644 src/interfaces/IStepwiseWeightBoost.sol create mode 100644 test/helpers/CuratedProviderFixture.sol create mode 100644 test/helpers/StepwiseWeightBoostBehaviour.sol diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 9951a8cfa..4e93f2eab 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -22,9 +22,9 @@ import { MetaRegistry } from "../../src/MetaRegistry.sol"; import { AdditionalBondRegistry } from "../../src/AdditionalBondRegistry.sol"; import { NodeOperatorStrikes } from "../../src/NodeOperatorStrikes.sol"; import { CustomFeeRegistry } from "../../src/CustomFeeRegistry.sol"; -import { BoostStep } from "../../src/interfaces/IAdditionalBondRegistry.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"; @@ -34,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"; @@ -72,9 +70,9 @@ 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 ERC20LockBoostProviderConfig { @@ -83,7 +81,7 @@ struct ERC20LockBoostProviderConfig { address snapshotDelegation; uint256 minLockPeriod; uint256 lockPeriod; - IERC20LockBoostProvider.LockBoostStep[] lockBoostSteps; + Step[] lockBoostSteps; } struct CurveFeeModifierConfig { @@ -95,6 +93,7 @@ struct CurveFeeModifierConfig { struct CustomFeeRegistryConfig { uint256 defaultMinFee; uint256 feeIncreaseCooldown; + Step[] feeWeightSteps; CurveFeeModifierConfig[] feeModifiers; } @@ -166,7 +165,7 @@ struct CuratedDeployParams { AdditionalBondRegistryConfig additionalBondRegistryConfig; // NodeOperatorStrikes address strikesCommittee; - StrikeThreshold[] strikesThresholds; + Step[] strikesThresholds; // LDO lock boost provider ERC20LockBoostProviderConfig ldoLockBoostProviderConfig; // CustomFeeRegistry @@ -362,56 +361,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.strikesThresholds)) + ); // LDO lock boost provider { @@ -433,52 +420,45 @@ 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) }); - { - OssifiableProxy customFeeRegistryProxy = OssifiableProxy(payable(address(customFeeRegistry))); - customFeeRegistryProxy.proxy__upgradeToAndCall( - address(customFeeRegistryImpl), - abi.encodeCall( - CustomFeeRegistry.initialize, - ( - deployer, - config.customFeeRegistryConfig.defaultMinFee, - config.customFeeRegistryConfig.feeIncreaseCooldown - ) + _upgradeAndHandoffProxy( + address(customFeeRegistry), + address(customFeeRegistryImpl), + abi.encodeCall( + CustomFeeRegistry.initialize, + ( + deployer, + config.customFeeRegistryConfig.defaultMinFee, + config.customFeeRegistryConfig.feeIncreaseCooldown, + config.customFeeRegistryConfig.feeWeightSteps ) - ); - customFeeRegistryProxy.proxy__changeAdmin(config.proxyAdmin); - } + ) + ); accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), address(additionalBondRegistry)); - metaRegistry.addWeightBoostProvider( - IWeightBoostProvider(address(additionalBondRegistry)), - IMetaRegistry.WeightBoostProviderMode.PerNodeOperator - ); - metaRegistry.addWeightBoostProvider( - IWeightBoostProvider(address(nodeOperatorStrikes)), + _addWeightBoostProvider( + address(additionalBondRegistry), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator ); - metaRegistry.addWeightBoostProvider( - IWeightBoostProvider(address(customFeeRegistry)), + _addWeightBoostProvider( + address(nodeOperatorStrikes), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator ); + _addWeightBoostProvider(address(ldoLockBoostProvider), IMetaRegistry.WeightBoostProviderMode.MaxPerGroup); + _addWeightBoostProvider(address(customFeeRegistry), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator); nodeOperatorStrikes.grantRole(nodeOperatorStrikes.STRIKES_COMMITTEE_ROLE(), config.strikesCommittee); - metaRegistry.addWeightBoostProvider( - IWeightBoostProvider(address(ldoLockBoostProvider)), - IMetaRegistry.WeightBoostProviderMode.MaxPerGroup - ); - ldoLockBoostProvider.setLockBoostSteps(config.ldoLockBoostProviderConfig.lockBoostSteps); metaRegistry.grantRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); for (uint256 i = 0; i < gatesCount; i++) { @@ -796,10 +776,20 @@ 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 _addLDOLockBoostStep(uint128 minAmount, uint128 weightBoostBP) internal { + config.ldoLockBoostProviderConfig.lockBoostSteps.push(Step({ threshold: minAmount, value: weightBoostBP })); } function _deployProxy(address admin, address implementation) internal returns (address) { diff --git a/script/curated/DeployHoodi.s.sol b/script/curated/DeployHoodi.s.sol index 834466d60..a165a7c5b 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.33; import { DeployBase, CuratedGateConfig, CurveFeeModifierConfig } from "./DeployBase.s.sol"; -import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; +import { Step } from "../../src/interfaces/IStepwiseWeightBoost.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployHoodi is DeployBase { @@ -202,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 // 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.strikesThresholds.push(Step({ threshold: 2, value: 2_500 })); + config.strikesThresholds.push(Step({ threshold: 3, value: 5_000 })); + config.strikesThresholds.push(Step({ threshold: 4, value: 7_500 })); + config.strikesThresholds.push(Step({ threshold: 5, value: 10_000 })); // LDO lock boost provider config.ldoLockBoostProviderConfig.token = 0xEf2573966D009CcEA0Fc74451dee2193564198dc; @@ -228,6 +228,9 @@ contract DeployHoodi is DeployBase { // TODO: finalize custom fee parameters. config.customFeeRegistryConfig.defaultMinFee = 2_500; config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + for (uint128 i = 1; i < 35; ++i) { + config.customFeeRegistryConfig.feeWeightSteps.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 }) diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index a25deca2f..294199845 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.33; import { DeployBase, CuratedGateConfig, CurveFeeModifierConfig } from "./DeployBase.s.sol"; -import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.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"; @@ -190,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.strikesThresholds.push(Step({ threshold: 2, value: 2_500 })); + config.strikesThresholds.push(Step({ threshold: 3, value: 5_000 })); + config.strikesThresholds.push(Step({ threshold: 4, value: 7_500 })); + config.strikesThresholds.push(Step({ threshold: 5, value: 10_000 })); // LDO lock boost provider config.ldoLockBoostProviderConfig.token = vm.envAddress("CSM_LDO_TOKEN_ADDRESS"); @@ -214,6 +214,9 @@ contract DeployLocalDevNet is DeployBase { // CustomFeeRegistry config.customFeeRegistryConfig.defaultMinFee = 2_500; config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + for (uint128 i = 1; i < 35; ++i) { + config.customFeeRegistryConfig.feeWeightSteps.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 }) diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index 7d41c68bd..856308e43 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.33; import { DeployBase, CuratedGateConfig, CurveFeeModifierConfig } from "./DeployBase.s.sol"; -import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; +import { Step } from "../../src/interfaces/IStepwiseWeightBoost.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployMainnet is DeployBase { @@ -201,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 // 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.strikesThresholds.push(Step({ threshold: 2, value: 2_500 })); + config.strikesThresholds.push(Step({ threshold: 3, value: 5_000 })); + config.strikesThresholds.push(Step({ threshold: 4, value: 7_500 })); + config.strikesThresholds.push(Step({ threshold: 5, value: 10_000 })); // LDO lock boost provider config.ldoLockBoostProviderConfig.token = 0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32; @@ -227,6 +227,9 @@ contract DeployMainnet is DeployBase { // TODO: finalize custom fee parameters. config.customFeeRegistryConfig.defaultMinFee = 2_500; config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + for (uint128 i = 1; i < 35; ++i) { + config.customFeeRegistryConfig.feeWeightSteps.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 }) 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..12bd0f839 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 previousEffectiveCurveMultiplier = _getEffectiveCurveMultiplier(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, + previousEffectiveCurveMultiplier, + 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(_getEffectiveCurveMultiplier(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 During a downgrade cooldown, weight follows the pending lower multiplier while Accounting + /// continues to hold the current higher multiplier. + function _getEffectiveCurveMultiplier( + 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 index c7154484f..f20aa3942 100644 --- a/src/CustomFeeRegistry.sol +++ b/src/CustomFeeRegistry.sol @@ -3,21 +3,19 @@ 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 { 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 { ICuratedModule } from "./interfaces/ICuratedModule.sol"; -import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; import { ICustomFeeRegistry, OperatorFee, 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 custom fees and the allocation weight boost derived from them. See /// ICustomFeeRegistry for the model. -contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEnumerableUpgradeable { +contract CustomFeeRegistry is ICustomFeeRegistry, StepwiseWeightBoost { using SafeCast for uint256; /// @custom:storage-location erc7201:CustomFeeRegistry @@ -28,73 +26,75 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn mapping(uint256 nodeOperatorId => OperatorFee) fees; } - // All fees are basis points of the operator's own rewards. - // A custom fee moves in these increments: 2.5% of the operator's rewards, which is 0.1% - // of the total staking rewards at a 4% module reward share. + // All fees are basis points of the operator's own rewards. At the module's 4% share of + // the protocol staking rewards, one step is 0.1% of the total. uint256 public constant FEE_STEP = 250; // 2.5% // The fee of an unset operator and the inclusive upper bound for custom fees. uint256 public constant DEFAULT_MAX_FEE = 35 * FEE_STEP; // 87.5% - // Slope of the weight line: multiplier basis points per FEE_STEP below DEFAULT_MAX_FEE. - uint256 public constant WEIGHT_BOOST_PER_STEP = 400; - uint256 public constant MAX_FEE_INCREASE_COOLDOWN = type(uint32).max; + uint256 public constant MAX_FEE_INCREASE_COOLDOWN = 365 days; - ICuratedModule public immutable MODULE; IAccounting public immutable ACCOUNTING; - IMetaRegistry public immutable META_REGISTRY; // 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) { - MODULE = ICuratedModule(module); + constructor(address module) StepwiseWeightBoost(module) { ACCOUNTING = IAccounting(MODULE.ACCOUNTING()); - META_REGISTRY = IMetaRegistry(MODULE.META_REGISTRY()); - - _disableInitializers(); } /// @inheritdoc ICustomFeeRegistry - function initialize(address admin, uint256 defaultMinFee, uint256 feeIncreaseCooldown) external initializer { - if (admin == address(0)) revert ZeroAdminAddress(); - _grantRole(DEFAULT_ADMIN_ROLE, admin); + function initialize( + address admin, + uint256 defaultMinFee, + uint256 feeIncreaseCooldown, + Step[] calldata steps + ) external initializer { _setDefaultMinFee(defaultMinFee, DEFAULT_MAX_FEE); _setFeeIncreaseCooldown(feeIncreaseCooldown); + StepwiseWeightBoost._initialize(admin, steps); } /// @inheritdoc ICustomFeeRegistry - function requestFee(uint256 nodeOperatorId, uint256 requestedFee) external { - _checkOperatorOwner(nodeOperatorId); + function requestFee(uint256 nodeOperatorId, uint256 fee) external { + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); uint256 currentFee = _getCurrentFee(nodeOperatorId); - if (requestedFee == currentFee) revert SameFee(); + if (fee == currentFee) revert SameFee(); - _validateFee(nodeOperatorId, requestedFee); + _validateFee(nodeOperatorId, fee); - if (requestedFee < currentFee) { - _setCurrentFee(nodeOperatorId, requestedFee); + if (fee < currentFee) { + _setCurrentFee(nodeOperatorId, fee); } else { - _scheduleFeeIncrease(nodeOperatorId, requestedFee); + _scheduleFeeIncrease(nodeOperatorId, fee); } } /// @inheritdoc ICustomFeeRegistry function cancelFeeIncrease(uint256 nodeOperatorId) external { - _checkOperatorOwner(nodeOperatorId); + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; if (operatorFee.cooldownUntil == 0) revert NoFeeIncreaseCooldown(); + uint256 previousFee = _getTargetFee(nodeOperatorId); + uint256 currentFee = _getCurrentFee(nodeOperatorId); + operatorFee.pendingFeeIncrease = 0; operatorFee.cooldownUntil = 0; emit FeeIncreaseCancelled(nodeOperatorId); - META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged( + nodeOperatorId, + _feeDiscount(previousFee), + _feeDiscount(currentFee) + ); } /// @inheritdoc ICustomFeeRegistry function applyFeeIncrease(uint256 nodeOperatorId) external { - _checkOperatorOwner(nodeOperatorId); + StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; if (operatorFee.cooldownUntil == 0) revert NoFeeIncreaseCooldown(); @@ -111,11 +111,6 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn // No notification: the allocation weight changed when the increase was requested. } - /// @inheritdoc ICustomFeeRegistry - function normalizeFee(uint256 nodeOperatorId) external { - if (!_normalizeFee(nodeOperatorId)) revert FeeNotBelowMinFee(); - } - /// @inheritdoc ICustomFeeRegistry function normalizeFees(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount) { for (uint256 i; i < nodeOperatorIds.length; ++i) { @@ -185,13 +180,7 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn /// @inheritdoc IWeightBoostProvider function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { - OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; - // A pending increase affects allocation weight before it becomes the current fee. - uint256 fee = operatorFee.cooldownUntil != 0 ? operatorFee.pendingFeeIncrease : _getCurrentFee(nodeOperatorId); - // The multiplier is 1x for an operator at DEFAULT_MAX_FEE and grows by - // WEIGHT_BOOST_PER_STEP for every step below it. Fees are multiples of FEE_STEP, - // so the division has no remainder. - multiplierBP = MAX_BP + ((DEFAULT_MAX_FEE - fee) * WEIGHT_BOOST_PER_STEP) / FEE_STEP; + multiplierBP = MAX_BP + StepwiseWeightBoost._stepValueAt(_feeDiscount(_getTargetFee(nodeOperatorId))); } /// @inheritdoc ICustomFeeRegistry @@ -212,27 +201,35 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn function _setCurrentFee(uint256 nodeOperatorId, uint256 newCurrentFee) internal { OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; bool hadPendingIncrease = operatorFee.cooldownUntil != 0; - // A pending increase, if any, is the fee currently affecting allocation weight. - uint256 previousFee = hadPendingIncrease ? operatorFee.pendingFeeIncrease : _getCurrentFee(nodeOperatorId); + uint256 previousFee = _getTargetFee(nodeOperatorId); operatorFee.currentFee = newCurrentFee.toUint16(); operatorFee.pendingFeeIncrease = 0; operatorFee.cooldownUntil = 0; if (hadPendingIncrease) emit FeeIncreaseCancelled(nodeOperatorId); emit FeeSet(nodeOperatorId, newCurrentFee); - if (previousFee != newCurrentFee) META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged( + nodeOperatorId, + _feeDiscount(previousFee), + _feeDiscount(newCurrentFee) + ); } - /// @dev Stores or replaces a pending increase, restarts its cooldown, and notifies MetaRegistry. - /// Notification is sent even if the pending target and multiplier are unchanged. + /// @dev Stores or replaces a pending increase, restarts its cooldown, and notifies if the + /// allocation weight changes. function _scheduleFeeIncrease(uint256 nodeOperatorId, uint256 pendingFee) internal { CustomFeeRegistryStorage storage $ = _storage(); OperatorFee storage operatorFee = $.fees[nodeOperatorId]; + uint256 previousFee = _getTargetFee(nodeOperatorId); uint256 cooldownUntil = block.timestamp + $.feeIncreaseCooldown; operatorFee.pendingFeeIncrease = pendingFee.toUint16(); operatorFee.cooldownUntil = cooldownUntil.toUint64(); emit FeeIncreaseRequested(nodeOperatorId, pendingFee, cooldownUntil); - META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged( + nodeOperatorId, + _feeDiscount(previousFee), + _feeDiscount(pendingFee) + ); } /// @dev The minimum must remain non-zero because zero currentFee is the unset marker. @@ -279,15 +276,22 @@ contract CustomFeeRegistry is ICustomFeeRegistry, Initializable, AccessControlEn return fee == 0 ? DEFAULT_MAX_FEE : fee; } - function _checkOperatorOwner(uint256 nodeOperatorId) internal view { - if (msg.sender != MODULE.getNodeOperatorOwner(nodeOperatorId)) { - revert SenderIsNotOperatorOwner(); - } + /// @dev The pending increase target, if any, otherwise the current fee. Allocation weight follows it. + function _getTargetFee(uint256 nodeOperatorId) internal view returns (uint256) { + OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; + return operatorFee.cooldownUntil != 0 ? operatorFee.pendingFeeIncrease : _getCurrentFee(nodeOperatorId); + } + + function _feeDiscount(uint256 fee) internal pure returns (uint256) { + return DEFAULT_MAX_FEE - fee; + } + + function _isValidStep(Step calldata step) internal pure override returns (bool) { + return step.threshold < DEFAULT_MAX_FEE && step.threshold % FEE_STEP == 0; } function _storage() internal pure returns (CustomFeeRegistryStorage storage $) { assembly ("memory-safe") { - // keccak256(abi.encode(uint256(keccak256("CustomFeeRegistry")) - 1)) & ~bytes32(uint256(0xff)) $.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..2cd907e32 --- /dev/null +++ b/src/abstract/StepwiseWeightBoost.sol @@ -0,0 +1,135 @@ +// 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 Base of every weight boost provider: storage, validation, configuration, and lookup of the +/// step function, module/MetaRegistry wiring, operator access checks, and weight notifications. +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; + + /// @dev Also locks the implementation: every provider is deployed behind a proxy. + 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 Initializes the common administrator and step function without requesting a weight refresh. + /// Called qualified — `StepwiseWeightBoost._initialize(...)` — from provider initializers. + function _initialize(address admin, Step[] calldata steps) internal onlyInitializing { + if (admin == address(0)) revert ZeroAdminAddress(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _setSteps(steps); + } + + /// @dev Notification policy shared by the providers: MetaRegistry is asked to refresh the operator's + /// cached weight only when the transition crosses a step boundary, since the step value is what + /// moves the weight multiplier. + 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 Reverts unless the Node Operator exists. Ids are dense, so the count is the authoritative bound. + 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 index 6d4bebd5f..22a9f659a 100644 --- a/src/interfaces/ICustomFeeRegistry.sol +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -4,9 +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"; +import { IStepwiseWeightBoost, Step } from "./IStepwiseWeightBoost.sol"; /// @dev Custom fee state of a Node Operator. `currentFee == 0` means "never set" and reads as /// `DEFAULT_MAX_FEE`. `cooldownUntil == 0` means no pending increase. Packed into a single slot. @@ -53,30 +51,6 @@ struct FeeModifier { * effective A ○ * effective B ○ * - * ── Custom fee to allocation weight ──────────────────────────────────────────── - * - * The weight multiplier depends on the fee only, never on a fee modifier — on - * the custom fee, or the pending target while an increase is pending (see the - * timeline below). Two things are fixed: 1x at DEFAULT_MAX_FEE and a straight - * slope of WEIGHT_BOOST_PER_STEP (400 BP) per FEE_STEP (250 BP) of discount. - * The minimum is a parameter. For the illustrated defaultMinFee of 2500, the - * multiplier at the minimum is 2x. Governance may lower the minimum along the - * same line. Since it must remain a non-zero multiple of FEE_STEP, the lowest - * reachable fee is 250 and the highest reachable multiplier is 2.36x. - * - * multiplier - * 2.4x ┤╲ custom 0: unreachable - * 2.2x ┤ ╲ below 2500 — only if DAO lowers the minimum - * 2.0x ┤ ● 2500 (illustrated defaultMinFee): 2x - * 1.8x ┤ ╲ - * 1.6x ┤ ╲ - * 1.4x ┤ ╲ - * 1.2x ┤ ╲ - * 1.0x ┤ ● 8750 (DEFAULT_MAX_FEE, unset): 1x - * └┬ ┬ ┬─► custom fee, portion BP - * 0 2500 8750 - * ├─── reachable at min 2500 ───┤ - * * ── Fee increase timeline and oracle frames ──────────────────────────────────── * * Z is the fee-increase cooldown. Off-chain fee-report construction is expected @@ -106,73 +80,53 @@ struct FeeModifier { /// @notice Per-operator custom fees and the allocation weight boost derived from them: the lower /// the custom fee, the higher the operator's allocation weight. The effective fee /// (custom fee adjusted by the fee modifier) is exposed for off-chain fee-report construction. -/// The registry itself does not enforce report timing or construction. See the diagrams above. -interface ICustomFeeRegistry is IWeightBoostProvider { - /// @notice Emitted when a decrease or normalization sets the current fee immediately. +/// In this provider, `Step.threshold` is the minimum fee discount from DEFAULT_MAX_FEE and +/// `Step.value` is the weight multiplier increment above MAX_BP. The registry itself does not +/// enforce report timing or construction. See the diagrams above. +interface ICustomFeeRegistry is IStepwiseWeightBoost { event FeeSet(uint256 indexed nodeOperatorId, uint256 fee); - /// @notice Emitted when a pending increase is created or replaced. event FeeIncreaseRequested(uint256 indexed nodeOperatorId, uint256 pendingFeeIncrease, uint256 cooldownUntil); - /// @notice Emitted when a pending increase becomes the current fee and pending state is cleared. event FeeIncreaseApplied(uint256 indexed nodeOperatorId, uint256 fee); - /// @notice Emitted when a pending increase is cancelled explicitly, by a decrease, or by normalization. event FeeIncreaseCancelled(uint256 indexed nodeOperatorId); - /// @notice Emitted during initialization and whenever the default minimum is lowered. event DefaultMinFeeSet(uint256 defaultMinFee); - /// @notice Emitted when the sign and magnitude of an existing curve's fee modifier are stored. event FeeModifierSet(uint256 indexed curveId, uint256 value, bool negative); - /// @notice Emitted when the cooldown for future requests is set; existing deadlines are unchanged. event FeeIncreaseCooldownSet(uint256 feeIncreaseCooldown); - /// @notice The initializer admin is the zero address. - error ZeroAdminAddress(); - /// @notice The caller is not the Node Operator owner. - error SenderIsNotOperatorOwner(); - /// @notice A requested or pending fee is outside its current valid range or is not step-aligned. error InvalidFee(); - /// @notice The requested fee equals the current fee. error SameFee(); - /// @notice Strict normalization was requested while the current fee was already valid. - error FeeNotBelowMinFee(); - /// @notice The Node Operator has no pending fee increase. error NoFeeIncreaseCooldown(); - /// @notice The pending increase cannot be applied before its stored deadline. error FeeIncreaseCooldownNotElapsed(); - /// @notice The default minimum is zero, not step-aligned, or not below its required upper bound. error InvalidDefaultMinFee(); - /// @notice The fee modifier exceeds its sign-dependent bound or is not step-aligned. error InvalidFeeModifier(); - /// @notice The cooldown duration is zero or exceeds MAX_FEE_INCREASE_COOLDOWN. error InvalidFeeIncreaseCooldown(); - /// @notice Curated module address. - function MODULE() external view returns (ICuratedModule); - /// @notice Accounting contract holding bond curves; an operator's type is its curve id. function ACCOUNTING() external view returns (IAccounting); - /// @notice MetaRegistry notified when fee operations may require an allocation-weight refresh. - function META_REGISTRY() external view returns (IMetaRegistry); - /// @notice Fee returned for an operator whose fee has never been set, and the inclusive upper /// bound for custom fees, in basis points. function DEFAULT_MAX_FEE() external view returns (uint256); - /// @notice Slope of the weight line: multiplier basis points per FEE_STEP below DEFAULT_MAX_FEE. - function WEIGHT_BOOST_PER_STEP() external view returns (uint256); - /// @notice Custom fee granularity in basis points. function FEE_STEP() external view returns (uint256); /// @notice Maximum configurable fee-increase cooldown in seconds. function MAX_FEE_INCREASE_COOLDOWN() external view returns (uint256); - /// @notice Initialize the registry. + /// @notice Initialize the provider. /// @param admin Address to receive DEFAULT_ADMIN_ROLE. /// @param defaultMinFee Initial minimum custom fee: a non-zero multiple of FEE_STEP below /// DEFAULT_MAX_FEE. /// @param feeIncreaseCooldown Stored cooldown duration in seconds, in /// [1, MAX_FEE_INCREASE_COOLDOWN]. - function initialize(address admin, uint256 defaultMinFee, uint256 feeIncreaseCooldown) external; + /// @param steps Initial steps. Thresholds must be FEE_STEP-aligned and below DEFAULT_MAX_FEE; values + /// must not exceed MAX_STEP_VALUE. + function initialize( + address admin, + uint256 defaultMinFee, + uint256 feeIncreaseCooldown, + Step[] calldata steps + ) external; /// @notice Request a custom fee. Only the Node Operator owner. A decrease applies immediately /// and cancels any pending increase. A request above the current fee creates or replaces @@ -195,13 +149,10 @@ interface ICustomFeeRegistry is IWeightBoostProvider { /// @param nodeOperatorId ID of the Node Operator. function applyFeeIncrease(uint256 nodeOperatorId) external; - /// @notice Permissionlessly normalize a custom fee left below the current minimum after a - /// curve or modifier change. Sets it to the minimum and cancels any pending increase. - /// @param nodeOperatorId ID of the Node Operator. - function normalizeFee(uint256 nodeOperatorId) external; - - /// @notice Permissionlessly normalize multiple custom fees. Operators whose fees are already - /// valid are skipped. Duplicate IDs are allowed and normalized at most once. + /// @notice Permissionlessly normalize custom fees left below the current minimum after a curve + /// or modifier change: each is set to the minimum and its pending increase is cancelled. + /// Operators whose fees are already valid are skipped. Duplicate IDs are allowed and + /// normalized at most once. /// @param nodeOperatorIds IDs of the Node Operators. /// @return normalizedCount Number of fees normalized. function normalizeFees(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount); 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 e77f05d72..d45d39fee 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -9,24 +9,79 @@ 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); @@ -68,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"); @@ -143,53 +186,56 @@ contract MetaRegistryDeploymentTest is DeploymentBaseTest { function test_weightBoostProviders_onlyFull() public view { 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(address(customFeeRegistry), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator); + _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 { @@ -202,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.strikesThresholds); } 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 { @@ -269,28 +284,15 @@ contract NodeOperatorStrikesDeploymentTest is DeploymentBaseTest { } 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"); } } @@ -303,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, @@ -343,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 { @@ -374,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); @@ -388,32 +378,19 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { } function test_proxy_onlyFull() public view { - OssifiableProxy proxy = OssifiableProxy(payable(address(ldoLockBoostProvider))); - assertEq( - proxy.proxy__getImplementation(), - address(ldoLockBoostProviderImpl), - "LDO lock provider proxy getter impl" - ); - assertEq( - ProxySlotUtils.getImplementation(address(ldoLockBoostProvider)), - address(ldoLockBoostProviderImpl), - "LDO lock provider proxy slot impl" - ); - assertEq(proxy.proxy__getAdmin(), address(deployParams.proxyAdmin), "LDO lock provider proxy getter admin"); - assertEq( - ProxySlotUtils.getAdmin(address(ldoLockBoostProvider)), - address(deployParams.proxyAdmin), - "LDO lock provider proxy slot admin" - ); - assertFalse(proxy.proxy__getIsOssified(), "LDO lock provider proxy ossified"); + _assertProxy(address(ldoLockBoostProvider), address(ldoLockBoostProviderImpl), "LDO lock provider"); } } contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { function test_state_onlyFull() public view { + assertEq(customFeeRegistry.getInitializedVersion(), 1); assertEq(customFeeRegistry.getDefaultMinFee(), deployParams.customFeeRegistryConfig.defaultMinFee); assertEq(customFeeRegistry.getFeeIncreaseCooldown(), deployParams.customFeeRegistryConfig.feeIncreaseCooldown); + // Fee discount thresholds map to weight multiplier increments. + _assertSteps(customFeeRegistry.getSteps(), deployParams.customFeeRegistryConfig.feeWeightSteps); + for (uint256 i; i < deployParams.customFeeRegistryConfig.feeModifiers.length; ++i) { FeeModifier memory actual = customFeeRegistry.getFeeModifier( deployParams.customFeeRegistryConfig.feeModifiers[i].curveId @@ -433,17 +410,13 @@ contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { } function test_immutables_onlyFull() public view { - assertEq(address(customFeeRegistry.MODULE()), address(curatedModule), "custom fee registry module"); + _assertProviderWiring(address(customFeeRegistry), "custom fee registry"); assertEq(address(customFeeRegistry.ACCOUNTING()), address(accounting), "custom fee registry accounting"); - assertEq( - address(customFeeRegistry.META_REGISTRY()), - address(metaRegistry), - "custom fee registry meta registry" - ); assertEq(customFeeRegistry.FEE_STEP(), 250, "custom fee step"); assertEq(customFeeRegistry.DEFAULT_MAX_FEE(), 8_750, "custom fee default max"); - assertEq(customFeeRegistry.WEIGHT_BOOST_PER_STEP(), 400, "custom fee weight boost per step"); - assertEq(customFeeRegistry.MAX_FEE_INCREASE_COOLDOWN(), type(uint32).max, "custom fee max increase cooldown"); + assertEq(customFeeRegistry.MAX_STEPS(), 35, "custom fee max weight steps"); + assertEq(customFeeRegistry.MAX_STEP_VALUE(), 90_000, "custom fee max weight multiplier"); + assertEq(customFeeRegistry.MAX_FEE_INCREASE_COOLDOWN(), 365 days, "custom fee max increase cooldown"); } function test_roles_onlyFull() public view { @@ -451,28 +424,18 @@ contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { } function test_initialization_onlyFull() public { - vm.expectRevert(Initializable.InvalidInitialization.selector); - customFeeRegistry.initialize(deployParams.aragonAgent, 2_500, 15 days); - - vm.expectRevert(Initializable.InvalidInitialization.selector); - customFeeRegistryImpl.initialize(deployParams.aragonAgent, 2_500, 15 days); + _assertNotReinitializable( + address(customFeeRegistry), + address(customFeeRegistryImpl), + abi.encodeCall( + customFeeRegistry.initialize, + (deployParams.aragonAgent, 2_500, 15 days, deployParams.customFeeRegistryConfig.feeWeightSteps) + ) + ); } function test_proxy_onlyFull() public view { - OssifiableProxy proxy = OssifiableProxy(payable(address(customFeeRegistry))); - assertEq(proxy.proxy__getImplementation(), address(customFeeRegistryImpl), "custom fee proxy getter impl"); - assertEq( - ProxySlotUtils.getImplementation(address(customFeeRegistry)), - address(customFeeRegistryImpl), - "custom fee proxy slot impl" - ); - assertEq(proxy.proxy__getAdmin(), address(deployParams.proxyAdmin), "custom fee proxy getter admin"); - assertEq( - ProxySlotUtils.getAdmin(address(customFeeRegistry)), - address(deployParams.proxyAdmin), - "custom fee proxy slot admin" - ); - assertFalse(proxy.proxy__getIsOssified(), "custom fee proxy ossified"); + _assertProxy(address(customFeeRegistry), address(customFeeRegistryImpl), "custom fee"); } } @@ -662,16 +625,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 36e7f56fe..cf0e0e3cd 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -595,9 +595,9 @@ 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]); } @@ -621,6 +621,9 @@ contract DeploymentHelpers is Test { // CustomFeeRegistry dst.customFeeRegistryConfig.defaultMinFee = src.customFeeRegistryConfig.defaultMinFee; dst.customFeeRegistryConfig.feeIncreaseCooldown = src.customFeeRegistryConfig.feeIncreaseCooldown; + for (uint256 i; i < src.customFeeRegistryConfig.feeWeightSteps.length; ++i) { + dst.customFeeRegistryConfig.feeWeightSteps.push(src.customFeeRegistryConfig.feeWeightSteps[i]); + } for (uint256 i; i < src.customFeeRegistryConfig.feeModifiers.length; ++i) { dst.customFeeRegistryConfig.feeModifiers.push(src.customFeeRegistryConfig.feeModifiers[i]); } @@ -705,9 +708,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; } 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 index ad9bcbf77..c48f15137 100644 --- a/test/unit/CustomFeeRegistry.t.sol +++ b/test/unit/CustomFeeRegistry.t.sol @@ -10,18 +10,16 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I 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 { 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 CustomFeeRegistryBaseTest is Test, Utilities, Fixtures { - CuratedMock public module; +contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures, CuratedProviderFixture { CustomFeeRegistry public feeRegistry; - MetaRegistryMock public metaRegistryMock; AccountingMock internal _accounting; address public admin; @@ -31,7 +29,6 @@ contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures { uint256 internal constant MAX_BP = 10_000; uint256 internal constant STEP = 250; uint256 internal constant MAX_FEE = 8_750; - uint256 internal constant SLOPE = 400; uint256 internal constant MIN_FEE = 2_500; uint256 internal constant COOLDOWN = 15 days; @@ -42,28 +39,32 @@ contract CustomFeeRegistryBaseTest 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)); + _deployModuleWithMetaRegistryMock(3); + _setNodeOperatorOwner(nodeOperatorOwner); feeRegistry = new CustomFeeRegistry(address(module)); _enableInitializers(address(feeRegistry)); - feeRegistry.initialize(admin, MIN_FEE, COOLDOWN); + feeRegistry.initialize(admin, MIN_FEE, 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 fee) internal pure returns (uint256) { - return MAX_BP + ((MAX_FEE - fee) / STEP) * SLOPE; + uint256 discount = MAX_FEE - fee; + 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 _requestFee(uint256 fee) internal { @@ -102,8 +103,14 @@ contract CustomFeeRegistryConstructorTest is CustomFeeRegistryBaseTest { function test_constructor_Constants() public view { assertEq(feeRegistry.FEE_STEP(), STEP); assertEq(feeRegistry.DEFAULT_MAX_FEE(), MAX_FEE); - assertEq(feeRegistry.WEIGHT_BOOST_PER_STEP(), SLOPE); - assertEq(feeRegistry.MAX_FEE_INCREASE_COOLDOWN(), type(uint32).max); + assertEq(feeRegistry.MAX_STEPS(), 35); + assertEq(feeRegistry.MAX_STEP_VALUE(), 9 * MAX_BP); + assertEq(feeRegistry.MAX_FEE_INCREASE_COOLDOWN(), 365 days); + } + + function test_constructor_RevertWhen_ZeroModule() public { + vm.expectRevert(IStepwiseWeightBoost.ZeroModuleAddress.selector); + new CustomFeeRegistry(address(0)); } } @@ -117,6 +124,13 @@ contract CustomFeeRegistryInitializeTest is CustomFeeRegistryBaseTest { assertTrue(feeRegistry.hasRole(feeRegistry.DEFAULT_ADMIN_ROLE(), admin)); assertEq(feeRegistry.getDefaultMinFee(), MIN_FEE); assertEq(feeRegistry.getFeeIncreaseCooldown(), 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 { @@ -125,48 +139,50 @@ contract CustomFeeRegistryInitializeTest is CustomFeeRegistryBaseTest { emit ICustomFeeRegistry.DefaultMinFeeSet(MIN_FEE); vm.expectEmit(address(registry)); emit ICustomFeeRegistry.FeeIncreaseCooldownSet(COOLDOWN); - registry.initialize(admin, MIN_FEE, COOLDOWN); + vm.expectEmit(address(registry)); + emit IStepwiseWeightBoost.StepsSet(_steps()); + registry.initialize(admin, MIN_FEE, COOLDOWN, _steps()); } function test_initialize_RevertWhen_ZeroAdmin() public { CustomFeeRegistry registry = _newRegistry(); - vm.expectRevert(ICustomFeeRegistry.ZeroAdminAddress.selector); - registry.initialize(address(0), MIN_FEE, COOLDOWN); + vm.expectRevert(IStepwiseWeightBoost.ZeroAdminAddress.selector); + registry.initialize(address(0), MIN_FEE, COOLDOWN, _steps()); } function test_initialize_RevertWhen_DoubleCall() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - feeRegistry.initialize(admin, MIN_FEE, COOLDOWN); + feeRegistry.initialize(admin, MIN_FEE, COOLDOWN, _steps()); } function test_initialize_RevertWhen_ZeroMinFee() public { CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); - registry.initialize(admin, 0, COOLDOWN); + registry.initialize(admin, 0, COOLDOWN, _steps()); } function test_initialize_RevertWhen_MinFeeAtOrAboveMax() public { CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); - registry.initialize(admin, MAX_FEE, COOLDOWN); + registry.initialize(admin, MAX_FEE, COOLDOWN, _steps()); } function test_initialize_RevertWhen_MinFeeNotStepAligned() public { CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); - registry.initialize(admin, MIN_FEE + 1, COOLDOWN); + registry.initialize(admin, MIN_FEE + 1, COOLDOWN, _steps()); } function test_initialize_RevertWhen_ZeroCooldown() public { CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); - registry.initialize(admin, MIN_FEE, 0); + registry.initialize(admin, MIN_FEE, 0, _steps()); } function test_initialize_RevertWhen_CooldownExceedsMax() public { CustomFeeRegistry registry = _newRegistry(); vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); - registry.initialize(admin, MIN_FEE, uint256(type(uint32).max) + 1); + registry.initialize(admin, MIN_FEE, 365 days + 1, _steps()); } } @@ -185,7 +201,18 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { function test_requestFee_Decrease_ToMinFee() public { _requestFee(MIN_FEE); assertEq(feeRegistry.getFee(NO_ID), MIN_FEE); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 2 * MAX_BP); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(MIN_FEE)); + } + + function test_requestFee_Decrease_DoesNotNotifyWhenStepValueUnchanged() public { + _requestFee(7_500); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _requestFee(7_250); + + assertEq(feeRegistry.getFee(NO_ID), 7_250); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_500)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } function test_requestFee_Increase_SetsPending() public { @@ -225,6 +252,29 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.getFee(NO_ID), 7_000); } + function test_requestFee_Increase_DoesNotNotifyWhenFeeBandUnchanged() public { + _requestFee(4_750); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _requestFee(5_000); + + assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 5_000); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(4_750)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + + function test_requestFee_Increase_OverwriteDoesNotNotifyWhenFeeBandUnchanged() public { + _requestFee(5_000); + _requestFee(6_000); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _requestFee(6_250); + + assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 6_250); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_000)); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + function test_requestFee_Increase_LoweringPendingRaisesWeight() public { _requestFee(5_000); _requestFee(7_000); @@ -254,7 +304,7 @@ contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { } function test_requestFee_RevertWhen_NotOwner() public { - vm.expectRevert(ICustomFeeRegistry.SenderIsNotOperatorOwner.selector); + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); vm.prank(stranger); feeRegistry.requestFee(NO_ID, 5_000); } @@ -326,6 +376,19 @@ contract CustomFeeRegistryCancelFeeIncreaseTest is CustomFeeRegistryBaseTest { _assertNoPendingFeeIncrease(); } + function test_cancelFeeIncrease_DoesNotNotifyWhenFeeBandUnchanged() public { + _cancelFeeIncrease(); + _requestFee(4_750); + _requestFee(5_000); + uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + _cancelFeeIncrease(); + + assertEq(feeRegistry.getFee(NO_ID), 4_750); + _assertNoPendingFeeIncrease(); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); + } + function test_cancelFeeIncrease_WhenCurrentFeeBelowNewMin() public { _setFeeModifier(0, 4_000, true); // New minimum is 6_500. _cancelFeeIncrease(); @@ -335,7 +398,7 @@ contract CustomFeeRegistryCancelFeeIncreaseTest is CustomFeeRegistryBaseTest { } function test_cancelFeeIncrease_RevertWhen_NotOwner() public { - vm.expectRevert(ICustomFeeRegistry.SenderIsNotOperatorOwner.selector); + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); vm.prank(stranger); feeRegistry.cancelFeeIncrease(NO_ID); } @@ -388,7 +451,7 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { function test_applyFeeIncrease_RevertWhen_NotOwner() public { vm.warp(cooldownUntil + 1); - vm.expectRevert(ICustomFeeRegistry.SenderIsNotOperatorOwner.selector); + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); vm.prank(stranger); feeRegistry.applyFeeIncrease(NO_ID); } @@ -427,7 +490,7 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { _applyFeeIncrease(); vm.prank(stranger); - feeRegistry.normalizeFee(NO_ID); + feeRegistry.normalizeFees(UintArr(NO_ID)); assertEq(feeRegistry.getFee(NO_ID), 6_500); _assertNoPendingFeeIncrease(); @@ -435,23 +498,32 @@ contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { } } -contract CustomFeeRegistryNormalizeFeeTest is CustomFeeRegistryBaseTest { +contract CustomFeeRegistryNormalizeFeesTest is CustomFeeRegistryBaseTest { function setUp() public override { super.setUp(); - // The operator settles at the type minimum, then the DAO tightens the modifier. + + // Operator 0 is left below the new minimum; the other operators remain unset and valid. _setFeeModifier(0, 2_500, true); _requestFee(5_000); _setFeeModifier(0, 5_000, true); - // The minimum is now 7_500 and the operator at 5_000 sits below it. } - function test_normalizeFee() public { + function test_normalizeFees() public { + uint256 normalizedCount = feeRegistry.normalizeFees(UintArr(0, 1, 2)); + + assertEq(normalizedCount, 1); + assertEq(feeRegistry.getFee(0), 7_500); + assertEq(feeRegistry.getFee(1), MAX_FEE); + assertEq(feeRegistry.getFee(2), MAX_FEE); + } + + function test_normalizeFees_SetsFeeToMinimumAndRestoresEffectiveFee() public { assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); // max(0, 5_000 - 5_000) vm.expectEmit(address(feeRegistry)); emit ICustomFeeRegistry.FeeSet(NO_ID, 7_500); vm.prank(stranger); // permissionless - feeRegistry.normalizeFee(NO_ID); + feeRegistry.normalizeFees(UintArr(NO_ID)); assertEq(feeRegistry.getFee(NO_ID), 7_500); // The effective fee is back at the default minimum. @@ -459,23 +531,7 @@ contract CustomFeeRegistryNormalizeFeeTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_500)); } - function test_normalizeFee_RevertWhen_FeeNotBelowMinFee() public { - vm.prank(stranger); - feeRegistry.normalizeFee(NO_ID); - - vm.expectRevert(ICustomFeeRegistry.FeeNotBelowMinFee.selector); - vm.prank(stranger); - feeRegistry.normalizeFee(NO_ID); - } - - function test_normalizeFee_RevertWhen_UnsetOperator() public { - // An unset operator reads as DEFAULT_MAX_FEE and can never be below the minimum. - vm.expectRevert(ICustomFeeRegistry.FeeNotBelowMinFee.selector); - vm.prank(stranger); - feeRegistry.normalizeFee(1); - } - - function test_normalizeFee_CancelsPendingIncrease() public { + function test_normalizeFees_CancelsPendingIncrease() public { _requestFee(8_000); vm.expectEmit(address(feeRegistry)); @@ -483,53 +539,33 @@ contract CustomFeeRegistryNormalizeFeeTest is CustomFeeRegistryBaseTest { vm.expectEmit(address(feeRegistry)); emit ICustomFeeRegistry.FeeSet(NO_ID, 7_500); vm.prank(stranger); - feeRegistry.normalizeFee(NO_ID); + feeRegistry.normalizeFees(UintArr(NO_ID)); assertEq(feeRegistry.getFee(NO_ID), 7_500); _assertNoPendingFeeIncrease(); } - function test_normalizeFee_CancelsExpiredPendingIncrease() public { + function test_normalizeFees_CancelsExpiredPendingIncrease() public { _requestFee(8_000); vm.warp(block.timestamp + COOLDOWN + 1); vm.prank(stranger); - feeRegistry.normalizeFee(NO_ID); + feeRegistry.normalizeFees(UintArr(NO_ID)); assertEq(feeRegistry.getFee(NO_ID), 7_500); _assertNoPendingFeeIncrease(); } - function test_normalizeFee_DoesNotNotifyWhenPendingFeeEqualsMin() public { + function test_normalizeFees_DoesNotNotifyWhenPendingFeeEqualsMin() public { _requestFee(7_500); uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); vm.prank(stranger); - feeRegistry.normalizeFee(NO_ID); + feeRegistry.normalizeFees(UintArr(NO_ID)); assertEq(feeRegistry.getFee(NO_ID), 7_500); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } -} - -contract CustomFeeRegistryNormalizeFeesTest is CustomFeeRegistryBaseTest { - function setUp() public override { - super.setUp(); - - // Operator 0 is left below the new minimum; the other operators remain unset and valid. - _setFeeModifier(0, 2_500, true); - _requestFee(5_000); - _setFeeModifier(0, 5_000, true); - } - - function test_normalizeFees() public { - uint256 normalizedCount = feeRegistry.normalizeFees(UintArr(0, 1, 2)); - - assertEq(normalizedCount, 1); - assertEq(feeRegistry.getFee(0), 7_500); - assertEq(feeRegistry.getFee(1), MAX_FEE); - assertEq(feeRegistry.getFee(2), MAX_FEE); - } function test_normalizeFees_SkipsDuplicatesAndValidOperators() public { uint256[] memory nodeOperatorIds = new uint256[](4); @@ -570,14 +606,13 @@ contract CustomFeeRegistrySetDefaultMinFeeTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.getDefaultMinFee(), 2_000); } - function test_setDefaultMinFee_OpensWeightsAboveTwo() public { + function test_setDefaultMinFee_UsesLastWeightStepBelowLastThreshold() public { vm.prank(admin); feeRegistry.setDefaultMinFee(1_000); - // The weight line does not move: fees below the initial minimum extrapolate above 2x. _requestFee(1_000); assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_000)); - assertGt(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 2 * MAX_BP); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 25_000); } function test_setDefaultMinFee_RevertWhen_NotAdmin() public { @@ -693,6 +728,81 @@ contract CustomFeeRegistrySetFeeModifierTest is CustomFeeRegistryBaseTest { } } +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_STEP-aligned and stay below DEFAULT_MAX_FEE. + steps[i] = Step({ threshold: uint128(i * STEP), 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_ChangesFeeWeights() public { + _requestFee(5_000); + 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(MAX_FEE - STEP), value: uint128(feeRegistry.MAX_STEP_VALUE()) }); + _setSteps(steps); + + assertEq(feeRegistry.getSteps()[0].threshold, MAX_FEE - STEP); + } + + function test_setSteps_RevertWhen_FeeDiscountAtMaxFee() public { + Step[] memory steps = new Step[](1); + steps[0] = Step({ threshold: uint128(MAX_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 CustomFeeRegistrySetFeeIncreaseCooldownTest is CustomFeeRegistryBaseTest { function test_setFeeIncreaseCooldown() public { vm.expectEmit(address(feeRegistry)); @@ -717,19 +827,19 @@ contract CustomFeeRegistrySetFeeIncreaseCooldownTest is CustomFeeRegistryBaseTes function test_setFeeIncreaseCooldown_Max() public { vm.prank(admin); - feeRegistry.setFeeIncreaseCooldown(type(uint32).max); + feeRegistry.setFeeIncreaseCooldown(365 days); _requestFee(5_000); _requestFee(6_000); - assertEq(feeRegistry.getFeeIncreaseCooldown(), type(uint32).max); - assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), block.timestamp + type(uint32).max); + assertEq(feeRegistry.getFeeIncreaseCooldown(), 365 days); + assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), block.timestamp + 365 days); } function test_setFeeIncreaseCooldown_RevertWhen_ExceedsMax() public { vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); vm.prank(admin); - feeRegistry.setFeeIncreaseCooldown(uint256(type(uint32).max) + 1); + feeRegistry.setFeeIncreaseCooldown(365 days + 1); } } @@ -753,12 +863,36 @@ contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP); } - function test_getWeightBoostMultiplierBP_Linearity() public { + function test_getWeightBoostMultiplierBP_StepsAndBands() public { _requestFee(MAX_FEE - STEP); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP + SLOPE); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP); + + _requestFee(7_500); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 12_000); + + _requestFee(7_250); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 12_000); _requestFee(MIN_FEE); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 2 * MAX_BP); + 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 * STEP), value: uint128(i * 1_000) }); + } + vm.prank(admin); + feeRegistry.setSteps(steps); + + _requestFee(4_500); // Discount 4_250 reaches step 17. + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP + 17_000); + + vm.prank(admin); + feeRegistry.setDefaultMinFee(STEP); + _requestFee(STEP); // Discount 8_500 reaches the final step 34. + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP + 34_000); } function test_getEffectiveFee_NoModifier() public { 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); } } From 6e85272de4b669b285823915b7842ab7e48f4cc6 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Thu, 6 Aug 2026 16:15:53 +0200 Subject: [PATCH 08/11] fix: polish --- script/curated/DeployBase.s.sol | 20 +++++++++++-------- script/curated/DeployHoodi.s.sol | 14 ++++++------- script/curated/DeployLocalDevNet.s.sol | 14 ++++++------- script/curated/DeployMainnet.s.sol | 14 ++++++------- src/abstract/StepwiseWeightBoost.sol | 13 ++++-------- .../deployment/PostDeploymentCurated.t.sol | 4 ++-- test/helpers/Fixtures.sol | 6 +++--- 7 files changed, 42 insertions(+), 43 deletions(-) diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 4e93f2eab..70bb6c813 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -75,6 +75,12 @@ struct AdditionalBondRegistryConfig { 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 { address token; address votingContract; @@ -164,8 +170,7 @@ struct CuratedDeployParams { // AdditionalBondRegistry AdditionalBondRegistryConfig additionalBondRegistryConfig; // NodeOperatorStrikes - address strikesCommittee; - Step[] strikesThresholds; + NodeOperatorStrikesConfig nodeOperatorStrikesConfig; // LDO lock boost provider ERC20LockBoostProviderConfig ldoLockBoostProviderConfig; // CustomFeeRegistry @@ -397,7 +402,7 @@ abstract contract DeployBase is Script { _upgradeAndHandoffProxy( address(nodeOperatorStrikes), address(nodeOperatorStrikesImpl), - abi.encodeCall(NodeOperatorStrikes.initialize, (deployer, config.strikesThresholds)) + abi.encodeCall(NodeOperatorStrikes.initialize, (deployer, config.nodeOperatorStrikesConfig.thresholds)) ); // LDO lock boost provider @@ -458,7 +463,10 @@ abstract contract DeployBase is Script { ); _addWeightBoostProvider(address(ldoLockBoostProvider), IMetaRegistry.WeightBoostProviderMode.MaxPerGroup); _addWeightBoostProvider(address(customFeeRegistry), IMetaRegistry.WeightBoostProviderMode.PerNodeOperator); - nodeOperatorStrikes.grantRole(nodeOperatorStrikes.STRIKES_COMMITTEE_ROLE(), config.strikesCommittee); + nodeOperatorStrikes.grantRole( + nodeOperatorStrikes.STRIKES_COMMITTEE_ROLE(), + config.nodeOperatorStrikesConfig.committee + ); metaRegistry.grantRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); for (uint256 i = 0; i < gatesCount; i++) { @@ -788,10 +796,6 @@ abstract contract DeployBase is Script { metaRegistry.addWeightBoostProvider(IWeightBoostProvider(provider), mode); } - function _addLDOLockBoostStep(uint128 minAmount, uint128 weightBoostBP) internal { - config.ldoLockBoostProviderConfig.lockBoostSteps.push(Step({ threshold: minAmount, value: weightBoostBP })); - } - function _deployProxy(address admin, address implementation) internal returns (address) { return _deployProxy(admin, implementation, new bytes(0)); } diff --git a/script/curated/DeployHoodi.s.sol b/script/curated/DeployHoodi.s.sol index a165a7c5b..a11f94535 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -208,12 +208,12 @@ contract DeployHoodi is DeployBase { 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(Step({ threshold: 2, value: 2_500 })); - config.strikesThresholds.push(Step({ threshold: 3, value: 5_000 })); - config.strikesThresholds.push(Step({ threshold: 4, value: 7_500 })); - config.strikesThresholds.push(Step({ threshold: 5, value: 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; @@ -221,8 +221,8 @@ 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. diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index 294199845..62664056d 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -196,11 +196,11 @@ contract DeployLocalDevNet is DeployBase { 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(Step({ threshold: 2, value: 2_500 })); - config.strikesThresholds.push(Step({ threshold: 3, value: 5_000 })); - config.strikesThresholds.push(Step({ threshold: 4, value: 7_500 })); - config.strikesThresholds.push(Step({ threshold: 5, value: 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"); @@ -208,8 +208,8 @@ 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.defaultMinFee = 2_500; diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index 856308e43..c605f6ff7 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -207,12 +207,12 @@ contract DeployMainnet is DeployBase { 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(Step({ threshold: 2, value: 2_500 })); - config.strikesThresholds.push(Step({ threshold: 3, value: 5_000 })); - config.strikesThresholds.push(Step({ threshold: 4, value: 7_500 })); - config.strikesThresholds.push(Step({ threshold: 5, value: 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; @@ -220,8 +220,8 @@ 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. diff --git a/src/abstract/StepwiseWeightBoost.sol b/src/abstract/StepwiseWeightBoost.sol index 2cd907e32..64628268a 100644 --- a/src/abstract/StepwiseWeightBoost.sol +++ b/src/abstract/StepwiseWeightBoost.sol @@ -10,8 +10,7 @@ import { ICuratedModule } from "../interfaces/ICuratedModule.sol"; import { IStepwiseWeightBoost, Step } from "../interfaces/IStepwiseWeightBoost.sol"; import { MAX_BP } from "../lib/Constants.sol"; -/// @notice Base of every weight boost provider: storage, validation, configuration, and lookup of the -/// step function, module/MetaRegistry wiring, operator access checks, and weight notifications. +/// @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 { @@ -28,7 +27,6 @@ abstract contract StepwiseWeightBoost is IStepwiseWeightBoost, AccessControlEnum bytes32 private constant STEPWISE_WEIGHT_BOOST_STORAGE_LOCATION = 0x852fd528c3d50d3563ef75d3ae6120a75c34ba905ef4b17904bd8502a3b92900; - /// @dev Also locks the implementation: every provider is deployed behind a proxy. constructor(address module) { if (module == address(0)) revert ZeroModuleAddress(); MODULE = ICuratedModule(module); @@ -53,17 +51,14 @@ abstract contract StepwiseWeightBoost is IStepwiseWeightBoost, AccessControlEnum return _getInitializedVersion(); } - /// @dev Initializes the common administrator and step function without requesting a weight refresh. - /// Called qualified — `StepwiseWeightBoost._initialize(...)` — from provider initializers. + /// @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 Notification policy shared by the providers: MetaRegistry is asked to refresh the operator's - /// cached weight only when the transition crosses a step boundary, since the step value is what - /// moves the weight multiplier. + /// @dev Skips the refresh while the input stays within one step: the weight has not moved. function _notifyMetaRegistryIfWeightChanged( uint256 nodeOperatorId, uint256 previousInput, @@ -81,7 +76,7 @@ abstract contract StepwiseWeightBoost is IStepwiseWeightBoost, AccessControlEnum if (owner != msg.sender) revert SenderIsNotNodeOperatorOwner(); } - /// @dev Reverts unless the Node Operator exists. Ids are dense, so the count is the authoritative bound. + /// @dev Ids are sequential, so an id below the operators count exists. function _onlyExistingNodeOperator(uint256 nodeOperatorId) internal view { if (nodeOperatorId >= MODULE.getNodeOperatorsCount()) revert NodeOperatorDoesNotExist(); } diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index d45d39fee..1b661d8ea 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -265,7 +265,7 @@ contract NodeOperatorStrikesDeploymentTest is DeploymentBaseTest { function test_state_onlyFull() public view { assertEq(nodeOperatorStrikes.getInitializedVersion(), 1); // Strike count thresholds map to weight reductions in basis points. - _assertSteps(nodeOperatorStrikes.getSteps(), deployParams.strikesThresholds); + _assertSteps(nodeOperatorStrikes.getSteps(), deployParams.nodeOperatorStrikesConfig.thresholds); } function test_immutables_onlyFull() public view { @@ -280,7 +280,7 @@ 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 { diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index cf0e0e3cd..576ba9822 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -603,9 +603,9 @@ contract DeploymentHelpers is Test { } // 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 From 074f9ed9efc36a3039189d4f8a2e29ee3cd57774 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Fri, 7 Aug 2026 09:44:19 +0200 Subject: [PATCH 09/11] feat: fee discounts --- script/curated/DeployBase.s.sol | 12 +- script/curated/DeployHoodi.s.sol | 6 +- script/curated/DeployLocalDevNet.s.sol | 6 +- script/curated/DeployMainnet.s.sol | 6 +- src/AdditionalBondRegistry.sol | 12 +- src/CustomFeeRegistry.sol | 260 ++++--- src/interfaces/ICustomFeeRegistry.sol | 234 +++--- .../deployment/PostDeploymentCurated.t.sol | 26 +- test/helpers/Fixtures.sol | 10 +- test/unit/CustomFeeRegistry.t.sol | 708 +++++++++--------- 10 files changed, 659 insertions(+), 621 deletions(-) diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 70bb6c813..8de0edf11 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -97,9 +97,9 @@ struct CurveFeeModifierConfig { } struct CustomFeeRegistryConfig { - uint256 defaultMinFee; - uint256 feeIncreaseCooldown; - Step[] feeWeightSteps; + uint256 defaultMaxFeeDiscount; + uint256 feeDiscountCutCooldown; + Step[] feeDiscountWeightSteps; CurveFeeModifierConfig[] feeModifiers; } @@ -444,9 +444,9 @@ abstract contract DeployBase is Script { CustomFeeRegistry.initialize, ( deployer, - config.customFeeRegistryConfig.defaultMinFee, - config.customFeeRegistryConfig.feeIncreaseCooldown, - config.customFeeRegistryConfig.feeWeightSteps + config.customFeeRegistryConfig.defaultMaxFeeDiscount, + config.customFeeRegistryConfig.feeDiscountCutCooldown, + config.customFeeRegistryConfig.feeDiscountWeightSteps ) ) ); diff --git a/script/curated/DeployHoodi.s.sol b/script/curated/DeployHoodi.s.sol index a11f94535..71706d242 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -226,10 +226,10 @@ contract DeployHoodi is DeployBase { // CustomFeeRegistry // TODO: finalize custom fee parameters. - config.customFeeRegistryConfig.defaultMinFee = 2_500; - config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + config.customFeeRegistryConfig.defaultMaxFeeDiscount = 6_250; + config.customFeeRegistryConfig.feeDiscountCutCooldown = 15 days; for (uint128 i = 1; i < 35; ++i) { - config.customFeeRegistryConfig.feeWeightSteps.push(Step({ threshold: i * 250, value: i * 400 })); + 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( diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index 62664056d..bcafef133 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -212,10 +212,10 @@ contract DeployLocalDevNet is DeployBase { config.ldoLockBoostProviderConfig.lockBoostSteps.push(Step({ threshold: 200_000 ether, value: 1_500 })); // CustomFeeRegistry - config.customFeeRegistryConfig.defaultMinFee = 2_500; - config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + config.customFeeRegistryConfig.defaultMaxFeeDiscount = 6_250; + config.customFeeRegistryConfig.feeDiscountCutCooldown = 15 days; for (uint128 i = 1; i < 35; ++i) { - config.customFeeRegistryConfig.feeWeightSteps.push(Step({ threshold: i * 250, value: i * 400 })); + 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( diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index c605f6ff7..8bb26cd39 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -225,10 +225,10 @@ contract DeployMainnet is DeployBase { // CustomFeeRegistry // TODO: finalize custom fee parameters. - config.customFeeRegistryConfig.defaultMinFee = 2_500; - config.customFeeRegistryConfig.feeIncreaseCooldown = 15 days; + config.customFeeRegistryConfig.defaultMaxFeeDiscount = 6_250; + config.customFeeRegistryConfig.feeDiscountCutCooldown = 15 days; for (uint128 i = 1; i < 35; ++i) { - config.customFeeRegistryConfig.feeWeightSteps.push(Step({ threshold: i * 250, value: i * 400 })); + 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( diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index 12bd0f839..8c250e832 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -61,7 +61,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, StepwiseWeightBoost if (newMul == curMul) revert SameCurveMultiplier(); PendingCurveMultiplierReduction storage pending = $.pending[nodeOperatorId]; - uint256 previousEffectiveCurveMultiplier = _getEffectiveCurveMultiplier(pending, curMul); + uint256 previousTargetCurveMultiplier = _getTargetCurveMultiplier(pending, curMul); if (newMul > curMul) { // NOTE: Takes into account current bond amount and keys count. @@ -85,7 +85,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, StepwiseWeightBoost StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged( nodeOperatorId, - previousEffectiveCurveMultiplier, + previousTargetCurveMultiplier, curveMultiplier ); } @@ -152,7 +152,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, StepwiseWeightBoost uint256 currentMultiplierBP = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); multiplierBP = MAX_BP + - StepwiseWeightBoost._stepValueAt(_getEffectiveCurveMultiplier(pending, currentMultiplierBP)); + StepwiseWeightBoost._stepValueAt(_getTargetCurveMultiplier(pending, currentMultiplierBP)); } function _setCurveMultiplierReductionCooldown(uint256 curveMultiplierReductionCooldown) internal { @@ -166,9 +166,9 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, StepwiseWeightBoost emit CurveMultiplierReductionCooldownSet(curveMultiplierReductionCooldown); } - /// @dev During a downgrade cooldown, weight follows the pending lower multiplier while Accounting - /// continues to hold the current higher multiplier. - function _getEffectiveCurveMultiplier( + /// @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) { diff --git a/src/CustomFeeRegistry.sol b/src/CustomFeeRegistry.sol index f20aa3942..c4edc7539 100644 --- a/src/CustomFeeRegistry.sol +++ b/src/CustomFeeRegistry.sol @@ -8,30 +8,29 @@ 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, OperatorFee, FeeModifier } from "./interfaces/ICustomFeeRegistry.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 custom fees and the allocation weight boost derived from them. See +/// @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 defaultMinFee; - uint256 feeIncreaseCooldown; + uint256 defaultMaxFeeDiscount; + uint256 feeDiscountCutCooldown; mapping(uint256 curveId => FeeModifier) feeModifier; - mapping(uint256 nodeOperatorId => OperatorFee) fees; + mapping(uint256 nodeOperatorId => FeeDiscountState) feeDiscounts; } - // All fees are basis points of the operator's own rewards. At the module's 4% share of - // the protocol staking rewards, one step is 0.1% of the total. - uint256 public constant FEE_STEP = 250; // 2.5% - // The fee of an unset operator and the inclusive upper bound for custom fees. - uint256 public constant DEFAULT_MAX_FEE = 35 * FEE_STEP; // 87.5% - uint256 public constant MAX_FEE_INCREASE_COOLDOWN = 365 days; + // 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; @@ -47,81 +46,76 @@ contract CustomFeeRegistry is ICustomFeeRegistry, StepwiseWeightBoost { /// @inheritdoc ICustomFeeRegistry function initialize( address admin, - uint256 defaultMinFee, - uint256 feeIncreaseCooldown, + uint256 defaultMaxFeeDiscount, + uint256 feeDiscountCutCooldown, Step[] calldata steps ) external initializer { - _setDefaultMinFee(defaultMinFee, DEFAULT_MAX_FEE); - _setFeeIncreaseCooldown(feeIncreaseCooldown); + _setDefaultMaxFeeDiscount(defaultMaxFeeDiscount); + _setFeeDiscountCutCooldown(feeDiscountCutCooldown); StepwiseWeightBoost._initialize(admin, steps); } /// @inheritdoc ICustomFeeRegistry - function requestFee(uint256 nodeOperatorId, uint256 fee) external { + function requestFeeDiscount(uint256 nodeOperatorId, uint256 feeDiscount) external { StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); - uint256 currentFee = _getCurrentFee(nodeOperatorId); - if (fee == currentFee) revert SameFee(); + uint256 currentFeeDiscount = _storage().feeDiscounts[nodeOperatorId].currentFeeDiscount; + if (feeDiscount == currentFeeDiscount) revert SameFeeDiscount(); - _validateFee(nodeOperatorId, fee); + _validateFeeDiscount(nodeOperatorId, feeDiscount); - if (fee < currentFee) { - _setCurrentFee(nodeOperatorId, fee); + if (feeDiscount > currentFeeDiscount) { + _setCurrentFeeDiscount(nodeOperatorId, feeDiscount); } else { - _scheduleFeeIncrease(nodeOperatorId, fee); + _scheduleFeeDiscountCut(nodeOperatorId, feeDiscount); } } /// @inheritdoc ICustomFeeRegistry - function cancelFeeIncrease(uint256 nodeOperatorId) external { + function cancelFeeDiscountCut(uint256 nodeOperatorId) external { StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); - OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; - if (operatorFee.cooldownUntil == 0) revert NoFeeIncreaseCooldown(); + FeeDiscountState storage state = _storage().feeDiscounts[nodeOperatorId]; + if (state.cooldownUntil == 0) revert NoPendingFeeDiscountCut(); - uint256 previousFee = _getTargetFee(nodeOperatorId); - uint256 currentFee = _getCurrentFee(nodeOperatorId); + uint256 previousFeeDiscount = _getTargetFeeDiscount(nodeOperatorId); + uint256 currentFeeDiscount = state.currentFeeDiscount; - operatorFee.pendingFeeIncrease = 0; - operatorFee.cooldownUntil = 0; - emit FeeIncreaseCancelled(nodeOperatorId); - StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged( - nodeOperatorId, - _feeDiscount(previousFee), - _feeDiscount(currentFee) - ); + state.pendingFeeDiscount = 0; + state.cooldownUntil = 0; + emit FeeDiscountCutCancelled(nodeOperatorId); + StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged(nodeOperatorId, previousFeeDiscount, currentFeeDiscount); } /// @inheritdoc ICustomFeeRegistry - function applyFeeIncrease(uint256 nodeOperatorId) external { + function applyFeeDiscountCut(uint256 nodeOperatorId) external { StepwiseWeightBoost._onlyNodeOperatorOwner(nodeOperatorId); - OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; - if (operatorFee.cooldownUntil == 0) revert NoFeeIncreaseCooldown(); - if (operatorFee.cooldownUntil > block.timestamp) revert FeeIncreaseCooldownNotElapsed(); + FeeDiscountState storage state = _storage().feeDiscounts[nodeOperatorId]; + if (state.cooldownUntil == 0) revert NoPendingFeeDiscountCut(); + if (state.cooldownUntil > block.timestamp) revert FeeDiscountCutCooldownNotElapsed(); - uint16 pendingFee = operatorFee.pendingFeeIncrease; - // The fee may have become invalid during the cooldown after a curve or modifier change. - _validateFee(nodeOperatorId, pendingFee); + uint16 pendingFeeDiscount = state.pendingFeeDiscount; + // A curve or modifier change during the cooldown may have invalidated it. + _validateFeeDiscount(nodeOperatorId, pendingFeeDiscount); - operatorFee.currentFee = pendingFee; - operatorFee.pendingFeeIncrease = 0; - operatorFee.cooldownUntil = 0; - emit FeeIncreaseApplied(nodeOperatorId, pendingFee); - // No notification: the allocation weight changed when the increase was requested. + 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 normalizeFees(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount) { + function normalizeFeeDiscounts(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount) { for (uint256 i; i < nodeOperatorIds.length; ++i) { - if (_normalizeFee(nodeOperatorIds[i])) ++normalizedCount; + if (_normalizeFeeDiscount(nodeOperatorIds[i])) ++normalizedCount; } } /// @inheritdoc ICustomFeeRegistry - function setDefaultMinFee(uint256 defaultMinFee) external onlyRole(DEFAULT_ADMIN_ROLE) { - // Only downwards: fees already set stay valid. - _setDefaultMinFee(defaultMinFee, _storage().defaultMinFee); + function setDefaultMaxFeeDiscount(uint256 defaultMaxFeeDiscount) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setDefaultMaxFeeDiscount(defaultMaxFeeDiscount); } /// @inheritdoc ICustomFeeRegistry @@ -129,9 +123,9 @@ contract CustomFeeRegistry is ICustomFeeRegistry, StepwiseWeightBoost { if (curveId >= ACCOUNTING.getCurvesCount()) revert IBondCurve.InvalidBondCurveId(); CustomFeeRegistryStorage storage $ = _storage(); - // Keeps the effective fee within MAX_BP and the per-type minimum within DEFAULT_MAX_FEE. - uint256 maxValue = negative ? DEFAULT_MAX_FEE - $.defaultMinFee : MAX_BP - DEFAULT_MAX_FEE; - if (value > maxValue || value % FEE_STEP != 0) revert InvalidFeeModifier(); + // 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); @@ -139,18 +133,18 @@ contract CustomFeeRegistry is ICustomFeeRegistry, StepwiseWeightBoost { } /// @inheritdoc ICustomFeeRegistry - function setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) external onlyRole(DEFAULT_ADMIN_ROLE) { - _setFeeIncreaseCooldown(feeIncreaseCooldown); + function setFeeDiscountCutCooldown(uint256 feeDiscountCutCooldown) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setFeeDiscountCutCooldown(feeDiscountCutCooldown); } /// @inheritdoc ICustomFeeRegistry - function getDefaultMinFee() external view returns (uint256) { - return _storage().defaultMinFee; + function getDefaultMaxFeeDiscount() external view returns (uint256) { + return _storage().defaultMaxFeeDiscount; } /// @inheritdoc ICustomFeeRegistry - function getFeeIncreaseCooldown() external view returns (uint256) { - return _storage().feeIncreaseCooldown; + function getFeeDiscountCutCooldown() external view returns (uint256) { + return _storage().feeDiscountCutCooldown; } /// @inheritdoc ICustomFeeRegistry @@ -159,36 +153,36 @@ contract CustomFeeRegistry is ICustomFeeRegistry, StepwiseWeightBoost { } /// @inheritdoc ICustomFeeRegistry - function getFee(uint256 nodeOperatorId) external view returns (uint256) { - return _getCurrentFee(nodeOperatorId); + function getFeeDiscount(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().feeDiscounts[nodeOperatorId].currentFeeDiscount; } /// @inheritdoc ICustomFeeRegistry - function getPendingFeeIncrease(uint256 nodeOperatorId) external view returns (uint256) { - return _storage().fees[nodeOperatorId].pendingFeeIncrease; + function getPendingFeeDiscount(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().feeDiscounts[nodeOperatorId].pendingFeeDiscount; } /// @inheritdoc ICustomFeeRegistry - function getFeeIncreaseCooldownUntil(uint256 nodeOperatorId) external view returns (uint256) { - return _storage().fees[nodeOperatorId].cooldownUntil; + function getFeeDiscountCutCooldownUntil(uint256 nodeOperatorId) external view returns (uint256) { + return _storage().feeDiscounts[nodeOperatorId].cooldownUntil; } /// @inheritdoc ICustomFeeRegistry - function getMinFee(uint256 nodeOperatorId) external view returns (uint256) { - return _getMinFee(nodeOperatorId); + 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(_feeDiscount(_getTargetFee(nodeOperatorId))); + multiplierBP = MAX_BP + StepwiseWeightBoost._stepValueAt(_getTargetFeeDiscount(nodeOperatorId)); } /// @inheritdoc ICustomFeeRegistry function getEffectiveFee(uint256 nodeOperatorId) external view returns (uint256) { - uint256 currentFee = _getCurrentFee(nodeOperatorId); + 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 current fee below the negative modifier. + // A curve or modifier change can leave the fee below the negative modifier. return currentFee > feeModifier.value ? currentFee - feeModifier.value : 0; } @@ -196,98 +190,94 @@ contract CustomFeeRegistry is ICustomFeeRegistry, StepwiseWeightBoost { return currentFee + feeModifier.value; } - /// @dev Sets the custom fee immediately, drops any pending increase, and notifies if the - /// allocation weight changes. - function _setCurrentFee(uint256 nodeOperatorId, uint256 newCurrentFee) internal { - OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; - bool hadPendingIncrease = operatorFee.cooldownUntil != 0; - uint256 previousFee = _getTargetFee(nodeOperatorId); - - operatorFee.currentFee = newCurrentFee.toUint16(); - operatorFee.pendingFeeIncrease = 0; - operatorFee.cooldownUntil = 0; - if (hadPendingIncrease) emit FeeIncreaseCancelled(nodeOperatorId); - emit FeeSet(nodeOperatorId, newCurrentFee); + 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, - _feeDiscount(previousFee), - _feeDiscount(newCurrentFee) + previousFeeDiscount, + newCurrentFeeDiscount ); } - /// @dev Stores or replaces a pending increase, restarts its cooldown, and notifies if the - /// allocation weight changes. - function _scheduleFeeIncrease(uint256 nodeOperatorId, uint256 pendingFee) internal { + function _scheduleFeeDiscountCut(uint256 nodeOperatorId, uint256 pendingFeeDiscount) internal { CustomFeeRegistryStorage storage $ = _storage(); - OperatorFee storage operatorFee = $.fees[nodeOperatorId]; - uint256 previousFee = _getTargetFee(nodeOperatorId); - uint256 cooldownUntil = block.timestamp + $.feeIncreaseCooldown; - operatorFee.pendingFeeIncrease = pendingFee.toUint16(); - operatorFee.cooldownUntil = cooldownUntil.toUint64(); - emit FeeIncreaseRequested(nodeOperatorId, pendingFee, cooldownUntil); - StepwiseWeightBoost._notifyMetaRegistryIfWeightChanged( - nodeOperatorId, - _feeDiscount(previousFee), - _feeDiscount(pendingFee) - ); + 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 The minimum must remain non-zero because zero currentFee is the unset marker. - function _setDefaultMinFee(uint256 defaultMinFee, uint256 maxExclusive) internal { - if (defaultMinFee == 0 || defaultMinFee >= maxExclusive || defaultMinFee % FEE_STEP != 0) { - revert InvalidDefaultMinFee(); + /// @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(); } - _storage().defaultMinFee = defaultMinFee; - emit DefaultMinFeeSet(defaultMinFee); + $.defaultMaxFeeDiscount = defaultMaxFeeDiscount; + emit DefaultMaxFeeDiscountSet(defaultMaxFeeDiscount); } - function _setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) internal { - if (feeIncreaseCooldown == 0 || feeIncreaseCooldown > MAX_FEE_INCREASE_COOLDOWN) { - revert InvalidFeeIncreaseCooldown(); + function _setFeeDiscountCutCooldown(uint256 feeDiscountCutCooldown) internal { + if (feeDiscountCutCooldown == 0 || feeDiscountCutCooldown > MAX_FEE_DISCOUNT_CUT_COOLDOWN) { + revert InvalidFeeDiscountCutCooldown(); } - _storage().feeIncreaseCooldown = feeIncreaseCooldown; - emit FeeIncreaseCooldownSet(feeIncreaseCooldown); + _storage().feeDiscountCutCooldown = feeDiscountCutCooldown; + emit FeeDiscountCutCooldownSet(feeDiscountCutCooldown); } - /// @dev Normalizes the fee if a curve or modifier change left it below the current minimum. - function _normalizeFee(uint256 nodeOperatorId) internal returns (bool normalized) { - uint256 minFee = _getMinFee(nodeOperatorId); - if (_getCurrentFee(nodeOperatorId) >= minFee) return false; + function _normalizeFeeDiscount(uint256 nodeOperatorId) internal returns (bool normalized) { + uint256 maxFeeDiscount = _getMaxFeeDiscount(nodeOperatorId); + if (_storage().feeDiscounts[nodeOperatorId].currentFeeDiscount <= maxFeeDiscount) return false; - _setCurrentFee(nodeOperatorId, minFee); + _setCurrentFeeDiscount(nodeOperatorId, maxFeeDiscount); return true; } - function _validateFee(uint256 nodeOperatorId, uint256 fee) internal view { - if (fee < _getMinFee(nodeOperatorId) || fee > DEFAULT_MAX_FEE || fee % FEE_STEP != 0) revert InvalidFee(); + function _validateFeeDiscount(uint256 nodeOperatorId, uint256 feeDiscount) internal view { + if (feeDiscount > _getMaxFeeDiscount(nodeOperatorId) || feeDiscount % FEE_GRANULARITY != 0) { + revert InvalidFeeDiscount(); + } } - /// @dev A negative modifier raises the per-type minimum so a valid current fee has an effective - /// fee of at least defaultMinFee. Existing fees may remain below it until normalized. - function _getMinFee(uint256 nodeOperatorId) internal view returns (uint256) { + /// @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)]; - return feeModifier.negative ? $.defaultMinFee + feeModifier.value : $.defaultMinFee; - } - - /// @dev Zero means "never set" and reads as DEFAULT_MAX_FEE; a real fee is never zero (defaultMinFee > 0). - function _getCurrentFee(uint256 nodeOperatorId) internal view returns (uint256) { - uint256 fee = _storage().fees[nodeOperatorId].currentFee; - return fee == 0 ? DEFAULT_MAX_FEE : fee; + 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 pending increase target, if any, otherwise the current fee. Allocation weight follows it. - function _getTargetFee(uint256 nodeOperatorId) internal view returns (uint256) { - OperatorFee storage operatorFee = _storage().fees[nodeOperatorId]; - return operatorFee.cooldownUntil != 0 ? operatorFee.pendingFeeIncrease : _getCurrentFee(nodeOperatorId); + /// @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; } - function _feeDiscount(uint256 fee) internal pure returns (uint256) { - return DEFAULT_MAX_FEE - fee; + /// @dev The only crossing back into fee space, for the off-chain fee report. + 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 < DEFAULT_MAX_FEE && step.threshold % FEE_STEP == 0; + return step.threshold < BASE_FEE && step.threshold % FEE_GRANULARITY == 0; } function _storage() internal pure returns (CustomFeeRegistryStorage storage $) { diff --git a/src/interfaces/ICustomFeeRegistry.sol b/src/interfaces/ICustomFeeRegistry.sol index 22a9f659a..6e861e5dc 100644 --- a/src/interfaces/ICustomFeeRegistry.sol +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -6,62 +6,66 @@ pragma solidity 0.8.33; import { IAccounting } from "./IAccounting.sol"; import { IStepwiseWeightBoost, Step } from "./IStepwiseWeightBoost.sol"; -/// @dev Custom fee state of a Node Operator. `currentFee == 0` means "never set" and reads as -/// `DEFAULT_MAX_FEE`. `cooldownUntil == 0` means no pending increase. Packed into a single slot. -struct OperatorFee { - uint16 currentFee; - uint16 pendingFeeIncrease; +/// @dev Fee discount state of a Node Operator: zero is both "never set" and "no discount", and +/// `cooldownUntil == 0` means no pending cut. Packed into a single slot. +struct FeeDiscountState { + uint16 currentFeeDiscount; + uint16 pendingFeeDiscount; uint64 cooldownUntil; } /// @dev Sign-magnitude fee modifier of a Node Operator type: `value` basis points are subtracted from -/// the custom fee when `negative` is true, added otherwise. Packed into a single slot. +/// the fee kept when `negative` is true, added otherwise. Packed into a single slot. struct FeeModifier { uint248 value; bool negative; } /* - * ── Fee scale and per-type ranges ────────────────────────────────────────────── + * ── Discount scale and per-type ranges ───────────────────────────────────────── * - * All fees are basis points (BP) of the operator's own rewards; the lower - * axis shows the same fee as a share of the total staking rewards at a 4% - * module share. One character is 125 BP (half a fee step). - * An operator picks a custom fee between its current getMinFee(id) and - * DEFAULT_MAX_FEE; a negative fee modifier raises that lower bound above - * defaultMinFee. The lower the custom fee, the higher the allocation weight. - * A fee modifier shifts only the effective fee. - * ● custom (set by the operator) ○ effective (exposed for fee reporting) + * All values are basis points (BP) of the operator's own rewards; the axes + * show the fee the operator keeps, the matching discount, and that fee as a + * share of the total staking rewards at a 4% module share. One character is + * 125 BP (half a granularity unit). + * 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. A fee modifier shifts only the effective fee. + * ● fee kept (BASE_FEE - discount) ○ effective (exposed for fee reporting) * - * portion BP 0 2500 5000 6250 8750 10000 + * ┌ BASE_FEE + * fee kept BP 0 2500 5000 6250 8750 10000 * ├───────────────────┼───────────────────┼─────────┼───────────────────┼─────────┤ * total at 4% 0% 1% 2% 2.5% 3.5% 4% - * └ defaultMinFee └ DEFAULT_MAX_FEE + * discount BP 6250 3750 2500 0 + * └ defaultMaxFeeDiscount * - * type A — modifier +1250, effective = custom + 1250: - * custom ●─────────────────────────────────────────────────● + * type A — modifier +1250, effective = fee kept + 1250: + * fee kept ●─────────────────────────────────────────────────● * effective ○─────────────────────────────────────────────────○ * - * type B — modifier -2500, effective = custom - 2500; the minimum rises to 5000: - * custom ●─────────────────────────────● + * type B — modifier -2500, effective = fee kept - 2500; the ceiling drops to 3750: + * fee kept ●─────────────────────────────● * effective ○─────────────────────────────○ * - * A and B both pick custom = 5000: equal weights, different effective fees: - * custom A = B ● + * A and B both pick discount = 3750: equal weights, different effective fees: + * fee kept A = B ● * effective A ○ * effective B ○ * - * ── Fee increase timeline and oracle frames ──────────────────────────────────── + * ── Discount cut timeline and oracle frames ──────────────────────────────────── * - * Z is the fee-increase cooldown. Off-chain fee-report construction 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 - * increase. A decrease applies at once and cancels any pending increase. + * Z is the discount-cut cooldown. Off-chain fee-report construction 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. * - * requestFee(6000) - * │ applyFeeIncrease() + * requestFeeDiscount(2500) + * │ applyFeeDiscountCut() * │ │ * time ────────────●━━━━━ Z >= frame ━━━━━━●────────────────────────────────────► * frames ├───────────────────────┼───────────────────────┼───────────────────────┤ @@ -71,146 +75,144 @@ struct FeeModifier { * oracle samples ▲ old ▲ new ▲ new * frame billed old * new ** new * - * pending: an explicit cancellation or a decrease clears it; a new increase - * overwrites it and restarts the cooldown. + * 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 custom fees and the allocation weight boost derived from them: the lower -/// the custom fee, the higher the operator's allocation weight. The effective fee -/// (custom fee adjusted by the fee modifier) is exposed for off-chain fee-report construction. -/// In this provider, `Step.threshold` is the minimum fee discount from DEFAULT_MAX_FEE and -/// `Step.value` is the weight multiplier increment above MAX_BP. The registry itself does not -/// enforce report timing or construction. See the diagrams above. +/// @notice Per-operator fee discounts and the allocation weight boost derived from them: the larger +/// the discount from BASE_FEE, the higher the operator's allocation weight. The +/// effective fee (the fee kept, adjusted by the type's fee modifier) is exposed for off-chain +/// fee-report construction. In this provider, `Step.threshold` is the minimum discount and +/// `Step.value` is the weight multiplier increment above MAX_BP, so the stored discount feeds +/// the step function directly. The registry itself does not enforce report timing or +/// construction. See the diagrams above. interface ICustomFeeRegistry is IStepwiseWeightBoost { - event FeeSet(uint256 indexed nodeOperatorId, uint256 fee); - event FeeIncreaseRequested(uint256 indexed nodeOperatorId, uint256 pendingFeeIncrease, uint256 cooldownUntil); - event FeeIncreaseApplied(uint256 indexed nodeOperatorId, uint256 fee); - event FeeIncreaseCancelled(uint256 indexed nodeOperatorId); - event DefaultMinFeeSet(uint256 defaultMinFee); + 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 FeeIncreaseCooldownSet(uint256 feeIncreaseCooldown); + event FeeDiscountCutCooldownSet(uint256 feeDiscountCutCooldown); - error InvalidFee(); - error SameFee(); - error NoFeeIncreaseCooldown(); - error FeeIncreaseCooldownNotElapsed(); - error InvalidDefaultMinFee(); + error InvalidFeeDiscount(); + error SameFeeDiscount(); + error NoPendingFeeDiscountCut(); + error FeeDiscountCutCooldownNotElapsed(); + error InvalidDefaultMaxFeeDiscount(); error InvalidFeeModifier(); - error InvalidFeeIncreaseCooldown(); + error InvalidFeeDiscountCutCooldown(); /// @notice Accounting contract holding bond curves; an operator's type is its curve id. function ACCOUNTING() external view returns (IAccounting); - /// @notice Fee returned for an operator whose fee has never been set, and the inclusive upper - /// bound for custom fees, in basis points. - function DEFAULT_MAX_FEE() external view returns (uint256); + /// @notice Fee kept by an operator with no discount, and the base every discount is subtracted from, + /// in basis points. A positive fee modifier can still push the effective fee above it. + function BASE_FEE() external view returns (uint256); - /// @notice Custom fee granularity in basis points. - function FEE_STEP() 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 fee-increase cooldown in seconds. - function MAX_FEE_INCREASE_COOLDOWN() 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 defaultMinFee Initial minimum custom fee: a non-zero multiple of FEE_STEP below - /// DEFAULT_MAX_FEE. - /// @param feeIncreaseCooldown Stored cooldown duration in seconds, in - /// [1, MAX_FEE_INCREASE_COOLDOWN]. - /// @param steps Initial steps. Thresholds must be FEE_STEP-aligned and below DEFAULT_MAX_FEE; values + /// @param defaultMaxFeeDiscount Initial discount ceiling: a non-zero multiple of FEE_GRANULARITY below + /// BASE_FEE, so the operator always keeps a non-zero fee. + /// @param feeDiscountCutCooldown Stored cooldown duration in seconds, in + /// [1, MAX_FEE_DISCOUNT_CUT_COOLDOWN]. + /// @param steps Initial steps. Thresholds must be FEE_GRANULARITY-aligned and below BASE_FEE; values /// must not exceed MAX_STEP_VALUE. function initialize( address admin, - uint256 defaultMinFee, - uint256 feeIncreaseCooldown, + uint256 defaultMaxFeeDiscount, + uint256 feeDiscountCutCooldown, Step[] calldata steps ) external; - /// @notice Request a custom fee. Only the Node Operator owner. A decrease applies immediately - /// and cancels any pending increase. A request above the current fee creates or replaces - /// a pending increase: allocation weight immediately follows the requested target, while - /// the current and effective fees change only after `applyFeeIncrease`. Every replacement - /// restarts the cooldown. Requesting the current fee reverts. + /// @notice Request a fee discount. Only the Node Operator owner. Raising it applies immediately and + /// cancels any pending cut. A request below the current discount creates or replaces a + /// pending cut: allocation weight immediately follows the requested target, while the + /// stored discount and the effective fee change only after `applyFeeDiscountCut`. Every + /// replacement restarts the cooldown. Requesting the current discount reverts. /// @param nodeOperatorId ID of the Node Operator. - /// @param fee Fee in basis points, a multiple of FEE_STEP within - /// [getMinFee(nodeOperatorId), DEFAULT_MAX_FEE]. - function requestFee(uint256 nodeOperatorId, uint256 fee) external; + /// @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 fee increase. Only the Node Operator owner. Restores allocation - /// weight to the current fee and reverts if no increase is pending. + /// @notice Cancel a pending discount cut. Only the Node Operator owner. Restores allocation + /// weight to the stored discount and reverts if no cut is pending. /// @param nodeOperatorId ID of the Node Operator. - function cancelFeeIncrease(uint256 nodeOperatorId) external; + function cancelFeeDiscountCut(uint256 nodeOperatorId) external; - /// @notice Apply a pending fee increase after its cooldown. Only the Node Operator owner. - /// Allocation weight already follows the pending fee. The fee is validated again - /// against the current range because a curve or modifier may have changed during cooldown. + /// @notice Apply a pending discount cut after its cooldown. Only the Node Operator owner. + /// Allocation weight already follows the pending discount. The discount is validated again + /// against the current ceiling because a curve or modifier may have changed during cooldown. /// @param nodeOperatorId ID of the Node Operator. - function applyFeeIncrease(uint256 nodeOperatorId) external; + function applyFeeDiscountCut(uint256 nodeOperatorId) external; - /// @notice Permissionlessly normalize custom fees left below the current minimum after a curve - /// or modifier change: each is set to the minimum and its pending increase is cancelled. - /// Operators whose fees are already valid are skipped. Duplicate IDs are allowed and - /// normalized at most once. + /// @notice Permissionlessly normalize discounts left above the current ceiling after a curve or + /// modifier change: each is set to the ceiling and its pending cut is cancelled. Already valid + /// discounts are skipped, so duplicate IDs normalize at most once. /// @param nodeOperatorIds IDs of the Node Operators. - /// @return normalizedCount Number of fees normalized. - function normalizeFees(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount); + /// @return normalizedCount Number of discounts normalized. + function normalizeFeeDiscounts(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount); - /// @notice Lower the default minimum custom fee. Only DEFAULT_ADMIN_ROLE; only downwards and - /// never zero. - /// @param defaultMinFee New minimum in basis points, a non-zero multiple of FEE_STEP. - function setDefaultMinFee(uint256 defaultMinFee) external; + /// @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 only the effective fee, never the weight; a negative - /// modifier raises the type's minimum custom fee. Reverts for a nonexistent curve. + /// modifier lowers the type's discount ceiling. Reverts for a nonexistent curve. /// @param curveId Bond curve ID of the type. - /// @param value Magnitude in basis points, a multiple of FEE_STEP: at most - /// MAX_BP - DEFAULT_MAX_FEE when positive, DEFAULT_MAX_FEE - defaultMinFee when negative. - /// @param negative Whether the modifier is subtracted from the custom fee. + /// @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 used by future fee-increase requests. Only DEFAULT_ADMIN_ROLE; + /// @notice Set the cooldown used by future discount-cut requests. Only DEFAULT_ADMIN_ROLE; /// existing pending deadlines are unchanged. /// @dev Governance invariant, not enforced on-chain: keep it >= one oracle frame + margin; /// raise it when frames are lengthened. - /// @param feeIncreaseCooldown Stored duration in seconds, in [1, MAX_FEE_INCREASE_COOLDOWN]. - function setFeeIncreaseCooldown(uint256 feeIncreaseCooldown) external; + /// @param feeDiscountCutCooldown Stored duration in seconds, in [1, MAX_FEE_DISCOUNT_CUT_COOLDOWN]. + function setFeeDiscountCutCooldown(uint256 feeDiscountCutCooldown) external; - /// @notice Default minimum custom fee in basis points. - function getDefaultMinFee() external view returns (uint256); + /// @notice Default discount ceiling in basis points. + function getDefaultMaxFeeDiscount() external view returns (uint256); - /// @notice Fee increase cooldown in seconds. - function getFeeIncreaseCooldown() 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 Custom fee of the Node Operator; DEFAULT_MAX_FEE if never set. While an increase - /// is pending, the old fee is returned until `applyFeeIncrease`. + /// @notice Stored discount of the Node Operator; zero if never set. A pending cut is not reflected + /// until applied. /// @param nodeOperatorId ID of the Node Operator. - function getFee(uint256 nodeOperatorId) external view returns (uint256); + function getFeeDiscount(uint256 nodeOperatorId) external view returns (uint256); - /// @notice Stored pending increase target, or zero if none. It remains pending after the - /// deadline until applied, cancelled, overwritten, or cleared by normalization. + /// @notice Stored pending cut target. Meaningful only while a cooldown is active, since a legitimate + /// target may be zero. /// @param nodeOperatorId ID of the Node Operator. - function getPendingFeeIncrease(uint256 nodeOperatorId) external view returns (uint256); + function getPendingFeeDiscount(uint256 nodeOperatorId) external view returns (uint256); - /// @notice Earliest timestamp at which the pending increase passes its time check, or zero if - /// none. The deadline does not clear automatically, and application remains subject to - /// current fee validation. + /// @notice Earliest timestamp at which the pending cut passes its time check, or zero if none. It does + /// not clear automatically once elapsed. /// @param nodeOperatorId ID of the Node Operator. - function getFeeIncreaseCooldownUntil(uint256 nodeOperatorId) external view returns (uint256); + function getFeeDiscountCutCooldownUntil(uint256 nodeOperatorId) external view returns (uint256); - /// @notice Minimum custom fee of the Node Operator: the default minimum raised by the type's + /// @notice Discount ceiling of the Node Operator: the default ceiling lowered by the type's /// negative modifier. /// @param nodeOperatorId ID of the Node Operator. - function getMinFee(uint256 nodeOperatorId) external view returns (uint256); + function getMaxFeeDiscount(uint256 nodeOperatorId) external view returns (uint256); - /// @notice Effective fee in basis points, computed from the current custom fee and fee modifier. - /// A pending increase is excluded until applied. Negative results are clamped at zero. + /// @notice Effective fee in basis points, computed from the stored discount and the fee modifier. + /// A pending cut is excluded until applied. Negative results are clamped at zero. /// Exposed for off-chain fee-report construction. /// @param nodeOperatorId ID of the Node Operator. function getEffectiveFee(uint256 nodeOperatorId) external view returns (uint256); diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index 1b661d8ea..6fb97271d 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -385,11 +385,17 @@ contract LDOLockBoostProviderDeploymentTest is DeploymentBaseTest { contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { function test_state_onlyFull() public view { assertEq(customFeeRegistry.getInitializedVersion(), 1); - assertEq(customFeeRegistry.getDefaultMinFee(), deployParams.customFeeRegistryConfig.defaultMinFee); - assertEq(customFeeRegistry.getFeeIncreaseCooldown(), deployParams.customFeeRegistryConfig.feeIncreaseCooldown); + assertEq( + customFeeRegistry.getDefaultMaxFeeDiscount(), + deployParams.customFeeRegistryConfig.defaultMaxFeeDiscount + ); + assertEq( + customFeeRegistry.getFeeDiscountCutCooldown(), + deployParams.customFeeRegistryConfig.feeDiscountCutCooldown + ); // Fee discount thresholds map to weight multiplier increments. - _assertSteps(customFeeRegistry.getSteps(), deployParams.customFeeRegistryConfig.feeWeightSteps); + _assertSteps(customFeeRegistry.getSteps(), deployParams.customFeeRegistryConfig.feeDiscountWeightSteps); for (uint256 i; i < deployParams.customFeeRegistryConfig.feeModifiers.length; ++i) { FeeModifier memory actual = customFeeRegistry.getFeeModifier( @@ -399,7 +405,7 @@ contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { assertEq(actual.negative, deployParams.customFeeRegistryConfig.feeModifiers[i].negative); } - uint256 defaultFee = customFeeRegistry.DEFAULT_MAX_FEE(); + uint256 defaultFee = customFeeRegistry.BASE_FEE(); for (uint256 curveId; curveId < accounting.getCurvesCount(); ++curveId) { FeeModifier memory feeModifier = customFeeRegistry.getFeeModifier(curveId); uint256 effectiveFee = feeModifier.negative @@ -412,11 +418,15 @@ contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { 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_STEP(), 250, "custom fee step"); - assertEq(customFeeRegistry.DEFAULT_MAX_FEE(), 8_750, "custom fee default max"); + 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(customFeeRegistry.MAX_FEE_INCREASE_COOLDOWN(), 365 days, "custom fee max increase cooldown"); + assertEq( + customFeeRegistry.MAX_FEE_DISCOUNT_CUT_COOLDOWN(), + 365 days, + "custom fee max discount decrease cooldown" + ); } function test_roles_onlyFull() public view { @@ -429,7 +439,7 @@ contract CustomFeeRegistryDeploymentTest is DeploymentBaseTest { address(customFeeRegistryImpl), abi.encodeCall( customFeeRegistry.initialize, - (deployParams.aragonAgent, 2_500, 15 days, deployParams.customFeeRegistryConfig.feeWeightSteps) + (deployParams.aragonAgent, 6_250, 15 days, deployParams.customFeeRegistryConfig.feeDiscountWeightSteps) ) ); } diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index 576ba9822..cf35bf3ee 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -619,10 +619,12 @@ contract DeploymentHelpers is Test { } // CustomFeeRegistry - dst.customFeeRegistryConfig.defaultMinFee = src.customFeeRegistryConfig.defaultMinFee; - dst.customFeeRegistryConfig.feeIncreaseCooldown = src.customFeeRegistryConfig.feeIncreaseCooldown; - for (uint256 i; i < src.customFeeRegistryConfig.feeWeightSteps.length; ++i) { - dst.customFeeRegistryConfig.feeWeightSteps.push(src.customFeeRegistryConfig.feeWeightSteps[i]); + 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]); diff --git a/test/unit/CustomFeeRegistry.t.sol b/test/unit/CustomFeeRegistry.t.sol index c48f15137..b5411934e 100644 --- a/test/unit/CustomFeeRegistry.t.sol +++ b/test/unit/CustomFeeRegistry.t.sol @@ -27,9 +27,9 @@ contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures, CuratedProvider address public stranger; uint256 internal constant MAX_BP = 10_000; - uint256 internal constant STEP = 250; - uint256 internal constant MAX_FEE = 8_750; - uint256 internal constant MIN_FEE = 2_500; + 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; @@ -44,7 +44,7 @@ contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures, CuratedProvider feeRegistry = new CustomFeeRegistry(address(module)); _enableInitializers(address(feeRegistry)); - feeRegistry.initialize(admin, MIN_FEE, COOLDOWN, _steps()); + feeRegistry.initialize(admin, MAX_FEE_DISCOUNT, COOLDOWN, _steps()); _accounting = AccountingMock(address(module.ACCOUNTING())); } @@ -58,8 +58,7 @@ contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures, CuratedProvider steps[3] = Step({ threshold: 6_250, value: 15_000 }); } - function _weight(uint256 fee) internal pure returns (uint256) { - uint256 discount = MAX_FEE - fee; + 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; @@ -67,24 +66,24 @@ contract CustomFeeRegistryBaseTest is Test, Utilities, Fixtures, CuratedProvider return MAX_BP; } - function _requestFee(uint256 fee) internal { + function _requestFeeDiscount(uint256 discount) internal { vm.prank(nodeOperatorOwner); - feeRegistry.requestFee(NO_ID, fee); + feeRegistry.requestFeeDiscount(NO_ID, discount); } - function _applyFeeIncrease() internal { + function _applyFeeDiscountCut() internal { vm.prank(nodeOperatorOwner); - feeRegistry.applyFeeIncrease(NO_ID); + feeRegistry.applyFeeDiscountCut(NO_ID); } - function _cancelFeeIncrease() internal { + function _cancelFeeDiscountCut() internal { vm.prank(nodeOperatorOwner); - feeRegistry.cancelFeeIncrease(NO_ID); + feeRegistry.cancelFeeDiscountCut(NO_ID); } - function _assertNoPendingFeeIncrease() internal view { - assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 0); - assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), 0); + 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 { @@ -101,11 +100,11 @@ contract CustomFeeRegistryConstructorTest is CustomFeeRegistryBaseTest { } function test_constructor_Constants() public view { - assertEq(feeRegistry.FEE_STEP(), STEP); - assertEq(feeRegistry.DEFAULT_MAX_FEE(), MAX_FEE); + 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_INCREASE_COOLDOWN(), 365 days); + assertEq(feeRegistry.MAX_FEE_DISCOUNT_CUT_COOLDOWN(), 365 days); } function test_constructor_RevertWhen_ZeroModule() public { @@ -122,8 +121,8 @@ contract CustomFeeRegistryInitializeTest is CustomFeeRegistryBaseTest { function test_initialize() public view { assertTrue(feeRegistry.hasRole(feeRegistry.DEFAULT_ADMIN_ROLE(), admin)); - assertEq(feeRegistry.getDefaultMinFee(), MIN_FEE); - assertEq(feeRegistry.getFeeIncreaseCooldown(), COOLDOWN); + assertEq(feeRegistry.getDefaultMaxFeeDiscount(), MAX_FEE_DISCOUNT); + assertEq(feeRegistry.getFeeDiscountCutCooldown(), COOLDOWN); Step[] memory actual = feeRegistry.getSteps(); Step[] memory expected = _steps(); assertEq(actual.length, expected.length); @@ -136,438 +135,444 @@ contract CustomFeeRegistryInitializeTest is CustomFeeRegistryBaseTest { function test_initialize_EmitsEvents() public { CustomFeeRegistry registry = _newRegistry(); vm.expectEmit(address(registry)); - emit ICustomFeeRegistry.DefaultMinFeeSet(MIN_FEE); + emit ICustomFeeRegistry.DefaultMaxFeeDiscountSet(MAX_FEE_DISCOUNT); vm.expectEmit(address(registry)); - emit ICustomFeeRegistry.FeeIncreaseCooldownSet(COOLDOWN); + emit ICustomFeeRegistry.FeeDiscountCutCooldownSet(COOLDOWN); vm.expectEmit(address(registry)); emit IStepwiseWeightBoost.StepsSet(_steps()); - registry.initialize(admin, MIN_FEE, COOLDOWN, _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), MIN_FEE, COOLDOWN, _steps()); + registry.initialize(address(0), MAX_FEE_DISCOUNT, COOLDOWN, _steps()); } function test_initialize_RevertWhen_DoubleCall() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - feeRegistry.initialize(admin, MIN_FEE, COOLDOWN, _steps()); + feeRegistry.initialize(admin, MAX_FEE_DISCOUNT, COOLDOWN, _steps()); } - function test_initialize_RevertWhen_ZeroMinFee() public { + function test_initialize_RevertWhen_ZeroMaxFeeDiscount() public { CustomFeeRegistry registry = _newRegistry(); - vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); registry.initialize(admin, 0, COOLDOWN, _steps()); } - function test_initialize_RevertWhen_MinFeeAtOrAboveMax() public { + function test_initialize_RevertWhen_MaxFeeDiscountAtOrAboveBaseFee() public { CustomFeeRegistry registry = _newRegistry(); - vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); - registry.initialize(admin, MAX_FEE, COOLDOWN, _steps()); + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); + registry.initialize(admin, BASE_FEE, COOLDOWN, _steps()); } - function test_initialize_RevertWhen_MinFeeNotStepAligned() public { + function test_initialize_RevertWhen_MaxFeeDiscountNotGranularityAligned() public { CustomFeeRegistry registry = _newRegistry(); - vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); - registry.initialize(admin, MIN_FEE + 1, COOLDOWN, _steps()); + 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.InvalidFeeIncreaseCooldown.selector); - registry.initialize(admin, MIN_FEE, 0, _steps()); + 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.InvalidFeeIncreaseCooldown.selector); - registry.initialize(admin, MIN_FEE, 365 days + 1, _steps()); + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscountCutCooldown.selector); + registry.initialize(admin, MAX_FEE_DISCOUNT, 365 days + 1, _steps()); } } -contract CustomFeeRegistryRequestFeeTest is CustomFeeRegistryBaseTest { - function test_requestFee_Decrease_AppliesImmediately() public { +contract CustomFeeRegistryRequestFeeDiscountTest is CustomFeeRegistryBaseTest { + function test_requestFeeDiscount_Raise_AppliesImmediately() public { vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.FeeSet(NO_ID, 5_000); - _requestFee(5_000); + emit ICustomFeeRegistry.FeeDiscountSet(NO_ID, 3_750); + _requestFeeDiscount(3_750); - assertEq(feeRegistry.getFee(NO_ID), 5_000); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(5_000)); + 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_requestFee_Decrease_ToMinFee() public { - _requestFee(MIN_FEE); - assertEq(feeRegistry.getFee(NO_ID), MIN_FEE); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(MIN_FEE)); + 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_requestFee_Decrease_DoesNotNotifyWhenStepValueUnchanged() public { - _requestFee(7_500); + function test_requestFeeDiscount_Raise_DoesNotNotifyWhenStepValueUnchanged() public { + _requestFeeDiscount(1_250); uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); - _requestFee(7_250); + _requestFeeDiscount(1_500); - assertEq(feeRegistry.getFee(NO_ID), 7_250); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_500)); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_500); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_250)); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } - function test_requestFee_Increase_SetsPending() public { - _requestFee(5_000); + function test_requestFeeDiscount_Cut_SetsPending() public { + _requestFeeDiscount(3_750); uint256 cooldownUntil = block.timestamp + COOLDOWN; vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.FeeIncreaseRequested(NO_ID, 6_000, cooldownUntil); - _requestFee(6_000); + emit ICustomFeeRegistry.FeeDiscountCutRequested(NO_ID, 2_750, cooldownUntil); + _requestFeeDiscount(2_750); - // The fee itself is unchanged, but the weight already follows the pending increase. - assertEq(feeRegistry.getFee(NO_ID), 5_000); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_000)); + // 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_requestFee_Increase_OverwritesPendingAndRestartsCooldown() public { - _requestFee(5_000); - _requestFee(6_000); + 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.FeeIncreaseRequested(NO_ID, 7_000, cooldownUntil); - _requestFee(7_000); + emit ICustomFeeRegistry.FeeDiscountCutRequested(NO_ID, 1_750, cooldownUntil); + _requestFeeDiscount(1_750); - assertEq(feeRegistry.getFee(NO_ID), 5_000); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_000)); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_750)); - // The old deadline is void: the increase applies only at the restarted one. + // The old deadline is void: the decrease applies only at the restarted one. vm.warp(cooldownUntil - 1 days); - vm.expectRevert(ICustomFeeRegistry.FeeIncreaseCooldownNotElapsed.selector); - _applyFeeIncrease(); + vm.expectRevert(ICustomFeeRegistry.FeeDiscountCutCooldownNotElapsed.selector); + _applyFeeDiscountCut(); vm.warp(cooldownUntil); - _applyFeeIncrease(); - assertEq(feeRegistry.getFee(NO_ID), 7_000); + _applyFeeDiscountCut(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_750); } - function test_requestFee_Increase_DoesNotNotifyWhenFeeBandUnchanged() public { - _requestFee(4_750); + function test_requestFeeDiscount_Cut_DoesNotNotifyWhenFeeDiscountBandUnchanged() public { + _requestFeeDiscount(4_000); uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); - _requestFee(5_000); + _requestFeeDiscount(3_750); - assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 5_000); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(4_750)); + assertEq(feeRegistry.getPendingFeeDiscount(NO_ID), 3_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(4_000)); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } - function test_requestFee_Increase_OverwriteDoesNotNotifyWhenFeeBandUnchanged() public { - _requestFee(5_000); - _requestFee(6_000); + function test_requestFeeDiscount_Cut_OverwriteDoesNotNotifyWhenFeeDiscountBandUnchanged() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); - _requestFee(6_250); + _requestFeeDiscount(2_500); - assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 6_250); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_000)); + assertEq(feeRegistry.getPendingFeeDiscount(NO_ID), 2_500); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(2_750)); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } - function test_requestFee_Increase_LoweringPendingRaisesWeight() public { - _requestFee(5_000); - _requestFee(7_000); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_000)); + function test_requestFeeDiscount_Cut_RaisingPendingRaisesWeight() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(1_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_750)); - _requestFee(6_000); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_000)); - assertEq(feeRegistry.getFee(NO_ID), 5_000); + _requestFeeDiscount(2_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(2_750)); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); } - function test_requestFee_Decrease_CancelsPending() public { - _requestFee(5_000); - _requestFee(6_000); + function test_requestFeeDiscount_Raise_CancelsPending() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.FeeIncreaseCancelled(NO_ID); + emit ICustomFeeRegistry.FeeDiscountCutCancelled(NO_ID); vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.FeeSet(NO_ID, 4_000); - _requestFee(4_000); + emit ICustomFeeRegistry.FeeDiscountSet(NO_ID, 4_750); + _requestFeeDiscount(4_750); - assertEq(feeRegistry.getFee(NO_ID), 4_000); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(4_000)); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 4_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(4_750)); vm.warp(block.timestamp + COOLDOWN + 1); - vm.expectRevert(ICustomFeeRegistry.NoFeeIncreaseCooldown.selector); - _applyFeeIncrease(); + vm.expectRevert(ICustomFeeRegistry.NoPendingFeeDiscountCut.selector); + _applyFeeDiscountCut(); } - function test_requestFee_RevertWhen_NotOwner() public { + function test_requestFeeDiscount_RevertWhen_NotOwner() public { vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); vm.prank(stranger); - feeRegistry.requestFee(NO_ID, 5_000); + feeRegistry.requestFeeDiscount(NO_ID, 3_750); } - function test_requestFee_RevertWhen_BelowMinFee() public { - vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); - _requestFee(MIN_FEE - STEP); + function test_requestFeeDiscount_RevertWhen_AboveMaxFeeDiscount() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _requestFeeDiscount(MAX_FEE_DISCOUNT + GRANULARITY); } - function test_requestFee_RevertWhen_AboveMaxFee() public { - vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); - _requestFee(MAX_FEE + STEP); + function test_requestFeeDiscount_RevertWhen_NotGranularityAligned() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _requestFeeDiscount(3_750 + 1); } - function test_requestFee_RevertWhen_NotStepAligned() public { - vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); - _requestFee(5_000 + 1); - } - - function test_requestFee_RevertWhen_BelowTypeMinimum() public { + function test_requestFeeDiscount_RevertWhen_AboveTypeCeiling() public { _setFeeModifier(0, 2_500, true); - // The negative modifier raises the operator's minimum to 5_000. - vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); - _requestFee(4_750); + // The negative modifier lowers the operator's discount ceiling to 3_750. + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _requestFeeDiscount(4_000); } - function test_requestFee_RevertWhen_SameFee_Unset() public { - vm.expectRevert(ICustomFeeRegistry.SameFee.selector); - _requestFee(MAX_FEE); + function test_requestFeeDiscount_RevertWhen_SameFeeDiscount_Unset() public { + vm.expectRevert(ICustomFeeRegistry.SameFeeDiscount.selector); + _requestFeeDiscount(0); } - function test_requestFee_RevertWhen_SameFee_Set() public { - _requestFee(5_000); - vm.expectRevert(ICustomFeeRegistry.SameFee.selector); - _requestFee(5_000); + function test_requestFeeDiscount_RevertWhen_SameFeeDiscount_Set() public { + _requestFeeDiscount(3_750); + vm.expectRevert(ICustomFeeRegistry.SameFeeDiscount.selector); + _requestFeeDiscount(3_750); } - function test_requestFee_RevertWhen_SameFeeWithPendingIncrease() public { - _requestFee(5_000); - _requestFee(6_000); + function test_requestFeeDiscount_RevertWhen_SameFeeDiscountWithPendingCut() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); - vm.expectRevert(ICustomFeeRegistry.SameFee.selector); - _requestFee(5_000); + vm.expectRevert(ICustomFeeRegistry.SameFeeDiscount.selector); + _requestFeeDiscount(3_750); } } -contract CustomFeeRegistryCancelFeeIncreaseTest is CustomFeeRegistryBaseTest { +contract CustomFeeRegistryCancelFeeDiscountCutTest is CustomFeeRegistryBaseTest { function setUp() public override { super.setUp(); - _requestFee(5_000); - _requestFee(6_000); + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); } - function test_cancelFeeIncrease() public { + function test_cancelFeeDiscountCut() public { uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.FeeIncreaseCancelled(NO_ID); - _cancelFeeIncrease(); + emit ICustomFeeRegistry.FeeDiscountCutCancelled(NO_ID); + _cancelFeeDiscountCut(); - assertEq(feeRegistry.getFee(NO_ID), 5_000); - _assertNoPendingFeeIncrease(); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(5_000)); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); + _assertNoPendingFeeDiscountCut(); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(3_750)); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore + 1); } - function test_cancelFeeIncrease_AfterCooldown() public { + function test_cancelFeeDiscountCut_AfterCooldown() public { vm.warp(block.timestamp + COOLDOWN + 1); - _cancelFeeIncrease(); - _assertNoPendingFeeIncrease(); + _cancelFeeDiscountCut(); + _assertNoPendingFeeDiscountCut(); } - function test_cancelFeeIncrease_DoesNotNotifyWhenFeeBandUnchanged() public { - _cancelFeeIncrease(); - _requestFee(4_750); - _requestFee(5_000); + function test_cancelFeeDiscountCut_DoesNotNotifyWhenFeeDiscountBandUnchanged() public { + _cancelFeeDiscountCut(); + _requestFeeDiscount(4_000); + _requestFeeDiscount(3_750); uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); - _cancelFeeIncrease(); + _cancelFeeDiscountCut(); - assertEq(feeRegistry.getFee(NO_ID), 4_750); - _assertNoPendingFeeIncrease(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 4_000); + _assertNoPendingFeeDiscountCut(); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } - function test_cancelFeeIncrease_WhenCurrentFeeBelowNewMin() public { - _setFeeModifier(0, 4_000, true); // New minimum is 6_500. - _cancelFeeIncrease(); + function test_cancelFeeDiscountCut_WhenCurrentFeeDiscountAboveNewCeiling() public { + _setFeeModifier(0, 4_000, true); // The new ceiling is 2_250. + _cancelFeeDiscountCut(); - _assertNoPendingFeeIncrease(); - assertEq(feeRegistry.getFee(NO_ID), 5_000); + _assertNoPendingFeeDiscountCut(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 3_750); } - function test_cancelFeeIncrease_RevertWhen_NotOwner() public { + function test_cancelFeeDiscountCut_RevertWhen_NotOwner() public { vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); vm.prank(stranger); - feeRegistry.cancelFeeIncrease(NO_ID); + feeRegistry.cancelFeeDiscountCut(NO_ID); } - function test_cancelFeeIncrease_RevertWhen_NoPendingIncrease() public { - _cancelFeeIncrease(); - vm.expectRevert(ICustomFeeRegistry.NoFeeIncreaseCooldown.selector); - _cancelFeeIncrease(); + function test_cancelFeeDiscountCut_RevertWhen_NoPendingCut() public { + _cancelFeeDiscountCut(); + vm.expectRevert(ICustomFeeRegistry.NoPendingFeeDiscountCut.selector); + _cancelFeeDiscountCut(); } } -contract CustomFeeRegistryApplyFeeIncreaseTest is CustomFeeRegistryBaseTest { +contract CustomFeeRegistryApplyFeeDiscountCutTest is CustomFeeRegistryBaseTest { uint256 internal cooldownUntil; function setUp() public override { super.setUp(); - _requestFee(5_000); - _requestFee(6_000); + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); cooldownUntil = block.timestamp + COOLDOWN; } - function test_applyFeeIncrease() public { + function test_applyFeeDiscountCut() public { vm.warp(cooldownUntil + 1); uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.FeeIncreaseApplied(NO_ID, 6_000); - _applyFeeIncrease(); + emit ICustomFeeRegistry.FeeDiscountCutApplied(NO_ID, 2_750); + _applyFeeDiscountCut(); - assertEq(feeRegistry.getFee(NO_ID), 6_000); - // The weight followed the pending fee already, so no new notification. - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_000)); + 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_applyFeeIncrease_AtExactDeadline() public { + function test_applyFeeDiscountCut_AtExactDeadline() public { vm.warp(cooldownUntil); - _applyFeeIncrease(); - assertEq(feeRegistry.getFee(NO_ID), 6_000); + _applyFeeDiscountCut(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 2_750); } - function test_applyFeeIncrease_CooldownChangeDoesNotAffectPending() public { + function test_applyFeeDiscountCut_CooldownChangeDoesNotAffectPending() public { vm.prank(admin); - feeRegistry.setFeeIncreaseCooldown(COOLDOWN * 2); + feeRegistry.setFeeDiscountCutCooldown(COOLDOWN * 2); vm.warp(cooldownUntil); - _applyFeeIncrease(); - assertEq(feeRegistry.getFee(NO_ID), 6_000); - } - - function test_applyFeeIncrease_RevertWhen_NotOwner() public { - vm.warp(cooldownUntil + 1); - vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); - vm.prank(stranger); - feeRegistry.applyFeeIncrease(NO_ID); + _applyFeeDiscountCut(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 2_750); } - function test_applyFeeIncrease_RevertWhen_NotElapsed() public { - vm.warp(cooldownUntil - 1); - vm.expectRevert(ICustomFeeRegistry.FeeIncreaseCooldownNotElapsed.selector); - _applyFeeIncrease(); - } + 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); - function test_applyFeeIncrease_RevertWhen_NoPending() public { - vm.warp(cooldownUntil + 1); - _applyFeeIncrease(); + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _applyFeeDiscountCut(); - vm.expectRevert(ICustomFeeRegistry.NoFeeIncreaseCooldown.selector); - _applyFeeIncrease(); + // 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_applyFeeIncrease_RevertWhen_PendingFeeBelowCurrentMin() public { - _setFeeModifier(0, 4_000, true); // New minimum is 6_500, pending fee is 6_000. + 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.InvalidFee.selector); - _applyFeeIncrease(); + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscount.selector); + _applyFeeDiscountCut(); + + vm.prank(stranger); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); - assertEq(feeRegistry.getFee(NO_ID), 5_000); - assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 6_000); - assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), cooldownUntil); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 2_250); + _assertNoPendingFeeDiscountCut(); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(2_250)); } - function test_applyFeeIncrease_InvalidPendingCanBeNormalizedPermissionlessly() public { - _setFeeModifier(0, 4_000, true); // New minimum is 6_500, pending fee is 6_000. - vm.warp(cooldownUntil); + function test_applyFeeDiscountCut_RevertWhen_NotOwner() public { + vm.warp(cooldownUntil + 1); + vm.expectRevert(IStepwiseWeightBoost.SenderIsNotNodeOperatorOwner.selector); + vm.prank(stranger); + feeRegistry.applyFeeDiscountCut(NO_ID); + } - vm.expectRevert(ICustomFeeRegistry.InvalidFee.selector); - _applyFeeIncrease(); + function test_applyFeeDiscountCut_RevertWhen_NotElapsed() public { + vm.warp(cooldownUntil - 1); + vm.expectRevert(ICustomFeeRegistry.FeeDiscountCutCooldownNotElapsed.selector); + _applyFeeDiscountCut(); + } - vm.prank(stranger); - feeRegistry.normalizeFees(UintArr(NO_ID)); + function test_applyFeeDiscountCut_RevertWhen_NoPending() public { + vm.warp(cooldownUntil + 1); + _applyFeeDiscountCut(); - assertEq(feeRegistry.getFee(NO_ID), 6_500); - _assertNoPendingFeeIncrease(); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(6_500)); + vm.expectRevert(ICustomFeeRegistry.NoPendingFeeDiscountCut.selector); + _applyFeeDiscountCut(); } } -contract CustomFeeRegistryNormalizeFeesTest is CustomFeeRegistryBaseTest { +contract CustomFeeRegistryNormalizeFeeDiscountsTest is CustomFeeRegistryBaseTest { function setUp() public override { super.setUp(); - // Operator 0 is left below the new minimum; the other operators remain unset and valid. + // Operator 0 is left above the new ceiling; the other operators remain unset and valid. _setFeeModifier(0, 2_500, true); - _requestFee(5_000); + _requestFeeDiscount(3_750); _setFeeModifier(0, 5_000, true); } - function test_normalizeFees() public { - uint256 normalizedCount = feeRegistry.normalizeFees(UintArr(0, 1, 2)); + function test_normalizeFeeDiscounts() public { + uint256 normalizedCount = feeRegistry.normalizeFeeDiscounts(UintArr(0, 1, 2)); assertEq(normalizedCount, 1); - assertEq(feeRegistry.getFee(0), 7_500); - assertEq(feeRegistry.getFee(1), MAX_FEE); - assertEq(feeRegistry.getFee(2), MAX_FEE); + assertEq(feeRegistry.getFeeDiscount(0), 1_250); + assertEq(feeRegistry.getFeeDiscount(1), 0); + assertEq(feeRegistry.getFeeDiscount(2), 0); } - function test_normalizeFees_SetsFeeToMinimumAndRestoresEffectiveFee() public { + function test_normalizeFeeDiscounts_SetsFeeDiscountToCeilingAndRestoresEffectiveFee() public { assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); // max(0, 5_000 - 5_000) vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.FeeSet(NO_ID, 7_500); + emit ICustomFeeRegistry.FeeDiscountSet(NO_ID, 1_250); vm.prank(stranger); // permissionless - feeRegistry.normalizeFees(UintArr(NO_ID)); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); - assertEq(feeRegistry.getFee(NO_ID), 7_500); - // The effective fee is back at the default minimum. - assertEq(feeRegistry.getEffectiveFee(NO_ID), MIN_FEE); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_500)); + 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_normalizeFees_CancelsPendingIncrease() public { - _requestFee(8_000); + 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.FeeIncreaseCancelled(NO_ID); + emit ICustomFeeRegistry.FeeDiscountCutCancelled(NO_ID); vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.FeeSet(NO_ID, 7_500); + emit ICustomFeeRegistry.FeeDiscountSet(NO_ID, 1_250); vm.prank(stranger); - feeRegistry.normalizeFees(UintArr(NO_ID)); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); - assertEq(feeRegistry.getFee(NO_ID), 7_500); - _assertNoPendingFeeIncrease(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_250); + _assertNoPendingFeeDiscountCut(); } - function test_normalizeFees_CancelsExpiredPendingIncrease() public { - _requestFee(8_000); + function test_normalizeFeeDiscounts_CancelsExpiredPendingCut() public { + _requestFeeDiscount(750); vm.warp(block.timestamp + COOLDOWN + 1); vm.prank(stranger); - feeRegistry.normalizeFees(UintArr(NO_ID)); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); - assertEq(feeRegistry.getFee(NO_ID), 7_500); - _assertNoPendingFeeIncrease(); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_250); + _assertNoPendingFeeDiscountCut(); } - function test_normalizeFees_DoesNotNotifyWhenPendingFeeEqualsMin() public { - _requestFee(7_500); + function test_normalizeFeeDiscounts_DoesNotNotifyWhenPendingFeeDiscountEqualsCeiling() public { + _requestFeeDiscount(1_250); uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); vm.prank(stranger); - feeRegistry.normalizeFees(UintArr(NO_ID)); + feeRegistry.normalizeFeeDiscounts(UintArr(NO_ID)); - assertEq(feeRegistry.getFee(NO_ID), 7_500); + assertEq(feeRegistry.getFeeDiscount(NO_ID), 1_250); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } - function test_normalizeFees_SkipsDuplicatesAndValidOperators() public { + function test_normalizeFeeDiscounts_SkipsDuplicatesAndValidOperators() public { uint256[] memory nodeOperatorIds = new uint256[](4); nodeOperatorIds[0] = 0; nodeOperatorIds[1] = 0; @@ -575,68 +580,74 @@ contract CustomFeeRegistryNormalizeFeesTest is CustomFeeRegistryBaseTest { nodeOperatorIds[3] = 1; uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); - uint256 normalizedCount = feeRegistry.normalizeFees(nodeOperatorIds); + uint256 normalizedCount = feeRegistry.normalizeFeeDiscounts(nodeOperatorIds); assertEq(normalizedCount, 1); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore + 1); } - function test_normalizeFees_EmptyArray() public { - uint256 normalizedCount = feeRegistry.normalizeFees(UintArr()); + function test_normalizeFeeDiscounts_EmptyArray() public { + uint256 normalizedCount = feeRegistry.normalizeFeeDiscounts(UintArr()); assertEq(normalizedCount, 0); } - function test_normalizeFees_NoFeesToNormalize() public { + function test_normalizeFeeDiscounts_NoFeeDiscountsToNormalize() public { uint256[] memory nodeOperatorIds = UintArr(0, 1); - feeRegistry.normalizeFees(nodeOperatorIds); + feeRegistry.normalizeFeeDiscounts(nodeOperatorIds); - uint256 normalizedCount = feeRegistry.normalizeFees(nodeOperatorIds); + uint256 normalizedCount = feeRegistry.normalizeFeeDiscounts(nodeOperatorIds); assertEq(normalizedCount, 0); } } -contract CustomFeeRegistrySetDefaultMinFeeTest is CustomFeeRegistryBaseTest { - function test_setDefaultMinFee() public { +contract CustomFeeRegistrySetDefaultMaxFeeDiscountTest is CustomFeeRegistryBaseTest { + function test_setDefaultMaxFeeDiscount() public { vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.DefaultMinFeeSet(2_000); + emit ICustomFeeRegistry.DefaultMaxFeeDiscountSet(6_500); vm.prank(admin); - feeRegistry.setDefaultMinFee(2_000); + feeRegistry.setDefaultMaxFeeDiscount(6_500); - assertEq(feeRegistry.getDefaultMinFee(), 2_000); + assertEq(feeRegistry.getDefaultMaxFeeDiscount(), 6_500); } - function test_setDefaultMinFee_UsesLastWeightStepBelowLastThreshold() public { + function test_setDefaultMaxFeeDiscount_UsesLastWeightStepAboveLastThreshold() public { vm.prank(admin); - feeRegistry.setDefaultMinFee(1_000); + feeRegistry.setDefaultMaxFeeDiscount(7_750); - _requestFee(1_000); - assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(1_000)); + _requestFeeDiscount(7_750); + assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), _weight(7_750)); assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 25_000); } - function test_setDefaultMinFee_RevertWhen_NotAdmin() public { + function test_setDefaultMaxFeeDiscount_RevertWhen_NotAdmin() public { expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); vm.prank(stranger); - feeRegistry.setDefaultMinFee(2_000); + feeRegistry.setDefaultMaxFeeDiscount(6_500); + } + + function test_setDefaultMaxFeeDiscount_RevertWhen_Zero() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); + vm.prank(admin); + feeRegistry.setDefaultMaxFeeDiscount(0); } - function test_setDefaultMinFee_RevertWhen_Zero() public { - vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + function test_setDefaultMaxFeeDiscount_RevertWhen_NotAboveCurrent() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); vm.prank(admin); - feeRegistry.setDefaultMinFee(0); + feeRegistry.setDefaultMaxFeeDiscount(MAX_FEE_DISCOUNT); } - function test_setDefaultMinFee_RevertWhen_NotBelowCurrent() public { - vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + function test_setDefaultMaxFeeDiscount_RevertWhen_AtOrAboveBaseFee() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); vm.prank(admin); - feeRegistry.setDefaultMinFee(MIN_FEE); + feeRegistry.setDefaultMaxFeeDiscount(BASE_FEE); } - function test_setDefaultMinFee_RevertWhen_NotStepAligned() public { - vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMinFee.selector); + function test_setDefaultMaxFeeDiscount_RevertWhen_NotGranularityAligned() public { + vm.expectRevert(ICustomFeeRegistry.InvalidDefaultMaxFeeDiscount.selector); vm.prank(admin); - feeRegistry.setDefaultMinFee(2_000 + 1); + feeRegistry.setDefaultMaxFeeDiscount(6_500 + 1); } } @@ -649,9 +660,9 @@ contract CustomFeeRegistrySetFeeModifierTest is CustomFeeRegistryBaseTest { 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 unset operator. + // The modifier tops the effective fee up to 100% for an operator without a discount. assertEq(feeRegistry.getEffectiveFee(NO_ID), MAX_BP); - assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), MAX_FEE_DISCOUNT); } function test_setFeeModifier_Negative() public { @@ -660,21 +671,44 @@ contract CustomFeeRegistrySetFeeModifierTest is CustomFeeRegistryBaseTest { FeeModifier memory feeModifier = feeRegistry.getFeeModifier(0); assertEq(feeModifier.value, 2_500); assertTrue(feeModifier.negative); - assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE + 2_500); + 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), MAX_FEE); - assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); + assertEq(feeRegistry.getEffectiveFee(NO_ID), BASE_FEE); + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), MAX_FEE_DISCOUNT); _setFeeModifier(0, 0, false); - assertEq(feeRegistry.getEffectiveFee(NO_ID), MAX_FEE); - assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); + assertEq(feeRegistry.getEffectiveFee(NO_ID), BASE_FEE); + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), MAX_FEE_DISCOUNT); } function test_setFeeModifier_DoesNotNotifyAndDoesNotMoveWeight() public { - _requestFee(5_000); + _requestFeeDiscount(3_750); uint256 weightBefore = feeRegistry.getWeightBoostMultiplierBP(NO_ID); uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); @@ -684,11 +718,11 @@ contract CustomFeeRegistrySetFeeModifierTest is CustomFeeRegistryBaseTest { assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), notifyCallsBefore); } - function test_setFeeModifier_SameFeeSameWeightAcrossTypes() public { + 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); - _requestFee(5_000); + _requestFeeDiscount(3_750); uint256 weightOnTypeA = feeRegistry.getWeightBoostMultiplierBP(NO_ID); assertEq(feeRegistry.getEffectiveFee(NO_ID), 6_250); @@ -706,18 +740,18 @@ contract CustomFeeRegistrySetFeeModifierTest is CustomFeeRegistryBaseTest { } function test_setFeeModifier_RevertWhen_PositiveAboveMax() public { - // MAX_BP - DEFAULT_MAX_FEE = 1_250 is the largest positive modifier. + // MAX_BP - BASE_FEE = 1_250 is the largest positive modifier. vm.expectRevert(ICustomFeeRegistry.InvalidFeeModifier.selector); - _setFeeModifier(0, 1_250 + STEP, false); + _setFeeModifier(0, 1_250 + GRANULARITY, false); } function test_setFeeModifier_RevertWhen_NegativeAboveMax() public { - // DEFAULT_MAX_FEE - defaultMinFee = 6_250 is the largest negative modifier. + // The default discount ceiling is the largest negative modifier. vm.expectRevert(ICustomFeeRegistry.InvalidFeeModifier.selector); - _setFeeModifier(0, 6_250 + STEP, true); + _setFeeModifier(0, MAX_FEE_DISCOUNT + GRANULARITY, true); } - function test_setFeeModifier_RevertWhen_NotStepAligned() public { + function test_setFeeModifier_RevertWhen_NotGranularityAligned() public { vm.expectRevert(ICustomFeeRegistry.InvalidFeeModifier.selector); _setFeeModifier(0, 100, false); } @@ -745,8 +779,8 @@ contract CustomFeeRegistrySetStepsTest is CustomFeeRegistryBaseTest, StepwiseWei 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_STEP-aligned and stay below DEFAULT_MAX_FEE. - steps[i] = Step({ threshold: uint128(i * STEP), value: uint128((i + 1) * 100) }); + // Fee discounts are FEE_GRANULARITY-aligned and stay below BASE_FEE. + steps[i] = Step({ threshold: uint128(i * GRANULARITY), value: uint128((i + 1) * 100) }); } } @@ -769,8 +803,8 @@ contract CustomFeeRegistrySetStepsTest is CustomFeeRegistryBaseTest, StepwiseWei assertEq(metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(), 1); } - function test_setSteps_ChangesFeeWeights() public { - _requestFee(5_000); + function test_setSteps_ChangesFeeDiscountWeights() public { + _requestFeeDiscount(3_750); assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 20_000); Step[] memory steps = new Step[](1); @@ -782,15 +816,15 @@ contract CustomFeeRegistrySetStepsTest is CustomFeeRegistryBaseTest, StepwiseWei function test_setSteps_AllowsMaximumValues() public { Step[] memory steps = new Step[](1); - steps[0] = Step({ threshold: uint128(MAX_FEE - STEP), value: uint128(feeRegistry.MAX_STEP_VALUE()) }); + steps[0] = Step({ threshold: uint128(BASE_FEE - GRANULARITY), value: uint128(feeRegistry.MAX_STEP_VALUE()) }); _setSteps(steps); - assertEq(feeRegistry.getSteps()[0].threshold, MAX_FEE - STEP); + assertEq(feeRegistry.getSteps()[0].threshold, BASE_FEE - GRANULARITY); } - function test_setSteps_RevertWhen_FeeDiscountAtMaxFee() public { + function test_setSteps_RevertWhen_FeeDiscountAtBaseFee() public { Step[] memory steps = new Step[](1); - steps[0] = Step({ threshold: uint128(MAX_FEE), value: 1 }); + steps[0] = Step({ threshold: uint128(BASE_FEE), value: 1 }); vm.expectRevert(abi.encodeWithSelector(IStepwiseWeightBoost.InvalidStep.selector, 0)); _setSteps(steps); } @@ -803,60 +837,60 @@ contract CustomFeeRegistrySetStepsTest is CustomFeeRegistryBaseTest, StepwiseWei } } -contract CustomFeeRegistrySetFeeIncreaseCooldownTest is CustomFeeRegistryBaseTest { - function test_setFeeIncreaseCooldown() public { +contract CustomFeeRegistrySetFeeDiscountCutCooldownTest is CustomFeeRegistryBaseTest { + function test_setFeeDiscountCutCooldown() public { vm.expectEmit(address(feeRegistry)); - emit ICustomFeeRegistry.FeeIncreaseCooldownSet(30 days); + emit ICustomFeeRegistry.FeeDiscountCutCooldownSet(30 days); vm.prank(admin); - feeRegistry.setFeeIncreaseCooldown(30 days); + feeRegistry.setFeeDiscountCutCooldown(30 days); - assertEq(feeRegistry.getFeeIncreaseCooldown(), 30 days); + assertEq(feeRegistry.getFeeDiscountCutCooldown(), 30 days); } - function test_setFeeIncreaseCooldown_RevertWhen_NotAdmin() public { - expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); - vm.prank(stranger); - feeRegistry.setFeeIncreaseCooldown(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_setFeeIncreaseCooldown_RevertWhen_Zero() public { - vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); - vm.prank(admin); - feeRegistry.setFeeIncreaseCooldown(0); + function test_setFeeDiscountCutCooldown_RevertWhen_NotAdmin() public { + expectRoleRevert(stranger, feeRegistry.DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + feeRegistry.setFeeDiscountCutCooldown(30 days); } - function test_setFeeIncreaseCooldown_Max() public { + function test_setFeeDiscountCutCooldown_RevertWhen_Zero() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscountCutCooldown.selector); vm.prank(admin); - feeRegistry.setFeeIncreaseCooldown(365 days); - - _requestFee(5_000); - _requestFee(6_000); - - assertEq(feeRegistry.getFeeIncreaseCooldown(), 365 days); - assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), block.timestamp + 365 days); + feeRegistry.setFeeDiscountCutCooldown(0); } - function test_setFeeIncreaseCooldown_RevertWhen_ExceedsMax() public { - vm.expectRevert(ICustomFeeRegistry.InvalidFeeIncreaseCooldown.selector); + function test_setFeeDiscountCutCooldown_RevertWhen_ExceedsMax() public { + vm.expectRevert(ICustomFeeRegistry.InvalidFeeDiscountCutCooldown.selector); vm.prank(admin); - feeRegistry.setFeeIncreaseCooldown(365 days + 1); + feeRegistry.setFeeDiscountCutCooldown(365 days + 1); } } contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { - function test_getFee_UnsetReadsAsDefaultMax() public view { - assertEq(feeRegistry.getFee(NO_ID), MAX_FEE); + function test_getFeeDiscount_UnsetIsZero() public view { + assertEq(feeRegistry.getFeeDiscount(NO_ID), 0); } - function test_getPendingFeeIncreaseAndCooldownUntil() public { - assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 0); - assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), 0); + function test_getPendingFeeDiscountAndCooldownUntil() public { + assertEq(feeRegistry.getPendingFeeDiscount(NO_ID), 0); + assertEq(feeRegistry.getFeeDiscountCutCooldownUntil(NO_ID), 0); - _requestFee(5_000); - _requestFee(6_000); + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); - assertEq(feeRegistry.getPendingFeeIncrease(NO_ID), 6_000); - assertEq(feeRegistry.getFeeIncreaseCooldownUntil(NO_ID), block.timestamp + COOLDOWN); + assertEq(feeRegistry.getPendingFeeDiscount(NO_ID), 2_750); + assertEq(feeRegistry.getFeeDiscountCutCooldownUntil(NO_ID), block.timestamp + COOLDOWN); } function test_getWeightBoostMultiplierBP_UnsetIsOne() public view { @@ -864,16 +898,16 @@ contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { } function test_getWeightBoostMultiplierBP_StepsAndBands() public { - _requestFee(MAX_FEE - STEP); + _requestFeeDiscount(GRANULARITY); assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP); - _requestFee(7_500); + _requestFeeDiscount(1_250); assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 12_000); - _requestFee(7_250); + _requestFeeDiscount(1_500); assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 12_000); - _requestFee(MIN_FEE); + _requestFeeDiscount(MAX_FEE_DISCOUNT); assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), 25_000); } @@ -881,40 +915,40 @@ contract CustomFeeRegistryViewsTest is CustomFeeRegistryBaseTest { uint256 stepsCount = feeRegistry.MAX_STEPS(); Step[] memory steps = new Step[](stepsCount); for (uint256 i; i < stepsCount; ++i) { - steps[i] = Step({ threshold: uint128(i * STEP), value: uint128(i * 1_000) }); + steps[i] = Step({ threshold: uint128(i * GRANULARITY), value: uint128(i * 1_000) }); } vm.prank(admin); feeRegistry.setSteps(steps); - _requestFee(4_500); // Discount 4_250 reaches step 17. + _requestFeeDiscount(4_250); // Reaches step 17. assertEq(feeRegistry.getWeightBoostMultiplierBP(NO_ID), MAX_BP + 17_000); vm.prank(admin); - feeRegistry.setDefaultMinFee(STEP); - _requestFee(STEP); // Discount 8_500 reaches the final step 34. + 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 { - _requestFee(5_000); + _requestFeeDiscount(3_750); assertEq(feeRegistry.getEffectiveFee(NO_ID), 5_000); } function test_getEffectiveFee_ClampsAtZero() public { _setFeeModifier(0, 2_500, true); - _requestFee(5_000); + _requestFeeDiscount(3_750); _setFeeModifier(0, 5_500, true); assertEq(feeRegistry.getEffectiveFee(NO_ID), 0); } - function test_getEffectiveFee_PendingIncreaseIsExcluded() public { - _requestFee(5_000); - _requestFee(6_000); + function test_getEffectiveFee_PendingCutIsExcluded() public { + _requestFeeDiscount(3_750); + _requestFeeDiscount(2_750); assertEq(feeRegistry.getEffectiveFee(NO_ID), 5_000); } - function test_getMinFee_Default() public view { - assertEq(feeRegistry.getMinFee(NO_ID), MIN_FEE); + function test_getMaxFeeDiscount_Default() public view { + assertEq(feeRegistry.getMaxFeeDiscount(NO_ID), MAX_FEE_DISCOUNT); } } From 1ead0d1b709294d29e9e5692af4efb1b4dda6fab Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Fri, 7 Aug 2026 10:01:26 +0200 Subject: [PATCH 10/11] feat: polish docs --- src/CustomFeeRegistry.sol | 2 +- src/interfaces/ICustomFeeRegistry.sol | 89 +++++++++++---------------- 2 files changed, 38 insertions(+), 53 deletions(-) diff --git a/src/CustomFeeRegistry.sol b/src/CustomFeeRegistry.sol index c4edc7539..fef72a7f4 100644 --- a/src/CustomFeeRegistry.sol +++ b/src/CustomFeeRegistry.sol @@ -271,7 +271,7 @@ contract CustomFeeRegistry is ICustomFeeRegistry, StepwiseWeightBoost { return state.cooldownUntil != 0 ? state.pendingFeeDiscount : state.currentFeeDiscount; } - /// @dev The only crossing back into fee space, for the off-chain fee report. + /// @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; } diff --git a/src/interfaces/ICustomFeeRegistry.sol b/src/interfaces/ICustomFeeRegistry.sol index 6e861e5dc..9417b7bd0 100644 --- a/src/interfaces/ICustomFeeRegistry.sol +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -24,20 +24,20 @@ struct FeeModifier { /* * ── 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 that fee as a - * share of the total staking rewards at a 4% module share. One character is + * 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 (half a granularity unit). * 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. A fee modifier shifts only the effective fee. - * ● fee kept (BASE_FEE - discount) ○ effective (exposed for fee reporting) + * ● fee kept (BASE_FEE - discount) ○ effective (drives reward distribution) * * ┌ BASE_FEE * fee kept BP 0 2500 5000 6250 8750 10000 * ├───────────────────┼───────────────────┼─────────┼───────────────────┼─────────┤ - * total at 4% 0% 1% 2% 2.5% 3.5% 4% + * total rewards 0% 1% 2% 2.5% 3.5% 4% * discount BP 6250 3750 2500 0 * └ defaultMaxFeeDiscount * @@ -56,9 +56,9 @@ struct FeeModifier { * * ── Discount cut timeline and oracle frames ──────────────────────────────────── * - * Z is the discount-cut cooldown. Off-chain fee-report construction is - * expected to snapshot getEffectiveFee at the report refSlot and use that - * snapshot for the corresponding frame. Under this convention, keeping + * 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 @@ -81,13 +81,10 @@ struct FeeModifier { * 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 operator's allocation weight. The -/// effective fee (the fee kept, adjusted by the type's fee modifier) is exposed for off-chain -/// fee-report construction. In this provider, `Step.threshold` is the minimum discount and -/// `Step.value` is the weight multiplier increment above MAX_BP, so the stored discount feeds -/// the step function directly. The registry itself does not enforce report timing or -/// construction. See the diagrams above. +/// @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); @@ -108,8 +105,8 @@ interface ICustomFeeRegistry is IStepwiseWeightBoost { /// @notice Accounting contract holding bond curves; an operator's type is its curve id. function ACCOUNTING() external view returns (IAccounting); - /// @notice Fee kept by an operator with no discount, and the base every discount is subtracted from, - /// in basis points. A positive fee modifier can still push the effective fee above it. + /// @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. @@ -120,12 +117,10 @@ interface ICustomFeeRegistry is IStepwiseWeightBoost { /// @notice Initialize the provider. /// @param admin Address to receive DEFAULT_ADMIN_ROLE. - /// @param defaultMaxFeeDiscount Initial discount ceiling: a non-zero multiple of FEE_GRANULARITY below - /// BASE_FEE, so the operator always keeps a non-zero fee. - /// @param feeDiscountCutCooldown Stored cooldown duration in seconds, in - /// [1, MAX_FEE_DISCOUNT_CUT_COOLDOWN]. - /// @param steps Initial steps. Thresholds must be FEE_GRANULARITY-aligned and below BASE_FEE; values - /// must not exceed MAX_STEP_VALUE. + /// @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, @@ -133,30 +128,26 @@ interface ICustomFeeRegistry is IStepwiseWeightBoost { Step[] calldata steps ) external; - /// @notice Request a fee discount. Only the Node Operator owner. Raising it applies immediately and - /// cancels any pending cut. A request below the current discount creates or replaces a - /// pending cut: allocation weight immediately follows the requested target, while the - /// stored discount and the effective fee change only after `applyFeeDiscountCut`. Every - /// replacement restarts the cooldown. Requesting the current discount reverts. + /// @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. Only the Node Operator owner. Restores allocation - /// weight to the stored discount and reverts if no cut is pending. + /// @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 Apply a pending discount cut after its cooldown. Only the Node Operator owner. - /// Allocation weight already follows the pending discount. The discount is validated again - /// against the current ceiling because a curve or modifier may have changed during cooldown. + /// @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 normalize discounts left above the current ceiling after a curve or - /// modifier change: each is set to the ceiling and its pending cut is cancelled. Already valid - /// discounts are skipped, so duplicate IDs normalize at most once. + /// @notice Permissionlessly clamp discounts left above their ceiling by a curve or modifier change, + /// cancelling any pending cut. Already valid discounts are skipped. /// @param nodeOperatorIds IDs of the Node Operators. /// @return normalizedCount Number of discounts normalized. function normalizeFeeDiscounts(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount); @@ -165,17 +156,16 @@ interface ICustomFeeRegistry is IStepwiseWeightBoost { /// @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 only the effective fee, never the weight; a negative - /// modifier lowers the type's discount ceiling. Reverts for a nonexistent curve. + /// @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 used by future discount-cut requests. Only DEFAULT_ADMIN_ROLE; - /// existing pending deadlines are unchanged. + /// @notice Set the cooldown for future discount cuts; pending deadlines are unchanged. 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]. @@ -191,29 +181,24 @@ interface ICustomFeeRegistry is IStepwiseWeightBoost { /// @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. A pending cut is not reflected - /// until applied. + /// @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 Stored pending cut target. Meaningful only while a cooldown is active, since a legitimate - /// target may be zero. + /// @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 Earliest timestamp at which the pending cut passes its time check, or zero if none. It does - /// not clear automatically once elapsed. + /// @notice Deadline of the pending cut, 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 lowered by the type's - /// negative modifier. + /// @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, computed from the stored discount and the fee modifier. - /// A pending cut is excluded until applied. Negative results are clamped at zero. - /// Exposed for off-chain fee-report construction. + /// @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); } From 8e2531c9f84dc665cfc9cb2f0f1cff7562c619b6 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Fri, 7 Aug 2026 13:31:36 +0200 Subject: [PATCH 11/11] feat: normalizeFeeDiscounts returns IDs --- src/CustomFeeRegistry.sol | 15 ++++++++++++-- src/interfaces/ICustomFeeRegistry.sol | 29 +++++++++++++-------------- test/unit/CustomFeeRegistry.t.sol | 26 +++++++++++++++--------- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/CustomFeeRegistry.sol b/src/CustomFeeRegistry.sol index fef72a7f4..64395e375 100644 --- a/src/CustomFeeRegistry.sol +++ b/src/CustomFeeRegistry.sol @@ -107,9 +107,20 @@ contract CustomFeeRegistry is ICustomFeeRegistry, StepwiseWeightBoost { } /// @inheritdoc ICustomFeeRegistry - function normalizeFeeDiscounts(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount) { + function normalizeFeeDiscounts(uint256[] calldata nodeOperatorIds) external returns (uint256[] memory normalized) { + normalized = new uint256[](nodeOperatorIds.length); + uint256 count; for (uint256 i; i < nodeOperatorIds.length; ++i) { - if (_normalizeFeeDiscount(nodeOperatorIds[i])) ++normalizedCount; + 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) } } diff --git a/src/interfaces/ICustomFeeRegistry.sol b/src/interfaces/ICustomFeeRegistry.sol index 9417b7bd0..70d5f3c31 100644 --- a/src/interfaces/ICustomFeeRegistry.sol +++ b/src/interfaces/ICustomFeeRegistry.sol @@ -6,16 +6,16 @@ pragma solidity 0.8.33; import { IAccounting } from "./IAccounting.sol"; import { IStepwiseWeightBoost, Step } from "./IStepwiseWeightBoost.sol"; -/// @dev Fee discount state of a Node Operator: zero is both "never set" and "no discount", and -/// `cooldownUntil == 0` means no pending cut. Packed into a single slot. +/// @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 Sign-magnitude fee modifier of a Node Operator type: `value` basis points are subtracted from -/// the fee kept when `negative` is true, added otherwise. Packed into a single slot. +/// @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; @@ -26,12 +26,10 @@ struct FeeModifier { * * 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 (half a granularity unit). + * 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. A fee modifier shifts only the effective fee. + * 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 @@ -41,11 +39,11 @@ struct FeeModifier { * discount BP 6250 3750 2500 0 * └ defaultMaxFeeDiscount * - * type A — modifier +1250, effective = fee kept + 1250: + * Node Operator type A — modifier +1250, effective = fee kept + 1250: * fee kept ●─────────────────────────────────────────────────● * effective ○─────────────────────────────────────────────────○ * - * type B — modifier -2500, effective = fee kept - 2500; the ceiling drops to 3750: + * Node Operator type B — modifier -2500, effective = fee kept - 2500; the ceiling drops to 3750: * fee kept ●─────────────────────────────● * effective ○─────────────────────────────○ * @@ -147,10 +145,11 @@ interface ICustomFeeRegistry is IStepwiseWeightBoost { 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. + /// 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 normalizedCount Number of discounts normalized. - function normalizeFeeDiscounts(uint256[] calldata nodeOperatorIds) external returns (uint256 normalizedCount); + /// @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. @@ -164,7 +163,7 @@ interface ICustomFeeRegistry is IStepwiseWeightBoost { /// @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 deadlines are unchanged. Only + /// @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. @@ -189,7 +188,7 @@ interface ICustomFeeRegistry is IStepwiseWeightBoost { /// @param nodeOperatorId ID of the Node Operator. function getPendingFeeDiscount(uint256 nodeOperatorId) external view returns (uint256); - /// @notice Deadline of the pending cut, or zero if none; it does not clear once elapsed. + /// @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); diff --git a/test/unit/CustomFeeRegistry.t.sol b/test/unit/CustomFeeRegistry.t.sol index b5411934e..46a51cf3b 100644 --- a/test/unit/CustomFeeRegistry.t.sol +++ b/test/unit/CustomFeeRegistry.t.sol @@ -504,9 +504,10 @@ contract CustomFeeRegistryNormalizeFeeDiscountsTest is CustomFeeRegistryBaseTest } function test_normalizeFeeDiscounts() public { - uint256 normalizedCount = feeRegistry.normalizeFeeDiscounts(UintArr(0, 1, 2)); + uint256[] memory normalized = feeRegistry.normalizeFeeDiscounts(UintArr(0, 1, 2)); - assertEq(normalizedCount, 1); + 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); @@ -580,24 +581,31 @@ contract CustomFeeRegistryNormalizeFeeDiscountsTest is CustomFeeRegistryBaseTest nodeOperatorIds[3] = 1; uint256 notifyCallsBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); - uint256 normalizedCount = feeRegistry.normalizeFeeDiscounts(nodeOperatorIds); + uint256[] memory normalized = feeRegistry.normalizeFeeDiscounts(nodeOperatorIds); - assertEq(normalizedCount, 1); + 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 { - uint256 normalizedCount = feeRegistry.normalizeFeeDiscounts(UintArr()); - assertEq(normalizedCount, 0); + assertEq(feeRegistry.normalizeFeeDiscounts(UintArr()).length, 0); } function test_normalizeFeeDiscounts_NoFeeDiscountsToNormalize() public { uint256[] memory nodeOperatorIds = UintArr(0, 1); feeRegistry.normalizeFeeDiscounts(nodeOperatorIds); - uint256 normalizedCount = feeRegistry.normalizeFeeDiscounts(nodeOperatorIds); - - assertEq(normalizedCount, 0); + assertEq(feeRegistry.normalizeFeeDiscounts(nodeOperatorIds).length, 0); } }