From 39d72fe96d94a8c0b99d5090a9c738165d6560cb Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Wed, 3 Jun 2026 17:36:13 +0200 Subject: [PATCH 01/17] feat: bond curve multiplier --- foundry.toml | 2 +- src/Accounting.sol | 72 ++- src/MetaRegistry.sol | 22 +- src/TiersRegistry.sol | 160 +++++++ src/abstract/BondCurve.sol | 31 +- src/abstract/FeeSplits.sol | 2 +- src/interfaces/IAccounting.sol | 31 ++ src/interfaces/IBondCurve.sol | 29 ++ src/interfaces/IMetaRegistry.sol | 4 + src/interfaces/ITiersRegistry.sol | 97 ++++ src/lib/BondCurvesLib.sol | 40 +- src/lib/Constants.sol | 6 + test/helpers/mocks/AccountingMock.sol | 34 ++ test/helpers/mocks/MetaRegistryMock.sol | 8 + test/helpers/mocks/TiersRegistryMock.sol | 22 + test/unit/Accounting/BondCalculations.t.sol | 92 ++++ test/unit/Accounting/ClaimRewards.t.sol | 76 ++++ test/unit/Accounting/UnbondedKeys.t.sol | 14 + test/unit/Accounting/_Base.t.sol | 13 + test/unit/MetaRegistry.t.sol | 51 ++- test/unit/TiersRegistry.t.sol | 463 ++++++++++++++++++++ test/unit/abstract/BondCurve.t.sol | 112 +++++ 22 files changed, 1330 insertions(+), 51 deletions(-) create mode 100644 src/TiersRegistry.sol create mode 100644 src/interfaces/ITiersRegistry.sol create mode 100644 src/lib/Constants.sol create mode 100644 test/helpers/mocks/TiersRegistryMock.sol create mode 100644 test/unit/TiersRegistry.t.sol diff --git a/foundry.toml b/foundry.toml index 21a872b3..df7af207 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,7 +1,7 @@ [profile.default] evm_version = "osaka" optimizer = true -optimizer_runs = 200 +optimizer_runs = 50 bytecode_hash = "none" # The metadata hash removed from the bytecode (not the metadata itself). # uncomment this to inspect storage layouts in build artifacts # extra_output = ["storageLayout"] diff --git a/src/Accounting.sol b/src/Accounting.sol index 935e56db..7bd73e27 100644 --- a/src/Accounting.sol +++ b/src/Accounting.sol @@ -38,6 +38,7 @@ contract Accounting is bytes32 public constant MANAGE_BOND_CURVES_ROLE = keccak256("MANAGE_BOND_CURVES_ROLE"); bytes32 public constant SET_BOND_CURVE_ROLE = keccak256("SET_BOND_CURVE_ROLE"); + bytes32 public constant SET_BOND_CURVE_MULTIPLIER_ROLE = keccak256("SET_BOND_CURVE_MULTIPLIER_ROLE"); IBaseModule public immutable MODULE; IFeeDistributor public immutable FEE_DISTRIBUTOR; @@ -153,6 +154,16 @@ contract Accounting is MODULE.updateDepositInfo(nodeOperatorId); } + /// @inheritdoc IAccounting + function setBondCurveMultiplier( + uint256 nodeOperatorId, + uint256 multiplier + ) external onlyRole(SET_BOND_CURVE_MULTIPLIER_ROLE) { + _onlyExistingNodeOperator(nodeOperatorId); + BondCurve._setBondCurveMultiplier(nodeOperatorId, multiplier); + MODULE.updateDepositInfo(nodeOperatorId); + } + /// @inheritdoc IAccounting function depositETH(address from, uint256 nodeOperatorId) external payable whenResumed onlyModule { BondCore._depositETH(from, nodeOperatorId); @@ -405,6 +416,15 @@ contract Accounting is return _sharesByEth(getRequiredBondForNextKeys(nodeOperatorId, additionalKeys)); } + /// @inheritdoc IAccounting + function getRequiredBondForNextKeysWstETH( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 multiplier + ) external view returns (uint256) { + return _sharesByEth(getRequiredBondForNextKeys(nodeOperatorId, additionalKeys, multiplier)); + } + /// @inheritdoc IAccounting function getClaimableBondShares(uint256 nodeOperatorId) external view returns (uint256) { return _getClaimableBondShares(nodeOperatorId); @@ -431,7 +451,7 @@ contract Accounting is /// @inheritdoc IAccounting function getNodeOperatorBondInfo(uint256 nodeOperatorId) external view returns (NodeOperatorBondInfo memory info) { info.currentBond = BondCore.getBond(nodeOperatorId); - info.requiredBond = _getRequiredBond(nodeOperatorId, 0); + info.requiredBond = _getRequiredBond(nodeOperatorId, 0, BondCurve.getBondCurveMultiplier(nodeOperatorId)); info.lockedBond = BondLock.getLockedBond(nodeOperatorId); info.bondDebt = BondCore.getBondDebt(nodeOperatorId); info.pendingSharesToSplit = FeeSplits.getPendingSharesToSplit(nodeOperatorId); @@ -440,19 +460,35 @@ contract Accounting is /// @inheritdoc IAccounting function getBondSummary(uint256 nodeOperatorId) public view returns (uint256 current, uint256 required) { current = BondCore.getBond(nodeOperatorId); - required = _getRequiredBond(nodeOperatorId, 0); + required = _getRequiredBond(nodeOperatorId, 0, BondCurve.getBondCurveMultiplier(nodeOperatorId)); } /// @inheritdoc IAccounting function getBondSummaryShares(uint256 nodeOperatorId) public view returns (uint256 current, uint256 required) { current = BondCore.getBondShares(nodeOperatorId); - required = _getRequiredBondShares(nodeOperatorId, 0); + required = _sharesByEth(_getRequiredBond(nodeOperatorId, 0, BondCurve.getBondCurveMultiplier(nodeOperatorId))); } /// @inheritdoc IAccounting function getRequiredBondForNextKeys(uint256 nodeOperatorId, uint256 additionalKeys) public view returns (uint256) { uint256 current = BondCore.getBond(nodeOperatorId); - uint256 totalRequired = _getRequiredBond(nodeOperatorId, additionalKeys); + uint256 totalRequired = _getRequiredBond( + nodeOperatorId, + additionalKeys, + BondCurve.getBondCurveMultiplier(nodeOperatorId) + ); + + return Math.saturatingSub(totalRequired, current); + } + + /// @inheritdoc IAccounting + function getRequiredBondForNextKeys( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 multiplier + ) public view returns (uint256) { + uint256 current = BondCore.getBond(nodeOperatorId); + uint256 totalRequired = _getRequiredBond(nodeOperatorId, additionalKeys, multiplier); return Math.saturatingSub(totalRequired, current); } @@ -526,18 +562,19 @@ contract Accounting is return Math.saturatingSub(currentShares, requiredShares); } - function _getRequiredBond(uint256 nodeOperatorId, uint256 additionalKeys) internal view returns (uint256) { - uint256 curveId = BondCurve.getBondCurveId(nodeOperatorId); - uint256 nonWithdrawnKeys = MODULE.getNodeOperatorNonWithdrawnKeys(nodeOperatorId); - uint256 requiredBondForKeys = BondCurve.getBondAmountByKeysCount(nonWithdrawnKeys + additionalKeys, curveId); - uint256 lockedBond = BondLock.getLockedBond(nodeOperatorId); - uint256 bondDebt = BondCore.getBondDebt(nodeOperatorId); - - return requiredBondForKeys + lockedBond + bondDebt; - } - - function _getRequiredBondShares(uint256 nodeOperatorId, uint256 additionalKeys) internal view returns (uint256) { - return _sharesByEth(_getRequiredBond(nodeOperatorId, additionalKeys)); + function _getRequiredBond( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 mul + ) internal view returns (uint256) { + return + BondCurve.getBondAmountByKeysCount( + MODULE.getNodeOperatorNonWithdrawnKeys(nodeOperatorId) + additionalKeys, + BondCurve.getBondCurveId(nodeOperatorId), + mul + ) + + BondLock.getLockedBond(nodeOperatorId) + + BondCore.getBondDebt(nodeOperatorId); } /// @dev Unbonded stands for the amount of keys not fully covered with bond @@ -564,7 +601,8 @@ contract Accounting is // Should be sufficient for ~ 40 years uint256 bondedKeys = BondCurve.getKeysCountByBondAmount( currentBond + 10 wei, - BondCurve.getBondCurveId(nodeOperatorId) + BondCurve.getBondCurveId(nodeOperatorId), + BondCurve.getBondCurveMultiplier(nodeOperatorId) ); return Math.saturatingSub(nonWithdrawnKeys, bondedKeys); } diff --git a/src/MetaRegistry.sol b/src/MetaRegistry.sol index f8c49650..1281243c 100644 --- a/src/MetaRegistry.sol +++ b/src/MetaRegistry.sol @@ -15,9 +15,11 @@ import { IBaseModule } from "./interfaces/IBaseModule.sol"; import { IStakingModule } from "./interfaces/IStakingModule.sol"; import { IStakingRouter } from "./interfaces/IStakingRouter.sol"; import { IMetaRegistry, OperatorMetadata } from "./interfaces/IMetaRegistry.sol"; +import { ITiersRegistry } from "./interfaces/ITiersRegistry.sol"; import { ExternalOperatorLib, OperatorType } from "./lib/ExternalOperatorLib.sol"; +import { MAX_BP } from "./lib/Constants.sol"; -/// @notice Stores meta-operator group definitions for the curated module. +/// @notice Stores meta-operator group definitions and weight composition for the curated module. contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUpgradeable { using ExternalOperatorLib for ExternalOperator; @@ -61,8 +63,8 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp ICuratedModule public immutable MODULE; IAccounting public immutable ACCOUNTING; IStakingRouter public immutable STAKING_ROUTER; + ITiersRegistry public immutable TIERS_REGISTRY; - uint256 internal constant MAX_BP = 10000; uint256 internal constant EXTERNAL_STAKE_PER_VALIDATOR = 32 ether; uint256 internal constant MAX_NAME_LENGTH = 256; uint256 internal constant MAX_DESCRIPTION_LENGTH = 1024; @@ -71,12 +73,15 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp bytes32 private constant META_REGISTRY_STORAGE_LOCATION = 0xa7ec41e1a061c67796a04fcd9cc7cab9545b0a750beebc54139d9ed9d2251c00; - constructor(address module) { + /// @param module CuratedModule proxy address. + /// @param tiersRegistry TiersRegistry proxy address. + constructor(address module, address tiersRegistry) { if (module == address(0)) revert ZeroModuleAddress(); MODULE = ICuratedModule(module); ACCOUNTING = IAccounting(MODULE.ACCOUNTING()); STAKING_ROUTER = IStakingRouter(MODULE.LIDO_LOCATOR().stakingRouter()); + TIERS_REGISTRY = ITiersRegistry(tiersRegistry); _disableInitializers(); } @@ -402,13 +407,12 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp } function _getLatestEffectiveWeight(uint256 nodeOperatorId, uint256 share) internal view returns (uint256) { - uint256 baseWeight = _getOperatorBaseWeight(nodeOperatorId); + uint256 baseWeight = _storage().bondCurveWeight[ACCOUNTING.getBondCurveId(nodeOperatorId)]; if (baseWeight == 0 || share == 0) return 0; - return Math.mulDiv(baseWeight, share, MAX_BP); - } - - function _getOperatorBaseWeight(uint256 nodeOperatorId) internal view returns (uint256) { - return _storage().bondCurveWeight[ACCOUNTING.getBondCurveId(nodeOperatorId)]; + uint256 weighted = Math.mulDiv(baseWeight, share, MAX_BP); + uint256 weightMul = TIERS_REGISTRY.getOperatorTierState(nodeOperatorId).weightMultiplier; + if (weightMul == MAX_BP) return weighted; + return Math.mulDiv(weighted, weightMul, MAX_BP); } /// @dev Returns the cached module address. Reverts if the address was diff --git a/src/TiersRegistry.sol b/src/TiersRegistry.sol new file mode 100644 index 00000000..f09d9c9d --- /dev/null +++ b/src/TiersRegistry.sol @@ -0,0 +1,160 @@ +// 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 { IAccounting } from "./interfaces/IAccounting.sol"; +import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; +import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; +import { ITiersRegistry, TierInfo, OperatorTierState } from "./interfaces/ITiersRegistry.sol"; +import { MAX_BP } from "./lib/Constants.sol"; + +/// @notice Manages operator tiers. +contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerableUpgradeable { + /// @custom:storage-location erc7201:TiersRegistry + struct TiersRegistryStorage { + mapping(uint256 tierId => TierInfo) tiers; + uint256 tiersCount; + mapping(uint256 nodeOperatorId => uint256 tierId) operatorTier; + /// @dev Cooldown deadline (unix timestamp) after a tier downgrade. 0 = no active cooldown. + mapping(uint256 nodeOperatorId => uint256) curveMultiplierCooldownUntil; + } + + bytes32 public constant MANAGE_TIERS_ROLE = keccak256("MANAGE_TIERS_ROLE"); + + uint256 public constant MAX_CURVE_MULTIPLIER_INC = 10 * MAX_BP; + uint256 public constant MAX_WEIGHT_MULTIPLIER_INC = 10 * MAX_BP; + + ICuratedModule public immutable MODULE; + IAccounting public immutable ACCOUNTING; + IMetaRegistry public immutable META_REGISTRY; + uint256 public immutable CURVE_MULTIPLIER_COOLDOWN; + + // keccak256(abi.encode(uint256(keccak256("TiersRegistry")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant TIERS_REGISTRY_STORAGE_LOCATION = + 0x24229ad7430930455d78884db559ea2267e225054337247a405ccbe0a9cfca00; + + /// @param module CuratedModule address. + /// @param curveMultiplierCooldown Cooldown in seconds after a tier downgrade before `releaseCurveMultiplier` can be called. + constructor(address module, uint256 curveMultiplierCooldown) { + MODULE = ICuratedModule(module); + ACCOUNTING = IAccounting(MODULE.ACCOUNTING()); + META_REGISTRY = IMetaRegistry(MODULE.META_REGISTRY()); + + CURVE_MULTIPLIER_COOLDOWN = curveMultiplierCooldown; + + _disableInitializers(); + } + + /// @inheritdoc ITiersRegistry + function initialize(address admin) external initializer { + if (admin == address(0)) revert ZeroAdminAddress(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + /// @inheritdoc ITiersRegistry + function addTier( + uint256 curveMultiplierInc, + uint256 weightMultiplierInc + ) external onlyRole(MANAGE_TIERS_ROLE) returns (uint256 tierId) { + if (curveMultiplierInc > MAX_CURVE_MULTIPLIER_INC) revert InvalidCurveMultiplier(); + if (weightMultiplierInc > MAX_WEIGHT_MULTIPLIER_INC) revert InvalidWeightMultiplier(); + TiersRegistryStorage storage $ = _storage(); + tierId = ++$.tiersCount; + $.tiers[tierId] = TierInfo({ + curveMultiplierInc: uint128(curveMultiplierInc), + weightMultiplierInc: uint128(weightMultiplierInc) + }); + emit TierAdded(tierId, curveMultiplierInc, weightMultiplierInc); + } + + /// @inheritdoc ITiersRegistry + function selectTier(uint256 nodeOperatorId, uint256 tierId) external { + TiersRegistryStorage storage $ = _storage(); + _checkOperatorOwner(nodeOperatorId); + + if (tierId > $.tiersCount) revert InvalidTierId(); + if (tierId == $.operatorTier[nodeOperatorId]) revert SameTier(); + + uint256 newMulInc = getTierInfo(tierId).curveMultiplierInc; + uint256 newMul = MAX_BP + newMulInc; + if (newMul > ACCOUNTING.getBondCurveMultiplier(nodeOperatorId)) { + // NOTE: Takes into account current bond amount and keys count. + if (ACCOUNTING.getRequiredBondForNextKeys(nodeOperatorId, 0, newMul) > 0) revert InsufficientBondForTier(); + if ($.curveMultiplierCooldownUntil[nodeOperatorId] != 0) { + delete $.curveMultiplierCooldownUntil[nodeOperatorId]; + emit CurveMultiplierCooldownRemoved(nodeOperatorId); + } + ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, newMulInc); + } else { + if ($.curveMultiplierCooldownUntil[nodeOperatorId] != 0) revert CurveMultiplierCooldownActive(); + _setCurveMultiplierCooldown(nodeOperatorId); + } + + $.operatorTier[nodeOperatorId] = tierId; + emit TierSelected(nodeOperatorId, tierId); + + META_REGISTRY.refreshOperatorWeight(nodeOperatorId); + } + + /// @inheritdoc ITiersRegistry + function releaseCurveMultiplier(uint256 nodeOperatorId) external { + _checkOperatorOwner(nodeOperatorId); + + TiersRegistryStorage storage $ = _storage(); + uint256 cooldownUntil = $.curveMultiplierCooldownUntil[nodeOperatorId]; + if (cooldownUntil == 0) revert NoCurveMultiplierCooldown(); + if (cooldownUntil > block.timestamp) revert CurveMultiplierCooldownNotElapsed(); + + delete $.curveMultiplierCooldownUntil[nodeOperatorId]; + emit CurveMultiplierCooldownRemoved(nodeOperatorId); + + ACCOUNTING.setBondCurveMultiplier( + nodeOperatorId, + getTierInfo($.operatorTier[nodeOperatorId]).curveMultiplierInc + ); + } + + /// @inheritdoc ITiersRegistry + function getTiersCount() external view returns (uint256) { + return _storage().tiersCount; + } + + /// @inheritdoc ITiersRegistry + function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory state) { + TiersRegistryStorage storage $ = _storage(); + state.tierId = $.operatorTier[nodeOperatorId]; + state.curveMultiplierCooldownUntil = $.curveMultiplierCooldownUntil[nodeOperatorId]; + state.weightMultiplier = MAX_BP + $.tiers[state.tierId].weightMultiplierInc; + state.curveMultiplier = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); + } + + /// @inheritdoc ITiersRegistry + function getTierInfo(uint256 tierId) public view returns (TierInfo memory) { + TiersRegistryStorage storage $ = _storage(); + if (tierId > $.tiersCount) revert InvalidTierId(); + if (tierId == 0) return TierInfo({ curveMultiplierInc: 0, weightMultiplierInc: 0 }); + return $.tiers[tierId]; + } + + /// @dev Sets the cooldown deadline to `block.timestamp + CURVE_MULTIPLIER_COOLDOWN`. + function _setCurveMultiplierCooldown(uint256 nodeOperatorId) private { + uint256 cooldownUntil = block.timestamp + CURVE_MULTIPLIER_COOLDOWN; + _storage().curveMultiplierCooldownUntil[nodeOperatorId] = cooldownUntil; + emit CurveMultiplierCooldownSet(nodeOperatorId, cooldownUntil); + } + + function _checkOperatorOwner(uint256 nodeOperatorId) internal view { + if (msg.sender != MODULE.getNodeOperatorOwner(nodeOperatorId)) revert SenderIsNotOperatorOwner(); + } + + function _storage() internal pure returns (TiersRegistryStorage storage $) { + assembly ("memory-safe") { + // keccak256(abi.encode(uint256(keccak256("TiersRegistry")) - 1)) & ~bytes32(uint256(0xff)) + $.slot := TIERS_REGISTRY_STORAGE_LOCATION + } + } +} diff --git a/src/abstract/BondCurve.sol b/src/abstract/BondCurve.sol index 5d37d151..5bdad2e5 100644 --- a/src/abstract/BondCurve.sol +++ b/src/abstract/BondCurve.sol @@ -8,6 +8,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { BondCurvesLib } from "../lib/BondCurvesLib.sol"; import { IBondCurve } from "../interfaces/IBondCurve.sol"; +import { MAX_BP } from "../lib/Constants.sol"; /// @dev Bond curve mechanics abstract contract /// @@ -35,6 +36,8 @@ abstract contract BondCurve is IBondCurve, Initializable { /// @dev Mapping of Node Operator id to bond curve id mapping(uint256 nodeOperatorId => uint256 bondCurveId) operatorBondCurveId; BondCurveData[] bondCurves; + /// @dev Node Operator id to bond curve multiplier increment above MAX_BP (0 = no scaling) + mapping(uint256 nodeOperatorId => uint256 multiplier) operatorBondCurveMultiplier; } // keccak256(abi.encode(uint256(keccak256("CSBondCurve")) - 1)) & ~bytes32(uint256(0xff)) @@ -63,14 +66,38 @@ abstract contract BondCurve is IBondCurve, Initializable { return _getBondCurveStorage().operatorBondCurveId[nodeOperatorId]; } + /// @inheritdoc IBondCurve + function getBondCurveMultiplier(uint256 nodeOperatorId) public view returns (uint256) { + return MAX_BP + _getBondCurveStorage().operatorBondCurveMultiplier[nodeOperatorId]; + } + + /// @dev Stores the bond curve multiplier increment above MAX_BP (0 = no scaling). + function _setBondCurveMultiplier(uint256 nodeOperatorId, uint256 multiplier) internal { + _getBondCurveStorage().operatorBondCurveMultiplier[nodeOperatorId] = multiplier; + } + /// @inheritdoc IBondCurve function getBondAmountByKeysCount(uint256 keys, uint256 curveId) public view returns (uint256) { - return BondCurvesLib.getBondAmountByKeysCount(_getBondCurveStorage(), keys, curveId); + return BondCurvesLib.getBondAmountByKeysCount(_getBondCurveStorage(), keys, curveId, MAX_BP); + } + + /// @inheritdoc IBondCurve + function getBondAmountByKeysCount(uint256 keys, uint256 curveId, uint256 multiplier) public view returns (uint256) { + return BondCurvesLib.getBondAmountByKeysCount(_getBondCurveStorage(), keys, curveId, multiplier); } /// @inheritdoc IBondCurve function getKeysCountByBondAmount(uint256 amount, uint256 curveId) public view returns (uint256) { - return BondCurvesLib.getKeysCountByBondAmount(_getBondCurveStorage(), amount, curveId); + return BondCurvesLib.getKeysCountByBondAmount(_getBondCurveStorage(), amount, curveId, MAX_BP); + } + + /// @inheritdoc IBondCurve + function getKeysCountByBondAmount( + uint256 amount, + uint256 curveId, + uint256 multiplier + ) public view returns (uint256) { + return BondCurvesLib.getKeysCountByBondAmount(_getBondCurveStorage(), amount, curveId, multiplier); } // solhint-disable-next-line func-name-mixedcase diff --git a/src/abstract/FeeSplits.sol b/src/abstract/FeeSplits.sol index 2f765aea..c235dbe8 100644 --- a/src/abstract/FeeSplits.sol +++ b/src/abstract/FeeSplits.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.33; import { IFeeSplits } from "../interfaces/IFeeSplits.sol"; +import { MAX_BP } from "../lib/Constants.sol"; /// @dev Fee split mechanics abstract contract /// @@ -25,7 +26,6 @@ abstract contract FeeSplits is IFeeSplits { bytes32 private constant FEE_SPLITS_STORAGE_LOCATION = 0xac5584dcb35bfb1b3f4187762b10cb284ff937e63b5eb675e2e8e8876c7ee000; - uint256 internal constant MAX_BP = 10_000; uint256 public constant MAX_FEE_SPLITS = 10; /// @inheritdoc IFeeSplits diff --git a/src/interfaces/IAccounting.sol b/src/interfaces/IAccounting.sol index 33bb28d1..a8d86921 100644 --- a/src/interfaces/IAccounting.sol +++ b/src/interfaces/IAccounting.sol @@ -47,6 +47,8 @@ interface IAccounting is IBondCore, IBondCurve, IBondLock, IFeeSplits, IAssetRec function SET_BOND_CURVE_ROLE() external view returns (bytes32); + function SET_BOND_CURVE_MULTIPLIER_ROLE() external view returns (bytes32); + function MODULE() external view returns (IBaseModule); function FEE_DISTRIBUTOR() external view returns (IFeeDistributor); @@ -115,6 +117,17 @@ interface IAccounting is IBondCore, IBondCurve, IBondLock, IFeeSplits, IAssetRec /// @return Required bond amount in ETH function getRequiredBondForNextKeys(uint256 nodeOperatorId, uint256 additionalKeys) external view returns (uint256); + /// @notice Get the required bond in ETH (inc. missed and excess) at the given curve multiplier for the given Node Operator to upload new deposit data. + /// @param nodeOperatorId ID of the Node Operator + /// @param additionalKeys Number of new keys to add + /// @param multiplier Full curve multiplier in basis points (>= MAX_BP; MAX_BP = no scaling). + /// @return Required bond amount in ETH + function getRequiredBondForNextKeys( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 multiplier + ) external view returns (uint256); + /// @notice Get the bond amount in wstETH required for the `keysCount` keys for the given bond curve /// @param keysCount Keys count to calculate the required bond amount /// @param curveId Id of the curve to perform calculations against @@ -130,6 +143,17 @@ interface IAccounting is IBondCore, IBondCurve, IBondLock, IFeeSplits, IAssetRec uint256 additionalKeys ) external view returns (uint256); + /// @notice Get the required bond in wstETH (inc. missed and excess) at the given curve multiplier for the given Node Operator to upload new keys. + /// @param nodeOperatorId ID of the Node Operator + /// @param additionalKeys Number of new keys to add + /// @param multiplier Full curve multiplier in basis points (>= MAX_BP; MAX_BP = no scaling). + /// @return Required bond in wstETH + function getRequiredBondForNextKeysWstETH( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 multiplier + ) external view returns (uint256); + /// @notice Get the number of the unbonded keys /// @param nodeOperatorId ID of the Node Operator /// @return Unbonded keys count @@ -317,6 +341,13 @@ interface IAccounting is IBondCore, IBondCurve, IBondLock, IFeeSplits, IAssetRec /// @param curveId ID of the bond curve to set function setBondCurve(uint256 nodeOperatorId, uint256 curveId) external; + /// @notice Set the bond curve multiplier increment (above MAX_BP) for the given Node Operator. + /// Pass 0 to reset to the default (no scaling). + /// @dev Triggers a deposit info update so key pointers stay consistent. + /// @param nodeOperatorId ID of the Node Operator + /// @param multiplier Bond curve multiplier increment above MAX_BP in basis points (0 = no scaling) + function setBondCurveMultiplier(uint256 nodeOperatorId, uint256 multiplier) external; + /// @notice Penalize bond by burning stETH shares of the given Node Operator /// @dev Penalty application has a priority over the locked bond. /// Method call can result in the remaining bond being lower than the locked bond. diff --git a/src/interfaces/IBondCurve.sol b/src/interfaces/IBondCurve.sol index fcfeaff2..73b7de9b 100644 --- a/src/interfaces/IBondCurve.sol +++ b/src/interfaces/IBondCurve.sol @@ -67,6 +67,7 @@ interface IBondCurve { error InvalidBondCurveId(); error InvalidInitializationCurveId(); error SameBondCurveId(); + error InvalidMultiplier(); function DEFAULT_BOND_CURVE_ID() external view returns (uint256); @@ -90,6 +91,10 @@ interface IBondCurve { /// @return Bond curve ID function getBondCurveId(uint256 nodeOperatorId) external view returns (uint256); + /// @notice Bond curve multiplier for the given Node Operator in basis points. + /// MAX_BP (10_000) means no scaling, and is the default when none is set. + function getBondCurveMultiplier(uint256 nodeOperatorId) external view returns (uint256); + /// @notice Get required bond in ETH for the given number of keys for the given bond curve /// @dev To calculate the amount for the new keys 2 calls are required: /// getBondAmountByKeysCount(newTotal) - getBondAmountByKeysCount(currentTotal) @@ -98,9 +103,33 @@ interface IBondCurve { /// @return Amount for particular keys count function getBondAmountByKeysCount(uint256 keys, uint256 curveId) external view returns (uint256); + /// @notice Get required bond in ETH for the given number of keys, with the bond axis scaled by `multiplier`. + /// @dev Reverts with `InvalidMultiplier` if `multiplier < MAX_BP`. + /// @param keys Number of keys to get required bond for + /// @param curveId Id of the curve to perform calculations against + /// @param multiplier Curve scaling factor in basis points (>= MAX_BP; MAX_BP = no scaling) + /// @return Scaled amount for particular keys count + function getBondAmountByKeysCount( + uint256 keys, + uint256 curveId, + uint256 multiplier + ) external view returns (uint256); + /// @notice Get keys count for the given bond amount with the given bond curve /// @param amount Bond amount in ETH (stETH) to get keys count for /// @param curveId Id of the curve to perform calculations against /// @return Keys count function getKeysCountByBondAmount(uint256 amount, uint256 curveId) external view returns (uint256); + + /// @notice Get keys count for the given bond amount, with the bond axis scaled by `multiplier`. + /// @dev Reverts with `InvalidMultiplier` if `multiplier < MAX_BP`. + /// @param amount Bond amount in ETH (stETH) to get keys count for + /// @param curveId Id of the curve to perform calculations against + /// @param multiplier Curve scaling factor in basis points (>= MAX_BP; MAX_BP = no scaling) + /// @return Keys count on the scaled curve + function getKeysCountByBondAmount( + uint256 amount, + uint256 curveId, + uint256 multiplier + ) external view returns (uint256); } diff --git a/src/interfaces/IMetaRegistry.sol b/src/interfaces/IMetaRegistry.sol index 55ea72be..8964cca0 100644 --- a/src/interfaces/IMetaRegistry.sol +++ b/src/interfaces/IMetaRegistry.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.33; import { IAccounting } from "./IAccounting.sol"; import { ICuratedModule } from "./ICuratedModule.sol"; +import { ITiersRegistry } from "./ITiersRegistry.sol"; /// @notice Stored operator metadata. struct OperatorMetadata { @@ -72,6 +73,9 @@ interface IMetaRegistry { /// @notice Accounting contract used for bond curve lookups. function ACCOUNTING() external view returns (IAccounting); + /// @notice Tier provider that manages operator bond tiers. + function TIERS_REGISTRY() external view returns (ITiersRegistry); + /// @notice Initialize the registry. /// @param admin Address to receive DEFAULT_ADMIN_ROLE. function initialize(address admin) external; diff --git a/src/interfaces/ITiersRegistry.sol b/src/interfaces/ITiersRegistry.sol new file mode 100644 index 00000000..c35d2b67 --- /dev/null +++ b/src/interfaces/ITiersRegistry.sol @@ -0,0 +1,97 @@ +// 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"; + +/// @dev Bond tier. Multipliers are stored as increments above MAX_BP (effective = MAX_BP + increment). +struct TierInfo { + uint128 curveMultiplierInc; + uint128 weightMultiplierInc; +} + +/// @dev Operator's effective tier state, with multipliers as full basis-point values (not `TierInfo` increments). +/// During a downgrade cooldown `curveMultiplier` keeps the pre-downgrade value until `releaseCurveMultiplier`, +/// so it may exceed the current tier's value (and stay above MAX_BP while `tierId == 0`). +struct OperatorTierState { + uint256 tierId; + uint256 curveMultiplier; + uint256 weightMultiplier; + uint256 curveMultiplierCooldownUntil; +} + +/// @notice Manages operator bond tiers and associated tier downgrade cooldown state. +interface ITiersRegistry { + event TierAdded(uint256 indexed tierId, uint256 curveMultiplierInc, uint256 weightMultiplierInc); + event TierSelected(uint256 indexed nodeOperatorId, uint256 tierId); + event CurveMultiplierCooldownSet(uint256 indexed nodeOperatorId, uint256 cooldownUntil); + event CurveMultiplierCooldownRemoved(uint256 indexed nodeOperatorId); + + error ZeroAdminAddress(); + error InvalidCurveMultiplier(); + error InvalidWeightMultiplier(); + error InvalidTierId(); + error SameTier(); + error InsufficientBondForTier(); + error SenderIsNotOperatorOwner(); + error NoCurveMultiplierCooldown(); + error CurveMultiplierCooldownNotElapsed(); + error CurveMultiplierCooldownActive(); + + /// @notice Role required to add tiers. + function MANAGE_TIERS_ROLE() external view returns (bytes32); + + /// @notice Curated module address. + function MODULE() external view returns (ICuratedModule); + + /// @notice Accounting contract holding bond curves and the operator curve multiplier. + function ACCOUNTING() external view returns (IAccounting); + + /// @notice MetaRegistry called back via `refreshOperatorWeight` on tier changes. + function META_REGISTRY() external view returns (IMetaRegistry); + + /// @notice Upper bound for `curveMultiplierInc`. + function MAX_CURVE_MULTIPLIER_INC() external view returns (uint256); + + /// @notice Upper bound for `weightMultiplierInc`. + function MAX_WEIGHT_MULTIPLIER_INC() external view returns (uint256); + + /// @notice Cooldown in seconds after a downgrade before `releaseCurveMultiplier` can be called. + function CURVE_MULTIPLIER_COOLDOWN() external view returns (uint256); + + /// @notice Initialize the provider. + /// @param admin Address to receive DEFAULT_ADMIN_ROLE. + function initialize(address admin) external; + + /// @notice Add a new bond tier. Tier IDs are assigned sequentially starting from 1. + /// @param curveMultiplierInc Curve multiplier increment above MAX_BP (must be <= MAX_CURVE_MULTIPLIER_INC). + /// @param weightMultiplierInc Weight multiplier increment above MAX_BP (must be <= MAX_WEIGHT_MULTIPLIER_INC). + /// @return tierId ID of the newly created tier. + function addTier(uint256 curveMultiplierInc, uint256 weightMultiplierInc) external returns (uint256 tierId); + + /// @notice Select a bond tier for the Node Operator. An upgrade (target effective curve multiplier above the + /// operator's current one) applies both multipliers at once and requires the bond to cover the new + /// requirement; a downgrade applies the new weight now but keeps the higher curve multiplier until + /// `releaseCurveMultiplier`. Either clears an active cooldown (upgrade) or reverts on it (downgrade). + /// @param nodeOperatorId ID of the Node Operator. + /// @param tierId Target tier ID (0 = default tier). + function selectTier(uint256 nodeOperatorId, uint256 tierId) external; + + /// @notice Apply a pending downgrade after its cooldown elapses, lowering the curve multiplier to the current + /// tier. Callable only by the Node Operator owner. + /// @param nodeOperatorId ID of the Node Operator. + function releaseCurveMultiplier(uint256 nodeOperatorId) external; + + /// @notice Number of stored tiers (not counting the implicit default tier 0). + function getTiersCount() external view returns (uint256); + + /// @notice Static parameters of a tier definition, as increments above MAX_BP. + /// @dev Do not use this to read an operator's effective bond multiplier — use `getOperatorTierState`. + function getTierInfo(uint256 tierId) external view returns (TierInfo memory); + + /// @notice Full effective tier-related state of a Node Operator. + function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory); +} diff --git a/src/lib/BondCurvesLib.sol b/src/lib/BondCurvesLib.sol index 49d9d1cc..7cf94fb6 100644 --- a/src/lib/BondCurvesLib.sol +++ b/src/lib/BondCurvesLib.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.33; import { IBondCurve } from "../interfaces/IBondCurve.sol"; import { BondCurve } from "../abstract/BondCurve.sol"; +import { MAX_BP } from "../lib/Constants.sol"; /// Library for managing BondCurves /// @dev External deployment-linked library used by Accounting. @@ -37,8 +38,10 @@ library BondCurvesLib { function getBondAmountByKeysCount( BondCurve.BondCurveStorage storage bondCurveStorage, uint256 keys, - uint256 curveId + uint256 curveId, + uint256 multiplier ) external view returns (uint256) { + if (multiplier < MAX_BP) revert IBondCurve.InvalidMultiplier(); _ensureCurveExists(bondCurveStorage, curveId); IBondCurve.BondCurveInterval[] storage intervals = bondCurveStorage.bondCurves[curveId].intervals; if (keys == 0) return 0; @@ -55,35 +58,36 @@ library BondCurvesLib { } } IBondCurve.BondCurveInterval storage interval = intervals[low]; - return interval.minBond + (keys - interval.minKeysCount) * interval.trend; + uint256 sMinBond = _scaled(interval.minBond, multiplier); + uint256 sTrend = _scaled(interval.trend, multiplier); + return sMinBond + (keys - interval.minKeysCount) * sTrend; } } function getKeysCountByBondAmount( BondCurve.BondCurveStorage storage bondCurveStorage, uint256 amount, - uint256 curveId + uint256 curveId, + uint256 multiplier ) external view returns (uint256) { + if (multiplier < MAX_BP) revert IBondCurve.InvalidMultiplier(); _ensureCurveExists(bondCurveStorage, curveId); IBondCurve.BondCurveInterval[] storage intervals = bondCurveStorage.bondCurves[curveId].intervals; - // intervals[0].minBond is essentially the amount of bond required for the very first key - if (amount < intervals[0].minBond) return 0; - unchecked { + if (amount < _scaled(intervals[0].minBond, multiplier)) return 0; + uint256 low = 0; uint256 high = intervals.length - 1; while (low < high) { uint256 mid = (low + high + 1) / 2; - if (amount < intervals[mid].minBond) { + if (amount < _scaled(intervals[mid].minBond, multiplier)) { high = mid - 1; } else { low = mid; } } - IBondCurve.BondCurveInterval storage interval; - // // Imagine we have: // Interval 0: minKeysCount = 1, minBond = 2 ETH, trend = 2 ETH @@ -93,11 +97,16 @@ library BondCurvesLib { // So we need a special check for bond amounts between Interval 0 maxBond and Interval 1 minBond. // if (low < intervals.length - 1) { - interval = intervals[low + 1]; - if (amount > interval.minBond - interval.trend) return interval.minKeysCount - 1; + IBondCurve.BondCurveInterval storage next = intervals[low + 1]; + uint256 sNextMinBond = _scaled(next.minBond, multiplier); + uint256 sNextTrend = _scaled(next.trend, multiplier); + if (amount > sNextMinBond - sNextTrend) return next.minKeysCount - 1; } - interval = intervals[low]; - return interval.minKeysCount + (amount - interval.minBond) / interval.trend; + + IBondCurve.BondCurveInterval storage interval = intervals[low]; + uint256 sMinBond = _scaled(interval.minBond, multiplier); + uint256 sTrend = _scaled(interval.trend, multiplier); + return interval.minKeysCount + (amount - sMinBond) / sTrend; } } @@ -145,4 +154,9 @@ library BondCurvesLib { } } } + + /// @dev Scales a curve value (minBond/trend) by the multiplier in basis points. + function _scaled(uint256 value, uint256 multiplier) private pure returns (uint256) { + return (value * multiplier) / MAX_BP; + } } diff --git a/src/lib/Constants.sol b/src/lib/Constants.sol new file mode 100644 index 00000000..ed6c5ee6 --- /dev/null +++ b/src/lib/Constants.sol @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.33; + +/// @dev Basis points denominator (100 % = 10 000 bp). +uint256 constant MAX_BP = 10_000; diff --git a/test/helpers/mocks/AccountingMock.sol b/test/helpers/mocks/AccountingMock.sol index 2e50fe84..73d147dc 100644 --- a/test/helpers/mocks/AccountingMock.sol +++ b/test/helpers/mocks/AccountingMock.sol @@ -25,6 +25,8 @@ contract AccountingMock { mapping(uint256 nodeOperatorId => IBondLock.BondLockData) bondLock; mapping(uint256 nodeOperatorId => uint256) bond; + mapping(uint256 nodeOperatorId => uint256) curveMultiplier; + mapping(uint256 nodeOperatorId => uint256) requiredBondAtMul; mapping(uint256 nodeOperatorId => uint256 bondCurveId) operatorBondCurveId; uint256[] bondCurves; @@ -42,6 +44,30 @@ contract AccountingMock { FEE_DISTRIBUTOR = IFeeDistributor(_feeDistributor); } + function setBondCurveMultiplier(uint256 nodeOperatorId, uint256 multiplier) external { + curveMultiplier[nodeOperatorId] = multiplier; + } + + function getBondCurveMultiplier(uint256 nodeOperatorId) external view returns (uint256) { + return 10_000 + curveMultiplier[nodeOperatorId]; + } + + /// @dev Sets the base required bond (at identity multiplier MAX_BP). The 3-arg `getRequiredBondForNextKeys` + /// scales it by the requested multiplier and subtracts the current bond, mirroring real Accounting. + function mock_setRequiredBond(uint256 nodeOperatorId, uint256 amount) external { + requiredBondAtMul[nodeOperatorId] = amount; + } + + function getRequiredBondForNextKeys( + uint256 nodeOperatorId, + uint256 /* additionalKeys */, + uint256 multiplier + ) public view returns (uint256) { + uint256 totalRequired = (requiredBondAtMul[nodeOperatorId] * multiplier) / 10_000; + uint256 current = getBond(nodeOperatorId); + return totalRequired > current ? totalRequired - current : 0; + } + function setModule(IBaseModule _module) external { MODULE = _module; } @@ -243,6 +269,14 @@ contract AccountingMock { return wstETH.getWstETHByStETH(getRequiredBondForNextKeys(nodeOperatorId, additionalKeys)); } + function getRequiredBondForNextKeysWstETH( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 multiplier + ) external view returns (uint256) { + return wstETH.getWstETHByStETH(getRequiredBondForNextKeys(nodeOperatorId, additionalKeys, multiplier)); + } + function getLockedBond(uint256 nodeOperatorId) public view returns (uint256) { return bondLock[nodeOperatorId].amount; } diff --git a/test/helpers/mocks/MetaRegistryMock.sol b/test/helpers/mocks/MetaRegistryMock.sol index bc1e0465..3f924b36 100644 --- a/test/helpers/mocks/MetaRegistryMock.sol +++ b/test/helpers/mocks/MetaRegistryMock.sol @@ -6,7 +6,15 @@ pragma solidity 0.8.33; import { IMetaRegistry, OperatorMetadata } from "src/interfaces/IMetaRegistry.sol"; contract MetaRegistryMock { + uint256 public refreshOperatorWeightCallCount; + uint256 public lastRefreshedOperatorId; + function setOperatorMetadataAsAdmin(uint256 nodeOperatorId, OperatorMetadata calldata metadata) external { emit IMetaRegistry.OperatorMetadataSet({ nodeOperatorId: nodeOperatorId, metadata: metadata }); } + + function refreshOperatorWeight(uint256 nodeOperatorId) external { + refreshOperatorWeightCallCount++; + lastRefreshedOperatorId = nodeOperatorId; + } } diff --git a/test/helpers/mocks/TiersRegistryMock.sol b/test/helpers/mocks/TiersRegistryMock.sol new file mode 100644 index 00000000..ccfb10ac --- /dev/null +++ b/test/helpers/mocks/TiersRegistryMock.sol @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { MAX_BP } from "src/lib/Constants.sol"; +import { OperatorTierState } from "src/interfaces/ITiersRegistry.sol"; + +/// @dev Minimal TiersRegistry mock for MetaRegistry tests. +/// Stores weight multiplier increments above MAX_BP, mirroring TiersRegistry storage. +/// Effective weightMultiplier = MAX_BP + increment (0 increment = identity = MAX_BP). +contract TiersRegistryMock { + mapping(uint256 => uint256) private _weightMultiplierInc; + + function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory state) { + state.weightMultiplier = MAX_BP + _weightMultiplierInc[nodeOperatorId]; + } + + function mock_setWeightMultiplierInc(uint256 nodeOperatorId, uint256 increment) external { + _weightMultiplierInc[nodeOperatorId] = increment; + } +} diff --git a/test/unit/Accounting/BondCalculations.t.sol b/test/unit/Accounting/BondCalculations.t.sol index eae03fcf..0c15d7fc 100644 --- a/test/unit/Accounting/BondCalculations.t.sol +++ b/test/unit/Accounting/BondCalculations.t.sol @@ -7,6 +7,7 @@ import { stdError } from "forge-std/Test.sol"; import { BaseTest, BondStateBaseTest, GetRequiredBondBaseTest, GetRequiredBondForKeysBaseTest, RewardsBaseTest } from "./_Base.t.sol"; import { Accounting } from "src/Accounting.sol"; +import { MAX_BP } from "src/lib/Constants.sol"; import { IBondCurve } from "src/interfaces/IBondCurve.sol"; import { IBondLock } from "src/interfaces/IBondLock.sol"; import { IBaseModule } from "src/interfaces/IBaseModule.sol"; @@ -162,6 +163,21 @@ contract ClaimableBondTest is RewardsBaseTest { ); } + function test_WithMultiplier() public override { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 claimableBondShares = accounting.getClaimableBondShares(0); + + assertApproxEqAbs( + claimableBondShares, + stETH.getSharesByPooledEth(2 ether), + 1 wei, + "claimable bond shares should be the excess over the scaled requirement" + ); + } + function test_WithOneWithdrawnValidator() public override { _operator({ ongoing: 16, withdrawn: 1 }); _deposit({ bond: 32 ether }); @@ -682,6 +698,12 @@ contract GetRequiredETHBondTest is GetRequiredBondBaseTest { assertEq(accounting.getRequiredBondForNextKeys(0, 0), 18 ether); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _multiplier({ multiplier: 15_000 }); + assertEq(accounting.getRequiredBondForNextKeys(0, 0), 48 ether); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); assertEq(accounting.getRequiredBondForNextKeys(0, 0), 30 ether); @@ -781,6 +803,13 @@ contract GetRequiredWstETHBondTest is GetRequiredBondBaseTest { assertEq(accounting.getRequiredBondForNextKeysWstETH(0, 0), wstETH.getWstETHByStETH(required - current)); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _multiplier({ multiplier: 15_000 }); + (uint256 current, uint256 required) = accounting.getBondSummary(0); + assertEq(accounting.getRequiredBondForNextKeysWstETH(0, 0), wstETH.getWstETHByStETH(required - current)); + } + function test_WithCurveAndLocked() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 0 }); _curve(curveWithDiscount); @@ -896,6 +925,37 @@ contract GetBondAmountByKeysCountWstETHTest is GetRequiredBondForKeysBaseTest { } } +contract GetRequiredBondForNextKeysAtMultiplierTest is BaseTest { + function test_default() public { + _operator({ ongoing: 16, withdrawn: 0 }); + assertEq(accounting.getRequiredBondForNextKeys(0, 0, MAX_BP), 32 ether); + } + + function test_WithMultiplier() public { + _operator({ ongoing: 16, withdrawn: 0 }); + assertEq(accounting.getRequiredBondForNextKeys(0, 0, 15_000), 48 ether); + } + + function test_ScalesCurveButNotLocked() public { + _operator({ ongoing: 16, withdrawn: 0 }); + vm.prank(address(stakingModule)); + accounting.lockBond(0, 1 ether); + assertEq(accounting.getRequiredBondForNextKeys(0, 0, 15_000), 49 ether); + } + + function test_SubtractsCurrentBond() public { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 16 ether }); + assertEq(accounting.getRequiredBondForNextKeys(0, 0, 15_000), 48 ether - accounting.getBond(0)); + } + + function test_RevertWhen_MultiplierBelowMaxBP() public { + _operator({ ongoing: 16, withdrawn: 0 }); + vm.expectRevert(IBondCurve.InvalidMultiplier.selector); + accounting.getRequiredBondForNextKeys(0, 0, MAX_BP - 1); + } +} + // Combined bond summary and shares tests contract GetBondSummaryTest is BondStateBaseTest { @@ -939,6 +999,14 @@ contract GetBondSummaryTest is BondStateBaseTest { assertEq(required, 18 ether); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _multiplier({ multiplier: 15_000 }); + (uint256 current, uint256 required) = accounting.getBondSummary(0); + assertEq(current, 0 ether); + assertEq(required, 48 ether); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); (uint256 current, uint256 required) = accounting.getBondSummary(0); @@ -1045,6 +1113,14 @@ contract GetBondSummarySharesTest is BondStateBaseTest { assertEq(required, stETH.getSharesByPooledEth(18 ether)); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _multiplier({ multiplier: 15_000 }); + (uint256 current, uint256 required) = accounting.getBondSummaryShares(0); + assertEq(current, 0 ether); + assertEq(required, stETH.getSharesByPooledEth(48 ether)); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); (uint256 current, uint256 required) = accounting.getBondSummaryShares(0); @@ -1188,6 +1264,22 @@ contract ClaimableRewardsAndBondSharesTest is RewardsBaseTest { ); } + function test_WithMultiplier() public override { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _rewards({ fee: 0.1 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 claimableBondShares = accounting.getClaimableRewardsAndBondShares(0, leaf.shares, leaf.proof); + + assertApproxEqAbs( + claimableBondShares, + stETH.getSharesByPooledEth(2.1 ether), + 1 wei, + "claimable bond shares should be the excess over the scaled requirement + rewards" + ); + } + function test_WithOneWithdrawnValidator() public override { _operator({ ongoing: 16, withdrawn: 1 }); _deposit({ bond: 32 ether }); diff --git a/test/unit/Accounting/ClaimRewards.t.sol b/test/unit/Accounting/ClaimRewards.t.sol index 43572b67..68e802d5 100644 --- a/test/unit/Accounting/ClaimRewards.t.sol +++ b/test/unit/Accounting/ClaimRewards.t.sol @@ -87,6 +87,33 @@ contract ClaimStETHRewardsTest is ClaimRewardsBaseTest { assertEq(accounting.getBondShares(0), stETH.sharesOf(address(accounting))); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _rewards({ fee: 0.1 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 bondSharesBefore = accounting.getBondShares(0); + vm.prank(user); + accounting.claimRewardsStETH(leaf.nodeOperatorId, UINT256_MAX, leaf.shares, leaf.proof); + uint256 bondSharesAfter = accounting.getBondShares(0); + + assertApproxEqAbs( + stETH.balanceOf(rewardAddress), + stETHAsFee + 2 ether, + 1 wei, + "reward address balance should be the fee reward plus the excess over the scaled requirement" + ); + assertApproxEqAbs( + bondSharesAfter, + bondSharesBefore - stETH.getSharesByPooledEth(2 ether), + 1 wei, + "bond shares should drop by the claimed excess" + ); + assertEq(stETH.sharesOf(address(accounting)), bondSharesAfter); + assertEq(accounting.totalBondShares(), bondSharesAfter); + } + function test_WithCurveAndLocked() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 0 }); _deposit({ bond: 32 ether }); @@ -568,6 +595,34 @@ contract ClaimWstETHRewardsTest is ClaimRewardsBaseTest { assertEq(wstETH.balanceOf(rewardAddress), 0); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _rewards({ fee: 0.1 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 bondSharesBefore = accounting.getBondShares(0); + vm.prank(user); + accounting.claimRewardsWstETH(leaf.nodeOperatorId, UINT256_MAX, leaf.shares, leaf.proof); + uint256 bondSharesAfter = accounting.getBondShares(0); + + assertApproxEqAbs( + wstETH.balanceOf(rewardAddress), + wstETHAsFee + stETH.getSharesByPooledEth(2 ether), + 1 wei, + "reward address balance should be the fee reward plus the excess over the scaled requirement" + ); + assertApproxEqAbs( + bondSharesAfter, + bondSharesBefore - stETH.getSharesByPooledEth(2 ether), + 1 wei, + "bond shares should drop by the claimed excess" + ); + assertEq(wstETH.balanceOf(address(accounting)), 0, "bond manager wstETH balance should be 0"); + assertEq(stETH.sharesOf(address(accounting)), bondSharesAfter); + assertEq(accounting.totalBondShares(), bondSharesAfter); + } + function test_WithCurveAndLocked() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 0 }); _deposit({ bond: 32 ether }); @@ -1045,6 +1100,27 @@ contract ClaimRewardsUnstETHTest is ClaimRewardsBaseTest { assertEq(requestId, 0); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _rewards({ fee: 0.1 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 bondSharesBefore = accounting.getBondShares(0); + vm.prank(user); + accounting.claimRewardsUnstETH(leaf.nodeOperatorId, UINT256_MAX, leaf.shares, leaf.proof); + uint256 bondSharesAfter = accounting.getBondShares(0); + + assertApproxEqAbs( + bondSharesAfter, + bondSharesBefore - stETH.getSharesByPooledEth(2 ether), + 1 wei, + "bond shares should drop by the withdrawn excess" + ); + assertEq(stETH.sharesOf(rewardAddress), 0, "reward address shares should be 0"); + assertEq(accounting.totalBondShares(), bondSharesAfter, "total bond shares should be equal to after"); + } + function test_WithCurveAndLocked() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 0 }); _deposit({ bond: 32 ether }); diff --git a/test/unit/Accounting/UnbondedKeys.t.sol b/test/unit/Accounting/UnbondedKeys.t.sol index 3be0980e..7015cc91 100644 --- a/test/unit/Accounting/UnbondedKeys.t.sol +++ b/test/unit/Accounting/UnbondedKeys.t.sol @@ -44,6 +44,13 @@ contract GetUnbondedKeysCountTest is BondStateBaseTest { assertEq(accounting.getUnbondedKeysCount(0), 7); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 11.5 ether }); + _multiplier({ multiplier: 15_000 }); + assertEq(accounting.getUnbondedKeysCount(0), 13); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); _deposit({ bond: 11.5 ether }); @@ -158,6 +165,13 @@ contract GetUnbondedKeysCountToEjectTest is BondStateBaseTest { assertEq(accounting.getUnbondedKeysCountToEject(0), 6); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 11.5 ether }); + _multiplier({ multiplier: 15_000 }); + assertEq(accounting.getUnbondedKeysCountToEject(0), 13); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); _deposit({ bond: 11.5 ether }); diff --git a/test/unit/Accounting/_Base.t.sol b/test/unit/Accounting/_Base.t.sol index 1b9102ea..c3d41d66 100644 --- a/test/unit/Accounting/_Base.t.sol +++ b/test/unit/Accounting/_Base.t.sol @@ -10,6 +10,7 @@ import { IStakingModule } from "src/interfaces/IStakingModule.sol"; import { IBondCurve } from "src/interfaces/IBondCurve.sol"; import { Accounting } from "src/Accounting.sol"; +import { MAX_BP } from "src/lib/Constants.sol"; import { Stub } from "../../helpers/mocks/Stub.sol"; import { LidoMock } from "../../helpers/mocks/LidoMock.sol"; @@ -170,6 +171,7 @@ contract BaseTest is AccountingFixtures { accounting.grantRole(accounting.RESUME_ROLE(), admin); accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), admin); accounting.grantRole(accounting.SET_BOND_CURVE_ROLE(), admin); + accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), admin); vm.stopPrank(); } @@ -205,6 +207,10 @@ abstract contract BondAmountModifiersTest { // 2 keys -> 3 ether + 1 ether // n keys -> 2 + (n - 1) * 1 ether + 1 ether function test_WithCurveAndLocked() public virtual; + + // bond curve scaled by an effective multiplier (e.g. 1.5x): + // n keys -> (2 + (n - 1) * 2) ether * multiplier / MAX_BP + function test_WithMultiplier() public virtual; } abstract contract BondStateBaseTest is BondAmountModifiersTest, BaseTest { @@ -242,6 +248,13 @@ abstract contract BondStateBaseTest is BondAmountModifiersTest, BaseTest { accounting.penalize(0, bondBefore + amount); } + // @dev Sets the operator's bond curve multiplier. `multiplier` is the effective value in + // basis points (>= MAX_BP); stored as the increment above MAX_BP. + function _multiplier(uint256 multiplier) internal virtual { + vm.prank(admin); + accounting.setBondCurveMultiplier(0, multiplier - MAX_BP); + } + function test_WithOneWithdrawnValidator() public virtual; function test_WithBond() public virtual; diff --git a/test/unit/MetaRegistry.t.sol b/test/unit/MetaRegistry.t.sol index 94e7d6e3..5b39af9c 100644 --- a/test/unit/MetaRegistry.t.sol +++ b/test/unit/MetaRegistry.t.sol @@ -17,13 +17,15 @@ import { IStakingRouter } from "src/interfaces/IStakingRouter.sol"; import { ExternalOperatorLib } from "src/lib/ExternalOperatorLib.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; +import { AccountingMock } from "../helpers/mocks/AccountingMock.sol"; +import { TiersRegistryMock } from "../helpers/mocks/TiersRegistryMock.sol"; import { NodeOperatorsRegistryMock } from "../helpers/mocks/NodeOperatorsRegistryMock.sol"; import { StakingRouterMock } from "../helpers/mocks/StakingRouterMock.sol"; import { Utilities } from "../helpers/Utilities.sol"; import { Fixtures } from "../helpers/Fixtures.sol"; contract MetaRegistryForTest is MetaRegistry { - constructor(address module) MetaRegistry(module) {} + constructor(address module, address tiersRegistry) MetaRegistry(module, tiersRegistry) {} function mock_setModuleAddressInCache(uint256 moduleId, address moduleAddress) external { _storage().moduleAddressCache[moduleId] = moduleAddress; @@ -38,6 +40,7 @@ contract MetaRegistryBaseTest is Test, Utilities, Fixtures { CuratedMock public module; StakingRouterMock public stakingRouter; MetaRegistryForTest public registry; + TiersRegistryMock public tiersRegistry; address public admin; address public metadataAdmin; @@ -76,7 +79,9 @@ contract MetaRegistryBaseTest is Test, Utilities, Fixtures { modules[0] = address(module); stakingRouter.setModules(modules); - registry = new MetaRegistryForTest(address(module)); + tiersRegistry = new TiersRegistryMock(); + + registry = new MetaRegistryForTest(address(module), address(tiersRegistry)); _enableInitializers(address(registry)); registry.initialize(admin); @@ -229,15 +234,16 @@ contract MetaRegistryGroupsBaseTest is MetaRegistryBaseTest { contract MetaRegistryConstructorTest is MetaRegistryBaseTest { function test_constructor_SetsImmutables() public { - MetaRegistry r = new MetaRegistry(address(module)); + MetaRegistry r = new MetaRegistry(address(module), address(tiersRegistry)); assertEq(address(r.STAKING_ROUTER()), address(stakingRouter)); assertEq(address(r.MODULE()), address(module)); assertEq(address(r.ACCOUNTING()), address(module.ACCOUNTING())); + assertEq(address(r.TIERS_REGISTRY()), address(tiersRegistry)); } function test_constructor_RevertWhen_ZeroModule() public { vm.expectRevert(IMetaRegistry.ZeroModuleAddress.selector); - new MetaRegistry(address(0)); + new MetaRegistry(address(0), address(tiersRegistry)); } } @@ -247,14 +253,14 @@ contract MetaRegistryInitializeTest is MetaRegistryBaseTest { } function test_initialize_SetsAdmin() public { - MetaRegistry r = new MetaRegistry(address(module)); + MetaRegistry r = new MetaRegistry(address(module), address(tiersRegistry)); _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)); + MetaRegistry r = new MetaRegistry(address(module), address(tiersRegistry)); _enableInitializers(address(r)); r.initialize(admin); @@ -267,14 +273,14 @@ contract MetaRegistryInitializeTest is MetaRegistryBaseTest { } function test_initialize_RevertWhen_ZeroAdmin() public { - MetaRegistry r = new MetaRegistry(address(module)); + MetaRegistry r = new MetaRegistry(address(module), address(tiersRegistry)); _enableInitializers(address(r)); vm.expectRevert(IMetaRegistry.ZeroAdminAddress.selector); r.initialize(address(0)); } function test_initialize_RevertWhen_DoubleCall() public { - MetaRegistry r = new MetaRegistry(address(module)); + MetaRegistry r = new MetaRegistry(address(module), address(tiersRegistry)); _enableInitializers(address(r)); r.initialize(admin); vm.expectRevert(Initializable.InvalidInitialization.selector); @@ -1199,6 +1205,35 @@ contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { (uint256 w1After, ) = registry.getNodeOperatorWeightAndExternalStake(1); assertEq(w1After, 4000); } + + function test_refreshOperatorWeight_AppliesTierWeightMultiplier() public { + uint64 noId = 0; + + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(noId, MAX_BP), _extOperatorsArr0()); + + _setBondCurveWeight(0, CURVE_WEIGHT); + tiersRegistry.mock_setWeightMultiplierInc(noId, 5_000); + registry.refreshOperatorWeight(noId); + + (uint256 weight, ) = registry.getNodeOperatorWeightAndExternalStake(noId); + assertEq(weight, 15_000); // 10000 * 15000 / 10000 + } + + function test_refreshOperatorWeight_TierWeightMultiplierScalesAfterShare() public { + IMetaRegistry.SubNodeOperator memory op0 = IMetaRegistry.SubNodeOperator({ nodeOperatorId: 0, share: 6000 }); + IMetaRegistry.SubNodeOperator memory op1 = IMetaRegistry.SubNodeOperator({ nodeOperatorId: 1, share: 4000 }); + + vm.prank(groupManager); + _createGroup(_subOperatorsArr2(op0, op1), _extOperatorsArr0()); + + _setBondCurveWeight(0, CURVE_WEIGHT); + tiersRegistry.mock_setWeightMultiplierInc(0, 5_000); + registry.refreshOperatorWeight(0); + + (uint256 weight, ) = registry.getNodeOperatorWeightAndExternalStake(0); + assertEq(weight, 9000); // weighted=6000 (share), 6000 * 15000 / 10000 = 9000 + } } contract MetaRegistryModuleAddressCacheTest is MetaRegistryGroupsBaseTest { diff --git a/test/unit/TiersRegistry.t.sol b/test/unit/TiersRegistry.t.sol new file mode 100644 index 00000000..cf52132f --- /dev/null +++ b/test/unit/TiersRegistry.t.sol @@ -0,0 +1,463 @@ +// 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 { TiersRegistry } from "src/TiersRegistry.sol"; +import { ITiersRegistry, TierInfo, OperatorTierState } from "src/interfaces/ITiersRegistry.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 TiersRegistryBaseTest is Test, Utilities, Fixtures { + CuratedMock public module; + TiersRegistry public tiersRegistry; + MetaRegistryMock public metaRegistryMock; + AccountingMock internal acct; + + address public admin; + address public nodeOperatorOwner; + address public stranger; + + uint16 internal constant MAX_BP = 10_000; + uint256 internal constant CURVE_MULTIPLIER_COOLDOWN = 7 days; + + 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)); + + tiersRegistry = new TiersRegistry({ + module: address(module), + curveMultiplierCooldown: CURVE_MULTIPLIER_COOLDOWN + }); + _enableInitializers(address(tiersRegistry)); + tiersRegistry.initialize(admin); + + acct = AccountingMock(address(module.ACCOUNTING())); + } +} + +contract TiersRegistryConstructorTest is TiersRegistryBaseTest { + function test_constructor_SetsImmutables() public view { + assertEq(address(tiersRegistry.MODULE()), address(module)); + assertEq(address(tiersRegistry.ACCOUNTING()), address(module.ACCOUNTING())); + assertEq(address(tiersRegistry.META_REGISTRY()), address(metaRegistryMock)); + assertEq(tiersRegistry.MAX_CURVE_MULTIPLIER_INC(), 100_000); + assertEq(tiersRegistry.MAX_WEIGHT_MULTIPLIER_INC(), 100_000); + assertEq(tiersRegistry.CURVE_MULTIPLIER_COOLDOWN(), CURVE_MULTIPLIER_COOLDOWN); + } +} + +contract TiersRegistryInitializeTest is TiersRegistryBaseTest { + function test_initialize_SetsAdmin() public view { + assertTrue(tiersRegistry.hasRole(tiersRegistry.DEFAULT_ADMIN_ROLE(), admin)); + } + + function test_initialize_RevertWhen_ZeroAdmin() public { + TiersRegistry tp = new TiersRegistry(address(module), CURVE_MULTIPLIER_COOLDOWN); + _enableInitializers(address(tp)); + vm.expectRevert(ITiersRegistry.ZeroAdminAddress.selector); + tp.initialize(address(0)); + } + + function test_initialize_RevertWhen_DoubleCall() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + tiersRegistry.initialize(admin); + } +} + +contract TiersRegistryAddTierTest is TiersRegistryBaseTest { + address internal tiersManager; + + function setUp() public virtual override { + super.setUp(); + tiersManager = nextAddress("TIERS_MANAGER"); + bytes32 role = tiersRegistry.MANAGE_TIERS_ROLE(); + vm.prank(admin); + tiersRegistry.grantRole(role, tiersManager); + } + + function _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { + vm.prank(tiersManager); + tierId = tiersRegistry.addTier(bond, weight); + } + + 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 test_addTier() public { + vm.expectEmit(true, false, false, true, address(tiersRegistry)); + emit ITiersRegistry.TierAdded(1, T1_BOND, T1_WEIGHT); + uint256 tierId = _addTier(T1_BOND, T1_WEIGHT); + + assertEq(tierId, 1); + assertEq(tiersRegistry.getTiersCount(), 1); + TierInfo memory t = tiersRegistry.getTierInfo(1); + assertEq(t.curveMultiplierInc, T1_BOND); + assertEq(t.weightMultiplierInc, T1_WEIGHT); + } + + function test_addTier_SecondTier() public { + _addTier(T1_BOND, T1_WEIGHT); + uint256 tierId = _addTier(T2_BOND, T2_WEIGHT); + assertEq(tierId, 2); + assertEq(tiersRegistry.getTiersCount(), 2); + } + + function test_addTier_AllowsZeroCurveMultiplierInc() public { + uint256 tierId = _addTier(0, T1_WEIGHT); + TierInfo memory t = tiersRegistry.getTierInfo(tierId); + assertEq(t.curveMultiplierInc, 0); + assertEq(t.weightMultiplierInc, T1_WEIGHT); + } + + function test_addTier_AllowsZeroWeightMultiplierInc() public { + uint256 tierId = _addTier(T1_BOND, 0); + TierInfo memory t = tiersRegistry.getTierInfo(tierId); + assertEq(t.curveMultiplierInc, T1_BOND); + assertEq(t.weightMultiplierInc, 0); + } + + function test_addTier_AllowsMaxIncrement() public { + uint256 maxCurve = tiersRegistry.MAX_CURVE_MULTIPLIER_INC(); + uint256 maxWeight = tiersRegistry.MAX_WEIGHT_MULTIPLIER_INC(); + uint256 tierId = _addTier(maxCurve, maxWeight); + TierInfo memory t = tiersRegistry.getTierInfo(tierId); + assertEq(t.curveMultiplierInc, maxCurve); + assertEq(t.weightMultiplierInc, maxWeight); + } + + function test_addTier_RevertWhen_NotManager() public { + vm.expectRevert(); + vm.prank(stranger); + tiersRegistry.addTier(T1_BOND, T1_WEIGHT); + } + + function test_addTier_RevertWhen_BondMulAboveMax() public { + uint256 aboveMax = tiersRegistry.MAX_CURVE_MULTIPLIER_INC() + 1; + vm.expectRevert(ITiersRegistry.InvalidCurveMultiplier.selector); + _addTier(aboveMax, T1_WEIGHT); + } + + function test_addTier_RevertWhen_WeightMulAboveMax() public { + uint256 aboveMax = tiersRegistry.MAX_WEIGHT_MULTIPLIER_INC() + 1; + vm.expectRevert(ITiersRegistry.InvalidWeightMultiplier.selector); + _addTier(T1_BOND, aboveMax); + } +} + +contract TiersRegistrySelectTierBaseTest is TiersRegistryBaseTest { + address internal tiersManager; + + 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 setUp() public virtual override { + super.setUp(); + tiersManager = nextAddress("TIERS_MANAGER"); + + bytes32 role = tiersRegistry.MANAGE_TIERS_ROLE(); + vm.prank(admin); + tiersRegistry.grantRole(role, tiersManager); + } + + function _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { + vm.prank(tiersManager); + tierId = tiersRegistry.addTier(bond, weight); + } +} + +contract TiersRegistrySelectTierTest is TiersRegistrySelectTierBaseTest { + function setUp() public override { + super.setUp(); + _addTier(T1_BOND, T1_WEIGHT); + _addTier(T2_BOND, T2_WEIGHT); + } + + function test_selectTier_Upgrade_Tier0ToTier1() public { + vm.expectEmit(true, false, false, true, address(tiersRegistry)); + emit ITiersRegistry.TierSelected(0, 1); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + + assertEq(tiersRegistry.getOperatorTierState(0).tierId, 1); + assertEq(tiersRegistry.getOperatorTierState(0).weightMultiplier, MAX_BP + T1_WEIGHT); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); + assertEq(metaRegistryMock.refreshOperatorWeightCallCount(), 1); + assertEq(metaRegistryMock.lastRefreshedOperatorId(), 0); + } + + function test_selectTier_Upgrade_Tier1ToTier2() public { + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + + vm.expectEmit(true, false, false, true, address(tiersRegistry)); + emit ITiersRegistry.TierSelected(0, 2); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 2); + + OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + assertEq(s.tierId, 2); + assertEq(s.weightMultiplier, MAX_BP + T2_WEIGHT); + assertEq(s.curveMultiplier, MAX_BP + T2_BOND); + assertEq(s.curveMultiplierCooldownUntil, 0); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); + } + + function test_selectTier_Downgrade_Tier1ToTier0() public { + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + + uint256 expectedCooldown = block.timestamp + CURVE_MULTIPLIER_COOLDOWN; + vm.expectEmit(true, false, false, true, address(tiersRegistry)); + emit ITiersRegistry.CurveMultiplierCooldownSet(0, expectedCooldown); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 0); + + OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + assertEq(s.tierId, 0); + assertEq(s.weightMultiplier, MAX_BP); + assertEq(s.curveMultiplierCooldownUntil, expectedCooldown); + // Tier 0 keeps the pre-downgrade multiplier until release, so it stays above MAX_BP. + assertEq(s.curveMultiplier, MAX_BP + T1_BOND); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); + assertEq(metaRegistryMock.refreshOperatorWeightCallCount(), 2); + } + + function test_selectTier_Upgrade_ClearsCooldownIfActive() public { + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 0); + assertGt(tiersRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + + vm.prank(nodeOperatorOwner); + vm.expectEmit(true, false, false, false, address(tiersRegistry)); + emit ITiersRegistry.CurveMultiplierCooldownRemoved(0); + tiersRegistry.selectTier(0, 2); + + assertEq(tiersRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); + } + + function test_selectTier_RevertWhen_NotOwner() public { + vm.expectRevert(ITiersRegistry.SenderIsNotOperatorOwner.selector); + vm.prank(stranger); + tiersRegistry.selectTier(0, 1); + } + + function test_selectTier_RevertWhen_InvalidTierId() public { + vm.expectRevert(ITiersRegistry.InvalidTierId.selector); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 99); + } + + function test_selectTier_RevertWhen_SameTier() public { + vm.expectRevert(ITiersRegistry.SameTier.selector); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 0); + } + + function test_selectTier_Upgrade_SucceedsWhenBondCoversScaledRequirement() public { + uint256 baseRequired = 10 ether; + acct.mock_setRequiredBond(0, baseRequired); + uint256 scaledRequired = (baseRequired * (MAX_BP + T1_BOND)) / MAX_BP; + vm.deal(address(this), scaledRequired); + acct.depositETH{ value: scaledRequired }(0); + + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + + assertEq(tiersRegistry.getOperatorTierState(0).tierId, 1); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); + } + + function test_selectTier_RevertWhen_BondCoversBaseButNotScaledRequirement() public { + uint256 baseRequired = 10 ether; + acct.mock_setRequiredBond(0, baseRequired); + uint256 scaledRequired = (baseRequired * (MAX_BP + T1_BOND)) / MAX_BP; + // 1 wei short of the scaled requirement (would suffice at MAX_BP). + vm.deal(address(this), scaledRequired - 1); + acct.depositETH{ value: scaledRequired - 1 }(0); + + vm.expectRevert(ITiersRegistry.InsufficientBondForTier.selector); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + } + + function test_selectTier_RevertWhen_CurveMultiplierCooldownActive() public { + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 2); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + + vm.expectRevert(ITiersRegistry.CurveMultiplierCooldownActive.selector); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 0); + } + + function test_selectTier_RevertWhen_DowngradeReducesViaIntermediateTier() public { + uint256 t3Bond = 7_000; // between tier 1 (5_000) and the held tier 2 (10_000) + _addTier(t3Bond, T1_WEIGHT); + + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 2); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + + // Tier 3 reads as an upgrade vs tier 1's nominal value but is still below the held tier-2 value, + // so the cooldown must block it — otherwise the operator sheds bond before the cooldown elapses. + vm.expectRevert(ITiersRegistry.CurveMultiplierCooldownActive.selector); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 3); + + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); + } +} + +contract TiersRegistryReleaseCurveMultiplierTest is TiersRegistrySelectTierBaseTest { + function setUp() public override { + super.setUp(); + _addTier(T1_BOND, T1_WEIGHT); + _addTier(T2_BOND, T2_WEIGHT); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 0); + } + + function test_releaseCurveMultiplier() public { + vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + + vm.expectEmit(true, false, false, false, address(tiersRegistry)); + emit ITiersRegistry.CurveMultiplierCooldownRemoved(0); + vm.prank(nodeOperatorOwner); + tiersRegistry.releaseCurveMultiplier(0); + + assertEq(tiersRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP); + } + + function test_releaseCurveMultiplier_SettlesToCurrentTierNotDefault() public { + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 2); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); + + vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + vm.prank(nodeOperatorOwner); + tiersRegistry.releaseCurveMultiplier(0); + + assertEq(tiersRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); + } + + function test_releaseCurveMultiplier_RevertWhen_NotOwner() public { + vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + vm.expectRevert(ITiersRegistry.SenderIsNotOperatorOwner.selector); + vm.prank(stranger); + tiersRegistry.releaseCurveMultiplier(0); + } + + function test_releaseCurveMultiplier_RevertWhen_NoCurveMultiplierCooldown() public { + vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + vm.prank(nodeOperatorOwner); + tiersRegistry.releaseCurveMultiplier(0); + vm.expectRevert(ITiersRegistry.NoCurveMultiplierCooldown.selector); + vm.prank(nodeOperatorOwner); + tiersRegistry.releaseCurveMultiplier(0); + } + + function test_releaseCurveMultiplier_RevertWhen_CurveMultiplierCooldownNotElapsed() public { + vm.expectRevert(ITiersRegistry.CurveMultiplierCooldownNotElapsed.selector); + vm.prank(nodeOperatorOwner); + tiersRegistry.releaseCurveMultiplier(0); + } +} + +contract TiersRegistryViewsTest is TiersRegistrySelectTierBaseTest { + function setUp() public override { + super.setUp(); + _addTier(T1_BOND, T1_WEIGHT); + _addTier(T2_BOND, T2_WEIGHT); + } + + function test_getTiersCount() public view { + assertEq(tiersRegistry.getTiersCount(), 2); + } + + function test_getTierInfo_Tier0() public view { + TierInfo memory t = tiersRegistry.getTierInfo(0); + assertEq(t.curveMultiplierInc, 0); + assertEq(t.weightMultiplierInc, 0); + } + + function test_getTierInfo_Tier1() public view { + TierInfo memory t = tiersRegistry.getTierInfo(1); + assertEq(t.curveMultiplierInc, T1_BOND); + assertEq(t.weightMultiplierInc, T1_WEIGHT); + } + + function test_getTierInfo_RevertWhen_InvalidTierId() public { + vm.expectRevert(ITiersRegistry.InvalidTierId.selector); + tiersRegistry.getTierInfo(99); + } + + function test_getOperatorTierState_Default() public view { + OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + assertEq(s.tierId, 0); + assertEq(s.weightMultiplier, MAX_BP); + assertEq(s.curveMultiplier, MAX_BP); + assertEq(s.curveMultiplierCooldownUntil, 0); + } + + function test_getOperatorTierState_AfterUpgrade() public { + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + assertEq(s.tierId, 1); + assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); + assertEq(s.curveMultiplier, MAX_BP + T1_BOND); + assertEq(s.curveMultiplierCooldownUntil, 0); + } + + function test_getOperatorTierState_AfterDowngrade_CooldownMultiplierDivergesFromTier() public { + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 2); + vm.prank(nodeOperatorOwner); + tiersRegistry.selectTier(0, 1); + + OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + assertEq(s.tierId, 1); + assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); + assertEq(s.curveMultiplier, MAX_BP + T2_BOND); + assertEq(s.curveMultiplierCooldownUntil, block.timestamp + CURVE_MULTIPLIER_COOLDOWN); + } +} diff --git a/test/unit/abstract/BondCurve.t.sol b/test/unit/abstract/BondCurve.t.sol index 5f477d4f..7a5f1edc 100644 --- a/test/unit/abstract/BondCurve.t.sol +++ b/test/unit/abstract/BondCurve.t.sol @@ -535,12 +535,85 @@ contract BondCurveTest is Test { } } +contract BondCurveScaledTest is BondCurveTest { + uint256 internal constant MAX_BP = 10_000; + uint256 internal constant MUL_1_5X = 15_000; + + function test_getBondAmountByKeysCount_withMultiplier_IdentityAtMaxBP() public view { + assertEq(bondCurve.getBondAmountByKeysCount(0, 0, MAX_BP), bondCurve.getBondAmountByKeysCount(0, 0)); + assertEq(bondCurve.getBondAmountByKeysCount(1, 0, MAX_BP), bondCurve.getBondAmountByKeysCount(1, 0)); + assertEq(bondCurve.getBondAmountByKeysCount(2, 0, MAX_BP), bondCurve.getBondAmountByKeysCount(2, 0)); + assertEq(bondCurve.getBondAmountByKeysCount(3, 0, MAX_BP), bondCurve.getBondAmountByKeysCount(3, 0)); + assertEq(bondCurve.getBondAmountByKeysCount(4, 0, MAX_BP), bondCurve.getBondAmountByKeysCount(4, 0)); + } + + function test_getBondAmountByKeysCount_withMultiplier() public view { + assertEq(bondCurve.getBondAmountByKeysCount(0, 0, MUL_1_5X), 0); + assertEq(bondCurve.getBondAmountByKeysCount(1, 0, MUL_1_5X), 3 ether); + assertEq(bondCurve.getBondAmountByKeysCount(2, 0, MUL_1_5X), 6 ether); + assertEq(bondCurve.getBondAmountByKeysCount(3, 0, MUL_1_5X), 7.5 ether); + assertEq(bondCurve.getBondAmountByKeysCount(4, 0, MUL_1_5X), 9 ether); + } + + function test_getKeysCountByBondAmount_withMultiplier_IdentityAtMaxBP() public view { + assertEq(bondCurve.getKeysCountByBondAmount(0, 0, MAX_BP), bondCurve.getKeysCountByBondAmount(0, 0)); + assertEq( + bondCurve.getKeysCountByBondAmount(2 ether, 0, MAX_BP), + bondCurve.getKeysCountByBondAmount(2 ether, 0) + ); + assertEq( + bondCurve.getKeysCountByBondAmount(4 ether, 0, MAX_BP), + bondCurve.getKeysCountByBondAmount(4 ether, 0) + ); + assertEq( + bondCurve.getKeysCountByBondAmount(5 ether, 0, MAX_BP), + bondCurve.getKeysCountByBondAmount(5 ether, 0) + ); + assertEq( + bondCurve.getKeysCountByBondAmount(6 ether, 0, MAX_BP), + bondCurve.getKeysCountByBondAmount(6 ether, 0) + ); + } + + function test_getKeysCountByBondAmount_withMultiplier() public view { + assertEq(bondCurve.getKeysCountByBondAmount(0, 0, MUL_1_5X), 0); + assertEq(bondCurve.getKeysCountByBondAmount(2.9 ether, 0, MUL_1_5X), 0); + assertEq(bondCurve.getKeysCountByBondAmount(3 ether, 0, MUL_1_5X), 1); + assertEq(bondCurve.getKeysCountByBondAmount(6 ether, 0, MUL_1_5X), 2); + assertEq(bondCurve.getKeysCountByBondAmount(7.5 ether, 0, MUL_1_5X), 3); + assertEq(bondCurve.getKeysCountByBondAmount(9 ether, 0, MUL_1_5X), 4); + } + + function test_viceVersa_withMultiplier() public view { + for (uint256 k = 0; k < 100; ++k) { + uint256 bond = bondCurve.getBondAmountByKeysCount(k, 0, MUL_1_5X); + assertEq(bondCurve.getKeysCountByBondAmount(bond, 0, MUL_1_5X), k); + } + for (uint256 bond = 0; bond < 33 ether; bond += 1.5 ether) { + uint256 keys = bondCurve.getKeysCountByBondAmount(bond, 0, MUL_1_5X); + assertGe(bond, bondCurve.getBondAmountByKeysCount(keys, 0, MUL_1_5X)); + } + } + + function test_getBondAmountByKeysCount_RevertWhen_MultiplierBelowMaxBP() public { + vm.expectRevert(IBondCurve.InvalidMultiplier.selector); + bondCurve.getBondAmountByKeysCount(1, 0, MAX_BP - 1); + } + + function test_getKeysCountByBondAmount_RevertWhen_MultiplierBelowMaxBP() public { + vm.expectRevert(IBondCurve.InvalidMultiplier.selector); + bondCurve.getKeysCountByBondAmount(2 ether, 0, MAX_BP - 1); + } +} + contract BondCurveFuzz is Test { BondCurveTestable public bondCurve; uint256 public constant MAX_BOND_CURVE_INTERVALS_COUNT = 100; uint256 public constant MAX_FROM_KEYS_COUNT_VALUE = 10000; uint256 public constant MAX_TREND_VALUE = 1000 ether; + uint256 public constant MAX_BP = 10_000; + uint256 public constant MAX_MULTIPLIER = 100_000; function testFuzz_keysAndBondValues( uint256[] memory minKeysCount, @@ -690,4 +763,43 @@ contract BondCurveFuzz is Test { bondToCheck = bound(bondToCheck, trend[0], type(uint256).max); return (_bondCurve, keysToCheck, bondToCheck); } + + function testFuzz_keysAndBondValues_withMultiplier( + uint256[] memory minKeysCount, + uint256[] memory trend, + uint256 keysToCheck, + uint256 bondToCheck, + uint256 multiplier + ) public { + uint256[2][] memory _bondCurve; + (_bondCurve, keysToCheck, bondToCheck) = prepareInputs(minKeysCount, trend, keysToCheck, bondToCheck); + // Bound multiplier to [MAX_BP, MAX_MULTIPLIER] — ensures sTrend >= 1 for any valid trend >= 1 wei. + multiplier = bound(multiplier, MAX_BP, MAX_MULTIPLIER); + + bondCurve = new BondCurveTestable(); + IBondCurve.BondCurveIntervalInput[] memory bondCurveInput = new IBondCurve.BondCurveIntervalInput[]( + _bondCurve.length + ); + for (uint256 i = 0; i < _bondCurve.length; ++i) { + bondCurveInput[i] = IBondCurve.BondCurveIntervalInput(_bondCurve[i][0], _bondCurve[i][1]); + } + bondCurve.initialize(bondCurveInput); + + uint256 bondOut = bondCurve.getBondAmountByKeysCount(keysToCheck, 0, multiplier); + assertEq(bondCurve.getKeysCountByBondAmount(bondOut, 0, multiplier), keysToCheck); + + uint256 keysOut = bondCurve.getKeysCountByBondAmount(bondToCheck, 0, multiplier); + assertGe(bondToCheck, bondCurve.getBondAmountByKeysCount(keysOut, 0, multiplier)); + + assertEq( + bondCurve.getBondAmountByKeysCount(keysToCheck, 0, MAX_BP), + bondCurve.getBondAmountByKeysCount(keysToCheck, 0), + "3-arg with MAX_BP must equal 2-arg" + ); + assertEq( + bondCurve.getKeysCountByBondAmount(bondToCheck, 0, MAX_BP), + bondCurve.getKeysCountByBondAmount(bondToCheck, 0), + "3-arg with MAX_BP must equal 2-arg" + ); + } } From 3fb868776d130a7aa8704e9f76839d9410c703b8 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Thu, 4 Jun 2026 17:30:57 +0200 Subject: [PATCH 02/17] fix: use `_scaleCurve` --- src/lib/BondCurvesLib.sol | 59 +++++++++++++--------- test/unit/abstract/BondCurve.t.sol | 78 +++++++++++++++--------------- 2 files changed, 76 insertions(+), 61 deletions(-) diff --git a/src/lib/BondCurvesLib.sol b/src/lib/BondCurvesLib.sol index 7cf94fb6..e63704c2 100644 --- a/src/lib/BondCurvesLib.sol +++ b/src/lib/BondCurvesLib.sol @@ -43,7 +43,10 @@ library BondCurvesLib { ) external view returns (uint256) { if (multiplier < MAX_BP) revert IBondCurve.InvalidMultiplier(); _ensureCurveExists(bondCurveStorage, curveId); - IBondCurve.BondCurveInterval[] storage intervals = bondCurveStorage.bondCurves[curveId].intervals; + IBondCurve.BondCurveInterval[] memory intervals = _scaleCurve( + bondCurveStorage.bondCurves[curveId].intervals, + multiplier + ); if (keys == 0) return 0; unchecked { @@ -57,10 +60,7 @@ library BondCurvesLib { low = mid; } } - IBondCurve.BondCurveInterval storage interval = intervals[low]; - uint256 sMinBond = _scaled(interval.minBond, multiplier); - uint256 sTrend = _scaled(interval.trend, multiplier); - return sMinBond + (keys - interval.minKeysCount) * sTrend; + return intervals[low].minBond + (keys - intervals[low].minKeysCount) * intervals[low].trend; } } @@ -72,16 +72,19 @@ library BondCurvesLib { ) external view returns (uint256) { if (multiplier < MAX_BP) revert IBondCurve.InvalidMultiplier(); _ensureCurveExists(bondCurveStorage, curveId); - IBondCurve.BondCurveInterval[] storage intervals = bondCurveStorage.bondCurves[curveId].intervals; + IBondCurve.BondCurveInterval[] memory intervals = _scaleCurve( + bondCurveStorage.bondCurves[curveId].intervals, + multiplier + ); unchecked { - if (amount < _scaled(intervals[0].minBond, multiplier)) return 0; + if (amount < intervals[0].minBond) return 0; uint256 low = 0; uint256 high = intervals.length - 1; while (low < high) { uint256 mid = (low + high + 1) / 2; - if (amount < _scaled(intervals[mid].minBond, multiplier)) { + if (amount < intervals[mid].minBond) { high = mid - 1; } else { low = mid; @@ -97,16 +100,10 @@ library BondCurvesLib { // So we need a special check for bond amounts between Interval 0 maxBond and Interval 1 minBond. // if (low < intervals.length - 1) { - IBondCurve.BondCurveInterval storage next = intervals[low + 1]; - uint256 sNextMinBond = _scaled(next.minBond, multiplier); - uint256 sNextTrend = _scaled(next.trend, multiplier); - if (amount > sNextMinBond - sNextTrend) return next.minKeysCount - 1; + if (amount > intervals[low + 1].minBond - intervals[low + 1].trend) + return intervals[low + 1].minKeysCount - 1; } - - IBondCurve.BondCurveInterval storage interval = intervals[low]; - uint256 sMinBond = _scaled(interval.minBond, multiplier); - uint256 sTrend = _scaled(interval.trend, multiplier); - return interval.minKeysCount + (amount - sMinBond) / sTrend; + return intervals[low].minKeysCount + (amount - intervals[low].minBond) / intervals[low].trend; } } @@ -132,6 +129,29 @@ library BondCurvesLib { } } + function _scaleCurve( + IBondCurve.BondCurveInterval[] storage src, + uint256 multiplier + ) private view returns (IBondCurve.BondCurveInterval[] memory scaled) { + uint256 len = src.length; + scaled = new IBondCurve.BondCurveInterval[](len); + + uint256 sTrend = (src[0].trend * multiplier) / MAX_BP; + scaled[0].minKeysCount = src[0].minKeysCount; + scaled[0].trend = sTrend; + scaled[0].minBond = sTrend; + + for (uint256 i = 1; i < len; ++i) { + IBondCurve.BondCurveInterval memory prev = scaled[i - 1]; + uint256 currMinKeysCount = src[i].minKeysCount; + uint256 currTrend = (src[i].trend * multiplier) / MAX_BP; + + scaled[i].minKeysCount = currMinKeysCount; + scaled[i].trend = currTrend; + scaled[i].minBond = prev.minBond + currTrend + (currMinKeysCount - prev.minKeysCount - 1) * prev.trend; + } + } + function _ensureCurveExists(BondCurve.BondCurveStorage storage bondCurveStorage, uint256 curveId) internal view { unchecked { if (curveId > bondCurveStorage.bondCurves.length - 1) revert IBondCurve.InvalidBondCurveId(); @@ -154,9 +174,4 @@ library BondCurvesLib { } } } - - /// @dev Scales a curve value (minBond/trend) by the multiplier in basis points. - function _scaled(uint256 value, uint256 multiplier) private pure returns (uint256) { - return (value * multiplier) / MAX_BP; - } } diff --git a/test/unit/abstract/BondCurve.t.sol b/test/unit/abstract/BondCurve.t.sol index 7a5f1edc..778acaee 100644 --- a/test/unit/abstract/BondCurve.t.sol +++ b/test/unit/abstract/BondCurve.t.sol @@ -653,6 +653,45 @@ contract BondCurveFuzz is Test { assertEq(keysMinBondAmount, keysToCheck, "keysMinBondAmount != keysToCheck"); } + function testFuzz_onTheFlyMultiplierEqualsMultipliedCurve( + uint256[] memory minKeysCount, + uint256[] memory trend, + uint256 keysToCheck, + uint256 bondToCheck, + uint256 multiplier + ) public { + uint256[2][] memory _bondCurve; + (_bondCurve, keysToCheck, bondToCheck) = prepareInputs(minKeysCount, trend, keysToCheck, bondToCheck); + multiplier = bound(multiplier, MAX_BP, MAX_MULTIPLIER); + + bondCurve = new BondCurveTestable(); + + IBondCurve.BondCurveIntervalInput[] memory refInput = new IBondCurve.BondCurveIntervalInput[]( + _bondCurve.length + ); + for (uint256 i = 0; i < _bondCurve.length; ++i) { + refInput[i] = IBondCurve.BondCurveIntervalInput(_bondCurve[i][0], _bondCurve[i][1]); + } + bondCurve.initialize(refInput); + + IBondCurve.BondCurveIntervalInput[] memory mulInput = new IBondCurve.BondCurveIntervalInput[]( + _bondCurve.length + ); + for (uint256 i = 0; i < _bondCurve.length; ++i) { + mulInput[i] = IBondCurve.BondCurveIntervalInput(_bondCurve[i][0], (_bondCurve[i][1] * multiplier) / MAX_BP); + } + uint256 mulId = bondCurve.addBondCurve(mulInput); + + assertEq( + bondCurve.getBondAmountByKeysCount(keysToCheck, 0, multiplier), + bondCurve.getBondAmountByKeysCount(keysToCheck, mulId) + ); + assertEq( + bondCurve.getKeysCountByBondAmount(bondToCheck, 0, multiplier), + bondCurve.getKeysCountByBondAmount(bondToCheck, mulId) + ); + } + /// NOTE: Ugly, ineffective version of binary search algorithm from the contract. // Needed only as a second opinion to compare outputs. function getBondAmountByKeysCountSecondOpinion( @@ -763,43 +802,4 @@ contract BondCurveFuzz is Test { bondToCheck = bound(bondToCheck, trend[0], type(uint256).max); return (_bondCurve, keysToCheck, bondToCheck); } - - function testFuzz_keysAndBondValues_withMultiplier( - uint256[] memory minKeysCount, - uint256[] memory trend, - uint256 keysToCheck, - uint256 bondToCheck, - uint256 multiplier - ) public { - uint256[2][] memory _bondCurve; - (_bondCurve, keysToCheck, bondToCheck) = prepareInputs(minKeysCount, trend, keysToCheck, bondToCheck); - // Bound multiplier to [MAX_BP, MAX_MULTIPLIER] — ensures sTrend >= 1 for any valid trend >= 1 wei. - multiplier = bound(multiplier, MAX_BP, MAX_MULTIPLIER); - - bondCurve = new BondCurveTestable(); - IBondCurve.BondCurveIntervalInput[] memory bondCurveInput = new IBondCurve.BondCurveIntervalInput[]( - _bondCurve.length - ); - for (uint256 i = 0; i < _bondCurve.length; ++i) { - bondCurveInput[i] = IBondCurve.BondCurveIntervalInput(_bondCurve[i][0], _bondCurve[i][1]); - } - bondCurve.initialize(bondCurveInput); - - uint256 bondOut = bondCurve.getBondAmountByKeysCount(keysToCheck, 0, multiplier); - assertEq(bondCurve.getKeysCountByBondAmount(bondOut, 0, multiplier), keysToCheck); - - uint256 keysOut = bondCurve.getKeysCountByBondAmount(bondToCheck, 0, multiplier); - assertGe(bondToCheck, bondCurve.getBondAmountByKeysCount(keysOut, 0, multiplier)); - - assertEq( - bondCurve.getBondAmountByKeysCount(keysToCheck, 0, MAX_BP), - bondCurve.getBondAmountByKeysCount(keysToCheck, 0), - "3-arg with MAX_BP must equal 2-arg" - ); - assertEq( - bondCurve.getKeysCountByBondAmount(bondToCheck, 0, MAX_BP), - bondCurve.getKeysCountByBondAmount(bondToCheck, 0), - "3-arg with MAX_BP must equal 2-arg" - ); - } } From 89e9e6214240155912c14470206dfa6d6e0715ee Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Thu, 4 Jun 2026 17:48:31 +0200 Subject: [PATCH 03/17] fix: do not scale if no multiplier --- src/lib/BondCurvesLib.sol | 49 +++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/lib/BondCurvesLib.sol b/src/lib/BondCurvesLib.sol index e63704c2..73434141 100644 --- a/src/lib/BondCurvesLib.sol +++ b/src/lib/BondCurvesLib.sol @@ -43,11 +43,8 @@ library BondCurvesLib { ) external view returns (uint256) { if (multiplier < MAX_BP) revert IBondCurve.InvalidMultiplier(); _ensureCurveExists(bondCurveStorage, curveId); - IBondCurve.BondCurveInterval[] memory intervals = _scaleCurve( - bondCurveStorage.bondCurves[curveId].intervals, - multiplier - ); if (keys == 0) return 0; + IBondCurve.BondCurveInterval[] memory intervals = _loadCurve(bondCurveStorage, curveId, multiplier); unchecked { uint256 low = 0; @@ -60,7 +57,8 @@ library BondCurvesLib { low = mid; } } - return intervals[low].minBond + (keys - intervals[low].minKeysCount) * intervals[low].trend; + IBondCurve.BondCurveInterval memory interval = intervals[low]; + return interval.minBond + (keys - interval.minKeysCount) * interval.trend; } } @@ -72,12 +70,10 @@ library BondCurvesLib { ) external view returns (uint256) { if (multiplier < MAX_BP) revert IBondCurve.InvalidMultiplier(); _ensureCurveExists(bondCurveStorage, curveId); - IBondCurve.BondCurveInterval[] memory intervals = _scaleCurve( - bondCurveStorage.bondCurves[curveId].intervals, - multiplier - ); + IBondCurve.BondCurveInterval[] memory intervals = _loadCurve(bondCurveStorage, curveId, multiplier); unchecked { + // intervals[0].minBond is essentially the amount of bond required for the very first key if (amount < intervals[0].minBond) return 0; uint256 low = 0; @@ -91,6 +87,8 @@ library BondCurvesLib { } } + IBondCurve.BondCurveInterval memory interval; + // // Imagine we have: // Interval 0: minKeysCount = 1, minBond = 2 ETH, trend = 2 ETH @@ -100,10 +98,11 @@ library BondCurvesLib { // So we need a special check for bond amounts between Interval 0 maxBond and Interval 1 minBond. // if (low < intervals.length - 1) { - if (amount > intervals[low + 1].minBond - intervals[low + 1].trend) - return intervals[low + 1].minKeysCount - 1; + interval = intervals[low + 1]; + if (amount > interval.minBond - interval.trend) return interval.minKeysCount - 1; } - return intervals[low].minKeysCount + (amount - intervals[low].minBond) / intervals[low].trend; + interval = intervals[low]; + return interval.minKeysCount + (amount - interval.minBond) / interval.trend; } } @@ -129,26 +128,30 @@ library BondCurvesLib { } } - function _scaleCurve( - IBondCurve.BondCurveInterval[] storage src, + function _loadCurve( + BondCurve.BondCurveStorage storage bondCurveStorage, + uint256 curveId, uint256 multiplier - ) private view returns (IBondCurve.BondCurveInterval[] memory scaled) { + ) private view returns (IBondCurve.BondCurveInterval[] memory curve) { + IBondCurve.BondCurveInterval[] storage src = bondCurveStorage.bondCurves[curveId].intervals; + if (multiplier == MAX_BP) return src; + uint256 len = src.length; - scaled = new IBondCurve.BondCurveInterval[](len); + curve = new IBondCurve.BondCurveInterval[](len); uint256 sTrend = (src[0].trend * multiplier) / MAX_BP; - scaled[0].minKeysCount = src[0].minKeysCount; - scaled[0].trend = sTrend; - scaled[0].minBond = sTrend; + curve[0].minKeysCount = src[0].minKeysCount; + curve[0].trend = sTrend; + curve[0].minBond = sTrend; for (uint256 i = 1; i < len; ++i) { - IBondCurve.BondCurveInterval memory prev = scaled[i - 1]; + IBondCurve.BondCurveInterval memory prev = curve[i - 1]; uint256 currMinKeysCount = src[i].minKeysCount; uint256 currTrend = (src[i].trend * multiplier) / MAX_BP; - scaled[i].minKeysCount = currMinKeysCount; - scaled[i].trend = currTrend; - scaled[i].minBond = prev.minBond + currTrend + (currMinKeysCount - prev.minKeysCount - 1) * prev.trend; + curve[i].minKeysCount = currMinKeysCount; + curve[i].trend = currTrend; + curve[i].minBond = prev.minBond + currTrend + (currMinKeysCount - prev.minKeysCount - 1) * prev.trend; } } From 93059cfa623aa8d1f1d72f1ca09964c5a258efbd Mon Sep 17 00:00:00 2001 From: Dmitry Gusakov Date: Fri, 5 Jun 2026 11:30:35 +0200 Subject: [PATCH 04/17] chore: Improve Fuzzing --- test/unit/abstract/BondCurve.t.sol | 110 +++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 21 deletions(-) diff --git a/test/unit/abstract/BondCurve.t.sol b/test/unit/abstract/BondCurve.t.sol index 778acaee..38526b0d 100644 --- a/test/unit/abstract/BondCurve.t.sol +++ b/test/unit/abstract/BondCurve.t.sol @@ -613,16 +613,17 @@ contract BondCurveFuzz is Test { uint256 public constant MAX_FROM_KEYS_COUNT_VALUE = 10000; uint256 public constant MAX_TREND_VALUE = 1000 ether; uint256 public constant MAX_BP = 10_000; - uint256 public constant MAX_MULTIPLIER = 100_000; + uint256 public constant MAX_MULTIPLIER = 100_000_000; function testFuzz_keysAndBondValues( uint256[] memory minKeysCount, uint256[] memory trend, uint256 keysToCheck, - uint256 bondToCheck + uint256 bondToCheck, + uint256 offset ) public { uint256[2][] memory _bondCurve; - (_bondCurve, keysToCheck, bondToCheck) = prepareInputs(minKeysCount, trend, keysToCheck, bondToCheck); + (_bondCurve, keysToCheck, bondToCheck) = prepareInputs(minKeysCount, trend, keysToCheck, bondToCheck, offset); bondCurve = new BondCurveTestable(); IBondCurve.BondCurveIntervalInput[] memory bondCurveInput = new IBondCurve.BondCurveIntervalInput[]( _bondCurve.length @@ -658,11 +659,12 @@ contract BondCurveFuzz is Test { uint256[] memory trend, uint256 keysToCheck, uint256 bondToCheck, - uint256 multiplier + uint256 multiplier, + uint256 offset ) public { + vm.assume(multiplier >= MAX_BP && multiplier <= MAX_MULTIPLIER); uint256[2][] memory _bondCurve; - (_bondCurve, keysToCheck, bondToCheck) = prepareInputs(minKeysCount, trend, keysToCheck, bondToCheck); - multiplier = bound(multiplier, MAX_BP, MAX_MULTIPLIER); + (_bondCurve, keysToCheck, bondToCheck) = prepareInputs(minKeysCount, trend, keysToCheck, bondToCheck, offset); bondCurve = new BondCurveTestable(); @@ -692,6 +694,62 @@ contract BondCurveFuzz is Test { ); } + function testFuzz_onTheFlyMultiplierEqualsMultipliedCurve_withPresetCurve( + uint256 multiplier, + uint256 bondStep + ) public { + vm.assume(multiplier >= MAX_BP && multiplier <= MAX_MULTIPLIER); + vm.assume(bondStep > 1 ether && bondStep < 10 ether); + + uint256[2][] memory _bondCurve = new uint256[2][](5); + _bondCurve[0][0] = 1; + _bondCurve[0][1] = 1 ether; + + _bondCurve[1][0] = 10; + _bondCurve[1][1] = 0.5 ether; + + _bondCurve[2][0] = 35; + _bondCurve[2][1] = 3.2 ether; + + _bondCurve[3][0] = 50; + _bondCurve[3][1] = 0.001 ether; + + _bondCurve[4][0] = 100; + _bondCurve[4][1] = 10.1000000000001 ether; + + bondCurve = new BondCurveTestable(); + + IBondCurve.BondCurveIntervalInput[] memory refInput = new IBondCurve.BondCurveIntervalInput[]( + _bondCurve.length + ); + for (uint256 i = 0; i < _bondCurve.length; ++i) { + refInput[i] = IBondCurve.BondCurveIntervalInput(_bondCurve[i][0], _bondCurve[i][1]); + } + bondCurve.initialize(refInput); + + IBondCurve.BondCurveIntervalInput[] memory mulInput = new IBondCurve.BondCurveIntervalInput[]( + _bondCurve.length + ); + for (uint256 i = 0; i < _bondCurve.length; ++i) { + mulInput[i] = IBondCurve.BondCurveIntervalInput(_bondCurve[i][0], (_bondCurve[i][1] * multiplier) / MAX_BP); + } + uint256 mulId = bondCurve.addBondCurve(mulInput); + + for (uint256 keysToCheck = 0; keysToCheck < 150; keysToCheck++) { + assertEq( + bondCurve.getBondAmountByKeysCount(keysToCheck, 0, multiplier), + bondCurve.getBondAmountByKeysCount(keysToCheck, mulId) + ); + } + + for (uint256 bondToCheck = 0; bondToCheck < bondStep * 100; bondToCheck += bondStep) { + assertEq( + bondCurve.getKeysCountByBondAmount(bondToCheck, 0, multiplier), + bondCurve.getKeysCountByBondAmount(bondToCheck, mulId) + ); + } + } + /// NOTE: Ugly, ineffective version of binary search algorithm from the contract. // Needed only as a second opinion to compare outputs. function getBondAmountByKeysCountSecondOpinion( @@ -758,48 +816,58 @@ contract BondCurveFuzz is Test { uint256[] memory minKeysCount, uint256[] memory trend, uint256 keysToCheck, - uint256 bondToCheck + uint256 bondToCheck, + uint256 offset ) public pure returns (uint256[2][] memory, uint256, uint256) { vm.assume(minKeysCount.length > 0); + vm.assume(minKeysCount.length < MAX_BOND_CURVE_INTERVALS_COUNT); vm.assume(trend.length > 0); + vm.assume(trend.length < MAX_BOND_CURVE_INTERVALS_COUNT); + offset = bound(offset, 1, 10); // Assume: intervals.length > 0 - uint256 intervalsCount = Math.max( - 1, - Math.min(minKeysCount.length, trend.length) % MAX_BOND_CURVE_INTERVALS_COUNT - ); + uint256 intervalsCount = Math.min(minKeysCount.length, trend.length); + + assembly ("memory-safe") { + // Shrink `minKeysCount` and `trend` arrays to `intervalsCount` + mstore(minKeysCount, intervalsCount) + mstore(trend, intervalsCount) + } + + assertEq(minKeysCount.length, trend.length); + for (uint256 i = 0; i < intervalsCount; ++i) { // Assume: minKeysCount[i] > 0 minKeysCount[i] = Math.max(1, minKeysCount[i] % MAX_FROM_KEYS_COUNT_VALUE); // Assume: trend[i] > 0 trend[i] = Math.max(1 wei, trend[i] % MAX_TREND_VALUE); } - assembly ("memory-safe") { - // Shrink `minKeysCount` and `trend` arrays to `intervalsCount` - mstore(minKeysCount, intervalsCount) - mstore(trend, intervalsCount) - } // Assume: minKeysCount[i] < minKeysCount[i + 1] Arrays.sort(minKeysCount); + // Assume: first interval starts from "1" keys count + minKeysCount[0] = 1; for (uint256 j = 0; j < minKeysCount.length - 1; j++) { if (minKeysCount[j] >= minKeysCount[j + 1]) { // Make it different because we need to have unique values - minKeysCount[j + 1] = minKeysCount[j] + 1; + minKeysCount[j + 1] = minKeysCount[j] + offset; } } - // Assume: first interval starts from "1" keys count - minKeysCount[0] = 1; - assertEq(minKeysCount.length, trend.length); + // Assume: minKeysCount sorted and unique + for (uint256 j = 0; j < minKeysCount.length - 1; j++) { + assertLt(minKeysCount[j], minKeysCount[j + 1]); + } // Dev: zip `minKeysCount` and `trend` arrays to `uint256[2][] intervals` uint256[2][] memory _bondCurve = new uint256[2][](minKeysCount.length); for (uint256 i = 0; i < intervalsCount; ++i) { _bondCurve[i] = [minKeysCount[i], trend[i]]; } - keysToCheck = bound(keysToCheck, 1, MAX_FROM_KEYS_COUNT_VALUE); + + keysToCheck = bound(keysToCheck, 1, minKeysCount[intervalsCount - 1] + offset); bondToCheck = bound(bondToCheck, trend[0], type(uint256).max); + return (_bondCurve, keysToCheck, bondToCheck); } } From e680ae76b25405a13b91b5764afd15fa5d77ba45 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Mon, 8 Jun 2026 15:02:45 +0200 Subject: [PATCH 05/17] fix: remove `MANAGE_TIERS_ROLE` --- src/TiersRegistry.sol | 4 +--- src/interfaces/ITiersRegistry.sol | 3 --- test/unit/TiersRegistry.t.sol | 27 +++------------------------ 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/src/TiersRegistry.sol b/src/TiersRegistry.sol index f09d9c9d..b0f54b25 100644 --- a/src/TiersRegistry.sol +++ b/src/TiersRegistry.sol @@ -23,8 +23,6 @@ contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerable mapping(uint256 nodeOperatorId => uint256) curveMultiplierCooldownUntil; } - bytes32 public constant MANAGE_TIERS_ROLE = keccak256("MANAGE_TIERS_ROLE"); - uint256 public constant MAX_CURVE_MULTIPLIER_INC = 10 * MAX_BP; uint256 public constant MAX_WEIGHT_MULTIPLIER_INC = 10 * MAX_BP; @@ -59,7 +57,7 @@ contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerable function addTier( uint256 curveMultiplierInc, uint256 weightMultiplierInc - ) external onlyRole(MANAGE_TIERS_ROLE) returns (uint256 tierId) { + ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 tierId) { if (curveMultiplierInc > MAX_CURVE_MULTIPLIER_INC) revert InvalidCurveMultiplier(); if (weightMultiplierInc > MAX_WEIGHT_MULTIPLIER_INC) revert InvalidWeightMultiplier(); TiersRegistryStorage storage $ = _storage(); diff --git a/src/interfaces/ITiersRegistry.sol b/src/interfaces/ITiersRegistry.sol index c35d2b67..810dbb25 100644 --- a/src/interfaces/ITiersRegistry.sol +++ b/src/interfaces/ITiersRegistry.sol @@ -41,9 +41,6 @@ interface ITiersRegistry { error CurveMultiplierCooldownNotElapsed(); error CurveMultiplierCooldownActive(); - /// @notice Role required to add tiers. - function MANAGE_TIERS_ROLE() external view returns (bytes32); - /// @notice Curated module address. function MODULE() external view returns (ICuratedModule); diff --git a/test/unit/TiersRegistry.t.sol b/test/unit/TiersRegistry.t.sol index cf52132f..78e4f553 100644 --- a/test/unit/TiersRegistry.t.sol +++ b/test/unit/TiersRegistry.t.sol @@ -89,18 +89,8 @@ contract TiersRegistryInitializeTest is TiersRegistryBaseTest { } contract TiersRegistryAddTierTest is TiersRegistryBaseTest { - address internal tiersManager; - - function setUp() public virtual override { - super.setUp(); - tiersManager = nextAddress("TIERS_MANAGER"); - bytes32 role = tiersRegistry.MANAGE_TIERS_ROLE(); - vm.prank(admin); - tiersRegistry.grantRole(role, tiersManager); - } - function _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { - vm.prank(tiersManager); + vm.prank(admin); tierId = tiersRegistry.addTier(bond, weight); } @@ -151,7 +141,7 @@ contract TiersRegistryAddTierTest is TiersRegistryBaseTest { assertEq(t.weightMultiplierInc, maxWeight); } - function test_addTier_RevertWhen_NotManager() public { + function test_addTier_RevertWhen_NotAdmin() public { vm.expectRevert(); vm.prank(stranger); tiersRegistry.addTier(T1_BOND, T1_WEIGHT); @@ -171,24 +161,13 @@ contract TiersRegistryAddTierTest is TiersRegistryBaseTest { } contract TiersRegistrySelectTierBaseTest is TiersRegistryBaseTest { - address internal tiersManager; - 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 setUp() public virtual override { - super.setUp(); - tiersManager = nextAddress("TIERS_MANAGER"); - - bytes32 role = tiersRegistry.MANAGE_TIERS_ROLE(); - vm.prank(admin); - tiersRegistry.grantRole(role, tiersManager); - } - function _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { - vm.prank(tiersManager); + vm.prank(admin); tierId = tiersRegistry.addTier(bond, weight); } } From c5bee53cc81c9d72b18e54639df833b63631aa0a Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Mon, 8 Jun 2026 15:29:36 +0200 Subject: [PATCH 06/17] refactor: curve lib --- src/lib/BondCurvesLib.sol | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/lib/BondCurvesLib.sol b/src/lib/BondCurvesLib.sol index 73434141..c046c46d 100644 --- a/src/lib/BondCurvesLib.sol +++ b/src/lib/BondCurvesLib.sol @@ -41,10 +41,8 @@ library BondCurvesLib { uint256 curveId, uint256 multiplier ) external view returns (uint256) { - if (multiplier < MAX_BP) revert IBondCurve.InvalidMultiplier(); - _ensureCurveExists(bondCurveStorage, curveId); - if (keys == 0) return 0; IBondCurve.BondCurveInterval[] memory intervals = _loadCurve(bondCurveStorage, curveId, multiplier); + if (keys == 0) return 0; unchecked { uint256 low = 0; @@ -68,8 +66,6 @@ library BondCurvesLib { uint256 curveId, uint256 multiplier ) external view returns (uint256) { - if (multiplier < MAX_BP) revert IBondCurve.InvalidMultiplier(); - _ensureCurveExists(bondCurveStorage, curveId); IBondCurve.BondCurveInterval[] memory intervals = _loadCurve(bondCurveStorage, curveId, multiplier); unchecked { @@ -133,8 +129,10 @@ library BondCurvesLib { uint256 curveId, uint256 multiplier ) private view returns (IBondCurve.BondCurveInterval[] memory curve) { + _ensureCurveExists(bondCurveStorage, curveId); IBondCurve.BondCurveInterval[] storage src = bondCurveStorage.bondCurves[curveId].intervals; if (multiplier == MAX_BP) return src; + if (multiplier < MAX_BP) revert IBondCurve.InvalidMultiplier(); uint256 len = src.length; curve = new IBondCurve.BondCurveInterval[](len); From 91dc96a17e1cac27a6eedb63e90fab63fc5132a5 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 9 Jun 2026 12:07:08 +0200 Subject: [PATCH 07/17] fix: rename --- ...egistry.sol => AdditionalBondRegistry.sol} | 44 ++-- src/MetaRegistry.sol | 12 +- ...gistry.sol => IAdditionalBondRegistry.sol} | 2 +- src/interfaces/IMetaRegistry.sol | 4 +- ...ock.sol => AdditionalBondRegistryMock.sol} | 0 ...try.t.sol => AdditionalBondRegistry.t.sol} | 218 +++++++++--------- test/unit/MetaRegistry.t.sol | 28 +-- 7 files changed, 154 insertions(+), 154 deletions(-) rename src/{TiersRegistry.sol => AdditionalBondRegistry.sol} (80%) rename src/interfaces/{ITiersRegistry.sol => IAdditionalBondRegistry.sol} (99%) rename test/helpers/mocks/{TiersRegistryMock.sol => AdditionalBondRegistryMock.sol} (100%) rename test/unit/{TiersRegistry.t.sol => AdditionalBondRegistry.t.sol} (59%) diff --git a/src/TiersRegistry.sol b/src/AdditionalBondRegistry.sol similarity index 80% rename from src/TiersRegistry.sol rename to src/AdditionalBondRegistry.sol index b0f54b25..ae0592d9 100644 --- a/src/TiersRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -9,13 +9,13 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { IAccounting } from "./interfaces/IAccounting.sol"; import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; -import { ITiersRegistry, TierInfo, OperatorTierState } from "./interfaces/ITiersRegistry.sol"; +import { IAdditionalBondRegistry, TierInfo, OperatorTierState } from "./interfaces/IAdditionalBondRegistry.sol"; import { MAX_BP } from "./lib/Constants.sol"; /// @notice Manages operator tiers. -contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerableUpgradeable { - /// @custom:storage-location erc7201:TiersRegistry - struct TiersRegistryStorage { +contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, AccessControlEnumerableUpgradeable { + /// @custom:storage-location erc7201:AdditionalBondRegistry + struct AdditionalBondRegistryStorage { mapping(uint256 tierId => TierInfo) tiers; uint256 tiersCount; mapping(uint256 nodeOperatorId => uint256 tierId) operatorTier; @@ -31,9 +31,9 @@ contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerable IMetaRegistry public immutable META_REGISTRY; uint256 public immutable CURVE_MULTIPLIER_COOLDOWN; - // keccak256(abi.encode(uint256(keccak256("TiersRegistry")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant TIERS_REGISTRY_STORAGE_LOCATION = - 0x24229ad7430930455d78884db559ea2267e225054337247a405ccbe0a9cfca00; + // 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 tier downgrade before `releaseCurveMultiplier` can be called. @@ -47,20 +47,20 @@ contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerable _disableInitializers(); } - /// @inheritdoc ITiersRegistry + /// @inheritdoc IAdditionalBondRegistry function initialize(address admin) external initializer { if (admin == address(0)) revert ZeroAdminAddress(); _grantRole(DEFAULT_ADMIN_ROLE, admin); } - /// @inheritdoc ITiersRegistry + /// @inheritdoc IAdditionalBondRegistry function addTier( uint256 curveMultiplierInc, uint256 weightMultiplierInc ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 tierId) { if (curveMultiplierInc > MAX_CURVE_MULTIPLIER_INC) revert InvalidCurveMultiplier(); if (weightMultiplierInc > MAX_WEIGHT_MULTIPLIER_INC) revert InvalidWeightMultiplier(); - TiersRegistryStorage storage $ = _storage(); + AdditionalBondRegistryStorage storage $ = _storage(); tierId = ++$.tiersCount; $.tiers[tierId] = TierInfo({ curveMultiplierInc: uint128(curveMultiplierInc), @@ -69,9 +69,9 @@ contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerable emit TierAdded(tierId, curveMultiplierInc, weightMultiplierInc); } - /// @inheritdoc ITiersRegistry + /// @inheritdoc IAdditionalBondRegistry function selectTier(uint256 nodeOperatorId, uint256 tierId) external { - TiersRegistryStorage storage $ = _storage(); + AdditionalBondRegistryStorage storage $ = _storage(); _checkOperatorOwner(nodeOperatorId); if (tierId > $.tiersCount) revert InvalidTierId(); @@ -98,11 +98,11 @@ contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerable META_REGISTRY.refreshOperatorWeight(nodeOperatorId); } - /// @inheritdoc ITiersRegistry + /// @inheritdoc IAdditionalBondRegistry function releaseCurveMultiplier(uint256 nodeOperatorId) external { _checkOperatorOwner(nodeOperatorId); - TiersRegistryStorage storage $ = _storage(); + AdditionalBondRegistryStorage storage $ = _storage(); uint256 cooldownUntil = $.curveMultiplierCooldownUntil[nodeOperatorId]; if (cooldownUntil == 0) revert NoCurveMultiplierCooldown(); if (cooldownUntil > block.timestamp) revert CurveMultiplierCooldownNotElapsed(); @@ -116,23 +116,23 @@ contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerable ); } - /// @inheritdoc ITiersRegistry + /// @inheritdoc IAdditionalBondRegistry function getTiersCount() external view returns (uint256) { return _storage().tiersCount; } - /// @inheritdoc ITiersRegistry + /// @inheritdoc IAdditionalBondRegistry function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory state) { - TiersRegistryStorage storage $ = _storage(); + AdditionalBondRegistryStorage storage $ = _storage(); state.tierId = $.operatorTier[nodeOperatorId]; state.curveMultiplierCooldownUntil = $.curveMultiplierCooldownUntil[nodeOperatorId]; state.weightMultiplier = MAX_BP + $.tiers[state.tierId].weightMultiplierInc; state.curveMultiplier = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); } - /// @inheritdoc ITiersRegistry + /// @inheritdoc IAdditionalBondRegistry function getTierInfo(uint256 tierId) public view returns (TierInfo memory) { - TiersRegistryStorage storage $ = _storage(); + AdditionalBondRegistryStorage storage $ = _storage(); if (tierId > $.tiersCount) revert InvalidTierId(); if (tierId == 0) return TierInfo({ curveMultiplierInc: 0, weightMultiplierInc: 0 }); return $.tiers[tierId]; @@ -149,10 +149,10 @@ contract TiersRegistry is ITiersRegistry, Initializable, AccessControlEnumerable if (msg.sender != MODULE.getNodeOperatorOwner(nodeOperatorId)) revert SenderIsNotOperatorOwner(); } - function _storage() internal pure returns (TiersRegistryStorage storage $) { + function _storage() internal pure returns (AdditionalBondRegistryStorage storage $) { assembly ("memory-safe") { - // keccak256(abi.encode(uint256(keccak256("TiersRegistry")) - 1)) & ~bytes32(uint256(0xff)) - $.slot := TIERS_REGISTRY_STORAGE_LOCATION + // keccak256(abi.encode(uint256(keccak256("AdditionalBondRegistry")) - 1)) & ~bytes32(uint256(0xff)) + $.slot := ADDITIONAL_BOND_REGISTRY_STORAGE_LOCATION } } } diff --git a/src/MetaRegistry.sol b/src/MetaRegistry.sol index 1281243c..a9c8268d 100644 --- a/src/MetaRegistry.sol +++ b/src/MetaRegistry.sol @@ -15,7 +15,7 @@ import { IBaseModule } from "./interfaces/IBaseModule.sol"; import { IStakingModule } from "./interfaces/IStakingModule.sol"; import { IStakingRouter } from "./interfaces/IStakingRouter.sol"; import { IMetaRegistry, OperatorMetadata } from "./interfaces/IMetaRegistry.sol"; -import { ITiersRegistry } from "./interfaces/ITiersRegistry.sol"; +import { IAdditionalBondRegistry } from "./interfaces/IAdditionalBondRegistry.sol"; import { ExternalOperatorLib, OperatorType } from "./lib/ExternalOperatorLib.sol"; import { MAX_BP } from "./lib/Constants.sol"; @@ -63,7 +63,7 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp ICuratedModule public immutable MODULE; IAccounting public immutable ACCOUNTING; IStakingRouter public immutable STAKING_ROUTER; - ITiersRegistry public immutable TIERS_REGISTRY; + IAdditionalBondRegistry public immutable ADDITIONAL_BOND_REGISTRY; uint256 internal constant EXTERNAL_STAKE_PER_VALIDATOR = 32 ether; uint256 internal constant MAX_NAME_LENGTH = 256; @@ -74,14 +74,14 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp 0xa7ec41e1a061c67796a04fcd9cc7cab9545b0a750beebc54139d9ed9d2251c00; /// @param module CuratedModule proxy address. - /// @param tiersRegistry TiersRegistry proxy address. - constructor(address module, address tiersRegistry) { + /// @param additionalBondRegistry AdditionalBondRegistry proxy address. + constructor(address module, address additionalBondRegistry) { if (module == address(0)) revert ZeroModuleAddress(); MODULE = ICuratedModule(module); ACCOUNTING = IAccounting(MODULE.ACCOUNTING()); STAKING_ROUTER = IStakingRouter(MODULE.LIDO_LOCATOR().stakingRouter()); - TIERS_REGISTRY = ITiersRegistry(tiersRegistry); + ADDITIONAL_BOND_REGISTRY = IAdditionalBondRegistry(additionalBondRegistry); _disableInitializers(); } @@ -410,7 +410,7 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp uint256 baseWeight = _storage().bondCurveWeight[ACCOUNTING.getBondCurveId(nodeOperatorId)]; if (baseWeight == 0 || share == 0) return 0; uint256 weighted = Math.mulDiv(baseWeight, share, MAX_BP); - uint256 weightMul = TIERS_REGISTRY.getOperatorTierState(nodeOperatorId).weightMultiplier; + uint256 weightMul = ADDITIONAL_BOND_REGISTRY.getOperatorTierState(nodeOperatorId).weightMultiplier; if (weightMul == MAX_BP) return weighted; return Math.mulDiv(weighted, weightMul, MAX_BP); } diff --git a/src/interfaces/ITiersRegistry.sol b/src/interfaces/IAdditionalBondRegistry.sol similarity index 99% rename from src/interfaces/ITiersRegistry.sol rename to src/interfaces/IAdditionalBondRegistry.sol index 810dbb25..26a6d228 100644 --- a/src/interfaces/ITiersRegistry.sol +++ b/src/interfaces/IAdditionalBondRegistry.sol @@ -24,7 +24,7 @@ struct OperatorTierState { } /// @notice Manages operator bond tiers and associated tier downgrade cooldown state. -interface ITiersRegistry { +interface IAdditionalBondRegistry { event TierAdded(uint256 indexed tierId, uint256 curveMultiplierInc, uint256 weightMultiplierInc); event TierSelected(uint256 indexed nodeOperatorId, uint256 tierId); event CurveMultiplierCooldownSet(uint256 indexed nodeOperatorId, uint256 cooldownUntil); diff --git a/src/interfaces/IMetaRegistry.sol b/src/interfaces/IMetaRegistry.sol index 8964cca0..99f235b0 100644 --- a/src/interfaces/IMetaRegistry.sol +++ b/src/interfaces/IMetaRegistry.sol @@ -5,7 +5,7 @@ pragma solidity 0.8.33; import { IAccounting } from "./IAccounting.sol"; import { ICuratedModule } from "./ICuratedModule.sol"; -import { ITiersRegistry } from "./ITiersRegistry.sol"; +import { IAdditionalBondRegistry } from "./IAdditionalBondRegistry.sol"; /// @notice Stored operator metadata. struct OperatorMetadata { @@ -74,7 +74,7 @@ interface IMetaRegistry { function ACCOUNTING() external view returns (IAccounting); /// @notice Tier provider that manages operator bond tiers. - function TIERS_REGISTRY() external view returns (ITiersRegistry); + function ADDITIONAL_BOND_REGISTRY() external view returns (IAdditionalBondRegistry); /// @notice Initialize the registry. /// @param admin Address to receive DEFAULT_ADMIN_ROLE. diff --git a/test/helpers/mocks/TiersRegistryMock.sol b/test/helpers/mocks/AdditionalBondRegistryMock.sol similarity index 100% rename from test/helpers/mocks/TiersRegistryMock.sol rename to test/helpers/mocks/AdditionalBondRegistryMock.sol diff --git a/test/unit/TiersRegistry.t.sol b/test/unit/AdditionalBondRegistry.t.sol similarity index 59% rename from test/unit/TiersRegistry.t.sol rename to test/unit/AdditionalBondRegistry.t.sol index 78e4f553..ed3d2fcf 100644 --- a/test/unit/TiersRegistry.t.sol +++ b/test/unit/AdditionalBondRegistry.t.sol @@ -7,8 +7,8 @@ import { Test } from "forge-std/Test.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import { TiersRegistry } from "src/TiersRegistry.sol"; -import { ITiersRegistry, TierInfo, OperatorTierState } from "src/interfaces/ITiersRegistry.sol"; +import { AdditionalBondRegistry } from "src/AdditionalBondRegistry.sol"; +import { IAdditionalBondRegistry, TierInfo, OperatorTierState } from "src/interfaces/IAdditionalBondRegistry.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; import { AccountingMock } from "../helpers/mocks/AccountingMock.sol"; @@ -17,9 +17,9 @@ import { NodeOperatorManagementProperties } from "src/interfaces/IBaseModule.sol import { Utilities } from "../helpers/Utilities.sol"; import { Fixtures } from "../helpers/Fixtures.sol"; -contract TiersRegistryBaseTest is Test, Utilities, Fixtures { +contract AdditionalBondRegistryBaseTest is Test, Utilities, Fixtures { CuratedMock public module; - TiersRegistry public tiersRegistry; + AdditionalBondRegistry public additionalBondRegistry; MetaRegistryMock public metaRegistryMock; AccountingMock internal acct; @@ -48,50 +48,50 @@ contract TiersRegistryBaseTest is Test, Utilities, Fixtures { metaRegistryMock = new MetaRegistryMock(); module.mock_setMetaRegistry(address(metaRegistryMock)); - tiersRegistry = new TiersRegistry({ + additionalBondRegistry = new AdditionalBondRegistry({ module: address(module), curveMultiplierCooldown: CURVE_MULTIPLIER_COOLDOWN }); - _enableInitializers(address(tiersRegistry)); - tiersRegistry.initialize(admin); + _enableInitializers(address(additionalBondRegistry)); + additionalBondRegistry.initialize(admin); acct = AccountingMock(address(module.ACCOUNTING())); } } -contract TiersRegistryConstructorTest is TiersRegistryBaseTest { +contract AdditionalBondRegistryConstructorTest is AdditionalBondRegistryBaseTest { function test_constructor_SetsImmutables() public view { - assertEq(address(tiersRegistry.MODULE()), address(module)); - assertEq(address(tiersRegistry.ACCOUNTING()), address(module.ACCOUNTING())); - assertEq(address(tiersRegistry.META_REGISTRY()), address(metaRegistryMock)); - assertEq(tiersRegistry.MAX_CURVE_MULTIPLIER_INC(), 100_000); - assertEq(tiersRegistry.MAX_WEIGHT_MULTIPLIER_INC(), 100_000); - assertEq(tiersRegistry.CURVE_MULTIPLIER_COOLDOWN(), CURVE_MULTIPLIER_COOLDOWN); + 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_INC(), 100_000); + assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC(), 100_000); + assertEq(additionalBondRegistry.CURVE_MULTIPLIER_COOLDOWN(), CURVE_MULTIPLIER_COOLDOWN); } } -contract TiersRegistryInitializeTest is TiersRegistryBaseTest { +contract AdditionalBondRegistryInitializeTest is AdditionalBondRegistryBaseTest { function test_initialize_SetsAdmin() public view { - assertTrue(tiersRegistry.hasRole(tiersRegistry.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(additionalBondRegistry.hasRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), admin)); } function test_initialize_RevertWhen_ZeroAdmin() public { - TiersRegistry tp = new TiersRegistry(address(module), CURVE_MULTIPLIER_COOLDOWN); + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module), CURVE_MULTIPLIER_COOLDOWN); _enableInitializers(address(tp)); - vm.expectRevert(ITiersRegistry.ZeroAdminAddress.selector); + vm.expectRevert(IAdditionalBondRegistry.ZeroAdminAddress.selector); tp.initialize(address(0)); } function test_initialize_RevertWhen_DoubleCall() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - tiersRegistry.initialize(admin); + additionalBondRegistry.initialize(admin); } } -contract TiersRegistryAddTierTest is TiersRegistryBaseTest { +contract AdditionalBondRegistryAddTierTest is AdditionalBondRegistryBaseTest { function _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { vm.prank(admin); - tierId = tiersRegistry.addTier(bond, weight); + tierId = additionalBondRegistry.addTier(bond, weight); } uint256 constant T1_BOND = 5_000; @@ -100,13 +100,13 @@ contract TiersRegistryAddTierTest is TiersRegistryBaseTest { uint256 constant T2_WEIGHT = 8_000; function test_addTier() public { - vm.expectEmit(true, false, false, true, address(tiersRegistry)); - emit ITiersRegistry.TierAdded(1, T1_BOND, T1_WEIGHT); + vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.TierAdded(1, T1_BOND, T1_WEIGHT); uint256 tierId = _addTier(T1_BOND, T1_WEIGHT); assertEq(tierId, 1); - assertEq(tiersRegistry.getTiersCount(), 1); - TierInfo memory t = tiersRegistry.getTierInfo(1); + assertEq(additionalBondRegistry.getTiersCount(), 1); + TierInfo memory t = additionalBondRegistry.getTierInfo(1); assertEq(t.curveMultiplierInc, T1_BOND); assertEq(t.weightMultiplierInc, T1_WEIGHT); } @@ -115,28 +115,28 @@ contract TiersRegistryAddTierTest is TiersRegistryBaseTest { _addTier(T1_BOND, T1_WEIGHT); uint256 tierId = _addTier(T2_BOND, T2_WEIGHT); assertEq(tierId, 2); - assertEq(tiersRegistry.getTiersCount(), 2); + assertEq(additionalBondRegistry.getTiersCount(), 2); } function test_addTier_AllowsZeroCurveMultiplierInc() public { uint256 tierId = _addTier(0, T1_WEIGHT); - TierInfo memory t = tiersRegistry.getTierInfo(tierId); + TierInfo memory t = additionalBondRegistry.getTierInfo(tierId); assertEq(t.curveMultiplierInc, 0); assertEq(t.weightMultiplierInc, T1_WEIGHT); } function test_addTier_AllowsZeroWeightMultiplierInc() public { uint256 tierId = _addTier(T1_BOND, 0); - TierInfo memory t = tiersRegistry.getTierInfo(tierId); + TierInfo memory t = additionalBondRegistry.getTierInfo(tierId); assertEq(t.curveMultiplierInc, T1_BOND); assertEq(t.weightMultiplierInc, 0); } function test_addTier_AllowsMaxIncrement() public { - uint256 maxCurve = tiersRegistry.MAX_CURVE_MULTIPLIER_INC(); - uint256 maxWeight = tiersRegistry.MAX_WEIGHT_MULTIPLIER_INC(); + uint256 maxCurve = additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC(); + uint256 maxWeight = additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC(); uint256 tierId = _addTier(maxCurve, maxWeight); - TierInfo memory t = tiersRegistry.getTierInfo(tierId); + TierInfo memory t = additionalBondRegistry.getTierInfo(tierId); assertEq(t.curveMultiplierInc, maxCurve); assertEq(t.weightMultiplierInc, maxWeight); } @@ -144,23 +144,23 @@ contract TiersRegistryAddTierTest is TiersRegistryBaseTest { function test_addTier_RevertWhen_NotAdmin() public { vm.expectRevert(); vm.prank(stranger); - tiersRegistry.addTier(T1_BOND, T1_WEIGHT); + additionalBondRegistry.addTier(T1_BOND, T1_WEIGHT); } function test_addTier_RevertWhen_BondMulAboveMax() public { - uint256 aboveMax = tiersRegistry.MAX_CURVE_MULTIPLIER_INC() + 1; - vm.expectRevert(ITiersRegistry.InvalidCurveMultiplier.selector); + uint256 aboveMax = additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC() + 1; + vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplier.selector); _addTier(aboveMax, T1_WEIGHT); } function test_addTier_RevertWhen_WeightMulAboveMax() public { - uint256 aboveMax = tiersRegistry.MAX_WEIGHT_MULTIPLIER_INC() + 1; - vm.expectRevert(ITiersRegistry.InvalidWeightMultiplier.selector); + uint256 aboveMax = additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC() + 1; + vm.expectRevert(IAdditionalBondRegistry.InvalidWeightMultiplier.selector); _addTier(T1_BOND, aboveMax); } } -contract TiersRegistrySelectTierBaseTest is TiersRegistryBaseTest { +contract AdditionalBondRegistrySelectTierBaseTest is AdditionalBondRegistryBaseTest { uint256 constant T1_BOND = 5_000; uint256 constant T1_WEIGHT = 2_000; uint256 constant T2_BOND = 10_000; @@ -168,11 +168,11 @@ contract TiersRegistrySelectTierBaseTest is TiersRegistryBaseTest { function _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { vm.prank(admin); - tierId = tiersRegistry.addTier(bond, weight); + tierId = additionalBondRegistry.addTier(bond, weight); } } -contract TiersRegistrySelectTierTest is TiersRegistrySelectTierBaseTest { +contract AdditionalBondRegistrySelectTierTest is AdditionalBondRegistrySelectTierBaseTest { function setUp() public override { super.setUp(); _addTier(T1_BOND, T1_WEIGHT); @@ -180,13 +180,13 @@ contract TiersRegistrySelectTierTest is TiersRegistrySelectTierBaseTest { } function test_selectTier_Upgrade_Tier0ToTier1() public { - vm.expectEmit(true, false, false, true, address(tiersRegistry)); - emit ITiersRegistry.TierSelected(0, 1); + vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.TierSelected(0, 1); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); - assertEq(tiersRegistry.getOperatorTierState(0).tierId, 1); - assertEq(tiersRegistry.getOperatorTierState(0).weightMultiplier, MAX_BP + T1_WEIGHT); + assertEq(additionalBondRegistry.getOperatorTierState(0).tierId, 1); + assertEq(additionalBondRegistry.getOperatorTierState(0).weightMultiplier, MAX_BP + T1_WEIGHT); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); assertEq(metaRegistryMock.refreshOperatorWeightCallCount(), 1); assertEq(metaRegistryMock.lastRefreshedOperatorId(), 0); @@ -194,14 +194,14 @@ contract TiersRegistrySelectTierTest is TiersRegistrySelectTierBaseTest { function test_selectTier_Upgrade_Tier1ToTier2() public { vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); - vm.expectEmit(true, false, false, true, address(tiersRegistry)); - emit ITiersRegistry.TierSelected(0, 2); + vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.TierSelected(0, 2); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 2); + additionalBondRegistry.selectTier(0, 2); - OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); assertEq(s.tierId, 2); assertEq(s.weightMultiplier, MAX_BP + T2_WEIGHT); assertEq(s.curveMultiplier, MAX_BP + T2_BOND); @@ -211,15 +211,15 @@ contract TiersRegistrySelectTierTest is TiersRegistrySelectTierBaseTest { function test_selectTier_Downgrade_Tier1ToTier0() public { vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); uint256 expectedCooldown = block.timestamp + CURVE_MULTIPLIER_COOLDOWN; - vm.expectEmit(true, false, false, true, address(tiersRegistry)); - emit ITiersRegistry.CurveMultiplierCooldownSet(0, expectedCooldown); + vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.CurveMultiplierCooldownSet(0, expectedCooldown); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 0); + additionalBondRegistry.selectTier(0, 0); - OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); assertEq(s.tierId, 0); assertEq(s.weightMultiplier, MAX_BP); assertEq(s.curveMultiplierCooldownUntil, expectedCooldown); @@ -231,36 +231,36 @@ contract TiersRegistrySelectTierTest is TiersRegistrySelectTierBaseTest { function test_selectTier_Upgrade_ClearsCooldownIfActive() public { vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 0); - assertGt(tiersRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + additionalBondRegistry.selectTier(0, 0); + assertGt(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); vm.prank(nodeOperatorOwner); - vm.expectEmit(true, false, false, false, address(tiersRegistry)); - emit ITiersRegistry.CurveMultiplierCooldownRemoved(0); - tiersRegistry.selectTier(0, 2); + vm.expectEmit(true, false, false, false, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.CurveMultiplierCooldownRemoved(0); + additionalBondRegistry.selectTier(0, 2); - assertEq(tiersRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); } function test_selectTier_RevertWhen_NotOwner() public { - vm.expectRevert(ITiersRegistry.SenderIsNotOperatorOwner.selector); + vm.expectRevert(IAdditionalBondRegistry.SenderIsNotOperatorOwner.selector); vm.prank(stranger); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); } function test_selectTier_RevertWhen_InvalidTierId() public { - vm.expectRevert(ITiersRegistry.InvalidTierId.selector); + vm.expectRevert(IAdditionalBondRegistry.InvalidTierId.selector); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 99); + additionalBondRegistry.selectTier(0, 99); } function test_selectTier_RevertWhen_SameTier() public { - vm.expectRevert(ITiersRegistry.SameTier.selector); + vm.expectRevert(IAdditionalBondRegistry.SameTier.selector); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 0); + additionalBondRegistry.selectTier(0, 0); } function test_selectTier_Upgrade_SucceedsWhenBondCoversScaledRequirement() public { @@ -271,9 +271,9 @@ contract TiersRegistrySelectTierTest is TiersRegistrySelectTierBaseTest { acct.depositETH{ value: scaledRequired }(0); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); - assertEq(tiersRegistry.getOperatorTierState(0).tierId, 1); + assertEq(additionalBondRegistry.getOperatorTierState(0).tierId, 1); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); } @@ -285,20 +285,20 @@ contract TiersRegistrySelectTierTest is TiersRegistrySelectTierBaseTest { vm.deal(address(this), scaledRequired - 1); acct.depositETH{ value: scaledRequired - 1 }(0); - vm.expectRevert(ITiersRegistry.InsufficientBondForTier.selector); + vm.expectRevert(IAdditionalBondRegistry.InsufficientBondForTier.selector); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); } function test_selectTier_RevertWhen_CurveMultiplierCooldownActive() public { vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 2); + additionalBondRegistry.selectTier(0, 2); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); - vm.expectRevert(ITiersRegistry.CurveMultiplierCooldownActive.selector); + vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierCooldownActive.selector); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 0); + additionalBondRegistry.selectTier(0, 0); } function test_selectTier_RevertWhen_DowngradeReducesViaIntermediateTier() public { @@ -306,82 +306,82 @@ contract TiersRegistrySelectTierTest is TiersRegistrySelectTierBaseTest { _addTier(t3Bond, T1_WEIGHT); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 2); + additionalBondRegistry.selectTier(0, 2); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); // Tier 3 reads as an upgrade vs tier 1's nominal value but is still below the held tier-2 value, // so the cooldown must block it — otherwise the operator sheds bond before the cooldown elapses. - vm.expectRevert(ITiersRegistry.CurveMultiplierCooldownActive.selector); + vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierCooldownActive.selector); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 3); + additionalBondRegistry.selectTier(0, 3); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); } } -contract TiersRegistryReleaseCurveMultiplierTest is TiersRegistrySelectTierBaseTest { +contract AdditionalBondRegistryReleaseCurveMultiplierTest is AdditionalBondRegistrySelectTierBaseTest { function setUp() public override { super.setUp(); _addTier(T1_BOND, T1_WEIGHT); _addTier(T2_BOND, T2_WEIGHT); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 0); + additionalBondRegistry.selectTier(0, 0); } function test_releaseCurveMultiplier() public { vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); - vm.expectEmit(true, false, false, false, address(tiersRegistry)); - emit ITiersRegistry.CurveMultiplierCooldownRemoved(0); + vm.expectEmit(true, false, false, false, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.CurveMultiplierCooldownRemoved(0); vm.prank(nodeOperatorOwner); - tiersRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.releaseCurveMultiplier(0); - assertEq(tiersRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP); } function test_releaseCurveMultiplier_SettlesToCurrentTierNotDefault() public { vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 2); + additionalBondRegistry.selectTier(0, 2); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); vm.prank(nodeOperatorOwner); - tiersRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.releaseCurveMultiplier(0); - assertEq(tiersRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); } function test_releaseCurveMultiplier_RevertWhen_NotOwner() public { vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); - vm.expectRevert(ITiersRegistry.SenderIsNotOperatorOwner.selector); + vm.expectRevert(IAdditionalBondRegistry.SenderIsNotOperatorOwner.selector); vm.prank(stranger); - tiersRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.releaseCurveMultiplier(0); } function test_releaseCurveMultiplier_RevertWhen_NoCurveMultiplierCooldown() public { vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); vm.prank(nodeOperatorOwner); - tiersRegistry.releaseCurveMultiplier(0); - vm.expectRevert(ITiersRegistry.NoCurveMultiplierCooldown.selector); + additionalBondRegistry.releaseCurveMultiplier(0); + vm.expectRevert(IAdditionalBondRegistry.NoCurveMultiplierCooldown.selector); vm.prank(nodeOperatorOwner); - tiersRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.releaseCurveMultiplier(0); } function test_releaseCurveMultiplier_RevertWhen_CurveMultiplierCooldownNotElapsed() public { - vm.expectRevert(ITiersRegistry.CurveMultiplierCooldownNotElapsed.selector); + vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierCooldownNotElapsed.selector); vm.prank(nodeOperatorOwner); - tiersRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.releaseCurveMultiplier(0); } } -contract TiersRegistryViewsTest is TiersRegistrySelectTierBaseTest { +contract AdditionalBondRegistryViewsTest is AdditionalBondRegistrySelectTierBaseTest { function setUp() public override { super.setUp(); _addTier(T1_BOND, T1_WEIGHT); @@ -389,28 +389,28 @@ contract TiersRegistryViewsTest is TiersRegistrySelectTierBaseTest { } function test_getTiersCount() public view { - assertEq(tiersRegistry.getTiersCount(), 2); + assertEq(additionalBondRegistry.getTiersCount(), 2); } function test_getTierInfo_Tier0() public view { - TierInfo memory t = tiersRegistry.getTierInfo(0); + TierInfo memory t = additionalBondRegistry.getTierInfo(0); assertEq(t.curveMultiplierInc, 0); assertEq(t.weightMultiplierInc, 0); } function test_getTierInfo_Tier1() public view { - TierInfo memory t = tiersRegistry.getTierInfo(1); + TierInfo memory t = additionalBondRegistry.getTierInfo(1); assertEq(t.curveMultiplierInc, T1_BOND); assertEq(t.weightMultiplierInc, T1_WEIGHT); } function test_getTierInfo_RevertWhen_InvalidTierId() public { - vm.expectRevert(ITiersRegistry.InvalidTierId.selector); - tiersRegistry.getTierInfo(99); + vm.expectRevert(IAdditionalBondRegistry.InvalidTierId.selector); + additionalBondRegistry.getTierInfo(99); } function test_getOperatorTierState_Default() public view { - OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); assertEq(s.tierId, 0); assertEq(s.weightMultiplier, MAX_BP); assertEq(s.curveMultiplier, MAX_BP); @@ -419,8 +419,8 @@ contract TiersRegistryViewsTest is TiersRegistrySelectTierBaseTest { function test_getOperatorTierState_AfterUpgrade() public { vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); - OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + additionalBondRegistry.selectTier(0, 1); + OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); assertEq(s.tierId, 1); assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); assertEq(s.curveMultiplier, MAX_BP + T1_BOND); @@ -429,11 +429,11 @@ contract TiersRegistryViewsTest is TiersRegistrySelectTierBaseTest { function test_getOperatorTierState_AfterDowngrade_CooldownMultiplierDivergesFromTier() public { vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 2); + additionalBondRegistry.selectTier(0, 2); vm.prank(nodeOperatorOwner); - tiersRegistry.selectTier(0, 1); + additionalBondRegistry.selectTier(0, 1); - OperatorTierState memory s = tiersRegistry.getOperatorTierState(0); + OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); assertEq(s.tierId, 1); assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); assertEq(s.curveMultiplier, MAX_BP + T2_BOND); diff --git a/test/unit/MetaRegistry.t.sol b/test/unit/MetaRegistry.t.sol index 5b39af9c..824b4ab5 100644 --- a/test/unit/MetaRegistry.t.sol +++ b/test/unit/MetaRegistry.t.sol @@ -18,14 +18,14 @@ import { ExternalOperatorLib } from "src/lib/ExternalOperatorLib.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; import { AccountingMock } from "../helpers/mocks/AccountingMock.sol"; -import { TiersRegistryMock } from "../helpers/mocks/TiersRegistryMock.sol"; +import { AdditionalBondRegistryMock } from "../helpers/mocks/AdditionalBondRegistryMock.sol"; import { NodeOperatorsRegistryMock } from "../helpers/mocks/NodeOperatorsRegistryMock.sol"; import { StakingRouterMock } from "../helpers/mocks/StakingRouterMock.sol"; import { Utilities } from "../helpers/Utilities.sol"; import { Fixtures } from "../helpers/Fixtures.sol"; contract MetaRegistryForTest is MetaRegistry { - constructor(address module, address tiersRegistry) MetaRegistry(module, tiersRegistry) {} + constructor(address module, address additionalBondRegistry) MetaRegistry(module, additionalBondRegistry) {} function mock_setModuleAddressInCache(uint256 moduleId, address moduleAddress) external { _storage().moduleAddressCache[moduleId] = moduleAddress; @@ -40,7 +40,7 @@ contract MetaRegistryBaseTest is Test, Utilities, Fixtures { CuratedMock public module; StakingRouterMock public stakingRouter; MetaRegistryForTest public registry; - TiersRegistryMock public tiersRegistry; + AdditionalBondRegistryMock public additionalBondRegistry; address public admin; address public metadataAdmin; @@ -79,9 +79,9 @@ contract MetaRegistryBaseTest is Test, Utilities, Fixtures { modules[0] = address(module); stakingRouter.setModules(modules); - tiersRegistry = new TiersRegistryMock(); + additionalBondRegistry = new AdditionalBondRegistryMock(); - registry = new MetaRegistryForTest(address(module), address(tiersRegistry)); + registry = new MetaRegistryForTest(address(module), address(additionalBondRegistry)); _enableInitializers(address(registry)); registry.initialize(admin); @@ -234,16 +234,16 @@ contract MetaRegistryGroupsBaseTest is MetaRegistryBaseTest { contract MetaRegistryConstructorTest is MetaRegistryBaseTest { function test_constructor_SetsImmutables() public { - MetaRegistry r = new MetaRegistry(address(module), address(tiersRegistry)); + MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); assertEq(address(r.STAKING_ROUTER()), address(stakingRouter)); assertEq(address(r.MODULE()), address(module)); assertEq(address(r.ACCOUNTING()), address(module.ACCOUNTING())); - assertEq(address(r.TIERS_REGISTRY()), address(tiersRegistry)); + assertEq(address(r.ADDITIONAL_BOND_REGISTRY()), address(additionalBondRegistry)); } function test_constructor_RevertWhen_ZeroModule() public { vm.expectRevert(IMetaRegistry.ZeroModuleAddress.selector); - new MetaRegistry(address(0), address(tiersRegistry)); + new MetaRegistry(address(0), address(additionalBondRegistry)); } } @@ -253,14 +253,14 @@ contract MetaRegistryInitializeTest is MetaRegistryBaseTest { } function test_initialize_SetsAdmin() public { - MetaRegistry r = new MetaRegistry(address(module), address(tiersRegistry)); + MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); _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(tiersRegistry)); + MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); _enableInitializers(address(r)); r.initialize(admin); @@ -273,14 +273,14 @@ contract MetaRegistryInitializeTest is MetaRegistryBaseTest { } function test_initialize_RevertWhen_ZeroAdmin() public { - MetaRegistry r = new MetaRegistry(address(module), address(tiersRegistry)); + MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); _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(tiersRegistry)); + MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); _enableInitializers(address(r)); r.initialize(admin); vm.expectRevert(Initializable.InvalidInitialization.selector); @@ -1213,7 +1213,7 @@ contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { _createGroup(_subOperatorsArr1(noId, MAX_BP), _extOperatorsArr0()); _setBondCurveWeight(0, CURVE_WEIGHT); - tiersRegistry.mock_setWeightMultiplierInc(noId, 5_000); + additionalBondRegistry.mock_setWeightMultiplierInc(noId, 5_000); registry.refreshOperatorWeight(noId); (uint256 weight, ) = registry.getNodeOperatorWeightAndExternalStake(noId); @@ -1228,7 +1228,7 @@ contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { _createGroup(_subOperatorsArr2(op0, op1), _extOperatorsArr0()); _setBondCurveWeight(0, CURVE_WEIGHT); - tiersRegistry.mock_setWeightMultiplierInc(0, 5_000); + additionalBondRegistry.mock_setWeightMultiplierInc(0, 5_000); registry.refreshOperatorWeight(0); (uint256 weight, ) = registry.getNodeOperatorWeightAndExternalStake(0); From 2d367746c3a2f6a81d066517af03423ae14770e9 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 9 Jun 2026 12:18:33 +0200 Subject: [PATCH 08/17] feat: deploy scripts and tests --- script/curated/DeployBase.s.sol | 35 +++++++- script/curated/DeployHoodi.s.sol | 6 +- script/curated/DeployLocalDevNet.s.sol | 6 +- script/curated/DeployMainnet.s.sol | 6 +- .../deployment/PostDeploymentCurated.t.sol | 89 +++++++++++++++++++ test/helpers/Fixtures.sol | 16 ++++ .../mocks/AdditionalBondRegistryMock.sol | 8 +- 7 files changed, 158 insertions(+), 8 deletions(-) diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 27316016..bd76be93 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -17,6 +17,7 @@ import { Verifier } from "../../src/Verifier.sol"; import { ParametersRegistry } from "../../src/ParametersRegistry.sol"; import { ExitPenalties } from "../../src/ExitPenalties.sol"; import { MetaRegistry } from "../../src/MetaRegistry.sol"; +import { AdditionalBondRegistry } from "../../src/AdditionalBondRegistry.sol"; import { CuratedGate } from "../../src/CuratedGate.sol"; import { MerkleGateFactory } from "../../src/MerkleGateFactory.sol"; @@ -58,6 +59,10 @@ struct CuratedGateConfig { GateCurveParams params; } +struct AdditionalBondRegistryConfig { + uint256 curveMultiplierCooldown; +} + struct CuratedDeployParams { // Lido addresses address lidoLocatorAddress; @@ -121,6 +126,8 @@ struct CuratedDeployParams { address resealManager; // Testnet stuff address secondAdminAddress; + // AdditionalBondRegistry + AdditionalBondRegistryConfig additionalBondRegistryConfig; } abstract contract DeployBase is Script { @@ -143,6 +150,7 @@ abstract contract DeployBase is Script { HashConsensus public hashConsensus; ParametersRegistry public parametersRegistry; MetaRegistry public metaRegistry; + AdditionalBondRegistry public additionalBondRegistry; MerkleGateFactory public curatedGateFactory; address[] public curatedGateInstances; address internal curatedGateImpl; @@ -231,6 +239,7 @@ abstract contract DeployBase is Script { accounting = Accounting(_deployProxy(deployer, address(dummyImpl))); oracle = FeeOracle(_deployProxy(deployer, address(dummyImpl))); metaRegistry = MetaRegistry(_deployProxy(deployer, address(dummyImpl))); + additionalBondRegistry = AdditionalBondRegistry(_deployProxy(deployer, address(dummyImpl))); FeeDistributor feeDistributorImpl = new FeeDistributor({ stETH: locator.lido(), @@ -309,7 +318,10 @@ abstract contract DeployBase is Script { moduleProxy.proxy__changeAdmin(config.proxyAdmin); } - MetaRegistry metaRegistryImpl = new MetaRegistry(address(curatedModule)); + MetaRegistry metaRegistryImpl = new MetaRegistry({ + module: address(curatedModule), + additionalBondRegistry: address(additionalBondRegistry) + }); { OssifiableProxy metaRegistryProxy = OssifiableProxy(payable(address(metaRegistry))); @@ -320,7 +332,22 @@ abstract contract DeployBase is Script { metaRegistryProxy.proxy__changeAdmin(config.proxyAdmin); } + AdditionalBondRegistry additionalBondRegistryImpl = new AdditionalBondRegistry({ + module: address(curatedModule), + curveMultiplierCooldown: config.additionalBondRegistryConfig.curveMultiplierCooldown + }); + + { + OssifiableProxy additionalBondRegistryProxy = OssifiableProxy(payable(address(additionalBondRegistry))); + additionalBondRegistryProxy.proxy__upgradeToAndCall( + address(additionalBondRegistryImpl), + abi.encodeCall(AdditionalBondRegistry.initialize, (deployer)) + ); + additionalBondRegistryProxy.proxy__changeAdmin(config.proxyAdmin); + } + accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); + accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), address(additionalBondRegistry)); metaRegistry.grantRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); for (uint256 i = 0; i < gatesCount; i++) { @@ -520,6 +547,9 @@ abstract contract DeployBase is Script { metaRegistry.grantRole(metaRegistry.DEFAULT_ADMIN_ROLE(), config.aragonAgent); metaRegistry.revokeRole(metaRegistry.DEFAULT_ADMIN_ROLE(), deployer); + additionalBondRegistry.grantRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), config.aragonAgent); + additionalBondRegistry.revokeRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), deployer); + verifier.grantRole(verifier.DEFAULT_ADMIN_ROLE(), config.aragonAgent); verifier.revokeRole(verifier.DEFAULT_ADMIN_ROLE(), deployer); @@ -544,6 +574,8 @@ abstract contract DeployBase is Script { deployJson.set("CuratedModuleImpl", address(curatedModuleImpl)); deployJson.set("MetaRegistry", address(metaRegistry)); deployJson.set("MetaRegistryImpl", address(metaRegistryImpl)); + deployJson.set("AdditionalBondRegistry", address(additionalBondRegistry)); + deployJson.set("AdditionalBondRegistryImpl", address(additionalBondRegistryImpl)); deployJson.set("ParametersRegistry", address(parametersRegistry)); deployJson.set("ParametersRegistryImpl", address(parametersRegistryImpl)); deployJson.set("Accounting", address(accounting)); @@ -639,6 +671,7 @@ abstract contract DeployBase is Script { hashConsensus.grantRole(hashConsensus.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); parametersRegistry.grantRole(parametersRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); metaRegistry.grantRole(metaRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); + additionalBondRegistry.grantRole(additionalBondRegistry.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 018fb29f..f2a7f312 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, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployHoodi is DeployBase { @@ -198,6 +198,10 @@ contract DeployHoodi is DeployBase { config.resealManager = 0x05172CbCDb7307228F781436b327679e4DAE166B; config.secondAdminAddress = 0x4AF43Ee34a6fcD1fEcA1e1F832124C763561dA53; // Dev team EOA + + // CurveMultiplier + config.additionalBondRegistryConfig.curveMultiplierCooldown = 7 days; + _setUp(); } } diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index fd44e813..29ed47c9 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, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployLocalDevNet is DeployBase { @@ -31,6 +31,7 @@ contract DeployLocalDevNet is DeployBase { config.gIFirstWithdrawal = GIndices.FIRST_WITHDRAWAL_ELECTRA; config.gIFirstValidator = GIndices.FIRST_VALIDATOR_ELECTRA; config.gIFirstHistoricalSummary = GIndices.FIRST_HISTORICAL_SUMMARY_ELECTRA; // prettier-ignore + config.gIFirstBalanceNode = GIndices.FIRST_BALANCE_NODE_ELECTRA; config.verifierFirstSupportedSlot = vm.envUint("DEVNET_ELECTRA_EPOCH") * config.slotsPerEpoch; config.capellaSlot = vm.envUint("DEVNET_CAPELLA_EPOCH") * config.slotsPerEpoch; config.minWithdrawalRatio = 9950; @@ -186,6 +187,9 @@ contract DeployLocalDevNet is DeployBase { config.secondAdminAddress = vm.envOr("CSM_SECOND_ADMIN_ADDRESS", address(0)); + // CurveMultiplier + config.additionalBondRegistryConfig.curveMultiplierCooldown = 1 days; + _setUp(); } } diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index 8be37162..6c30fda9 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, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployMainnet is DeployBase { @@ -193,6 +193,10 @@ contract DeployMainnet is DeployBase { // DG config.resealManager = 0x7914b5a1539b97Bd0bbd155757F25FD79A522d24; + + // CurveMultiplier + config.additionalBondRegistryConfig.curveMultiplierCooldown = 7 days; + _setUp(); } } diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index e564e8c3..d14eba0c 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -125,6 +125,95 @@ contract MetaRegistryDeploymentTest is DeploymentBaseTest { } } +contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { + function test_state_onlyFull() public view { + assertEq(additionalBondRegistry.getTiersCount(), 0); + } + + function test_immutables_onlyFull() public view { + assertEq(address(additionalBondRegistry.MODULE()), address(curatedModule), "additional bond registry module"); + 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_COOLDOWN(), + deployParams.additionalBondRegistryConfig.curveMultiplierCooldown, + "additional bond registry cooldown" + ); + assertEq( + additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC(), + 100_000, + "additional bond registry max curve multiplier" + ); + assertEq( + additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC(), + 100_000, + "additional bond registry max weight multiplier" + ); + } + + function test_roles_onlyFull() public view { + assertEq(additionalBondRegistry.getRoleMemberCount(additionalBondRegistry.DEFAULT_ADMIN_ROLE()), adminsCount); + assertTrue( + additionalBondRegistry.hasRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), deployParams.aragonAgent) + ); + + // AdditionalBondRegistry must be able to update the operator curve multiplier in Accounting. + assertTrue( + accounting.hasRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), address(additionalBondRegistry)), + "additional bond registry missing accounting set curve multiplier role" + ); + } + + function test_wiring_onlyFull() public view { + assertEq( + address(metaRegistry.ADDITIONAL_BOND_REGISTRY()), + address(additionalBondRegistry), + "meta registry additional bond registry wiring" + ); + } + + function test_initialization_onlyFull() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + additionalBondRegistry.initialize(deployParams.aragonAgent); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + additionalBondRegistryImpl.initialize(deployParams.aragonAgent); + } + + 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"); + } +} + 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 a3d371df..7873df0d 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -33,6 +33,7 @@ import { Verifier } from "src/Verifier.sol"; import { CuratedModule } from "src/CuratedModule.sol"; import { MetaRegistry } from "src/MetaRegistry.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; +import { AdditionalBondRegistry } from "src/AdditionalBondRegistry.sol"; import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; import { CuratedGate } from "src/CuratedGate.sol"; import { DeployParams } from "script/csm/DeployBase.s.sol"; @@ -246,6 +247,8 @@ contract DeploymentHelpers is Test { address hashConsensus; address metaRegistry; address metaRegistryImpl; + address additionalBondRegistry; + address additionalBondRegistryImpl; address curatedGateFactory; address curatedGateImpl; address[] curatedGates; @@ -424,6 +427,12 @@ contract DeploymentHelpers is Test { deploymentConfig.metaRegistryImpl = vm.parseJsonAddress(config, ".MetaRegistryImpl"); vm.label(deploymentConfig.metaRegistryImpl, "metaRegistryImpl"); + deploymentConfig.additionalBondRegistry = vm.parseJsonAddress(config, ".AdditionalBondRegistry"); + vm.label(deploymentConfig.additionalBondRegistry, "additionalBondRegistry"); + + deploymentConfig.additionalBondRegistryImpl = vm.parseJsonAddress(config, ".AdditionalBondRegistryImpl"); + vm.label(deploymentConfig.additionalBondRegistryImpl, "additionalBondRegistryImpl"); + if (vm.keyExistsJson(config, ".CuratedGateFactory")) { deploymentConfig.curatedGateFactory = vm.parseJsonAddress(config, ".CuratedGateFactory"); } @@ -553,6 +562,9 @@ contract DeploymentHelpers is Test { // Testnet stuff dst.secondAdminAddress = src.secondAdminAddress; + + // AdditionalBondRegistry + dst.additionalBondRegistryConfig = src.additionalBondRegistryConfig; } function parseCommonDeployParams(string memory config) internal view returns (CommonDeployParams memory params) { @@ -839,6 +851,8 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { CuratedModule public curatedModule; CuratedModule public curatedModuleImpl; MetaRegistry public metaRegistry; + AdditionalBondRegistry public additionalBondRegistry; + AdditionalBondRegistry public additionalBondRegistryImpl; CuratedGate public curatedGateImpl; address[] public curatedGates; @@ -952,6 +966,8 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { burner = IBurner(locator.burner()); metaRegistry = MetaRegistry(deploymentConfig.metaRegistry); + additionalBondRegistry = AdditionalBondRegistry(deploymentConfig.additionalBondRegistry); + additionalBondRegistryImpl = AdditionalBondRegistry(deploymentConfig.additionalBondRegistryImpl); curatedGateImpl = CuratedGate(deploymentConfig.curatedGateImpl); curatedGates = deploymentConfig.curatedGates; } diff --git a/test/helpers/mocks/AdditionalBondRegistryMock.sol b/test/helpers/mocks/AdditionalBondRegistryMock.sol index ccfb10ac..f70195b9 100644 --- a/test/helpers/mocks/AdditionalBondRegistryMock.sol +++ b/test/helpers/mocks/AdditionalBondRegistryMock.sol @@ -4,12 +4,12 @@ pragma solidity 0.8.33; import { MAX_BP } from "src/lib/Constants.sol"; -import { OperatorTierState } from "src/interfaces/ITiersRegistry.sol"; +import { OperatorTierState } from "src/interfaces/IAdditionalBondRegistry.sol"; -/// @dev Minimal TiersRegistry mock for MetaRegistry tests. -/// Stores weight multiplier increments above MAX_BP, mirroring TiersRegistry storage. +/// @dev Minimal AdditionalBondRegistry mock for MetaRegistry tests. +/// Stores weight multiplier increments above MAX_BP, mirroring AdditionalBondRegistry storage. /// Effective weightMultiplier = MAX_BP + increment (0 increment = identity = MAX_BP). -contract TiersRegistryMock { +contract AdditionalBondRegistryMock { mapping(uint256 => uint256) private _weightMultiplierInc; function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory state) { From db46c48d2b88810cd77773477ca834caceebff57 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 9 Jun 2026 15:00:52 +0200 Subject: [PATCH 09/17] add todo --- src/lib/Constants.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/Constants.sol b/src/lib/Constants.sol index ed6c5ee6..694f04ed 100644 --- a/src/lib/Constants.sol +++ b/src/lib/Constants.sol @@ -2,5 +2,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.33; +// TODO: Think about the other constants to be placed here + /// @dev Basis points denominator (100 % = 10 000 bp). uint256 constant MAX_BP = 10_000; From 2feff64b027d362580d64fd9ad71f47b30102b75 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 16 Jun 2026 12:55:07 +0200 Subject: [PATCH 10/17] tests: increase cov --- test/unit/Accounting/BondCalculations.t.sol | 70 +++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/test/unit/Accounting/BondCalculations.t.sol b/test/unit/Accounting/BondCalculations.t.sol index 0c15d7fc..2f5e0691 100644 --- a/test/unit/Accounting/BondCalculations.t.sol +++ b/test/unit/Accounting/BondCalculations.t.sol @@ -956,6 +956,76 @@ contract GetRequiredBondForNextKeysAtMultiplierTest is BaseTest { } } +contract GetRequiredBondForNextKeysAtMultiplierWstETHTest is BaseTest { + function test_default() public { + _operator({ ongoing: 16, withdrawn: 0 }); + assertEq(accounting.getRequiredBondForNextKeysWstETH(0, 0, MAX_BP), wstETH.getWstETHByStETH(32 ether)); + } + + function test_WithMultiplier() public { + _operator({ ongoing: 16, withdrawn: 0 }); + assertEq(accounting.getRequiredBondForNextKeysWstETH(0, 0, 15_000), wstETH.getWstETHByStETH(48 ether)); + } + + function test_SubtractsCurrentBond() public { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 16 ether }); + assertEq( + accounting.getRequiredBondForNextKeysWstETH(0, 0, 15_000), + wstETH.getWstETHByStETH(48 ether - accounting.getBond(0)) + ); + } + + function test_RevertWhen_MultiplierBelowMaxBP() public { + _operator({ ongoing: 16, withdrawn: 0 }); + vm.expectRevert(IBondCurve.InvalidMultiplier.selector); + accounting.getRequiredBondForNextKeysWstETH(0, 0, MAX_BP - 1); + } +} + +contract BondCurveMultiplierTest is BaseTest { + function test_default() public { + _operator({ ongoing: 16, withdrawn: 0 }); + assertEq(accounting.getBondCurveMultiplier(0), MAX_BP); + } + + function test_setBondCurveMultiplier() public { + _operator({ ongoing: 16, withdrawn: 0 }); + vm.prank(admin); + accounting.setBondCurveMultiplier(0, 5_000); + assertEq(accounting.getBondCurveMultiplier(0), MAX_BP + 5_000); + } + + function test_setBondCurveMultiplier_ResetsToDefault() public { + _operator({ ongoing: 16, withdrawn: 0 }); + vm.startPrank(admin); + accounting.setBondCurveMultiplier(0, 5_000); + accounting.setBondCurveMultiplier(0, 0); + vm.stopPrank(); + assertEq(accounting.getBondCurveMultiplier(0), MAX_BP); + } + + function test_setBondCurveMultiplier_UpdatesDepositInfo() public { + _operator({ ongoing: 16, withdrawn: 0 }); + vm.expectCall(address(accounting.MODULE()), abi.encodeWithSelector(IBaseModule.updateDepositInfo.selector, 0)); + vm.prank(admin); + accounting.setBondCurveMultiplier(0, 5_000); + } + + function test_setBondCurveMultiplier_RevertWhen_DoesNotHaveRole() public { + expectRoleRevert(stranger, accounting.SET_BOND_CURVE_MULTIPLIER_ROLE()); + vm.prank(stranger); + accounting.setBondCurveMultiplier(0, 5_000); + } + + function test_setBondCurveMultiplier_RevertWhen_OperatorDoesNotExist() public { + mock_getNodeOperatorsCount(0); + vm.expectRevert(IAccounting.NodeOperatorDoesNotExist.selector); + vm.prank(admin); + accounting.setBondCurveMultiplier(0, 5_000); + } +} + // Combined bond summary and shares tests contract GetBondSummaryTest is BondStateBaseTest { From 766789332482f378b18eedc69d507c9b6aa136ed Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 16 Jun 2026 15:23:44 +0200 Subject: [PATCH 11/17] fix: order + event --- src/Accounting.sol | 12 ++++++------ src/AdditionalBondRegistry.sol | 2 +- src/abstract/BondCurve.sol | 11 ++++++----- src/interfaces/IBondCurve.sol | 1 + src/lib/BondCurvesLib.sol | 7 +++---- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Accounting.sol b/src/Accounting.sol index 7bd73e27..c6af0752 100644 --- a/src/Accounting.sol +++ b/src/Accounting.sol @@ -288,12 +288,6 @@ contract Accounting is released = true; } - /// @inheritdoc IAccounting - function unlockExpiredLock(uint256 nodeOperatorId) public { - BondLock._unlockExpiredLock(nodeOperatorId); - MODULE.updateDepositableValidatorsCount(nodeOperatorId); - } - /// @inheritdoc IAccounting function compensateLockedBond(uint256 nodeOperatorId) external onlyModule returns (uint256 compensatedAmount) { uint256 lockedAmount = BondLock.getLockedBond(nodeOperatorId); @@ -457,6 +451,12 @@ contract Accounting is info.pendingSharesToSplit = FeeSplits.getPendingSharesToSplit(nodeOperatorId); } + /// @inheritdoc IAccounting + function unlockExpiredLock(uint256 nodeOperatorId) public { + BondLock._unlockExpiredLock(nodeOperatorId); + MODULE.updateDepositableValidatorsCount(nodeOperatorId); + } + /// @inheritdoc IAccounting function getBondSummary(uint256 nodeOperatorId) public view returns (uint256 current, uint256 required) { current = BondCore.getBond(nodeOperatorId); diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index ae0592d9..b6e1d46d 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -139,7 +139,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces } /// @dev Sets the cooldown deadline to `block.timestamp + CURVE_MULTIPLIER_COOLDOWN`. - function _setCurveMultiplierCooldown(uint256 nodeOperatorId) private { + function _setCurveMultiplierCooldown(uint256 nodeOperatorId) internal { uint256 cooldownUntil = block.timestamp + CURVE_MULTIPLIER_COOLDOWN; _storage().curveMultiplierCooldownUntil[nodeOperatorId] = cooldownUntil; emit CurveMultiplierCooldownSet(nodeOperatorId, cooldownUntil); diff --git a/src/abstract/BondCurve.sol b/src/abstract/BondCurve.sol index 5bdad2e5..08ecd113 100644 --- a/src/abstract/BondCurve.sol +++ b/src/abstract/BondCurve.sol @@ -71,11 +71,6 @@ abstract contract BondCurve is IBondCurve, Initializable { return MAX_BP + _getBondCurveStorage().operatorBondCurveMultiplier[nodeOperatorId]; } - /// @dev Stores the bond curve multiplier increment above MAX_BP (0 = no scaling). - function _setBondCurveMultiplier(uint256 nodeOperatorId, uint256 multiplier) internal { - _getBondCurveStorage().operatorBondCurveMultiplier[nodeOperatorId] = multiplier; - } - /// @inheritdoc IBondCurve function getBondAmountByKeysCount(uint256 keys, uint256 curveId) public view returns (uint256) { return BondCurvesLib.getBondAmountByKeysCount(_getBondCurveStorage(), keys, curveId, MAX_BP); @@ -128,6 +123,12 @@ abstract contract BondCurve is IBondCurve, Initializable { emit BondCurveSet(nodeOperatorId, curveId, msg.sender); } + /// @dev Stores the bond curve multiplier increment above MAX_BP (0 = no scaling). + function _setBondCurveMultiplier(uint256 nodeOperatorId, uint256 multiplier) internal { + _getBondCurveStorage().operatorBondCurveMultiplier[nodeOperatorId] = multiplier; + emit BondCurveMultiplierSet(nodeOperatorId, multiplier, msg.sender); + } + function _getCurveInfo(uint256 curveId) private view returns (BondCurveData storage) { BondCurveStorage storage $ = _getBondCurveStorage(); BondCurvesLib._ensureCurveExists($, curveId); diff --git a/src/interfaces/IBondCurve.sol b/src/interfaces/IBondCurve.sol index 73b7de9b..589949de 100644 --- a/src/interfaces/IBondCurve.sol +++ b/src/interfaces/IBondCurve.sol @@ -61,6 +61,7 @@ interface IBondCurve { event BondCurveAdded(uint256 indexed curveId, BondCurveIntervalInput[] bondCurveIntervals); event BondCurveUpdated(uint256 indexed curveId, BondCurveIntervalInput[] bondCurveIntervals); event BondCurveSet(uint256 indexed nodeOperatorId, uint256 curveId, address indexed setter); + event BondCurveMultiplierSet(uint256 indexed nodeOperatorId, uint256 multiplier, address indexed setter); error InvalidBondCurveLength(); error InvalidBondCurveValues(); diff --git a/src/lib/BondCurvesLib.sol b/src/lib/BondCurvesLib.sol index c046c46d..10e93cfc 100644 --- a/src/lib/BondCurvesLib.sol +++ b/src/lib/BondCurvesLib.sol @@ -67,11 +67,10 @@ library BondCurvesLib { uint256 multiplier ) external view returns (uint256) { IBondCurve.BondCurveInterval[] memory intervals = _loadCurve(bondCurveStorage, curveId, multiplier); + // intervals[0].minBond is essentially the amount of bond required for the very first key + if (amount < intervals[0].minBond) return 0; unchecked { - // intervals[0].minBond is essentially the amount of bond required for the very first key - if (amount < intervals[0].minBond) return 0; - uint256 low = 0; uint256 high = intervals.length - 1; while (low < high) { @@ -128,7 +127,7 @@ library BondCurvesLib { BondCurve.BondCurveStorage storage bondCurveStorage, uint256 curveId, uint256 multiplier - ) private view returns (IBondCurve.BondCurveInterval[] memory curve) { + ) internal view returns (IBondCurve.BondCurveInterval[] memory curve) { _ensureCurveExists(bondCurveStorage, curveId); IBondCurve.BondCurveInterval[] storage src = bondCurveStorage.bondCurves[curveId].intervals; if (multiplier == MAX_BP) return src; From afef133f47f540e1f1ae837f0cc83d08ce93a367 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 16 Jun 2026 15:26:04 +0200 Subject: [PATCH 12/17] fix: add comments --- src/AdditionalBondRegistry.sol | 1 + src/interfaces/IBondCurve.sol | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index b6e1d46d..6e958219 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -81,6 +81,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces uint256 newMul = MAX_BP + newMulInc; if (newMul > ACCOUNTING.getBondCurveMultiplier(nodeOperatorId)) { // 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 InsufficientBondForTier(); if ($.curveMultiplierCooldownUntil[nodeOperatorId] != 0) { delete $.curveMultiplierCooldownUntil[nodeOperatorId]; diff --git a/src/interfaces/IBondCurve.sol b/src/interfaces/IBondCurve.sol index 589949de..883d191e 100644 --- a/src/interfaces/IBondCurve.sol +++ b/src/interfaces/IBondCurve.sol @@ -96,7 +96,7 @@ interface IBondCurve { /// MAX_BP (10_000) means no scaling, and is the default when none is set. function getBondCurveMultiplier(uint256 nodeOperatorId) external view returns (uint256); - /// @notice Get required bond in ETH for the given number of keys for the given bond curve + /// @notice Get required bond in ETH for the given number of keys for the given bond curve with default `multiplier`. /// @dev To calculate the amount for the new keys 2 calls are required: /// getBondAmountByKeysCount(newTotal) - getBondAmountByKeysCount(currentTotal) /// @param keys Number of keys to get required bond for @@ -116,7 +116,7 @@ interface IBondCurve { uint256 multiplier ) external view returns (uint256); - /// @notice Get keys count for the given bond amount with the given bond curve + /// @notice Get keys count for the given bond amount with the given bond curve with default `multiplier`. /// @param amount Bond amount in ETH (stETH) to get keys count for /// @param curveId Id of the curve to perform calculations against /// @return Keys count From 7072970c66c38693a6fd6b5a2d096a986bc56304 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 16 Jun 2026 15:34:46 +0200 Subject: [PATCH 13/17] fix: add `_removeCurveMultiplierCooldown` --- src/AdditionalBondRegistry.sol | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index 6e958219..7149b960 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -23,8 +23,9 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces mapping(uint256 nodeOperatorId => uint256) curveMultiplierCooldownUntil; } - uint256 public constant MAX_CURVE_MULTIPLIER_INC = 10 * MAX_BP; - uint256 public constant MAX_WEIGHT_MULTIPLIER_INC = 10 * MAX_BP; + // NOTE: Sanity guard for tier creation: effective multiplier <= 10x the default multiplier. + uint256 public constant MAX_CURVE_MULTIPLIER_INC = 9 * MAX_BP; + uint256 public constant MAX_WEIGHT_MULTIPLIER_INC = 9 * MAX_BP; ICuratedModule public immutable MODULE; IAccounting public immutable ACCOUNTING; @@ -84,8 +85,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces // Value `0` as a second arg for the following method means current keys count. if (ACCOUNTING.getRequiredBondForNextKeys(nodeOperatorId, 0, newMul) > 0) revert InsufficientBondForTier(); if ($.curveMultiplierCooldownUntil[nodeOperatorId] != 0) { - delete $.curveMultiplierCooldownUntil[nodeOperatorId]; - emit CurveMultiplierCooldownRemoved(nodeOperatorId); + _removeCurveMultiplierCooldown(nodeOperatorId); } ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, newMulInc); } else { @@ -108,8 +108,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces if (cooldownUntil == 0) revert NoCurveMultiplierCooldown(); if (cooldownUntil > block.timestamp) revert CurveMultiplierCooldownNotElapsed(); - delete $.curveMultiplierCooldownUntil[nodeOperatorId]; - emit CurveMultiplierCooldownRemoved(nodeOperatorId); + _removeCurveMultiplierCooldown(nodeOperatorId); ACCOUNTING.setBondCurveMultiplier( nodeOperatorId, @@ -146,6 +145,11 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces emit CurveMultiplierCooldownSet(nodeOperatorId, cooldownUntil); } + function _removeCurveMultiplierCooldown(uint256 nodeOperatorId) internal { + delete _storage().curveMultiplierCooldownUntil[nodeOperatorId]; + emit CurveMultiplierCooldownRemoved(nodeOperatorId); + } + function _checkOperatorOwner(uint256 nodeOperatorId) internal view { if (msg.sender != MODULE.getNodeOperatorOwner(nodeOperatorId)) revert SenderIsNotOperatorOwner(); } From e429e6edc7948eac0d26e3fce99b6eace663bba5 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 16 Jun 2026 17:01:19 +0200 Subject: [PATCH 14/17] fix: releaseCurveMultiplier -> applyCurveMultiplier --- src/AdditionalBondRegistry.sol | 4 +-- src/interfaces/IAdditionalBondRegistry.sol | 8 +++--- .../deployment/PostDeploymentCurated.t.sol | 4 +-- test/unit/AdditionalBondRegistry.t.sol | 26 +++++++++---------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index 7149b960..4780befc 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -37,7 +37,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces 0xe06435b00cfe5ab72c52612ef2f4c7b5f9c4cc44634ef79a78a1888f5b1eb300; /// @param module CuratedModule address. - /// @param curveMultiplierCooldown Cooldown in seconds after a tier downgrade before `releaseCurveMultiplier` can be called. + /// @param curveMultiplierCooldown Cooldown in seconds after a tier downgrade before `applyCurveMultiplier` can be called. constructor(address module, uint256 curveMultiplierCooldown) { MODULE = ICuratedModule(module); ACCOUNTING = IAccounting(MODULE.ACCOUNTING()); @@ -100,7 +100,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces } /// @inheritdoc IAdditionalBondRegistry - function releaseCurveMultiplier(uint256 nodeOperatorId) external { + function applyCurveMultiplier(uint256 nodeOperatorId) external { _checkOperatorOwner(nodeOperatorId); AdditionalBondRegistryStorage storage $ = _storage(); diff --git a/src/interfaces/IAdditionalBondRegistry.sol b/src/interfaces/IAdditionalBondRegistry.sol index 26a6d228..64591f34 100644 --- a/src/interfaces/IAdditionalBondRegistry.sol +++ b/src/interfaces/IAdditionalBondRegistry.sol @@ -14,7 +14,7 @@ struct TierInfo { } /// @dev Operator's effective tier state, with multipliers as full basis-point values (not `TierInfo` increments). -/// During a downgrade cooldown `curveMultiplier` keeps the pre-downgrade value until `releaseCurveMultiplier`, +/// During a downgrade cooldown `curveMultiplier` keeps the pre-downgrade value until `applyCurveMultiplier`, /// so it may exceed the current tier's value (and stay above MAX_BP while `tierId == 0`). struct OperatorTierState { uint256 tierId; @@ -56,7 +56,7 @@ interface IAdditionalBondRegistry { /// @notice Upper bound for `weightMultiplierInc`. function MAX_WEIGHT_MULTIPLIER_INC() external view returns (uint256); - /// @notice Cooldown in seconds after a downgrade before `releaseCurveMultiplier` can be called. + /// @notice Cooldown in seconds after a downgrade before `applyCurveMultiplier` can be called. function CURVE_MULTIPLIER_COOLDOWN() external view returns (uint256); /// @notice Initialize the provider. @@ -72,7 +72,7 @@ interface IAdditionalBondRegistry { /// @notice Select a bond tier for the Node Operator. An upgrade (target effective curve multiplier above the /// operator's current one) applies both multipliers at once and requires the bond to cover the new /// requirement; a downgrade applies the new weight now but keeps the higher curve multiplier until - /// `releaseCurveMultiplier`. Either clears an active cooldown (upgrade) or reverts on it (downgrade). + /// `applyCurveMultiplier`. Either clears an active cooldown (upgrade) or reverts on it (downgrade). /// @param nodeOperatorId ID of the Node Operator. /// @param tierId Target tier ID (0 = default tier). function selectTier(uint256 nodeOperatorId, uint256 tierId) external; @@ -80,7 +80,7 @@ interface IAdditionalBondRegistry { /// @notice Apply a pending downgrade after its cooldown elapses, lowering the curve multiplier to the current /// tier. Callable only by the Node Operator owner. /// @param nodeOperatorId ID of the Node Operator. - function releaseCurveMultiplier(uint256 nodeOperatorId) external; + function applyCurveMultiplier(uint256 nodeOperatorId) external; /// @notice Number of stored tiers (not counting the implicit default tier 0). function getTiersCount() external view returns (uint256); diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index d14eba0c..246560d3 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -149,12 +149,12 @@ contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { ); assertEq( additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC(), - 100_000, + 90_000, "additional bond registry max curve multiplier" ); assertEq( additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC(), - 100_000, + 90_000, "additional bond registry max weight multiplier" ); } diff --git a/test/unit/AdditionalBondRegistry.t.sol b/test/unit/AdditionalBondRegistry.t.sol index ed3d2fcf..bd3f73b9 100644 --- a/test/unit/AdditionalBondRegistry.t.sol +++ b/test/unit/AdditionalBondRegistry.t.sol @@ -64,8 +64,8 @@ contract AdditionalBondRegistryConstructorTest is AdditionalBondRegistryBaseTest assertEq(address(additionalBondRegistry.MODULE()), address(module)); assertEq(address(additionalBondRegistry.ACCOUNTING()), address(module.ACCOUNTING())); assertEq(address(additionalBondRegistry.META_REGISTRY()), address(metaRegistryMock)); - assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC(), 100_000); - assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC(), 100_000); + assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC(), 90_000); + assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC(), 90_000); assertEq(additionalBondRegistry.CURVE_MULTIPLIER_COOLDOWN(), CURVE_MULTIPLIER_COOLDOWN); } } @@ -331,19 +331,19 @@ contract AdditionalBondRegistryReleaseCurveMultiplierTest is AdditionalBondRegis additionalBondRegistry.selectTier(0, 0); } - function test_releaseCurveMultiplier() public { + function test_applyCurveMultiplier() public { vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); vm.expectEmit(true, false, false, false, address(additionalBondRegistry)); emit IAdditionalBondRegistry.CurveMultiplierCooldownRemoved(0); vm.prank(nodeOperatorOwner); - additionalBondRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplier(0); assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP); } - function test_releaseCurveMultiplier_SettlesToCurrentTierNotDefault() public { + function test_applyCurveMultiplier_SettlesToCurrentTierNotDefault() public { vm.prank(nodeOperatorOwner); additionalBondRegistry.selectTier(0, 2); vm.prank(nodeOperatorOwner); @@ -352,32 +352,32 @@ contract AdditionalBondRegistryReleaseCurveMultiplierTest is AdditionalBondRegis vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); vm.prank(nodeOperatorOwner); - additionalBondRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplier(0); assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); } - function test_releaseCurveMultiplier_RevertWhen_NotOwner() public { + function test_applyCurveMultiplier_RevertWhen_NotOwner() public { vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); vm.expectRevert(IAdditionalBondRegistry.SenderIsNotOperatorOwner.selector); vm.prank(stranger); - additionalBondRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplier(0); } - function test_releaseCurveMultiplier_RevertWhen_NoCurveMultiplierCooldown() public { + function test_applyCurveMultiplier_RevertWhen_NoCurveMultiplierCooldown() public { vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); vm.prank(nodeOperatorOwner); - additionalBondRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplier(0); vm.expectRevert(IAdditionalBondRegistry.NoCurveMultiplierCooldown.selector); vm.prank(nodeOperatorOwner); - additionalBondRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplier(0); } - function test_releaseCurveMultiplier_RevertWhen_CurveMultiplierCooldownNotElapsed() public { + function test_applyCurveMultiplier_RevertWhen_CurveMultiplierCooldownNotElapsed() public { vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierCooldownNotElapsed.selector); vm.prank(nodeOperatorOwner); - additionalBondRegistry.releaseCurveMultiplier(0); + additionalBondRegistry.applyCurveMultiplier(0); } } From 17a329b383788463148197903e7272c88435b095 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Wed, 17 Jun 2026 10:21:47 +0200 Subject: [PATCH 15/17] fix: `getTierInfo` --- src/AdditionalBondRegistry.sol | 36 ++++++++++--------- src/interfaces/IAdditionalBondRegistry.sol | 27 +++++++------- .../deployment/PostDeploymentCurated.t.sol | 4 +-- .../mocks/AdditionalBondRegistryMock.sol | 12 +++---- test/unit/AdditionalBondRegistry.t.sol | 36 +++++++++---------- test/unit/MetaRegistry.t.sol | 4 +-- 6 files changed, 61 insertions(+), 58 deletions(-) diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index 4780befc..0b2f8fa4 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -24,8 +24,8 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces } // NOTE: Sanity guard for tier creation: effective multiplier <= 10x the default multiplier. - uint256 public constant MAX_CURVE_MULTIPLIER_INC = 9 * MAX_BP; - uint256 public constant MAX_WEIGHT_MULTIPLIER_INC = 9 * MAX_BP; + uint256 public constant MAX_CURVE_MULTIPLIER = 9 * MAX_BP; + uint256 public constant MAX_WEIGHT_MULTIPLIER = 9 * MAX_BP; ICuratedModule public immutable MODULE; IAccounting public immutable ACCOUNTING; @@ -56,18 +56,18 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces /// @inheritdoc IAdditionalBondRegistry function addTier( - uint256 curveMultiplierInc, - uint256 weightMultiplierInc + uint256 curveMultiplier, + uint256 weightMultiplier ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 tierId) { - if (curveMultiplierInc > MAX_CURVE_MULTIPLIER_INC) revert InvalidCurveMultiplier(); - if (weightMultiplierInc > MAX_WEIGHT_MULTIPLIER_INC) revert InvalidWeightMultiplier(); + if (curveMultiplier > MAX_CURVE_MULTIPLIER) revert InvalidCurveMultiplier(); + if (weightMultiplier > MAX_WEIGHT_MULTIPLIER) revert InvalidWeightMultiplier(); AdditionalBondRegistryStorage storage $ = _storage(); tierId = ++$.tiersCount; $.tiers[tierId] = TierInfo({ - curveMultiplierInc: uint128(curveMultiplierInc), - weightMultiplierInc: uint128(weightMultiplierInc) + curveMultiplier: uint128(curveMultiplier), + weightMultiplier: uint128(weightMultiplier) }); - emit TierAdded(tierId, curveMultiplierInc, weightMultiplierInc); + emit TierAdded(tierId, curveMultiplier, weightMultiplier); } /// @inheritdoc IAdditionalBondRegistry @@ -78,7 +78,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces if (tierId > $.tiersCount) revert InvalidTierId(); if (tierId == $.operatorTier[nodeOperatorId]) revert SameTier(); - uint256 newMulInc = getTierInfo(tierId).curveMultiplierInc; + uint256 newMulInc = $.tiers[tierId].curveMultiplier; uint256 newMul = MAX_BP + newMulInc; if (newMul > ACCOUNTING.getBondCurveMultiplier(nodeOperatorId)) { // NOTE: Takes into account current bond amount and keys count. @@ -110,10 +110,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces _removeCurveMultiplierCooldown(nodeOperatorId); - ACCOUNTING.setBondCurveMultiplier( - nodeOperatorId, - getTierInfo($.operatorTier[nodeOperatorId]).curveMultiplierInc - ); + ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, $.tiers[$.operatorTier[nodeOperatorId]].curveMultiplier); } /// @inheritdoc IAdditionalBondRegistry @@ -126,7 +123,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces AdditionalBondRegistryStorage storage $ = _storage(); state.tierId = $.operatorTier[nodeOperatorId]; state.curveMultiplierCooldownUntil = $.curveMultiplierCooldownUntil[nodeOperatorId]; - state.weightMultiplier = MAX_BP + $.tiers[state.tierId].weightMultiplierInc; + state.weightMultiplier = MAX_BP + $.tiers[state.tierId].weightMultiplier; state.curveMultiplier = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); } @@ -134,8 +131,12 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces function getTierInfo(uint256 tierId) public view returns (TierInfo memory) { AdditionalBondRegistryStorage storage $ = _storage(); if (tierId > $.tiersCount) revert InvalidTierId(); - if (tierId == 0) return TierInfo({ curveMultiplierInc: 0, weightMultiplierInc: 0 }); - return $.tiers[tierId]; + TierInfo storage t = $.tiers[tierId]; + return + TierInfo({ + curveMultiplier: uint128(MAX_BP + t.curveMultiplier), + weightMultiplier: uint128(MAX_BP + t.weightMultiplier) + }); } /// @dev Sets the cooldown deadline to `block.timestamp + CURVE_MULTIPLIER_COOLDOWN`. @@ -150,6 +151,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces emit CurveMultiplierCooldownRemoved(nodeOperatorId); } + // 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/IAdditionalBondRegistry.sol b/src/interfaces/IAdditionalBondRegistry.sol index 64591f34..6b37e59d 100644 --- a/src/interfaces/IAdditionalBondRegistry.sol +++ b/src/interfaces/IAdditionalBondRegistry.sol @@ -7,10 +7,11 @@ import { IAccounting } from "./IAccounting.sol"; import { ICuratedModule } from "./ICuratedModule.sol"; import { IMetaRegistry } from "./IMetaRegistry.sol"; -/// @dev Bond tier. Multipliers are stored as increments above MAX_BP (effective = MAX_BP + increment). +/// @dev Bond tier. Fields hold increments above MAX_BP in storage; `getTierInfo` returns them as full +/// effective multipliers (MAX_BP + stored increment). struct TierInfo { - uint128 curveMultiplierInc; - uint128 weightMultiplierInc; + uint128 curveMultiplier; + uint128 weightMultiplier; } /// @dev Operator's effective tier state, with multipliers as full basis-point values (not `TierInfo` increments). @@ -25,7 +26,7 @@ struct OperatorTierState { /// @notice Manages operator bond tiers and associated tier downgrade cooldown state. interface IAdditionalBondRegistry { - event TierAdded(uint256 indexed tierId, uint256 curveMultiplierInc, uint256 weightMultiplierInc); + event TierAdded(uint256 indexed tierId, uint256 curveMultiplier, uint256 weightMultiplier); event TierSelected(uint256 indexed nodeOperatorId, uint256 tierId); event CurveMultiplierCooldownSet(uint256 indexed nodeOperatorId, uint256 cooldownUntil); event CurveMultiplierCooldownRemoved(uint256 indexed nodeOperatorId); @@ -50,11 +51,11 @@ interface IAdditionalBondRegistry { /// @notice MetaRegistry called back via `refreshOperatorWeight` on tier changes. function META_REGISTRY() external view returns (IMetaRegistry); - /// @notice Upper bound for `curveMultiplierInc`. - function MAX_CURVE_MULTIPLIER_INC() external view returns (uint256); + /// @notice Upper bound for `curveMultiplier`. + function MAX_CURVE_MULTIPLIER() external view returns (uint256); - /// @notice Upper bound for `weightMultiplierInc`. - function MAX_WEIGHT_MULTIPLIER_INC() external view returns (uint256); + /// @notice Upper bound for `weightMultiplier`. + function MAX_WEIGHT_MULTIPLIER() external view returns (uint256); /// @notice Cooldown in seconds after a downgrade before `applyCurveMultiplier` can be called. function CURVE_MULTIPLIER_COOLDOWN() external view returns (uint256); @@ -64,10 +65,10 @@ interface IAdditionalBondRegistry { function initialize(address admin) external; /// @notice Add a new bond tier. Tier IDs are assigned sequentially starting from 1. - /// @param curveMultiplierInc Curve multiplier increment above MAX_BP (must be <= MAX_CURVE_MULTIPLIER_INC). - /// @param weightMultiplierInc Weight multiplier increment above MAX_BP (must be <= MAX_WEIGHT_MULTIPLIER_INC). + /// @param curveMultiplier Curve multiplier increment above MAX_BP (must be <= MAX_CURVE_MULTIPLIER). + /// @param weightMultiplier Weight multiplier increment above MAX_BP (must be <= MAX_WEIGHT_MULTIPLIER). /// @return tierId ID of the newly created tier. - function addTier(uint256 curveMultiplierInc, uint256 weightMultiplierInc) external returns (uint256 tierId); + function addTier(uint256 curveMultiplier, uint256 weightMultiplier) external returns (uint256 tierId); /// @notice Select a bond tier for the Node Operator. An upgrade (target effective curve multiplier above the /// operator's current one) applies both multipliers at once and requires the bond to cover the new @@ -85,8 +86,8 @@ interface IAdditionalBondRegistry { /// @notice Number of stored tiers (not counting the implicit default tier 0). function getTiersCount() external view returns (uint256); - /// @notice Static parameters of a tier definition, as increments above MAX_BP. - /// @dev Do not use this to read an operator's effective bond multiplier — use `getOperatorTierState`. + /// @notice Effective multipliers of a tier as full basis-point values (tier 0 = MAX_BP, no scaling). + /// @dev For an operator's CURRENT curve multiplier (which may lag during a downgrade cooldown) use `getOperatorTierState`. function getTierInfo(uint256 tierId) external view returns (TierInfo memory); /// @notice Full effective tier-related state of a Node Operator. diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index 246560d3..090cc9cb 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -148,12 +148,12 @@ contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { "additional bond registry cooldown" ); assertEq( - additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC(), + additionalBondRegistry.MAX_CURVE_MULTIPLIER(), 90_000, "additional bond registry max curve multiplier" ); assertEq( - additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC(), + additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(), 90_000, "additional bond registry max weight multiplier" ); diff --git a/test/helpers/mocks/AdditionalBondRegistryMock.sol b/test/helpers/mocks/AdditionalBondRegistryMock.sol index f70195b9..daa600cc 100644 --- a/test/helpers/mocks/AdditionalBondRegistryMock.sol +++ b/test/helpers/mocks/AdditionalBondRegistryMock.sol @@ -7,16 +7,16 @@ import { MAX_BP } from "src/lib/Constants.sol"; import { OperatorTierState } from "src/interfaces/IAdditionalBondRegistry.sol"; /// @dev Minimal AdditionalBondRegistry mock for MetaRegistry tests. -/// Stores weight multiplier increments above MAX_BP, mirroring AdditionalBondRegistry storage. -/// Effective weightMultiplier = MAX_BP + increment (0 increment = identity = MAX_BP). +/// Stores the weight multiplier increment above MAX_BP, mirroring AdditionalBondRegistry storage; +/// getOperatorTierState returns the effective value MAX_BP + increment (0 = identity = MAX_BP). contract AdditionalBondRegistryMock { - mapping(uint256 => uint256) private _weightMultiplierInc; + mapping(uint256 => uint256) private _weightMultiplier; function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory state) { - state.weightMultiplier = MAX_BP + _weightMultiplierInc[nodeOperatorId]; + state.weightMultiplier = MAX_BP + _weightMultiplier[nodeOperatorId]; } - function mock_setWeightMultiplierInc(uint256 nodeOperatorId, uint256 increment) external { - _weightMultiplierInc[nodeOperatorId] = increment; + function mock_setWeightMultiplier(uint256 nodeOperatorId, uint256 weightMultiplier) external { + _weightMultiplier[nodeOperatorId] = weightMultiplier; } } diff --git a/test/unit/AdditionalBondRegistry.t.sol b/test/unit/AdditionalBondRegistry.t.sol index bd3f73b9..6fd9301c 100644 --- a/test/unit/AdditionalBondRegistry.t.sol +++ b/test/unit/AdditionalBondRegistry.t.sol @@ -64,8 +64,8 @@ contract AdditionalBondRegistryConstructorTest is AdditionalBondRegistryBaseTest assertEq(address(additionalBondRegistry.MODULE()), address(module)); assertEq(address(additionalBondRegistry.ACCOUNTING()), address(module.ACCOUNTING())); assertEq(address(additionalBondRegistry.META_REGISTRY()), address(metaRegistryMock)); - assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC(), 90_000); - assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC(), 90_000); + assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER(), 90_000); + assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(), 90_000); assertEq(additionalBondRegistry.CURVE_MULTIPLIER_COOLDOWN(), CURVE_MULTIPLIER_COOLDOWN); } } @@ -107,8 +107,8 @@ contract AdditionalBondRegistryAddTierTest is AdditionalBondRegistryBaseTest { assertEq(tierId, 1); assertEq(additionalBondRegistry.getTiersCount(), 1); TierInfo memory t = additionalBondRegistry.getTierInfo(1); - assertEq(t.curveMultiplierInc, T1_BOND); - assertEq(t.weightMultiplierInc, T1_WEIGHT); + assertEq(t.curveMultiplier, MAX_BP + T1_BOND); + assertEq(t.weightMultiplier, MAX_BP + T1_WEIGHT); } function test_addTier_SecondTier() public { @@ -121,24 +121,24 @@ contract AdditionalBondRegistryAddTierTest is AdditionalBondRegistryBaseTest { function test_addTier_AllowsZeroCurveMultiplierInc() public { uint256 tierId = _addTier(0, T1_WEIGHT); TierInfo memory t = additionalBondRegistry.getTierInfo(tierId); - assertEq(t.curveMultiplierInc, 0); - assertEq(t.weightMultiplierInc, T1_WEIGHT); + assertEq(t.curveMultiplier, MAX_BP); + assertEq(t.weightMultiplier, MAX_BP + T1_WEIGHT); } function test_addTier_AllowsZeroWeightMultiplierInc() public { uint256 tierId = _addTier(T1_BOND, 0); TierInfo memory t = additionalBondRegistry.getTierInfo(tierId); - assertEq(t.curveMultiplierInc, T1_BOND); - assertEq(t.weightMultiplierInc, 0); + assertEq(t.curveMultiplier, MAX_BP + T1_BOND); + assertEq(t.weightMultiplier, MAX_BP); } function test_addTier_AllowsMaxIncrement() public { - uint256 maxCurve = additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC(); - uint256 maxWeight = additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC(); + uint256 maxCurve = additionalBondRegistry.MAX_CURVE_MULTIPLIER(); + uint256 maxWeight = additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(); uint256 tierId = _addTier(maxCurve, maxWeight); TierInfo memory t = additionalBondRegistry.getTierInfo(tierId); - assertEq(t.curveMultiplierInc, maxCurve); - assertEq(t.weightMultiplierInc, maxWeight); + assertEq(t.curveMultiplier, MAX_BP + maxCurve); + assertEq(t.weightMultiplier, MAX_BP + maxWeight); } function test_addTier_RevertWhen_NotAdmin() public { @@ -148,13 +148,13 @@ contract AdditionalBondRegistryAddTierTest is AdditionalBondRegistryBaseTest { } function test_addTier_RevertWhen_BondMulAboveMax() public { - uint256 aboveMax = additionalBondRegistry.MAX_CURVE_MULTIPLIER_INC() + 1; + uint256 aboveMax = additionalBondRegistry.MAX_CURVE_MULTIPLIER() + 1; vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplier.selector); _addTier(aboveMax, T1_WEIGHT); } function test_addTier_RevertWhen_WeightMulAboveMax() public { - uint256 aboveMax = additionalBondRegistry.MAX_WEIGHT_MULTIPLIER_INC() + 1; + uint256 aboveMax = additionalBondRegistry.MAX_WEIGHT_MULTIPLIER() + 1; vm.expectRevert(IAdditionalBondRegistry.InvalidWeightMultiplier.selector); _addTier(T1_BOND, aboveMax); } @@ -394,14 +394,14 @@ contract AdditionalBondRegistryViewsTest is AdditionalBondRegistrySelectTierBase function test_getTierInfo_Tier0() public view { TierInfo memory t = additionalBondRegistry.getTierInfo(0); - assertEq(t.curveMultiplierInc, 0); - assertEq(t.weightMultiplierInc, 0); + assertEq(t.curveMultiplier, MAX_BP); + assertEq(t.weightMultiplier, MAX_BP); } function test_getTierInfo_Tier1() public view { TierInfo memory t = additionalBondRegistry.getTierInfo(1); - assertEq(t.curveMultiplierInc, T1_BOND); - assertEq(t.weightMultiplierInc, T1_WEIGHT); + assertEq(t.curveMultiplier, MAX_BP + T1_BOND); + assertEq(t.weightMultiplier, MAX_BP + T1_WEIGHT); } function test_getTierInfo_RevertWhen_InvalidTierId() public { diff --git a/test/unit/MetaRegistry.t.sol b/test/unit/MetaRegistry.t.sol index 824b4ab5..447243b4 100644 --- a/test/unit/MetaRegistry.t.sol +++ b/test/unit/MetaRegistry.t.sol @@ -1213,7 +1213,7 @@ contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { _createGroup(_subOperatorsArr1(noId, MAX_BP), _extOperatorsArr0()); _setBondCurveWeight(0, CURVE_WEIGHT); - additionalBondRegistry.mock_setWeightMultiplierInc(noId, 5_000); + additionalBondRegistry.mock_setWeightMultiplier(noId, 5_000); registry.refreshOperatorWeight(noId); (uint256 weight, ) = registry.getNodeOperatorWeightAndExternalStake(noId); @@ -1228,7 +1228,7 @@ contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { _createGroup(_subOperatorsArr2(op0, op1), _extOperatorsArr0()); _setBondCurveWeight(0, CURVE_WEIGHT); - additionalBondRegistry.mock_setWeightMultiplierInc(0, 5_000); + additionalBondRegistry.mock_setWeightMultiplier(0, 5_000); registry.refreshOperatorWeight(0); (uint256 weight, ) = registry.getNodeOperatorWeightAndExternalStake(0); From c0c7c2c62a27aeda2f77e06ee8fd3c092a697e0f Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Wed, 17 Jun 2026 15:52:46 +0200 Subject: [PATCH 16/17] fix: check event --- test/unit/Accounting/BondCalculations.t.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/Accounting/BondCalculations.t.sol b/test/unit/Accounting/BondCalculations.t.sol index 2f5e0691..aed81c82 100644 --- a/test/unit/Accounting/BondCalculations.t.sol +++ b/test/unit/Accounting/BondCalculations.t.sol @@ -991,6 +991,8 @@ contract BondCurveMultiplierTest is BaseTest { function test_setBondCurveMultiplier() public { _operator({ ongoing: 16, withdrawn: 0 }); + vm.expectEmit(address(accounting)); + emit IBondCurve.BondCurveMultiplierSet(0, 5_000, admin); vm.prank(admin); accounting.setBondCurveMultiplier(0, 5_000); assertEq(accounting.getBondCurveMultiplier(0), MAX_BP + 5_000); From befcad3bed791cb0b060007b9d5a85b6d551bfe0 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Thu, 18 Jun 2026 11:16:14 +0200 Subject: [PATCH 17/17] fix: `optimizer_runs` --- foundry.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundry.toml b/foundry.toml index df7af207..847da51c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,7 +1,7 @@ [profile.default] evm_version = "osaka" optimizer = true -optimizer_runs = 50 +optimizer_runs = 25 bytecode_hash = "none" # The metadata hash removed from the bytecode (not the metadata itself). # uncomment this to inspect storage layouts in build artifacts # extra_output = ["storageLayout"]