From 4776772cd85e2e4328abd60f54001fa65b3e830c Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Tue, 12 May 2026 17:29:53 +0200 Subject: [PATCH 1/4] feat: bump foundry --- .foundryref | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.foundryref b/.foundryref index 53b5bbb12..a97fc4411 100644 --- a/.foundryref +++ b/.foundryref @@ -1 +1 @@ -v1.5.1 +v1.7.1 From 3650d3ecbf5243d099a3b6b51da781c54fd5ba47 Mon Sep 17 00:00:00 2001 From: Vladimir Gorkavenko <32727352+vgorkavenko@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:37:49 +0200 Subject: [PATCH 2/4] feat: bond curve multiplier (#810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `AdditionalBondRegistry`: operators call `selectTier(tierId)` to apply a **curve multiplier** (scales bond requirement) + **weight multiplier** (scales MetaRegistry effective weight); stored as increments above `MAX_BP`. - Curve scaled on-the-fly in `BondCurvesLib` (effective curve = stored trends × multiplier). - **Upgrade**: applies both multipliers, requires bond ≥ new requirement. - **Downgrade**: weight drops immediately, curve multiplier retained until `releaseCurveMultiplier` (after `CURVE_MULTIPLIER_COOLDOWN`); decided vs current **effective** multiplier → no intermediate-tier escape. - [x] Appropriate PR labels applied - [x] Test coverage maintained (`just coverage`) - [x] Tests are added/updated - [x] Documentation maintained - [x] No need to update --------- Co-authored-by: Dmitry Gusakov --- foundry.toml | 2 +- script/curated/DeployBase.s.sol | 35 +- script/curated/DeployHoodi.s.sol | 6 +- script/curated/DeployLocalDevNet.s.sol | 6 +- script/curated/DeployMainnet.s.sol | 6 +- src/Accounting.sol | 84 +++- src/AdditionalBondRegistry.sol | 165 +++++++ src/MetaRegistry.sol | 22 +- src/abstract/BondCurve.sol | 32 +- src/abstract/FeeSplits.sol | 2 +- src/interfaces/IAccounting.sol | 31 ++ src/interfaces/IAdditionalBondRegistry.sol | 95 ++++ src/interfaces/IBondCurve.sol | 34 +- src/interfaces/IMetaRegistry.sol | 4 + src/lib/BondCurvesLib.sol | 47 +- src/lib/Constants.sol | 8 + .../deployment/PostDeploymentCurated.t.sol | 89 ++++ test/helpers/Fixtures.sol | 16 + test/helpers/mocks/AccountingMock.sol | 34 ++ .../mocks/AdditionalBondRegistryMock.sol | 22 + test/helpers/mocks/MetaRegistryMock.sol | 8 + test/unit/Accounting/BondCalculations.t.sol | 164 +++++++ test/unit/Accounting/ClaimRewards.t.sol | 76 +++ test/unit/Accounting/UnbondedKeys.t.sol | 14 + test/unit/Accounting/_Base.t.sol | 13 + test/unit/AdditionalBondRegistry.t.sol | 442 ++++++++++++++++++ test/unit/MetaRegistry.t.sol | 51 +- test/unit/abstract/BondCurve.t.sol | 214 ++++++++- 28 files changed, 1646 insertions(+), 76 deletions(-) create mode 100644 src/AdditionalBondRegistry.sol create mode 100644 src/interfaces/IAdditionalBondRegistry.sol create mode 100644 src/lib/Constants.sol create mode 100644 test/helpers/mocks/AdditionalBondRegistryMock.sol create mode 100644 test/unit/AdditionalBondRegistry.t.sol diff --git a/foundry.toml b/foundry.toml index bd63328d6..847da51cb 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,7 +1,7 @@ [profile.default] evm_version = "osaka" optimizer = true -optimizer_runs = 150 +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"] diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 273160167..bd76be936 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 018fb29f0..f2a7f3124 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 c806ae76d..2944c46a1 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"; import { BaseOracle } from "../../src/lib/base-oracle/BaseOracle.sol"; import { HashConsensus } from "../../src/lib/base-oracle/HashConsensus.sol"; @@ -30,6 +30,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 67535bb0a..381a2ecb8 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 { @@ -194,6 +194,10 @@ contract DeployMainnet is DeployBase { // DG config.resealManager = 0x7914b5a1539b97Bd0bbd155757F25FD79A522d24; + + // CurveMultiplier + config.additionalBondRegistryConfig.curveMultiplierCooldown = 7 days; + _setUp(); } } diff --git a/src/Accounting.sol b/src/Accounting.sol index ebdfb7a66..b39eb048e 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); @@ -277,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); @@ -403,6 +408,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); @@ -429,28 +443,50 @@ 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); } + /// @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); - 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); } @@ -524,18 +560,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 @@ -562,7 +599,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/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol new file mode 100644 index 000000000..0b2f8fa4c --- /dev/null +++ b/src/AdditionalBondRegistry.sol @@ -0,0 +1,165 @@ +// 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 { IAdditionalBondRegistry, TierInfo, OperatorTierState } from "./interfaces/IAdditionalBondRegistry.sol"; +import { MAX_BP } from "./lib/Constants.sol"; + +/// @notice Manages operator tiers. +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; + /// @dev Cooldown deadline (unix timestamp) after a tier downgrade. 0 = no active cooldown. + mapping(uint256 nodeOperatorId => uint256) curveMultiplierCooldownUntil; + } + + // NOTE: Sanity guard for tier creation: effective multiplier <= 10x the default multiplier. + 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; + IMetaRegistry public immutable META_REGISTRY; + uint256 public immutable CURVE_MULTIPLIER_COOLDOWN; + + // keccak256(abi.encode(uint256(keccak256("AdditionalBondRegistry")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ADDITIONAL_BOND_REGISTRY_STORAGE_LOCATION = + 0xe06435b00cfe5ab72c52612ef2f4c7b5f9c4cc44634ef79a78a1888f5b1eb300; + + /// @param module CuratedModule address. + /// @param curveMultiplierCooldown Cooldown in seconds after a tier downgrade before `applyCurveMultiplier` 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 IAdditionalBondRegistry + function initialize(address admin) external initializer { + if (admin == address(0)) revert ZeroAdminAddress(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + /// @inheritdoc IAdditionalBondRegistry + function addTier( + uint256 curveMultiplier, + uint256 weightMultiplier + ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 tierId) { + if (curveMultiplier > MAX_CURVE_MULTIPLIER) revert InvalidCurveMultiplier(); + if (weightMultiplier > MAX_WEIGHT_MULTIPLIER) revert InvalidWeightMultiplier(); + AdditionalBondRegistryStorage storage $ = _storage(); + tierId = ++$.tiersCount; + $.tiers[tierId] = TierInfo({ + curveMultiplier: uint128(curveMultiplier), + weightMultiplier: uint128(weightMultiplier) + }); + emit TierAdded(tierId, curveMultiplier, weightMultiplier); + } + + /// @inheritdoc IAdditionalBondRegistry + function selectTier(uint256 nodeOperatorId, uint256 tierId) external { + AdditionalBondRegistryStorage storage $ = _storage(); + _checkOperatorOwner(nodeOperatorId); + + if (tierId > $.tiersCount) revert InvalidTierId(); + if (tierId == $.operatorTier[nodeOperatorId]) revert SameTier(); + + 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. + // 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) { + _removeCurveMultiplierCooldown(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 IAdditionalBondRegistry + function applyCurveMultiplier(uint256 nodeOperatorId) external { + _checkOperatorOwner(nodeOperatorId); + + AdditionalBondRegistryStorage storage $ = _storage(); + uint256 cooldownUntil = $.curveMultiplierCooldownUntil[nodeOperatorId]; + if (cooldownUntil == 0) revert NoCurveMultiplierCooldown(); + if (cooldownUntil > block.timestamp) revert CurveMultiplierCooldownNotElapsed(); + + _removeCurveMultiplierCooldown(nodeOperatorId); + + ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, $.tiers[$.operatorTier[nodeOperatorId]].curveMultiplier); + } + + /// @inheritdoc IAdditionalBondRegistry + function getTiersCount() external view returns (uint256) { + return _storage().tiersCount; + } + + /// @inheritdoc IAdditionalBondRegistry + function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory state) { + AdditionalBondRegistryStorage storage $ = _storage(); + state.tierId = $.operatorTier[nodeOperatorId]; + state.curveMultiplierCooldownUntil = $.curveMultiplierCooldownUntil[nodeOperatorId]; + state.weightMultiplier = MAX_BP + $.tiers[state.tierId].weightMultiplier; + state.curveMultiplier = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); + } + + /// @inheritdoc IAdditionalBondRegistry + function getTierInfo(uint256 tierId) public view returns (TierInfo memory) { + AdditionalBondRegistryStorage storage $ = _storage(); + if (tierId > $.tiersCount) revert InvalidTierId(); + 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`. + function _setCurveMultiplierCooldown(uint256 nodeOperatorId) internal { + uint256 cooldownUntil = block.timestamp + CURVE_MULTIPLIER_COOLDOWN; + _storage().curveMultiplierCooldownUntil[nodeOperatorId] = cooldownUntil; + emit CurveMultiplierCooldownSet(nodeOperatorId, cooldownUntil); + } + + function _removeCurveMultiplierCooldown(uint256 nodeOperatorId) internal { + delete _storage().curveMultiplierCooldownUntil[nodeOperatorId]; + 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(); + } + + function _storage() internal pure returns (AdditionalBondRegistryStorage storage $) { + assembly ("memory-safe") { + // keccak256(abi.encode(uint256(keccak256("AdditionalBondRegistry")) - 1)) & ~bytes32(uint256(0xff)) + $.slot := ADDITIONAL_BOND_REGISTRY_STORAGE_LOCATION + } + } +} diff --git a/src/MetaRegistry.sol b/src/MetaRegistry.sol index f8c496507..a9c8268d7 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 { IAdditionalBondRegistry } from "./interfaces/IAdditionalBondRegistry.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; + IAdditionalBondRegistry public immutable ADDITIONAL_BOND_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 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()); + ADDITIONAL_BOND_REGISTRY = IAdditionalBondRegistry(additionalBondRegistry); _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 = ADDITIONAL_BOND_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/abstract/BondCurve.sol b/src/abstract/BondCurve.sol index 5d37d151c..08ecd1135 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,33 @@ 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]; + } + /// @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 @@ -101,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/abstract/FeeSplits.sol b/src/abstract/FeeSplits.sol index 2f765aeac..c235dbe8e 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 aff9000a7..db640318b 100644 --- a/src/interfaces/IAccounting.sol +++ b/src/interfaces/IAccounting.sol @@ -48,6 +48,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); @@ -117,6 +119,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 @@ -132,6 +145,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 @@ -319,6 +343,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/IAdditionalBondRegistry.sol b/src/interfaces/IAdditionalBondRegistry.sol new file mode 100644 index 000000000..6b37e59d8 --- /dev/null +++ b/src/interfaces/IAdditionalBondRegistry.sol @@ -0,0 +1,95 @@ +// 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. Fields hold increments above MAX_BP in storage; `getTierInfo` returns them as full +/// effective multipliers (MAX_BP + stored increment). +struct TierInfo { + uint128 curveMultiplier; + uint128 weightMultiplier; +} + +/// @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 `applyCurveMultiplier`, +/// 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 IAdditionalBondRegistry { + 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); + + error ZeroAdminAddress(); + error InvalidCurveMultiplier(); + error InvalidWeightMultiplier(); + error InvalidTierId(); + error SameTier(); + error InsufficientBondForTier(); + error SenderIsNotOperatorOwner(); + error NoCurveMultiplierCooldown(); + error CurveMultiplierCooldownNotElapsed(); + error CurveMultiplierCooldownActive(); + + /// @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 `curveMultiplier`. + function MAX_CURVE_MULTIPLIER() 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); + + /// @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 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 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 + /// requirement; a downgrade applies the new weight now but keeps the higher curve multiplier until + /// `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; + + /// @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 applyCurveMultiplier(uint256 nodeOperatorId) external; + + /// @notice Number of stored tiers (not counting the implicit default tier 0). + function getTiersCount() external view returns (uint256); + + /// @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. + function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory); +} diff --git a/src/interfaces/IBondCurve.sol b/src/interfaces/IBondCurve.sol index fcfeaff28..883d191ee 100644 --- a/src/interfaces/IBondCurve.sol +++ b/src/interfaces/IBondCurve.sol @@ -61,12 +61,14 @@ 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(); error InvalidBondCurveId(); error InvalidInitializationCurveId(); error SameBondCurveId(); + error InvalidMultiplier(); function DEFAULT_BOND_CURVE_ID() external view returns (uint256); @@ -90,7 +92,11 @@ interface IBondCurve { /// @return Bond curve ID function getBondCurveId(uint256 nodeOperatorId) external view returns (uint256); - /// @notice Get required bond in ETH for the given number of keys for the given bond curve + /// @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 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 @@ -98,9 +104,33 @@ interface IBondCurve { /// @return Amount for particular keys count function getBondAmountByKeysCount(uint256 keys, uint256 curveId) external view returns (uint256); - /// @notice Get keys count for the given bond amount with the given bond curve + /// @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 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 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 55ea72be8..99f235b08 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 { IAdditionalBondRegistry } from "./IAdditionalBondRegistry.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 ADDITIONAL_BOND_REGISTRY() external view returns (IAdditionalBondRegistry); + /// @notice Initialize the registry. /// @param admin Address to receive DEFAULT_ADMIN_ROLE. function initialize(address admin) external; diff --git a/src/lib/BondCurvesLib.sol b/src/lib/BondCurvesLib.sol index 49d9d1cc1..10e93cfc7 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,10 +38,10 @@ library BondCurvesLib { function getBondAmountByKeysCount( BondCurve.BondCurveStorage storage bondCurveStorage, uint256 keys, - uint256 curveId + uint256 curveId, + uint256 multiplier ) external view returns (uint256) { - _ensureCurveExists(bondCurveStorage, curveId); - IBondCurve.BondCurveInterval[] storage intervals = bondCurveStorage.bondCurves[curveId].intervals; + IBondCurve.BondCurveInterval[] memory intervals = _loadCurve(bondCurveStorage, curveId, multiplier); if (keys == 0) return 0; unchecked { @@ -54,7 +55,7 @@ library BondCurvesLib { low = mid; } } - IBondCurve.BondCurveInterval storage interval = intervals[low]; + IBondCurve.BondCurveInterval memory interval = intervals[low]; return interval.minBond + (keys - interval.minKeysCount) * interval.trend; } } @@ -62,11 +63,10 @@ library BondCurvesLib { function getKeysCountByBondAmount( BondCurve.BondCurveStorage storage bondCurveStorage, uint256 amount, - uint256 curveId + uint256 curveId, + uint256 multiplier ) external view returns (uint256) { - _ensureCurveExists(bondCurveStorage, curveId); - IBondCurve.BondCurveInterval[] storage intervals = bondCurveStorage.bondCurves[curveId].intervals; - + 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; @@ -82,7 +82,7 @@ library BondCurvesLib { } } - IBondCurve.BondCurveInterval storage interval; + IBondCurve.BondCurveInterval memory interval; // // Imagine we have: @@ -123,6 +123,35 @@ library BondCurvesLib { } } + function _loadCurve( + BondCurve.BondCurveStorage storage bondCurveStorage, + uint256 curveId, + uint256 multiplier + ) internal 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); + + uint256 sTrend = (src[0].trend * multiplier) / MAX_BP; + 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 = curve[i - 1]; + uint256 currMinKeysCount = src[i].minKeysCount; + uint256 currTrend = (src[i].trend * multiplier) / MAX_BP; + + curve[i].minKeysCount = currMinKeysCount; + curve[i].trend = currTrend; + curve[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(); diff --git a/src/lib/Constants.sol b/src/lib/Constants.sol new file mode 100644 index 000000000..694f04ed4 --- /dev/null +++ b/src/lib/Constants.sol @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2026 Lido +// 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; diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index e564e8c36..090cc9cbf 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(), + 90_000, + "additional bond registry max curve multiplier" + ); + assertEq( + additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(), + 90_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 a3d371df5..7873df0d1 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/AccountingMock.sol b/test/helpers/mocks/AccountingMock.sol index 06f2a9319..497d0ba16 100644 --- a/test/helpers/mocks/AccountingMock.sol +++ b/test/helpers/mocks/AccountingMock.sol @@ -26,6 +26,8 @@ contract AccountingMock { mapping(uint256 nodeOperatorId => IBondLock.BondLockData) bondLock; mapping(uint256 nodeOperatorId => uint256) bondLockNonce; mapping(uint256 nodeOperatorId => uint256) bond; + mapping(uint256 nodeOperatorId => uint256) curveMultiplier; + mapping(uint256 nodeOperatorId => uint256) requiredBondAtMul; mapping(uint256 nodeOperatorId => uint256 bondCurveId) operatorBondCurveId; uint256[] bondCurves; @@ -43,6 +45,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; } @@ -240,6 +266,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/AdditionalBondRegistryMock.sol b/test/helpers/mocks/AdditionalBondRegistryMock.sol new file mode 100644 index 000000000..daa600cca --- /dev/null +++ b/test/helpers/mocks/AdditionalBondRegistryMock.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/IAdditionalBondRegistry.sol"; + +/// @dev Minimal AdditionalBondRegistry mock for MetaRegistry tests. +/// 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 _weightMultiplier; + + function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory state) { + state.weightMultiplier = MAX_BP + _weightMultiplier[nodeOperatorId]; + } + + function mock_setWeightMultiplier(uint256 nodeOperatorId, uint256 weightMultiplier) external { + _weightMultiplier[nodeOperatorId] = weightMultiplier; + } +} diff --git a/test/helpers/mocks/MetaRegistryMock.sol b/test/helpers/mocks/MetaRegistryMock.sol index bc1e0465a..3f924b361 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/unit/Accounting/BondCalculations.t.sol b/test/unit/Accounting/BondCalculations.t.sol index 32a122d9b..569a84cc0 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 }); @@ -675,6 +691,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); @@ -774,6 +796,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); @@ -889,6 +918,109 @@ 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); + } +} + +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.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); + } + + 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 { @@ -932,6 +1064,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); @@ -1038,6 +1178,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); @@ -1181,6 +1329,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 43572b67a..68e802d51 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 3be0980e6..7015cc913 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 1b9102eaf..c3d41d665 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/AdditionalBondRegistry.t.sol b/test/unit/AdditionalBondRegistry.t.sol new file mode 100644 index 000000000..6fd9301c5 --- /dev/null +++ b/test/unit/AdditionalBondRegistry.t.sol @@ -0,0 +1,442 @@ +// 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 { 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"; +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 AdditionalBondRegistryBaseTest is Test, Utilities, Fixtures { + CuratedMock public module; + AdditionalBondRegistry public additionalBondRegistry; + 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)); + + additionalBondRegistry = new AdditionalBondRegistry({ + module: address(module), + curveMultiplierCooldown: CURVE_MULTIPLIER_COOLDOWN + }); + _enableInitializers(address(additionalBondRegistry)); + additionalBondRegistry.initialize(admin); + + acct = AccountingMock(address(module.ACCOUNTING())); + } +} + +contract AdditionalBondRegistryConstructorTest is AdditionalBondRegistryBaseTest { + function test_constructor_SetsImmutables() public view { + assertEq(address(additionalBondRegistry.MODULE()), address(module)); + assertEq(address(additionalBondRegistry.ACCOUNTING()), address(module.ACCOUNTING())); + assertEq(address(additionalBondRegistry.META_REGISTRY()), address(metaRegistryMock)); + assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER(), 90_000); + assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(), 90_000); + assertEq(additionalBondRegistry.CURVE_MULTIPLIER_COOLDOWN(), CURVE_MULTIPLIER_COOLDOWN); + } +} + +contract AdditionalBondRegistryInitializeTest is AdditionalBondRegistryBaseTest { + function test_initialize_SetsAdmin() public view { + assertTrue(additionalBondRegistry.hasRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), admin)); + } + + function test_initialize_RevertWhen_ZeroAdmin() public { + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module), CURVE_MULTIPLIER_COOLDOWN); + _enableInitializers(address(tp)); + vm.expectRevert(IAdditionalBondRegistry.ZeroAdminAddress.selector); + tp.initialize(address(0)); + } + + function test_initialize_RevertWhen_DoubleCall() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + additionalBondRegistry.initialize(admin); + } +} + +contract AdditionalBondRegistryAddTierTest is AdditionalBondRegistryBaseTest { + function _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { + vm.prank(admin); + tierId = additionalBondRegistry.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(additionalBondRegistry)); + emit IAdditionalBondRegistry.TierAdded(1, T1_BOND, T1_WEIGHT); + uint256 tierId = _addTier(T1_BOND, T1_WEIGHT); + + assertEq(tierId, 1); + assertEq(additionalBondRegistry.getTiersCount(), 1); + TierInfo memory t = additionalBondRegistry.getTierInfo(1); + assertEq(t.curveMultiplier, MAX_BP + T1_BOND); + assertEq(t.weightMultiplier, MAX_BP + T1_WEIGHT); + } + + function test_addTier_SecondTier() public { + _addTier(T1_BOND, T1_WEIGHT); + uint256 tierId = _addTier(T2_BOND, T2_WEIGHT); + assertEq(tierId, 2); + assertEq(additionalBondRegistry.getTiersCount(), 2); + } + + function test_addTier_AllowsZeroCurveMultiplierInc() public { + uint256 tierId = _addTier(0, T1_WEIGHT); + TierInfo memory t = additionalBondRegistry.getTierInfo(tierId); + 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.curveMultiplier, MAX_BP + T1_BOND); + assertEq(t.weightMultiplier, MAX_BP); + } + + function test_addTier_AllowsMaxIncrement() public { + 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.curveMultiplier, MAX_BP + maxCurve); + assertEq(t.weightMultiplier, MAX_BP + maxWeight); + } + + function test_addTier_RevertWhen_NotAdmin() public { + vm.expectRevert(); + vm.prank(stranger); + additionalBondRegistry.addTier(T1_BOND, T1_WEIGHT); + } + + function test_addTier_RevertWhen_BondMulAboveMax() public { + 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() + 1; + vm.expectRevert(IAdditionalBondRegistry.InvalidWeightMultiplier.selector); + _addTier(T1_BOND, aboveMax); + } +} + +contract AdditionalBondRegistrySelectTierBaseTest is AdditionalBondRegistryBaseTest { + 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 _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { + vm.prank(admin); + tierId = additionalBondRegistry.addTier(bond, weight); + } +} + +contract AdditionalBondRegistrySelectTierTest is AdditionalBondRegistrySelectTierBaseTest { + 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(additionalBondRegistry)); + emit IAdditionalBondRegistry.TierSelected(0, 1); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 1); + + 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); + } + + function test_selectTier_Upgrade_Tier1ToTier2() public { + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 1); + + vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.TierSelected(0, 2); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 2); + + OperatorTierState memory s = additionalBondRegistry.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); + additionalBondRegistry.selectTier(0, 1); + + uint256 expectedCooldown = block.timestamp + CURVE_MULTIPLIER_COOLDOWN; + vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.CurveMultiplierCooldownSet(0, expectedCooldown); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 0); + + OperatorTierState memory s = additionalBondRegistry.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); + additionalBondRegistry.selectTier(0, 1); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 0); + assertGt(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + + vm.prank(nodeOperatorOwner); + vm.expectEmit(true, false, false, false, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.CurveMultiplierCooldownRemoved(0); + additionalBondRegistry.selectTier(0, 2); + + assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); + } + + function test_selectTier_RevertWhen_NotOwner() public { + vm.expectRevert(IAdditionalBondRegistry.SenderIsNotOperatorOwner.selector); + vm.prank(stranger); + additionalBondRegistry.selectTier(0, 1); + } + + function test_selectTier_RevertWhen_InvalidTierId() public { + vm.expectRevert(IAdditionalBondRegistry.InvalidTierId.selector); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 99); + } + + function test_selectTier_RevertWhen_SameTier() public { + vm.expectRevert(IAdditionalBondRegistry.SameTier.selector); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.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); + additionalBondRegistry.selectTier(0, 1); + + assertEq(additionalBondRegistry.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(IAdditionalBondRegistry.InsufficientBondForTier.selector); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 1); + } + + function test_selectTier_RevertWhen_CurveMultiplierCooldownActive() public { + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 2); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 1); + + vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierCooldownActive.selector); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.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); + additionalBondRegistry.selectTier(0, 2); + vm.prank(nodeOperatorOwner); + 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(IAdditionalBondRegistry.CurveMultiplierCooldownActive.selector); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 3); + + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); + } +} + +contract AdditionalBondRegistryReleaseCurveMultiplierTest is AdditionalBondRegistrySelectTierBaseTest { + function setUp() public override { + super.setUp(); + _addTier(T1_BOND, T1_WEIGHT); + _addTier(T2_BOND, T2_WEIGHT); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 1); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 0); + } + + 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.applyCurveMultiplier(0); + + assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP); + } + + function test_applyCurveMultiplier_SettlesToCurrentTierNotDefault() public { + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 2); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 1); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); + + vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.applyCurveMultiplier(0); + + assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); + } + + function test_applyCurveMultiplier_RevertWhen_NotOwner() public { + vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + vm.expectRevert(IAdditionalBondRegistry.SenderIsNotOperatorOwner.selector); + vm.prank(stranger); + additionalBondRegistry.applyCurveMultiplier(0); + } + + function test_applyCurveMultiplier_RevertWhen_NoCurveMultiplierCooldown() public { + vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.applyCurveMultiplier(0); + vm.expectRevert(IAdditionalBondRegistry.NoCurveMultiplierCooldown.selector); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.applyCurveMultiplier(0); + } + + function test_applyCurveMultiplier_RevertWhen_CurveMultiplierCooldownNotElapsed() public { + vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierCooldownNotElapsed.selector); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.applyCurveMultiplier(0); + } +} + +contract AdditionalBondRegistryViewsTest is AdditionalBondRegistrySelectTierBaseTest { + function setUp() public override { + super.setUp(); + _addTier(T1_BOND, T1_WEIGHT); + _addTier(T2_BOND, T2_WEIGHT); + } + + function test_getTiersCount() public view { + assertEq(additionalBondRegistry.getTiersCount(), 2); + } + + function test_getTierInfo_Tier0() public view { + TierInfo memory t = additionalBondRegistry.getTierInfo(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.curveMultiplier, MAX_BP + T1_BOND); + assertEq(t.weightMultiplier, MAX_BP + T1_WEIGHT); + } + + function test_getTierInfo_RevertWhen_InvalidTierId() public { + vm.expectRevert(IAdditionalBondRegistry.InvalidTierId.selector); + additionalBondRegistry.getTierInfo(99); + } + + function test_getOperatorTierState_Default() public view { + OperatorTierState memory s = additionalBondRegistry.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); + 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); + assertEq(s.curveMultiplierCooldownUntil, 0); + } + + function test_getOperatorTierState_AfterDowngrade_CooldownMultiplierDivergesFromTier() public { + vm.prank(nodeOperatorOwner); + additionalBondRegistry.selectTier(0, 2); + vm.prank(nodeOperatorOwner); + 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 + T2_BOND); + assertEq(s.curveMultiplierCooldownUntil, block.timestamp + CURVE_MULTIPLIER_COOLDOWN); + } +} diff --git a/test/unit/MetaRegistry.t.sol b/test/unit/MetaRegistry.t.sol index 94e7d6e34..447243b42 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 { 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) MetaRegistry(module) {} + constructor(address module, address additionalBondRegistry) MetaRegistry(module, additionalBondRegistry) {} 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; + AdditionalBondRegistryMock public additionalBondRegistry; 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)); + additionalBondRegistry = new AdditionalBondRegistryMock(); + + registry = new MetaRegistryForTest(address(module), address(additionalBondRegistry)); _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(additionalBondRegistry)); assertEq(address(r.STAKING_ROUTER()), address(stakingRouter)); assertEq(address(r.MODULE()), address(module)); assertEq(address(r.ACCOUNTING()), address(module.ACCOUNTING())); + assertEq(address(r.ADDITIONAL_BOND_REGISTRY()), address(additionalBondRegistry)); } function test_constructor_RevertWhen_ZeroModule() public { vm.expectRevert(IMetaRegistry.ZeroModuleAddress.selector); - new MetaRegistry(address(0)); + new MetaRegistry(address(0), address(additionalBondRegistry)); } } @@ -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(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)); + MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); _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(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)); + MetaRegistry r = new MetaRegistry(address(module), address(additionalBondRegistry)); _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); + additionalBondRegistry.mock_setWeightMultiplier(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); + additionalBondRegistry.mock_setWeightMultiplier(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/abstract/BondCurve.t.sol b/test/unit/abstract/BondCurve.t.sol index 5f477d4f9..38526b0dc 100644 --- a/test/unit/abstract/BondCurve.t.sol +++ b/test/unit/abstract/BondCurve.t.sol @@ -535,21 +535,95 @@ 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_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 @@ -580,6 +654,102 @@ contract BondCurveFuzz is Test { assertEq(keysMinBondAmount, keysToCheck, "keysMinBondAmount != keysToCheck"); } + function testFuzz_onTheFlyMultiplierEqualsMultipliedCurve( + uint256[] memory minKeysCount, + uint256[] memory trend, + uint256 keysToCheck, + uint256 bondToCheck, + 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, offset); + + 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) + ); + } + + 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( @@ -646,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 28d876498edd9c27c6599a233a75c85654bccf4c Mon Sep 17 00:00:00 2001 From: Sergey Khomutinin <31664571+skhomuti@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:50:59 +0500 Subject: [PATCH 3/4] feat: add weight boost providers (#840) ## Summary - add generic `IWeightBoostProvider` support in `MetaRegistry` - add node-operator and group-max provider modes with async explicit refresh - route `AdditionalBondRegistry` through the weight boost provider interface - include the requested merge commit from `origin/develop` ## Checks - `forge test --offline --match-path test/unit/MetaRegistry.t.sol` - `forge test --offline --match-path test/unit/AdditionalBondRegistry.t.sol` --- script/curated/DeployBase.s.sol | 6 + src/Accounting.sol | 3 +- src/AdditionalBondRegistry.sol | 9 +- src/MetaRegistry.sol | 252 +++++++- src/abstract/BondCurve.sol | 4 +- src/interfaces/IAdditionalBondRegistry.sol | 7 +- src/interfaces/IMetaRegistry.sol | 72 +++ src/interfaces/IWeightBoostProvider.sol | 12 + .../deployment/PostDeploymentCommon.t.sol | 11 +- .../mocks/AdditionalBondRegistryMock.sol | 11 +- test/helpers/mocks/MetaRegistryMock.sol | 10 +- test/unit/AdditionalBondRegistry.t.sol | 17 +- test/unit/MetaRegistry.t.sol | 573 +++++++++++++++++- 13 files changed, 943 insertions(+), 44 deletions(-) create mode 100644 src/interfaces/IWeightBoostProvider.sol diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 6adecf99b..9cefdfcb8 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -27,6 +27,8 @@ import { BaseOracle } from "../../src/lib/base-oracle/BaseOracle.sol"; import { IVerifier } from "../../src/interfaces/IVerifier.sol"; import { IParametersRegistry } from "../../src/interfaces/IParametersRegistry.sol"; import { IBondCurve } from "../../src/interfaces/IBondCurve.sol"; +import { IMetaRegistry } from "../../src/interfaces/IMetaRegistry.sol"; +import { IWeightBoostProvider } from "../../src/interfaces/IWeightBoostProvider.sol"; import { JsonObj, Json } from "../utils/Json.sol"; import { Dummy } from "../utils/Dummy.sol"; @@ -348,6 +350,10 @@ abstract contract DeployBase is Script { accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), address(additionalBondRegistry)); + metaRegistry.addWeightBoostProvider( + IWeightBoostProvider(address(additionalBondRegistry)), + IMetaRegistry.WeightBoostProviderMode.NodeOperator + ); metaRegistry.grantRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); for (uint256 i = 0; i < gatesCount; i++) { diff --git a/src/Accounting.sol b/src/Accounting.sol index b39eb048e..085310e4c 100644 --- a/src/Accounting.sol +++ b/src/Accounting.sol @@ -15,7 +15,6 @@ import { PausableWithRoles } from "./abstract/PausableWithRoles.sol"; import { AssetRecovererLib } from "./lib/AssetRecovererLib.sol"; -import { IStakingModule } from "./interfaces/IStakingModule.sol"; import { IBaseModule, NodeOperatorManagementProperties } from "./interfaces/IBaseModule.sol"; import { IAccounting } from "./interfaces/IAccounting.sol"; import { IFeeDistributor } from "./interfaces/IFeeDistributor.sol"; @@ -614,7 +613,7 @@ contract Accounting is } function _onlyExistingNodeOperator(uint256 nodeOperatorId) internal view { - if (nodeOperatorId < IStakingModule(address(MODULE)).getNodeOperatorsCount()) return; + if (nodeOperatorId < MODULE.getNodeOperatorsCount()) return; revert NodeOperatorDoesNotExist(); } diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index 0b2f8fa4c..0d0a8192e 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -10,6 +10,7 @@ import { IAccounting } from "./interfaces/IAccounting.sol"; import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; import { IAdditionalBondRegistry, TierInfo, OperatorTierState } from "./interfaces/IAdditionalBondRegistry.sol"; +import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; import { MAX_BP } from "./lib/Constants.sol"; /// @notice Manages operator tiers. @@ -96,7 +97,7 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces $.operatorTier[nodeOperatorId] = tierId; emit TierSelected(nodeOperatorId, tierId); - META_REGISTRY.refreshOperatorWeight(nodeOperatorId); + META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); } /// @inheritdoc IAdditionalBondRegistry @@ -127,6 +128,12 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces state.curveMultiplier = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); } + /// @inheritdoc IWeightBoostProvider + function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { + AdditionalBondRegistryStorage storage $ = _storage(); + multiplierBP = MAX_BP + $.tiers[$.operatorTier[nodeOperatorId]].weightMultiplier; + } + /// @inheritdoc IAdditionalBondRegistry function getTierInfo(uint256 tierId) public view returns (TierInfo memory) { AdditionalBondRegistryStorage storage $ = _storage(); diff --git a/src/MetaRegistry.sol b/src/MetaRegistry.sol index a9c8268d7..45bee6167 100644 --- a/src/MetaRegistry.sol +++ b/src/MetaRegistry.sol @@ -12,6 +12,7 @@ import { IBondCurve } from "./interfaces/IBondCurve.sol"; import { INodeOperatorsRegistry } from "./interfaces/INodeOperatorsRegistry.sol"; import { ICuratedModule } from "./interfaces/ICuratedModule.sol"; import { IBaseModule } from "./interfaces/IBaseModule.sol"; +import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; import { IStakingModule } from "./interfaces/IStakingModule.sol"; import { IStakingRouter } from "./interfaces/IStakingRouter.sol"; import { IMetaRegistry, OperatorMetadata } from "./interfaces/IMetaRegistry.sol"; @@ -50,6 +51,9 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp mapping(uint256 nodeOperatorId => OperatorMetadata) operatorMetadata; mapping(uint256 moduleId => address moduleAddress) moduleAddressCache; uint256 groupsCount; + mapping(uint256 providerId => WeightBoostProviderEntry entry) weightBoostProviders; + mapping(address provider => uint256 providerId) weightBoostProviderIdByAddress; + uint256 weightBoostProvidersCount; } bytes32 public constant MANAGE_OPERATOR_GROUPS_ROLE = keccak256("MANAGE_OPERATOR_GROUPS_ROLE"); @@ -148,7 +152,44 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp $.bondCurveWeight[curveId] = weight; emit BondCurveWeightSet(curveId, weight); - MODULE.requestFullDepositInfoUpdate(); + _requestFullDepositInfoUpdate(); + } + + /// @inheritdoc IMetaRegistry + function addWeightBoostProvider( + IWeightBoostProvider provider, + WeightBoostProviderMode mode + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + address providerAddr = address(provider); + if (providerAddr == address(0)) revert InvalidWeightBoostProvider(); + + MetaRegistryStorage storage $ = _storage(); + if ($.weightBoostProviderIdByAddress[providerAddr] != 0) { + revert WeightBoostProviderAlreadyAdded(); + } + + uint256 providerId = ++$.weightBoostProvidersCount; + $.weightBoostProviders[providerId] = WeightBoostProviderEntry({ + provider: provider, + mode: mode, + enabled: true + }); + $.weightBoostProviderIdByAddress[providerAddr] = providerId; + emit WeightBoostProviderAdded(providerAddr, mode); + _requestFullDepositInfoUpdate(); + } + + /// @inheritdoc IMetaRegistry + function setWeightBoostProviderEnabled(uint256 providerId, bool enabled) external onlyRole(DEFAULT_ADMIN_ROLE) { + MetaRegistryStorage storage $ = _storage(); + WeightBoostProviderEntry storage entry = $.weightBoostProviders[providerId]; + address providerAddr = address(entry.provider); + if (providerAddr == address(0)) revert WeightBoostProviderNotFound(); + if (entry.enabled == enabled) revert SameWeightBoostProviderEnabled(); + + entry.enabled = enabled; + emit WeightBoostProviderEnabledSet(providerAddr, enabled); + _requestFullDepositInfoUpdate(); } /// @inheritdoc IMetaRegistry @@ -159,6 +200,82 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp _refreshOperatorWeight(groupId, nodeOperatorId); } + /// @inheritdoc IMetaRegistry + function refreshGroupWeights(uint256 groupId) external { + if (groupId == NO_GROUP_ID) revert InvalidOperatorGroupId(); + if (groupId > _storage().groupsCount) revert InvalidOperatorGroupId(); + + _refreshGroupWeights(groupId); + } + + /// @inheritdoc IMetaRegistry + function notifyWeightBoostProviderConfigChanged() external { + MetaRegistryStorage storage $ = _storage(); + uint256 providerId = $.weightBoostProviderIdByAddress[msg.sender]; + if (providerId == 0) revert WeightBoostProviderNotFound(); + + if (!$.weightBoostProviders[providerId].enabled) return; + + emit WeightBoostProviderConfigChanged(msg.sender); + _requestFullDepositInfoUpdate(); + } + + /// @inheritdoc IMetaRegistry + function notifyWeightBoostChanged(uint256 nodeOperatorId) external { + MetaRegistryStorage storage $ = _storage(); + uint256 providerId = $.weightBoostProviderIdByAddress[msg.sender]; + if (providerId == 0) revert WeightBoostProviderNotFound(); + + uint256 groupId = $.groupIndex.groupIdByOperatorId[nodeOperatorId]; + // Provider notifications are node-operator scoped; operators outside groups have no group cache to refresh. + if (groupId == NO_GROUP_ID) return; + + WeightBoostProviderEntry storage entry = $.weightBoostProviders[providerId]; + if (!entry.enabled) return; + + if (entry.mode == WeightBoostProviderMode.NodeOperator) { + _refreshOperatorWeight(groupId, nodeOperatorId); + return; + } + + if (entry.mode == WeightBoostProviderMode.GroupMax) { + _refreshGroupWeights(groupId); + return; + } + + revert InvalidWeightBoostProviderMode(); + } + + /// @inheritdoc IMetaRegistry + function getWeightBoostProviders() external view returns (IWeightBoostProvider[] memory providers) { + MetaRegistryStorage storage $ = _storage(); + uint256 providersCount = $.weightBoostProvidersCount; + providers = new IWeightBoostProvider[](providersCount); + for (uint256 i; i < providersCount; ++i) { + providers[i] = $.weightBoostProviders[i + 1].provider; + } + } + + /// @inheritdoc IMetaRegistry + function getWeightBoostProvidersCount() external view returns (uint256 count) { + count = _storage().weightBoostProvidersCount; + } + + /// @inheritdoc IMetaRegistry + function getWeightBoostProvider(uint256 providerId) external view returns (WeightBoostProviderEntry memory entry) { + entry = _storage().weightBoostProviders[providerId]; + } + + /// @inheritdoc IMetaRegistry + function getWeightBoostProviderMode(uint256 providerId) external view returns (WeightBoostProviderMode mode) { + mode = _storage().weightBoostProviders[providerId].mode; + } + + /// @inheritdoc IMetaRegistry + function getWeightBoostProviderId(address provider) external view returns (uint256 providerId) { + providerId = _storage().weightBoostProviderIdByAddress[provider]; + } + /// @inheritdoc IMetaRegistry function getOperatorMetadata(uint256 nodeOperatorId) external view returns (OperatorMetadata memory metadata) { return _storage().operatorMetadata[nodeOperatorId]; @@ -249,6 +366,7 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp uint256 groupId = ++_storage().groupsCount; _storeGroupData(groupId, groupInfo); + _refreshGroupWeights(groupId); emit OperatorGroupCreated(groupId, groupInfo); } @@ -263,6 +381,7 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp emit OperatorGroupCleared(groupId); } else { _storeGroupData(groupId, groupInfo); + _refreshGroupWeights(groupId); emit OperatorGroupUpdated(groupId, groupInfo); } } @@ -310,7 +429,6 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp CachedOperatorGroup storage group = $.groups[groupId]; uint256 shareSum; - uint256 effectiveWeightSum; for (uint256 i; i < subNodeOperators.length; ++i) { uint64 noId = subNodeOperators[i].nodeOperatorId; uint16 share = subNodeOperators[i].share; @@ -322,15 +440,10 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp $.groupIndex.shareByOperatorId[noId] = share; group.subNodeOperatorIds.push(noId); - uint256 effectiveWeight = _getLatestEffectiveWeight(noId, share); - _setEffectiveWeight(noId, effectiveWeight); - effectiveWeightSum += effectiveWeight; shareSum += share; } if (shareSum != MAX_BP) revert InvalidSubNodeOperatorShares(); - - $.effectiveWeightCache.groupEffectiveWeightSum[groupId] = effectiveWeightSum; } function _storeExternalOperators(uint256 groupId, ExternalOperator[] calldata externalOperators) internal { @@ -356,7 +469,8 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp MetaRegistryStorage storage $ = _storage(); uint256 share = $.groupIndex.shareByOperatorId[noId]; - uint256 newWeight = _getLatestEffectiveWeight(noId, share); + uint256 multiplierBP = _getWeightBoostMultiplierBP($.groups[groupId], noId); + uint256 newWeight = _getLatestEffectiveWeight(noId, share, multiplierBP); uint256 oldWeight = _setEffectiveWeight(noId, newWeight); if (oldWeight != newWeight) { @@ -371,6 +485,35 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp } } + function _refreshGroupWeights(uint256 groupId) internal { + MetaRegistryStorage storage $ = _storage(); + CachedOperatorGroup storage group = $.groups[groupId]; + uint256 providersCount = $.weightBoostProvidersCount; + uint256[] memory groupMaxMultipliersBP = new uint256[](providersCount); + bool[] memory groupMaxMultiplierCached = new bool[](providersCount); + + uint256 effectiveWeightSum; + uint256 subOperatorsCount = group.subNodeOperatorIds.length; + for (uint256 i; i < subOperatorsCount; ++i) { + uint256 noId = group.subNodeOperatorIds[i]; + uint256 share = $.groupIndex.shareByOperatorId[noId]; + // Keep full-group and single-operator refreshes on the same ordered multiplier path. + // Each Math.mulDiv floors, so pre-aggregating providers by mode can produce different weights. + uint256 multiplierBP = _getWeightBoostMultiplierBP( + group, + noId, + groupMaxMultipliersBP, + groupMaxMultiplierCached + ); + uint256 effectiveWeight = _getLatestEffectiveWeight(noId, share, multiplierBP); + _setEffectiveWeight(noId, effectiveWeight); + effectiveWeightSum += effectiveWeight; + } + + $.effectiveWeightCache.groupEffectiveWeightSum[groupId] = effectiveWeightSum; + emit GroupWeightsRefreshed(groupId); + } + function _setEffectiveWeight(uint256 nodeOperatorId, uint256 newWeight) internal returns (uint256 oldWeight) { MetaRegistryStorage storage $ = _storage(); oldWeight = $.effectiveWeightCache.operatorEffectiveWeight[nodeOperatorId]; @@ -383,6 +526,10 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp MODULE.notifyNodeOperatorWeightChange(nodeOperatorId, oldWeight, newWeight); } + function _requestFullDepositInfoUpdate() internal { + MODULE.requestFullDepositInfoUpdate(); + } + function _storeOperatorMetadata(uint256 nodeOperatorId, OperatorMetadata memory metadata) internal { if (bytes(metadata.name).length > MAX_NAME_LENGTH) revert OperatorNameTooLong(); if (bytes(metadata.description).length > MAX_DESCRIPTION_LENGTH) revert OperatorDescriptionTooLong(); @@ -406,13 +553,90 @@ contract MetaRegistry is IMetaRegistry, Initializable, AccessControlEnumerableUp } } - function _getLatestEffectiveWeight(uint256 nodeOperatorId, uint256 share) internal view returns (uint256) { - uint256 baseWeight = _storage().bondCurveWeight[ACCOUNTING.getBondCurveId(nodeOperatorId)]; + function _getLatestEffectiveWeight( + uint256 nodeOperatorId, + uint256 share, + uint256 multiplierBP + ) internal view returns (uint256) { + uint256 baseWeight = _getOperatorBaseWeight(nodeOperatorId); if (baseWeight == 0 || share == 0) return 0; - uint256 weighted = Math.mulDiv(baseWeight, share, MAX_BP); - uint256 weightMul = ADDITIONAL_BOND_REGISTRY.getOperatorTierState(nodeOperatorId).weightMultiplier; - if (weightMul == MAX_BP) return weighted; - return Math.mulDiv(weighted, weightMul, MAX_BP); + + uint256 sharedBaseWeight = Math.mulDiv(baseWeight, share, MAX_BP); + return Math.mulDiv(sharedBaseWeight, multiplierBP, MAX_BP); + } + + function _getOperatorBaseWeight(uint256 nodeOperatorId) internal view returns (uint256) { + return _storage().bondCurveWeight[ACCOUNTING.getBondCurveId(nodeOperatorId)]; + } + + function _getWeightBoostMultiplierBP( + CachedOperatorGroup storage group, + uint256 nodeOperatorId + ) internal view returns (uint256 multiplierBP) { + MetaRegistryStorage storage $ = _storage(); + multiplierBP = MAX_BP; + uint256 providersCount = $.weightBoostProvidersCount; + for (uint256 i; i < providersCount; ++i) { + WeightBoostProviderEntry storage entry = $.weightBoostProviders[i + 1]; + if (!entry.enabled) continue; + + IWeightBoostProvider provider = entry.provider; + if (entry.mode == WeightBoostProviderMode.NodeOperator) { + multiplierBP = Math.mulDiv(multiplierBP, provider.getWeightBoostMultiplierBP(nodeOperatorId), MAX_BP); + } else if (entry.mode == WeightBoostProviderMode.GroupMax) { + multiplierBP = Math.mulDiv( + multiplierBP, + _getProviderGroupMaxWeightBoostMultiplierBP(provider, group), + MAX_BP + ); + } else { + revert InvalidWeightBoostProviderMode(); + } + } + } + + function _getWeightBoostMultiplierBP( + CachedOperatorGroup storage group, + uint256 nodeOperatorId, + uint256[] memory groupMaxMultipliersBP, + bool[] memory groupMaxMultiplierCached + ) internal view returns (uint256 multiplierBP) { + MetaRegistryStorage storage $ = _storage(); + multiplierBP = MAX_BP; + uint256 providersCount = groupMaxMultipliersBP.length; + for (uint256 i; i < providersCount; ++i) { + WeightBoostProviderEntry storage entry = $.weightBoostProviders[i + 1]; + if (!entry.enabled) continue; + + IWeightBoostProvider provider = entry.provider; + if (entry.mode == WeightBoostProviderMode.NodeOperator) { + multiplierBP = Math.mulDiv(multiplierBP, provider.getWeightBoostMultiplierBP(nodeOperatorId), MAX_BP); + } else if (entry.mode == WeightBoostProviderMode.GroupMax) { + if (!groupMaxMultiplierCached[i]) { + groupMaxMultipliersBP[i] = _getProviderGroupMaxWeightBoostMultiplierBP(provider, group); + groupMaxMultiplierCached[i] = true; + } + multiplierBP = Math.mulDiv(multiplierBP, groupMaxMultipliersBP[i], MAX_BP); + } else { + revert InvalidWeightBoostProviderMode(); + } + } + } + + function _getProviderGroupMaxWeightBoostMultiplierBP( + IWeightBoostProvider provider, + CachedOperatorGroup storage group + ) internal view returns (uint256 maxMultiplierBP) { + uint256 subOperatorsCount = group.subNodeOperatorIds.length; + if (subOperatorsCount == 0) return MAX_BP; + + maxMultiplierBP = provider.getWeightBoostMultiplierBP(group.subNodeOperatorIds[0]); + for (uint256 i = 1; i < subOperatorsCount; ++i) { + uint256 candidateMultiplierBP = provider.getWeightBoostMultiplierBP(group.subNodeOperatorIds[i]); + if (candidateMultiplierBP > maxMultiplierBP) { + maxMultiplierBP = candidateMultiplierBP; + } + } } /// @dev Returns the cached module address. Reverts if the address was diff --git a/src/abstract/BondCurve.sol b/src/abstract/BondCurve.sol index 08ecd1135..8fb684d64 100644 --- a/src/abstract/BondCurve.sol +++ b/src/abstract/BondCurve.sol @@ -73,7 +73,7 @@ abstract contract BondCurve is IBondCurve, Initializable { /// @inheritdoc IBondCurve function getBondAmountByKeysCount(uint256 keys, uint256 curveId) public view returns (uint256) { - return BondCurvesLib.getBondAmountByKeysCount(_getBondCurveStorage(), keys, curveId, MAX_BP); + return getBondAmountByKeysCount(keys, curveId, MAX_BP); } /// @inheritdoc IBondCurve @@ -83,7 +83,7 @@ abstract contract BondCurve is IBondCurve, Initializable { /// @inheritdoc IBondCurve function getKeysCountByBondAmount(uint256 amount, uint256 curveId) public view returns (uint256) { - return BondCurvesLib.getKeysCountByBondAmount(_getBondCurveStorage(), amount, curveId, MAX_BP); + return getKeysCountByBondAmount(amount, curveId, MAX_BP); } /// @inheritdoc IBondCurve diff --git a/src/interfaces/IAdditionalBondRegistry.sol b/src/interfaces/IAdditionalBondRegistry.sol index 6b37e59d8..8d9696749 100644 --- a/src/interfaces/IAdditionalBondRegistry.sol +++ b/src/interfaces/IAdditionalBondRegistry.sol @@ -6,6 +6,7 @@ pragma solidity 0.8.33; import { IAccounting } from "./IAccounting.sol"; import { ICuratedModule } from "./ICuratedModule.sol"; import { IMetaRegistry } from "./IMetaRegistry.sol"; +import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; /// @dev Bond tier. Fields hold increments above MAX_BP in storage; `getTierInfo` returns them as full /// effective multipliers (MAX_BP + stored increment). @@ -14,7 +15,7 @@ struct TierInfo { uint128 weightMultiplier; } -/// @dev Operator's effective tier state, with multipliers as full basis-point values (not `TierInfo` increments). +/// @dev Operator's effective tier state, with multipliers as full basis-point values. /// 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 { @@ -25,7 +26,7 @@ struct OperatorTierState { } /// @notice Manages operator bond tiers and associated tier downgrade cooldown state. -interface IAdditionalBondRegistry { +interface IAdditionalBondRegistry is IWeightBoostProvider { event TierAdded(uint256 indexed tierId, uint256 curveMultiplier, uint256 weightMultiplier); event TierSelected(uint256 indexed nodeOperatorId, uint256 tierId); event CurveMultiplierCooldownSet(uint256 indexed nodeOperatorId, uint256 cooldownUntil); @@ -48,7 +49,7 @@ interface IAdditionalBondRegistry { /// @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. + /// @notice MetaRegistry called back via `notifyWeightBoostChanged` on tier changes. function META_REGISTRY() external view returns (IMetaRegistry); /// @notice Upper bound for `curveMultiplier`. diff --git a/src/interfaces/IMetaRegistry.sol b/src/interfaces/IMetaRegistry.sol index 99f235b08..729cd18cb 100644 --- a/src/interfaces/IMetaRegistry.sol +++ b/src/interfaces/IMetaRegistry.sol @@ -6,6 +6,7 @@ pragma solidity 0.8.33; import { IAccounting } from "./IAccounting.sol"; import { ICuratedModule } from "./ICuratedModule.sol"; import { IAdditionalBondRegistry } from "./IAdditionalBondRegistry.sol"; +import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; /// @notice Stored operator metadata. struct OperatorMetadata { @@ -31,10 +32,25 @@ interface IMetaRegistry { ExternalOperator[] externalOperators; } + enum WeightBoostProviderMode { + NodeOperator, + GroupMax + } + + struct WeightBoostProviderEntry { + IWeightBoostProvider provider; + WeightBoostProviderMode mode; + bool enabled; + } + event OperatorGroupCreated(uint256 indexed groupId, OperatorGroup groupInfo); event OperatorGroupUpdated(uint256 indexed groupId, OperatorGroup groupInfo); event OperatorGroupCleared(uint256 indexed groupId); event BondCurveWeightSet(uint256 indexed curveId, uint256 weight); + event WeightBoostProviderAdded(address indexed provider, WeightBoostProviderMode mode); + event WeightBoostProviderEnabledSet(address indexed provider, bool enabled); + event WeightBoostProviderConfigChanged(address indexed provider); + event GroupWeightsRefreshed(uint256 indexed groupId); event OperatorMetadataSet(uint256 indexed nodeOperatorId, OperatorMetadata metadata); event NodeOperatorEffectiveWeightChanged(uint256 indexed nodeOperatorId, uint256 oldWeight, uint256 newWeight); @@ -51,6 +67,11 @@ interface IMetaRegistry { error OwnerEditsRestricted(); error SameBondCurveWeight(); error InvalidBondCurveWeight(); + error InvalidWeightBoostProvider(); + error InvalidWeightBoostProviderMode(); + error WeightBoostProviderAlreadyAdded(); + error WeightBoostProviderNotFound(); + error SameWeightBoostProviderEnabled(); error ModuleAddressNotCached(); error OperatorNameTooLong(); error OperatorDescriptionTooLong(); @@ -76,6 +97,27 @@ interface IMetaRegistry { /// @notice Tier provider that manages operator bond tiers. function ADDITIONAL_BOND_REGISTRY() external view returns (IAdditionalBondRegistry); + /// @notice Returns configured weight boost providers. + function getWeightBoostProviders() external view returns (IWeightBoostProvider[] memory providers); + + /// @notice Returns configured weight boost providers count. + function getWeightBoostProvidersCount() external view returns (uint256 count); + + /// @notice Returns configured weight boost provider entry by ID. + /// @param providerId Provider ID. + /// @return entry Configured boost provider entry. + function getWeightBoostProvider(uint256 providerId) external view returns (WeightBoostProviderEntry memory entry); + + /// @notice Returns configured weight boost provider mode by ID. + /// @param providerId Provider ID. + /// @return mode Provider aggregation mode. + function getWeightBoostProviderMode(uint256 providerId) external view returns (WeightBoostProviderMode mode); + + /// @notice Returns configured weight boost provider ID by address. + /// @param provider Address to check. + /// @return providerId Provider ID, or zero if the address is not a configured provider. + function getWeightBoostProviderId(address provider) external view returns (uint256 providerId); + /// @notice Initialize the registry. /// @param admin Address to receive DEFAULT_ADMIN_ROLE. function initialize(address admin) external; @@ -141,6 +183,21 @@ interface IMetaRegistry { /// @param weight Base allocation weight. function setBondCurveWeight(uint256 curveId, uint256 weight) external; + /// @notice Add a weight boost provider. + /// @dev Adding a provider is expected to be a rare operation and does not refresh cached weights automatically. + /// A full deposit info update is requested and affected groups must be refreshed asynchronously. + /// Added providers are enabled by default. Providers are append-only and can only be disabled. + /// @param provider Boost provider consumed during weight calculation. + /// @param mode Provider aggregation mode. + function addWeightBoostProvider(IWeightBoostProvider provider, WeightBoostProviderMode mode) external; + + /// @notice Enable or disable a weight boost provider. + /// @dev Enabling or disabling a provider does not refresh cached weights automatically. + /// A full deposit info update is requested and affected groups must be refreshed asynchronously. + /// @param providerId Boost provider ID to update. + /// @param enabled Whether the provider should participate in weight calculations. + function setWeightBoostProviderEnabled(uint256 providerId, bool enabled) external; + /// @notice Returns effective weight for the node operator. /// @param nodeOperatorId Node operator ID to query. /// @return weight Effective allocation weight. @@ -170,4 +227,19 @@ interface IMetaRegistry { /// @notice Trigger the operator weight update routine in the registry. /// @param nodeOperatorId Node operator ID to trigger the update for. function refreshOperatorWeight(uint256 nodeOperatorId) external; + + /// @notice Trigger the group weight update routine in the registry. + /// @param groupId Operator group ID to trigger the update for. + /// @dev Use this after asynchronous provider configuration changes such as addWeightBoostProvider(), + /// notifyWeightBoostProviderConfigChanged(), and setWeightBoostProviderEnabled(). + function refreshGroupWeights(uint256 groupId) external; + + /// @notice Notify the registry that a configured provider changed a node operator boost. + /// @param nodeOperatorId Node operator ID whose provider boost changed. + function notifyWeightBoostChanged(uint256 nodeOperatorId) external; + + /// @notice Notify the registry that a configured provider changed global boost parameters. + /// @dev Requests a full deposit info update when the sender is an enabled provider. + /// Unregistered or disabled providers are ignored since cached weights do not depend on them. + function notifyWeightBoostProviderConfigChanged() external; } diff --git a/src/interfaces/IWeightBoostProvider.sol b/src/interfaces/IWeightBoostProvider.sol new file mode 100644 index 000000000..24cacb2db --- /dev/null +++ b/src/interfaces/IWeightBoostProvider.sol @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +/// @notice Weight multiplier provider consumed by MetaRegistry. +interface IWeightBoostProvider { + /// @notice Return weight multiplier in basis points for a node operator. + /// @param nodeOperatorId Node Operator ID. + /// @return multiplierBP Full multiplier in basis points. 10_000 means no scaling. + function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP); +} diff --git a/test/fork/deployment/PostDeploymentCommon.t.sol b/test/fork/deployment/PostDeploymentCommon.t.sol index 36f83166d..0902c7f1e 100644 --- a/test/fork/deployment/PostDeploymentCommon.t.sol +++ b/test/fork/deployment/PostDeploymentCommon.t.sol @@ -24,6 +24,8 @@ contract DeploymentBaseTest is Test, Utilities, DeploymentFixtures { CommonDeployParams internal deployParams; uint256 adminsCount; uint256 expectedModuleScratchNonce; + uint256 expectedModuleScratchNonceFromGates; + uint256 expectedModuleScratchNonceFromWeightBoostProviders; function setUp() public { Env memory env = envVars(); @@ -36,7 +38,14 @@ contract DeploymentBaseTest is Test, Utilities, DeploymentFixtures { if (moduleType == ModuleType.Curated) { // Curated deployment sets bond-curve weights once per gate. Each set triggers // requestFullDepositInfoUpdate(), which increments module nonce. - expectedModuleScratchNonce = vm.parseJsonAddressArray(config, ".CuratedGates").length; + expectedModuleScratchNonceFromGates = vm.parseJsonAddressArray(config, ".CuratedGates").length; + + // Each registered weight boost provider also requests full update and contributes one nonce. + expectedModuleScratchNonceFromWeightBoostProviders = metaRegistry.getWeightBoostProvidersCount(); + + expectedModuleScratchNonce = + expectedModuleScratchNonceFromGates + + expectedModuleScratchNonceFromWeightBoostProviders; } } } diff --git a/test/helpers/mocks/AdditionalBondRegistryMock.sol b/test/helpers/mocks/AdditionalBondRegistryMock.sol index daa600cca..c7708e5ec 100644 --- a/test/helpers/mocks/AdditionalBondRegistryMock.sol +++ b/test/helpers/mocks/AdditionalBondRegistryMock.sol @@ -3,17 +3,16 @@ pragma solidity 0.8.33; +import { IWeightBoostProvider } from "src/interfaces/IWeightBoostProvider.sol"; import { MAX_BP } from "src/lib/Constants.sol"; -import { OperatorTierState } from "src/interfaces/IAdditionalBondRegistry.sol"; /// @dev Minimal AdditionalBondRegistry mock for MetaRegistry tests. -/// 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 { +/// Stores the weight multiplier increment above MAX_BP, mirroring AdditionalBondRegistry storage. +contract AdditionalBondRegistryMock is IWeightBoostProvider { mapping(uint256 => uint256) private _weightMultiplier; - function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory state) { - state.weightMultiplier = MAX_BP + _weightMultiplier[nodeOperatorId]; + function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { + multiplierBP = MAX_BP + _weightMultiplier[nodeOperatorId]; } function mock_setWeightMultiplier(uint256 nodeOperatorId, uint256 weightMultiplier) external { diff --git a/test/helpers/mocks/MetaRegistryMock.sol b/test/helpers/mocks/MetaRegistryMock.sol index 3f924b361..8178c1164 100644 --- a/test/helpers/mocks/MetaRegistryMock.sol +++ b/test/helpers/mocks/MetaRegistryMock.sol @@ -6,15 +6,15 @@ pragma solidity 0.8.33; import { IMetaRegistry, OperatorMetadata } from "src/interfaces/IMetaRegistry.sol"; contract MetaRegistryMock { - uint256 public refreshOperatorWeightCallCount; - uint256 public lastRefreshedOperatorId; + uint256 public notifyWeightBoostChangedCallCount; + uint256 public lastChangedBoostOperatorId; function setOperatorMetadataAsAdmin(uint256 nodeOperatorId, OperatorMetadata calldata metadata) external { emit IMetaRegistry.OperatorMetadataSet({ nodeOperatorId: nodeOperatorId, metadata: metadata }); } - function refreshOperatorWeight(uint256 nodeOperatorId) external { - refreshOperatorWeightCallCount++; - lastRefreshedOperatorId = nodeOperatorId; + function notifyWeightBoostChanged(uint256 nodeOperatorId) external { + notifyWeightBoostChangedCallCount++; + lastChangedBoostOperatorId = nodeOperatorId; } } diff --git a/test/unit/AdditionalBondRegistry.t.sol b/test/unit/AdditionalBondRegistry.t.sol index 6fd9301c5..3c93fd7b0 100644 --- a/test/unit/AdditionalBondRegistry.t.sol +++ b/test/unit/AdditionalBondRegistry.t.sol @@ -185,11 +185,13 @@ contract AdditionalBondRegistrySelectTierTest is AdditionalBondRegistrySelectTie vm.prank(nodeOperatorOwner); additionalBondRegistry.selectTier(0, 1); - assertEq(additionalBondRegistry.getOperatorTierState(0).tierId, 1); - assertEq(additionalBondRegistry.getOperatorTierState(0).weightMultiplier, MAX_BP + T1_WEIGHT); + OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); + assertEq(s.tierId, 1); + assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); - assertEq(metaRegistryMock.refreshOperatorWeightCallCount(), 1); - assertEq(metaRegistryMock.lastRefreshedOperatorId(), 0); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 1); + assertEq(metaRegistryMock.lastChangedBoostOperatorId(), 0); } function test_selectTier_Upgrade_Tier1ToTier2() public { @@ -204,6 +206,7 @@ contract AdditionalBondRegistrySelectTierTest is AdditionalBondRegistrySelectTie OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); assertEq(s.tierId, 2); assertEq(s.weightMultiplier, MAX_BP + T2_WEIGHT); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T2_WEIGHT); assertEq(s.curveMultiplier, MAX_BP + T2_BOND); assertEq(s.curveMultiplierCooldownUntil, 0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); @@ -226,7 +229,8 @@ contract AdditionalBondRegistrySelectTierTest is AdditionalBondRegistrySelectTie // 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); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 2); } function test_selectTier_Upgrade_ClearsCooldownIfActive() public { @@ -415,6 +419,7 @@ contract AdditionalBondRegistryViewsTest is AdditionalBondRegistrySelectTierBase assertEq(s.weightMultiplier, MAX_BP); assertEq(s.curveMultiplier, MAX_BP); assertEq(s.curveMultiplierCooldownUntil, 0); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP); } function test_getOperatorTierState_AfterUpgrade() public { @@ -423,6 +428,7 @@ contract AdditionalBondRegistryViewsTest is AdditionalBondRegistrySelectTierBase OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); assertEq(s.tierId, 1); assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); assertEq(s.curveMultiplier, MAX_BP + T1_BOND); assertEq(s.curveMultiplierCooldownUntil, 0); } @@ -436,6 +442,7 @@ contract AdditionalBondRegistryViewsTest is AdditionalBondRegistrySelectTierBase OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); assertEq(s.tierId, 1); assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); assertEq(s.curveMultiplier, MAX_BP + T2_BOND); assertEq(s.curveMultiplierCooldownUntil, block.timestamp + CURVE_MULTIPLIER_COOLDOWN); } diff --git a/test/unit/MetaRegistry.t.sol b/test/unit/MetaRegistry.t.sol index 447243b42..d79f02cc2 100644 --- a/test/unit/MetaRegistry.t.sol +++ b/test/unit/MetaRegistry.t.sol @@ -14,6 +14,7 @@ import { NodeOperatorManagementProperties } from "src/interfaces/IBaseModule.sol import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; import { IBaseModule } from "src/interfaces/IBaseModule.sol"; import { IStakingRouter } from "src/interfaces/IStakingRouter.sol"; +import { IWeightBoostProvider } from "src/interfaces/IWeightBoostProvider.sol"; import { ExternalOperatorLib } from "src/lib/ExternalOperatorLib.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; @@ -36,6 +37,21 @@ contract MetaRegistryForTest is MetaRegistry { } } +contract WeightBoostProviderMock is IWeightBoostProvider { + uint256 internal constant DEFAULT_MULTIPLIER_BP = 10_000; + + mapping(uint256 nodeOperatorId => uint256 multiplierBP) public multiplierBP; + + function mock_setMultiplierBP(uint256 nodeOperatorId, uint256 value) external { + multiplierBP[nodeOperatorId] = value; + } + + function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256) { + uint256 value = multiplierBP[nodeOperatorId]; + return value == 0 ? DEFAULT_MULTIPLIER_BP : value; + } +} + contract MetaRegistryBaseTest is Test, Utilities, Fixtures { CuratedMock public module; StakingRouterMock public stakingRouter; @@ -1074,6 +1090,544 @@ contract MetaRegistryWeightsTest is MetaRegistryGroupsBaseTest { } } +contract MetaRegistryWeightBoostProviderTest is MetaRegistryGroupsBaseTest { + WeightBoostProviderMock public provider; + WeightBoostProviderMock public secondProvider; + + IMetaRegistry.WeightBoostProviderMode internal constant NODE_OPERATOR_MODE = + IMetaRegistry.WeightBoostProviderMode.NodeOperator; + IMetaRegistry.WeightBoostProviderMode internal constant GROUP_MAX_MODE = + IMetaRegistry.WeightBoostProviderMode.GroupMax; + + function setUp() public override { + super.setUp(); + + provider = new WeightBoostProviderMock(); + secondProvider = new WeightBoostProviderMock(); + } + + function _assertWeightBoostProvider( + uint256 providerId, + IWeightBoostProvider expectedProvider, + IMetaRegistry.WeightBoostProviderMode expectedMode, + bool expectedEnabled + ) internal view { + IMetaRegistry.WeightBoostProviderEntry memory entry = registry.getWeightBoostProvider(providerId); + assertEq(address(entry.provider), address(expectedProvider)); + assertEq(uint256(entry.mode), uint256(expectedMode)); + assertEq(entry.enabled, expectedEnabled); + } + + function test_addWeightBoostProvider_StoresNodeOperatorProviderAndLeavesExistingGroupsStale() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + uint256 groupId = _nextGroupId(); + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + + provider.mock_setMultiplierBP(0, 11000); + + vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); + vm.expectEmit(address(registry)); + emit IMetaRegistry.WeightBoostProviderAdded(address(provider), NODE_OPERATOR_MODE); + vm.prank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + + IWeightBoostProvider[] memory providers = registry.getWeightBoostProviders(); + assertEq(providers.length, 1); + assertEq(address(providers[0]), address(provider)); + _assertWeightBoostProvider(1, provider, NODE_OPERATOR_MODE, true); + assertEq(uint256(registry.getWeightBoostProviderMode(1)), uint256(NODE_OPERATOR_MODE)); + assertEq(registry.getWeightBoostProviderId(address(provider)), 1); + assertEq(registry.getWeightBoostProvidersCount(), 1); + assertEq(registry.getNodeOperatorWeight(0), CURVE_WEIGHT); + + registry.refreshGroupWeights(groupId); + assertEq(registry.getNodeOperatorWeight(0), 11000); + } + + function test_addWeightBoostProvider_StoresGroupMaxProviderAndLeavesExistingGroupsStale() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + uint256 groupId = _nextGroupId(); + vm.prank(groupManager); + _createGroup( + _subOperatorsArr2( + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 0, share: MAX_BP / 2 }), + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 1, share: MAX_BP / 2 }) + ), + _extOperatorsArr0() + ); + + secondProvider.mock_setMultiplierBP(0, 11000); + secondProvider.mock_setMultiplierBP(1, 12000); + + vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); + vm.expectEmit(address(registry)); + emit IMetaRegistry.WeightBoostProviderAdded(address(secondProvider), GROUP_MAX_MODE); + vm.prank(admin); + registry.addWeightBoostProvider(secondProvider, GROUP_MAX_MODE); + + assertEq(registry.getWeightBoostProvidersCount(), 1); + _assertWeightBoostProvider(1, secondProvider, GROUP_MAX_MODE, true); + assertEq(registry.getNodeOperatorWeight(0), 5000); + assertEq(registry.getNodeOperatorWeight(1), 5000); + + registry.refreshGroupWeights(groupId); + assertEq(registry.getNodeOperatorWeight(0), 6000); + assertEq(registry.getNodeOperatorWeight(1), 6000); + } + + function test_addWeightBoostProvider_RevertWhen_InvalidOrDuplicate() public { + vm.startPrank(admin); + + vm.expectRevert(IMetaRegistry.InvalidWeightBoostProvider.selector); + registry.addWeightBoostProvider(IWeightBoostProvider(address(0)), NODE_OPERATOR_MODE); + + registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + + vm.expectRevert(IMetaRegistry.WeightBoostProviderAlreadyAdded.selector); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + + vm.stopPrank(); + } + + function test_addWeightBoostProvider_RevertWhen_NoRole() public { + expectRoleRevert(stranger, registry.DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + } + + function test_setWeightBoostProviderEnabled_DisablesProviderAndLeavesExistingGroupsStale() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + uint256 groupId = _nextGroupId(); + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + + provider.mock_setMultiplierBP(0, 11000); + vm.prank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + + registry.refreshGroupWeights(groupId); + assertEq(registry.getNodeOperatorWeight(0), 11000); + + vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); + vm.expectEmit(address(registry)); + emit IMetaRegistry.WeightBoostProviderEnabledSet(address(provider), false); + vm.prank(admin); + registry.setWeightBoostProviderEnabled(1, false); + + _assertWeightBoostProvider(1, provider, NODE_OPERATOR_MODE, false); + assertEq(registry.getNodeOperatorWeight(0), 11000); + + registry.refreshGroupWeights(groupId); + assertEq(registry.getNodeOperatorWeight(0), CURVE_WEIGHT); + } + + function test_refreshGroupWeights_SkipsDisabledProviderInMultiplierLoop() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + provider.mock_setMultiplierBP(0, 12000); + secondProvider.mock_setMultiplierBP(0, 11000); + + vm.startPrank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(secondProvider, NODE_OPERATOR_MODE); + registry.setWeightBoostProviderEnabled(1, false); + vm.stopPrank(); + + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + + assertEq(registry.getNodeOperatorWeight(0), 11000); + + registry.refreshOperatorWeight(0); + assertEq(registry.getNodeOperatorWeight(0), 11000); + } + + function test_setWeightBoostProviderEnabled_EnablesProviderAndLeavesExistingGroupsStale() public { + provider.mock_setMultiplierBP(0, 11000); + vm.startPrank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.setWeightBoostProviderEnabled(1, false); + vm.stopPrank(); + + _setBondCurveWeight(0, CURVE_WEIGHT); + uint256 groupId = _nextGroupId(); + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + assertEq(registry.getNodeOperatorWeight(0), CURVE_WEIGHT); + + vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); + vm.expectEmit(address(registry)); + emit IMetaRegistry.WeightBoostProviderEnabledSet(address(provider), true); + vm.prank(admin); + registry.setWeightBoostProviderEnabled(1, true); + + _assertWeightBoostProvider(1, provider, NODE_OPERATOR_MODE, true); + assertEq(registry.getNodeOperatorWeight(0), CURVE_WEIGHT); + + registry.refreshGroupWeights(groupId); + assertEq(registry.getNodeOperatorWeight(0), 11000); + } + + function test_refreshGroupWeights_UsesDefaultGroupMaxMultiplierForEmptyGroup() public { + vm.prank(admin); + registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + + uint256 groupId = _nextGroupId(); + vm.startPrank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + _clearGroup(groupId); + vm.stopPrank(); + + registry.refreshGroupWeights(groupId); + + assertEq(registry.getNodeOperatorWeight(0), 0); + } + + function test_setWeightBoostProviderEnabled_SkipsDisabledGroupMaxProvider() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + provider.mock_setMultiplierBP(0, 11000); + provider.mock_setMultiplierBP(1, 12000); + + vm.startPrank(admin); + registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.setWeightBoostProviderEnabled(1, false); + vm.stopPrank(); + + uint256 groupId = _nextGroupId(); + vm.prank(groupManager); + _createGroup( + _subOperatorsArr2( + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 0, share: MAX_BP / 2 }), + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 1, share: MAX_BP / 2 }) + ), + _extOperatorsArr0() + ); + + assertEq(registry.getNodeOperatorWeight(0), 5000); + assertEq(registry.getNodeOperatorWeight(1), 5000); + + vm.prank(admin); + registry.setWeightBoostProviderEnabled(1, true); + + registry.refreshGroupWeights(groupId); + assertEq(registry.getNodeOperatorWeight(0), 6000); + assertEq(registry.getNodeOperatorWeight(1), 6000); + } + + function test_setWeightBoostProviderEnabled_RevertWhen_NotFoundSameOrNoRole() public { + vm.prank(admin); + vm.expectRevert(IMetaRegistry.WeightBoostProviderNotFound.selector); + registry.setWeightBoostProviderEnabled(1, false); + + vm.prank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + + vm.prank(admin); + vm.expectRevert(IMetaRegistry.SameWeightBoostProviderEnabled.selector); + registry.setWeightBoostProviderEnabled(1, true); + + expectRoleRevert(stranger, registry.DEFAULT_ADMIN_ROLE()); + vm.prank(stranger); + registry.setWeightBoostProviderEnabled(1, false); + + vm.prank(admin); + registry.setWeightBoostProviderEnabled(1, false); + + vm.prank(admin); + vm.expectRevert(IMetaRegistry.SameWeightBoostProviderEnabled.selector); + registry.setWeightBoostProviderEnabled(1, false); + } + + function test_notifyWeightBoostChanged_FromDisabledProviderDoesNotRefresh() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + + vm.startPrank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.setWeightBoostProviderEnabled(1, false); + vm.stopPrank(); + + provider.mock_setMultiplierBP(0, 11000); + vm.prank(address(provider)); + registry.notifyWeightBoostChanged(0); + + assertEq(registry.getNodeOperatorWeight(0), CURVE_WEIGHT); + } + + function test_notifyWeightBoostChanged_NoOpWhenOperatorHasNoGroup() public { + vm.prank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + + provider.mock_setMultiplierBP(0, 11000); + vm.prank(address(provider)); + registry.notifyWeightBoostChanged(0); + + assertEq(registry.getNodeOperatorWeight(0), 0); + } + + function test_notifyWeightBoostProviderConfigChanged_RequestsFullDepositInfoUpdate() public { + vm.prank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + + vm.expectCall(address(module), abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector)); + vm.expectEmit(address(registry)); + emit IMetaRegistry.WeightBoostProviderConfigChanged(address(provider)); + vm.prank(address(provider)); + registry.notifyWeightBoostProviderConfigChanged(); + } + + function test_notifyWeightBoostProviderConfigChanged_RevertWhen_ProviderNotFound() public { + vm.prank(address(provider)); + vm.expectRevert(IMetaRegistry.WeightBoostProviderNotFound.selector); + registry.notifyWeightBoostProviderConfigChanged(); + } + + function test_notifyWeightBoostProviderConfigChanged_NoOpWhenProviderDisabled() public { + vm.startPrank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.setWeightBoostProviderEnabled(1, false); + vm.stopPrank(); + + vm.mockCallRevert( + address(module), + abi.encodeWithSelector(IBaseModule.requestFullDepositInfoUpdate.selector), + abi.encode("UNEXPECTED_REQUEST_FULL_DEPOSIT_INFO_UPDATE") + ); + + vm.prank(address(provider)); + registry.notifyWeightBoostProviderConfigChanged(); + } + + function test_createAndUpdateGroup_RecalculatesGroupMaxFromComposition() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + provider.mock_setMultiplierBP(0, 11000); + provider.mock_setMultiplierBP(1, 12000); + + vm.prank(admin); + registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + + uint256 groupId = _nextGroupId(); + vm.prank(groupManager); + _createGroup( + _subOperatorsArr2( + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 0, share: MAX_BP / 2 }), + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 1, share: MAX_BP / 2 }) + ), + _extOperatorsArr0() + ); + assertEq(registry.getNodeOperatorWeight(0), 6000); + assertEq(registry.getNodeOperatorWeight(1), 6000); + + vm.prank(groupManager); + _updateGroup(groupId, _subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + assertEq(registry.getNodeOperatorWeight(0), 11000); + assertEq(registry.getNodeOperatorWeight(1), 0); + + vm.prank(groupManager); + _clearGroup(groupId); + assertEq(registry.getNodeOperatorWeight(0), 0); + } + + function test_notifyWeightBoostChanged_FromGroupMaxProviderRefreshesWholeGroupWhenMaxChanges() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + vm.prank(groupManager); + _createGroup( + _subOperatorsArr2( + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 0, share: MAX_BP / 2 }), + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 1, share: MAX_BP / 2 }) + ), + _extOperatorsArr0() + ); + + vm.prank(admin); + registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + provider.mock_setMultiplierBP(0, 12000); + + vm.prank(address(provider)); + registry.notifyWeightBoostChanged(0); + + assertEq(registry.getNodeOperatorWeight(0), 6000); + assertEq(registry.getNodeOperatorWeight(1), 6000); + } + + function test_notifyWeightBoostChanged_FromGroupMaxProviderRefreshesWholeGroupWhenMaxUnchanged() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + provider.mock_setMultiplierBP(0, 11000); + provider.mock_setMultiplierBP(1, 12000); + + vm.prank(admin); + registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + uint256 groupId = _nextGroupId(); + vm.prank(groupManager); + _createGroup( + _subOperatorsArr2( + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 0, share: MAX_BP / 2 }), + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 1, share: MAX_BP / 2 }) + ), + _extOperatorsArr0() + ); + + provider.mock_setMultiplierBP(0, 11500); + + vm.expectEmit(address(registry)); + emit IMetaRegistry.GroupWeightsRefreshed(groupId); + vm.prank(address(provider)); + registry.notifyWeightBoostChanged(0); + + assertEq(registry.getNodeOperatorWeight(0), 6000); + assertEq(registry.getNodeOperatorWeight(1), 6000); + } + + function test_notifyWeightBoostChanged_FromNodeOperatorProviderRefreshesOnlyOperator() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + vm.prank(groupManager); + _createGroup( + _subOperatorsArr2( + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 0, share: MAX_BP / 2 }), + IMetaRegistry.SubNodeOperator({ nodeOperatorId: 1, share: MAX_BP / 2 }) + ), + _extOperatorsArr0() + ); + + vm.prank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + provider.mock_setMultiplierBP(0, 11000); + + vm.prank(address(provider)); + registry.notifyWeightBoostChanged(0); + + assertEq(registry.getNodeOperatorWeight(0), 5500); + assertEq(registry.getNodeOperatorWeight(1), 5000); + } + + function test_refreshGroupWeights_MultipliesProviderMultipliers() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + provider.mock_setMultiplierBP(0, 12000); + secondProvider.mock_setMultiplierBP(0, 11000); + + vm.startPrank(admin); + registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(secondProvider, NODE_OPERATOR_MODE); + vm.stopPrank(); + + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + + assertEq(registry.getNodeOperatorWeight(0), 13200); + } + + function test_refreshGroupWeights_CachesGroupMaxProviderMultiplierPerRefresh() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + provider.mock_setMultiplierBP(0, 11000); + provider.mock_setMultiplierBP(1, 12000); + provider.mock_setMultiplierBP(2, 13000); + + IMetaRegistry.SubNodeOperator[] memory subOperators = new IMetaRegistry.SubNodeOperator[](3); + subOperators[0] = IMetaRegistry.SubNodeOperator({ nodeOperatorId: 0, share: 3000 }); + subOperators[1] = IMetaRegistry.SubNodeOperator({ nodeOperatorId: 1, share: 3000 }); + subOperators[2] = IMetaRegistry.SubNodeOperator({ nodeOperatorId: 2, share: 4000 }); + + uint256 groupId = _nextGroupId(); + vm.prank(groupManager); + _createGroup(subOperators, _extOperatorsArr0()); + + vm.prank(admin); + registry.addWeightBoostProvider(provider, GROUP_MAX_MODE); + + vm.expectCall( + address(provider), + abi.encodeCall(IWeightBoostProvider.getWeightBoostMultiplierBP, (uint256(0))), + 1 + ); + vm.expectCall( + address(provider), + abi.encodeCall(IWeightBoostProvider.getWeightBoostMultiplierBP, (uint256(1))), + 1 + ); + vm.expectCall( + address(provider), + abi.encodeCall(IWeightBoostProvider.getWeightBoostMultiplierBP, (uint256(2))), + 1 + ); + + registry.refreshGroupWeights(groupId); + + assertEq(registry.getNodeOperatorWeight(0), 3900); + assertEq(registry.getNodeOperatorWeight(1), 3900); + assertEq(registry.getNodeOperatorWeight(2), 5200); + } + + function test_refreshGroupWeights_UsesSameOrderedMultiplierAsOperatorRefresh() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + WeightBoostProviderMock thirdProvider = new WeightBoostProviderMock(); + + provider.mock_setMultiplierBP(0, 16293); + secondProvider.mock_setMultiplierBP(0, 14016); + thirdProvider.mock_setMultiplierBP(0, 13527); + + vm.startPrank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + registry.addWeightBoostProvider(secondProvider, GROUP_MAX_MODE); + registry.addWeightBoostProvider(thirdProvider, NODE_OPERATOR_MODE); + vm.stopPrank(); + + uint256 groupId = _nextGroupId(); + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + + assertEq(registry.getNodeOperatorWeight(0), 30890); + + registry.refreshOperatorWeight(0); + + assertEq(registry.getNodeOperatorWeight(0), 30890); + registry.refreshGroupWeights(groupId); + assertEq(registry.getNodeOperatorWeight(0), 30890); + } + + function test_refreshOperatorWeight_AllowsProviderMultiplierBelowBaseline() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + + vm.prank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + provider.mock_setMultiplierBP(0, 9000); + + registry.refreshOperatorWeight(0); + + assertEq(registry.getNodeOperatorWeight(0), 9000); + } + + function test_notifyWeightBoostChanged_RevertWhen_ProviderNotFound() public { + vm.expectRevert(IMetaRegistry.WeightBoostProviderNotFound.selector); + registry.notifyWeightBoostChanged(0); + } + + function test_refreshGroupWeights_RevertWhen_InvalidGroupId() public { + vm.expectRevert(IMetaRegistry.InvalidOperatorGroupId.selector); + registry.refreshGroupWeights(NO_GROUP_ID); + + uint256 nonExistingGroupId = _nextGroupId(); + vm.expectRevert(IMetaRegistry.InvalidOperatorGroupId.selector); + registry.refreshGroupWeights(nonExistingGroupId); + } + + function test_refreshOperatorWeight_RecalculatesCurrentProviderValues() public { + _setBondCurveWeight(0, CURVE_WEIGHT); + vm.prank(groupManager); + _createGroup(_subOperatorsArr1(0, MAX_BP), _extOperatorsArr0()); + + vm.prank(admin); + registry.addWeightBoostProvider(provider, NODE_OPERATOR_MODE); + provider.mock_setMultiplierBP(0, 11000); + + registry.refreshOperatorWeight(0); + + assertEq(registry.getNodeOperatorWeight(0), 11000); + } +} + contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { uint256 internal constant VALID_BOND_CURVE_WEIGHT = CURVE_WEIGHT + 123; @@ -1209,6 +1763,9 @@ contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { function test_refreshOperatorWeight_AppliesTierWeightMultiplier() public { uint64 noId = 0; + vm.prank(admin); + registry.addWeightBoostProvider(additionalBondRegistry, IMetaRegistry.WeightBoostProviderMode.NodeOperator); + vm.prank(groupManager); _createGroup(_subOperatorsArr1(noId, MAX_BP), _extOperatorsArr0()); @@ -1221,18 +1778,24 @@ contract MetaRegistryBondCurveTest is MetaRegistryGroupsBaseTest { } 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 }); + IMetaRegistry.SubNodeOperator memory op0 = IMetaRegistry.SubNodeOperator({ nodeOperatorId: 0, share: 1 }); + IMetaRegistry.SubNodeOperator memory op1 = IMetaRegistry.SubNodeOperator({ + nodeOperatorId: 1, + share: MAX_BP - 1 + }); + + vm.prank(admin); + registry.addWeightBoostProvider(additionalBondRegistry, IMetaRegistry.WeightBoostProviderMode.NodeOperator); vm.prank(groupManager); _createGroup(_subOperatorsArr2(op0, op1), _extOperatorsArr0()); - _setBondCurveWeight(0, CURVE_WEIGHT); - additionalBondRegistry.mock_setWeightMultiplier(0, 5_000); + _setBondCurveWeight(0, CURVE_WEIGHT + 1); + additionalBondRegistry.mock_setWeightMultiplier(0, 9_999); registry.refreshOperatorWeight(0); (uint256 weight, ) = registry.getNodeOperatorWeightAndExternalStake(0); - assertEq(weight, 9000); // weighted=6000 (share), 6000 * 15000 / 10000 = 9000 + assertEq(weight, 1); // shared=1, 1 * 19999 / 10000 = 1 } } From be56b5d041a8f1e5d190f52dac9eb8cb813210d5 Mon Sep 17 00:00:00 2001 From: Vladimir Gorkavenko <32727352+vgorkavenko@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:58:00 +0200 Subject: [PATCH 4/4] feat: tiers -> steps (#851) ## Description Remove tiers. Use steps ## Checklist - [x] Appropriate PR labels applied - [x] Test coverage maintained (`just coverage`) - [x] Tests are added/updated - [x] Documentation maintained - [x] No need to update --- script/curated/DeployBase.s.sol | 8 +- script/curated/DeployHoodi.s.sol | 3 + script/curated/DeployLocalDevNet.s.sol | 3 + script/curated/DeployMainnet.s.sol | 3 + script/utils/Common.sol | 12 + src/AdditionalBondRegistry.sol | 165 ++++---- src/interfaces/IAdditionalBondRegistry.sol | 103 +++-- .../deployment/PostDeploymentCurated.t.sol | 15 +- test/helpers/Fixtures.sol | 7 +- test/helpers/mocks/MetaRegistryMock.sol | 5 + test/unit/AdditionalBondRegistry.t.sol | 396 ++++++++++-------- 11 files changed, 402 insertions(+), 318 deletions(-) diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index 9cefdfcb8..d8c12c614 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -18,6 +18,7 @@ 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 { BoostStep } from "../../src/interfaces/IAdditionalBondRegistry.sol"; import { CuratedGate } from "../../src/CuratedGate.sol"; import { MerkleGateFactory } from "../../src/MerkleGateFactory.sol"; @@ -63,6 +64,8 @@ struct CuratedGateConfig { struct AdditionalBondRegistryConfig { uint256 curveMultiplierCooldown; + // Each entry is [minCurveMultiplier, weightMultiplier] (increments above MAX_BP). + uint256[2][] boostSteps; } struct CuratedDeployParams { @@ -341,9 +344,12 @@ abstract contract DeployBase is Script { { OssifiableProxy additionalBondRegistryProxy = OssifiableProxy(payable(address(additionalBondRegistry))); + BoostStep[] memory initialBoostSteps = CommonScriptUtils.arraysToBoostSteps( + config.additionalBondRegistryConfig.boostSteps + ); additionalBondRegistryProxy.proxy__upgradeToAndCall( address(additionalBondRegistryImpl), - abi.encodeCall(AdditionalBondRegistry.initialize, (deployer)) + abi.encodeCall(AdditionalBondRegistry.initialize, (deployer, initialBoostSteps)) ); additionalBondRegistryProxy.proxy__changeAdmin(config.proxyAdmin); } diff --git a/script/curated/DeployHoodi.s.sol b/script/curated/DeployHoodi.s.sol index f2a7f3124..099b8bbf1 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -201,6 +201,9 @@ contract DeployHoodi is DeployBase { // CurveMultiplier config.additionalBondRegistryConfig.curveMultiplierCooldown = 7 days; + // TODO: reconsider — placeholder initial boost steps. + config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); + config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); _setUp(); } diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index 2944c46a1..d7a47d1c9 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -189,6 +189,9 @@ contract DeployLocalDevNet is DeployBase { // CurveMultiplier config.additionalBondRegistryConfig.curveMultiplierCooldown = 1 days; + // TODO: reconsider — placeholder initial boost steps. + config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); + config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); _setUp(); } diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index 930c9d558..40d4eeadf 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -200,6 +200,9 @@ contract DeployMainnet is DeployBase { // CurveMultiplier config.additionalBondRegistryConfig.curveMultiplierCooldown = 7 days; + // TODO: reconsider — placeholder initial boost steps. + config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); + config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); _setUp(); } diff --git a/script/utils/Common.sol b/script/utils/Common.sol index 194e6b41d..3f60a3c4a 100644 --- a/script/utils/Common.sol +++ b/script/utils/Common.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.33; import { IParametersRegistry } from "../../src/interfaces/IParametersRegistry.sol"; import { IBondCurve } from "../../src/interfaces/IBondCurve.sol"; +import { BoostStep } from "../../src/interfaces/IAdditionalBondRegistry.sol"; library CommonScriptUtils { function arraysToKeyIndexValueIntervals( @@ -32,4 +33,15 @@ library CommonScriptUtils { } return bondCurveInputs; } + + function arraysToBoostSteps(uint256[2][] memory data) internal pure returns (BoostStep[] memory) { + BoostStep[] memory boostSteps = new BoostStep[](data.length); + for (uint256 i = 0; i < data.length; i++) { + boostSteps[i] = BoostStep({ + minCurveMultiplier: uint128(data[i][0]), + weightMultiplier: uint128(data[i][1]) + }); + } + return boostSteps; + } } diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol index 0d0a8192e..22221069b 100644 --- a/src/AdditionalBondRegistry.sol +++ b/src/AdditionalBondRegistry.sol @@ -9,94 +9,87 @@ 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 { IAdditionalBondRegistry, TierInfo, OperatorTierState } from "./interfaces/IAdditionalBondRegistry.sol"; +import { IAdditionalBondRegistry, BoostStep, PendingCurveMultiplierReduction } from "./interfaces/IAdditionalBondRegistry.sol"; import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; import { MAX_BP } from "./lib/Constants.sol"; -/// @notice Manages operator tiers. +/// @notice Maps an operator's curve multiplier to a weight multiplier via governance-set boost steps. 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; - /// @dev Cooldown deadline (unix timestamp) after a tier downgrade. 0 = no active cooldown. - mapping(uint256 nodeOperatorId => uint256) curveMultiplierCooldownUntil; + BoostStep[] boostSteps; + /// @dev Downgrade cooldown and pending curve multiplier increment, per operator. + mapping(uint256 nodeOperatorId => PendingCurveMultiplierReduction) pending; } - // NOTE: Sanity guard for tier creation: effective multiplier <= 10x the default multiplier. + // Sanity guard: effective multiplier <= 10x. uint256 public constant MAX_CURVE_MULTIPLIER = 9 * MAX_BP; uint256 public constant MAX_WEIGHT_MULTIPLIER = 9 * MAX_BP; + // Requested curve multiplier must be a multiple of this (1%). + uint256 public constant CURVE_MULTIPLIER_STEP = MAX_BP / 100; ICuratedModule public immutable MODULE; IAccounting public immutable ACCOUNTING; IMetaRegistry public immutable META_REGISTRY; - uint256 public immutable CURVE_MULTIPLIER_COOLDOWN; + uint256 public immutable CURVE_MULTIPLIER_REDUCTION_COOLDOWN; // keccak256(abi.encode(uint256(keccak256("AdditionalBondRegistry")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant ADDITIONAL_BOND_REGISTRY_STORAGE_LOCATION = 0xe06435b00cfe5ab72c52612ef2f4c7b5f9c4cc44634ef79a78a1888f5b1eb300; /// @param module CuratedModule address. - /// @param curveMultiplierCooldown Cooldown in seconds after a tier downgrade before `applyCurveMultiplier` can be called. + /// @param curveMultiplierCooldown Cooldown in seconds after a downgrade before `applyCurveMultiplier` 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; + CURVE_MULTIPLIER_REDUCTION_COOLDOWN = curveMultiplierCooldown; _disableInitializers(); } /// @inheritdoc IAdditionalBondRegistry - function initialize(address admin) external initializer { + function initialize(address admin, BoostStep[] calldata boostSteps) external initializer { if (admin == address(0)) revert ZeroAdminAddress(); _grantRole(DEFAULT_ADMIN_ROLE, admin); + _setBoostSteps(boostSteps); } /// @inheritdoc IAdditionalBondRegistry - function addTier( - uint256 curveMultiplier, - uint256 weightMultiplier - ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 tierId) { - if (curveMultiplier > MAX_CURVE_MULTIPLIER) revert InvalidCurveMultiplier(); - if (weightMultiplier > MAX_WEIGHT_MULTIPLIER) revert InvalidWeightMultiplier(); - AdditionalBondRegistryStorage storage $ = _storage(); - tierId = ++$.tiersCount; - $.tiers[tierId] = TierInfo({ - curveMultiplier: uint128(curveMultiplier), - weightMultiplier: uint128(weightMultiplier) - }); - emit TierAdded(tierId, curveMultiplier, weightMultiplier); + function setBoostSteps(BoostStep[] calldata boostSteps) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setBoostSteps(boostSteps); + META_REGISTRY.notifyWeightBoostProviderConfigChanged(); } /// @inheritdoc IAdditionalBondRegistry - function selectTier(uint256 nodeOperatorId, uint256 tierId) external { + function requestCurveMultiplier(uint256 nodeOperatorId, uint256 curveMultiplier) external { AdditionalBondRegistryStorage storage $ = _storage(); _checkOperatorOwner(nodeOperatorId); - if (tierId > $.tiersCount) revert InvalidTierId(); - if (tierId == $.operatorTier[nodeOperatorId]) revert SameTier(); + if (curveMultiplier > MAX_CURVE_MULTIPLIER || curveMultiplier % CURVE_MULTIPLIER_STEP != 0) { + revert InvalidCurveMultiplier(); + } + + uint256 newMul = MAX_BP + curveMultiplier; + uint256 curMul = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); + if (newMul == curMul) revert SameCurveMultiplier(); - uint256 newMulInc = $.tiers[tierId].curveMultiplier; - uint256 newMul = MAX_BP + newMulInc; - if (newMul > ACCOUNTING.getBondCurveMultiplier(nodeOperatorId)) { + if (newMul > curMul) { // NOTE: Takes into account current bond amount and keys count. // Value `0` as a second arg for the following method means current keys count. - if (ACCOUNTING.getRequiredBondForNextKeys(nodeOperatorId, 0, newMul) > 0) revert InsufficientBondForTier(); - if ($.curveMultiplierCooldownUntil[nodeOperatorId] != 0) { - _removeCurveMultiplierCooldown(nodeOperatorId); + if (ACCOUNTING.getRequiredBondForNextKeys(nodeOperatorId, 0, newMul) > 0) { + revert InsufficientBond(); + } + if ($.pending[nodeOperatorId].cooldownUntil != 0) { + _removeCurveMultiplierReductionCooldown(nodeOperatorId); } - ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, newMulInc); + ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, curveMultiplier); } else { - if ($.curveMultiplierCooldownUntil[nodeOperatorId] != 0) revert CurveMultiplierCooldownActive(); - _setCurveMultiplierCooldown(nodeOperatorId); + _setCurveMultiplierReductionCooldown(nodeOperatorId, curveMultiplier); + emit CurveMultiplierReductionRequested(nodeOperatorId, curveMultiplier); } - $.operatorTier[nodeOperatorId] = tierId; - emit TierSelected(nodeOperatorId, tierId); - META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); } @@ -104,58 +97,65 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces function applyCurveMultiplier(uint256 nodeOperatorId) external { _checkOperatorOwner(nodeOperatorId); - AdditionalBondRegistryStorage storage $ = _storage(); - uint256 cooldownUntil = $.curveMultiplierCooldownUntil[nodeOperatorId]; - if (cooldownUntil == 0) revert NoCurveMultiplierCooldown(); - if (cooldownUntil > block.timestamp) revert CurveMultiplierCooldownNotElapsed(); - - _removeCurveMultiplierCooldown(nodeOperatorId); + PendingCurveMultiplierReduction storage p = _storage().pending[nodeOperatorId]; + if (p.cooldownUntil == 0) revert NoCurveMultiplierReductionCooldown(); + if (p.cooldownUntil > block.timestamp) revert CurveMultiplierReductionCooldownNotElapsed(); - ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, $.tiers[$.operatorTier[nodeOperatorId]].curveMultiplier); + uint256 curveMultiplier = p.curveMultiplier; + _removeCurveMultiplierReductionCooldown(nodeOperatorId); + ACCOUNTING.setBondCurveMultiplier(nodeOperatorId, curveMultiplier); } /// @inheritdoc IAdditionalBondRegistry - function getTiersCount() external view returns (uint256) { - return _storage().tiersCount; - } - - /// @inheritdoc IAdditionalBondRegistry - function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory state) { - AdditionalBondRegistryStorage storage $ = _storage(); - state.tierId = $.operatorTier[nodeOperatorId]; - state.curveMultiplierCooldownUntil = $.curveMultiplierCooldownUntil[nodeOperatorId]; - state.weightMultiplier = MAX_BP + $.tiers[state.tierId].weightMultiplier; - state.curveMultiplier = ACCOUNTING.getBondCurveMultiplier(nodeOperatorId); + function getBoostSteps() external view returns (BoostStep[] memory) { + return _storage().boostSteps; } /// @inheritdoc IWeightBoostProvider function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { - AdditionalBondRegistryStorage storage $ = _storage(); - multiplierBP = MAX_BP + $.tiers[$.operatorTier[nodeOperatorId]].weightMultiplier; + PendingCurveMultiplierReduction storage p = _storage().pending[nodeOperatorId]; + // During a downgrade cooldown, weight follows the pending (lower) multiplier; Accounting still holds the higher one. + uint256 curveMultiplier = p.cooldownUntil != 0 + ? p.curveMultiplier + : ACCOUNTING.getBondCurveMultiplier(nodeOperatorId) - MAX_BP; + multiplierBP = _weightMultiplierFor(curveMultiplier); } - /// @inheritdoc IAdditionalBondRegistry - function getTierInfo(uint256 tierId) public view returns (TierInfo memory) { + function _setBoostSteps(BoostStep[] calldata boostSteps) internal { + if (boostSteps.length == 0) revert EmptyBoostSteps(); AdditionalBondRegistryStorage storage $ = _storage(); - if (tierId > $.tiersCount) revert InvalidTierId(); - TierInfo storage t = $.tiers[tierId]; - return - TierInfo({ - curveMultiplier: uint128(MAX_BP + t.curveMultiplier), - weightMultiplier: uint128(MAX_BP + t.weightMultiplier) - }); + delete $.boostSteps; + for (uint256 i = 0; i < boostSteps.length; ++i) { + _validateBoostStep(boostSteps, i); + $.boostSteps.push(boostSteps[i]); + } + emit BoostStepsSet(boostSteps); + } + + /// @dev Starts the cooldown and stores the pending curve multiplier increment. + function _setCurveMultiplierReductionCooldown(uint256 nodeOperatorId, uint256 curveMultiplier) internal { + uint256 cooldownUntil = block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN; + _storage().pending[nodeOperatorId] = PendingCurveMultiplierReduction({ + cooldownUntil: uint128(cooldownUntil), + curveMultiplier: uint128(curveMultiplier) + }); + emit CurveMultiplierReductionCooldownSet(nodeOperatorId, cooldownUntil); } - /// @dev Sets the cooldown deadline to `block.timestamp + CURVE_MULTIPLIER_COOLDOWN`. - function _setCurveMultiplierCooldown(uint256 nodeOperatorId) internal { - uint256 cooldownUntil = block.timestamp + CURVE_MULTIPLIER_COOLDOWN; - _storage().curveMultiplierCooldownUntil[nodeOperatorId] = cooldownUntil; - emit CurveMultiplierCooldownSet(nodeOperatorId, cooldownUntil); + function _removeCurveMultiplierReductionCooldown(uint256 nodeOperatorId) internal { + delete _storage().pending[nodeOperatorId]; + emit CurveMultiplierReductionCooldownRemoved(nodeOperatorId); } - function _removeCurveMultiplierCooldown(uint256 nodeOperatorId) internal { - delete _storage().curveMultiplierCooldownUntil[nodeOperatorId]; - emit CurveMultiplierCooldownRemoved(nodeOperatorId); + /// @dev Weight multiplier for a curve multiplier increment: MAX_BP + the highest step at or below it, else MAX_BP. + function _weightMultiplierFor(uint256 curveMultiplier) internal view returns (uint256 weightMul) { + BoostStep[] storage boostSteps = _storage().boostSteps; + weightMul = MAX_BP; + uint256 len = boostSteps.length; + for (uint256 i = 0; i < len; ++i) { + if (curveMultiplier < boostSteps[i].minCurveMultiplier) break; + weightMul = MAX_BP + boostSteps[i].weightMultiplier; + } } // TODO: Have the same in many places. Move to lib @@ -163,6 +163,17 @@ contract AdditionalBondRegistry is IAdditionalBondRegistry, Initializable, Acces if (msg.sender != MODULE.getNodeOperatorOwner(nodeOperatorId)) revert SenderIsNotOperatorOwner(); } + /// @dev Validates step `i`: within bounds and strictly above the previous. Fields are increments (0 allowed). + function _validateBoostStep(BoostStep[] calldata boostSteps, uint256 i) internal pure { + BoostStep calldata s = boostSteps[i]; + if (s.minCurveMultiplier > MAX_CURVE_MULTIPLIER) revert InvalidCurveMultiplier(); + if (s.weightMultiplier > MAX_WEIGHT_MULTIPLIER) revert InvalidWeightMultiplier(); + if (i == 0) return; + // Strictly increasing: a higher curve multiplier maps to a higher weight. + if (s.minCurveMultiplier <= boostSteps[i - 1].minCurveMultiplier) revert InvalidCurveMultiplier(); + if (s.weightMultiplier <= boostSteps[i - 1].weightMultiplier) revert InvalidWeightMultiplier(); + } + function _storage() internal pure returns (AdditionalBondRegistryStorage storage $) { assembly ("memory-safe") { // keccak256(abi.encode(uint256(keccak256("AdditionalBondRegistry")) - 1)) & ~bytes32(uint256(0xff)) diff --git a/src/interfaces/IAdditionalBondRegistry.sol b/src/interfaces/IAdditionalBondRegistry.sol index 8d9696749..2b8a247cb 100644 --- a/src/interfaces/IAdditionalBondRegistry.sol +++ b/src/interfaces/IAdditionalBondRegistry.sol @@ -8,89 +8,82 @@ import { ICuratedModule } from "./ICuratedModule.sol"; import { IMetaRegistry } from "./IMetaRegistry.sol"; import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; -/// @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 curveMultiplier; +/// @dev A boost step: curve multiplier increments at or above `minCurveMultiplier` map to +/// `weightMultiplier`. Both are increments above MAX_BP in basis points (0 = no scaling). +struct BoostStep { + uint128 minCurveMultiplier; uint128 weightMultiplier; } -/// @dev Operator's effective tier state, with multipliers as full basis-point values. -/// 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; - uint256 curveMultiplier; - uint256 weightMultiplier; - uint256 curveMultiplierCooldownUntil; +/// @dev A pending downgrade: the cooldown deadline and the curve multiplier increment to apply once it +/// elapses. `cooldownUntil == 0` means no active cooldown. Packed into a single slot. +struct PendingCurveMultiplierReduction { + uint128 cooldownUntil; + uint128 curveMultiplier; } -/// @notice Manages operator bond tiers and associated tier downgrade cooldown state. +/// @notice Maps an operator's curve multiplier to a weight multiplier via governance-set boost steps. +/// The curve multiplier itself lives in Accounting; this registry only requests changes and serves +/// the resulting weight boost. Lowering the weight applies immediately, while the curve multiplier +/// decrease is deferred until the cooldown elapses. interface IAdditionalBondRegistry is IWeightBoostProvider { - event 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); + event BoostStepsSet(BoostStep[] boostSteps); + event CurveMultiplierReductionRequested(uint256 indexed nodeOperatorId, uint256 curveMultiplier); + event CurveMultiplierReductionCooldownSet(uint256 indexed nodeOperatorId, uint256 cooldownUntil); + event CurveMultiplierReductionCooldownRemoved(uint256 indexed nodeOperatorId); error ZeroAdminAddress(); + error EmptyBoostSteps(); error InvalidCurveMultiplier(); error InvalidWeightMultiplier(); - error InvalidTierId(); - error SameTier(); - error InsufficientBondForTier(); + error InsufficientBond(); + error SameCurveMultiplier(); error SenderIsNotOperatorOwner(); - error NoCurveMultiplierCooldown(); - error CurveMultiplierCooldownNotElapsed(); - error CurveMultiplierCooldownActive(); + error NoCurveMultiplierReductionCooldown(); + error CurveMultiplierReductionCooldownNotElapsed(); - /// @notice Curated module address. function MODULE() external view returns (ICuratedModule); - /// @notice Accounting contract holding bond curves and the operator curve multiplier. + /// @dev Holding bond curves and the operator curve multiplier. function ACCOUNTING() external view returns (IAccounting); - /// @notice MetaRegistry called back via `notifyWeightBoostChanged` on tier changes. + /// @dev Notified via `notifyWeightBoostChanged` on weight changes. function META_REGISTRY() external view returns (IMetaRegistry); - /// @notice Upper bound for `curveMultiplier`. + /// @dev Upper bound for a boost step's curve multiplier increment (above MAX_BP, in basis points). function MAX_CURVE_MULTIPLIER() external view returns (uint256); - /// @notice Upper bound for `weightMultiplier`. + /// @dev Upper bound for a boost step's weight multiplier increment (above MAX_BP, in basis points). function MAX_WEIGHT_MULTIPLIER() external view returns (uint256); - /// @notice Cooldown in seconds after a downgrade before `applyCurveMultiplier` can be called. - function CURVE_MULTIPLIER_COOLDOWN() external view returns (uint256); + /// @dev Cooldown in seconds after a downgrade request before `applyCurveMultiplier` can be called. + function CURVE_MULTIPLIER_REDUCTION_COOLDOWN() external view returns (uint256); + + /// @dev Requested curve multiplier must be a multiple of this (1%). + function CURVE_MULTIPLIER_STEP() 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 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 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 - /// requirement; a downgrade applies the new weight now but keeps the higher curve multiplier until - /// `applyCurveMultiplier`. Either clears an active cooldown (upgrade) or reverts on it (downgrade). + /// @param boostSteps Initial boost steps; must be non-empty (same rules as `setBoostSteps`). + function initialize(address admin, BoostStep[] calldata boostSteps) external; + + /// @notice Replace the boost steps. The list must be non-empty and strictly ascending by both fields, + /// each an increment above MAX_BP in [0, MAX_CURVE_MULTIPLIER] / [0, MAX_WEIGHT_MULTIPLIER]. + /// @param boostSteps New boost steps. + function setBoostSteps(BoostStep[] calldata boostSteps) external; + + /// @notice Request a curve multiplier for the Node Operator. Raising it applies immediately (needs enough + /// bond) and clears any pending downgrade; lowering it drops the weight now but reduces the + /// multiplier in Accounting only after the cooldown, via `applyCurveMultiplier`. Reverts if unchanged. /// @param nodeOperatorId ID of the Node Operator. - /// @param tierId Target tier ID (0 = default tier). - function selectTier(uint256 nodeOperatorId, uint256 tierId) external; + /// @param curveMultiplier Curve multiplier increment (above MAX_BP), a multiple of CURVE_MULTIPLIER_STEP; 0 = no boost. + function requestCurveMultiplier(uint256 nodeOperatorId, uint256 curveMultiplier) external; - /// @notice Apply a pending downgrade after its cooldown elapses, lowering the curve multiplier to the current - /// tier. Callable only by the Node Operator owner. + /// @notice Apply a pending downgrade after its cooldown elapses, lowering the curve multiplier in + /// Accounting to the requested value. Callable only by the Node Operator owner. /// @param nodeOperatorId ID of the Node Operator. function applyCurveMultiplier(uint256 nodeOperatorId) external; - /// @notice Number of stored tiers (not counting the implicit default tier 0). - function getTiersCount() external view returns (uint256); - - /// @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. - function getOperatorTierState(uint256 nodeOperatorId) external view returns (OperatorTierState memory); + /// @notice The current boost steps. + function getBoostSteps() external view returns (BoostStep[] memory); } diff --git a/test/fork/deployment/PostDeploymentCurated.t.sol b/test/fork/deployment/PostDeploymentCurated.t.sol index 73fd909f7..29909eefe 100644 --- a/test/fork/deployment/PostDeploymentCurated.t.sol +++ b/test/fork/deployment/PostDeploymentCurated.t.sol @@ -10,6 +10,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { CuratedDeployParams, CuratedGateConfig, GateCurveParams } from "script/curated/DeployBase.s.sol"; import { CuratedGate } from "src/CuratedGate.sol"; import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; +import { BoostStep } from "src/interfaces/IAdditionalBondRegistry.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; import { IParametersRegistry } from "src/interfaces/IParametersRegistry.sol"; import { OssifiableProxy } from "src/lib/proxy/OssifiableProxy.sol"; @@ -127,7 +128,13 @@ contract MetaRegistryDeploymentTest is DeploymentBaseTest { contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { function test_state_onlyFull() public view { - assertEq(additionalBondRegistry.getTiersCount(), 0); + BoostStep[] memory boostSteps = additionalBondRegistry.getBoostSteps(); + uint256[2][] memory expected = deployParams.additionalBondRegistryConfig.boostSteps; + assertEq(boostSteps.length, expected.length); + for (uint256 i; i < expected.length; ++i) { + assertEq(boostSteps[i].minCurveMultiplier, expected[i][0]); + assertEq(boostSteps[i].weightMultiplier, expected[i][1]); + } } function test_immutables_onlyFull() public view { @@ -143,7 +150,7 @@ contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { "additional bond registry meta registry" ); assertEq( - additionalBondRegistry.CURVE_MULTIPLIER_COOLDOWN(), + additionalBondRegistry.CURVE_MULTIPLIER_REDUCTION_COOLDOWN(), deployParams.additionalBondRegistryConfig.curveMultiplierCooldown, "additional bond registry cooldown" ); @@ -182,10 +189,10 @@ contract AdditionalBondRegistryDeploymentTest is DeploymentBaseTest { function test_initialization_onlyFull() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - additionalBondRegistry.initialize(deployParams.aragonAgent); + additionalBondRegistry.initialize(deployParams.aragonAgent, new BoostStep[](0)); vm.expectRevert(Initializable.InvalidInitialization.selector); - additionalBondRegistryImpl.initialize(deployParams.aragonAgent); + additionalBondRegistryImpl.initialize(deployParams.aragonAgent, new BoostStep[](0)); } function test_proxy_onlyFull() public view { diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index 61743f96b..fcfc28fa3 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -544,7 +544,12 @@ contract DeploymentHelpers is Test { dst.secondAdminAddress = src.secondAdminAddress; // AdditionalBondRegistry - dst.additionalBondRegistryConfig = src.additionalBondRegistryConfig; + dst.additionalBondRegistryConfig.curveMultiplierCooldown = src + .additionalBondRegistryConfig + .curveMultiplierCooldown; + for (uint256 i; i < src.additionalBondRegistryConfig.boostSteps.length; ++i) { + dst.additionalBondRegistryConfig.boostSteps.push(src.additionalBondRegistryConfig.boostSteps[i]); + } } function parseCommonDeployParams(string memory config) internal view returns (CommonDeployParams memory params) { diff --git a/test/helpers/mocks/MetaRegistryMock.sol b/test/helpers/mocks/MetaRegistryMock.sol index 8178c1164..2b4c415f7 100644 --- a/test/helpers/mocks/MetaRegistryMock.sol +++ b/test/helpers/mocks/MetaRegistryMock.sol @@ -8,6 +8,7 @@ import { IMetaRegistry, OperatorMetadata } from "src/interfaces/IMetaRegistry.so contract MetaRegistryMock { uint256 public notifyWeightBoostChangedCallCount; uint256 public lastChangedBoostOperatorId; + uint256 public notifyWeightBoostProviderConfigChangedCallCount; function setOperatorMetadataAsAdmin(uint256 nodeOperatorId, OperatorMetadata calldata metadata) external { emit IMetaRegistry.OperatorMetadataSet({ nodeOperatorId: nodeOperatorId, metadata: metadata }); @@ -17,4 +18,8 @@ contract MetaRegistryMock { notifyWeightBoostChangedCallCount++; lastChangedBoostOperatorId = nodeOperatorId; } + + function notifyWeightBoostProviderConfigChanged() external { + notifyWeightBoostProviderConfigChangedCallCount++; + } } diff --git a/test/unit/AdditionalBondRegistry.t.sol b/test/unit/AdditionalBondRegistry.t.sol index 3c93fd7b0..216613e4d 100644 --- a/test/unit/AdditionalBondRegistry.t.sol +++ b/test/unit/AdditionalBondRegistry.t.sol @@ -8,7 +8,7 @@ import { Test } from "forge-std/Test.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { AdditionalBondRegistry } from "src/AdditionalBondRegistry.sol"; -import { IAdditionalBondRegistry, TierInfo, OperatorTierState } from "src/interfaces/IAdditionalBondRegistry.sol"; +import { IAdditionalBondRegistry, BoostStep } from "src/interfaces/IAdditionalBondRegistry.sol"; import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; import { AccountingMock } from "../helpers/mocks/AccountingMock.sol"; @@ -28,7 +28,7 @@ contract AdditionalBondRegistryBaseTest is Test, Utilities, Fixtures { address public stranger; uint16 internal constant MAX_BP = 10_000; - uint256 internal constant CURVE_MULTIPLIER_COOLDOWN = 7 days; + uint256 internal constant CURVE_MULTIPLIER_REDUCTION_COOLDOWN = 7 days; function setUp() public virtual { admin = nextAddress("ADMIN"); @@ -50,10 +50,13 @@ contract AdditionalBondRegistryBaseTest is Test, Utilities, Fixtures { additionalBondRegistry = new AdditionalBondRegistry({ module: address(module), - curveMultiplierCooldown: CURVE_MULTIPLIER_COOLDOWN + curveMultiplierCooldown: CURVE_MULTIPLIER_REDUCTION_COOLDOWN }); + // Non-empty placeholder; suites that care about the scale replace it via setBoostSteps. + BoostStep[] memory boostSteps = new BoostStep[](1); + boostSteps[0] = BoostStep({ minCurveMultiplier: 100, weightMultiplier: 100 }); _enableInitializers(address(additionalBondRegistry)); - additionalBondRegistry.initialize(admin); + additionalBondRegistry.initialize(admin, boostSteps); acct = AccountingMock(address(module.ACCOUNTING())); } @@ -66,7 +69,8 @@ contract AdditionalBondRegistryConstructorTest is AdditionalBondRegistryBaseTest assertEq(address(additionalBondRegistry.META_REGISTRY()), address(metaRegistryMock)); assertEq(additionalBondRegistry.MAX_CURVE_MULTIPLIER(), 90_000); assertEq(additionalBondRegistry.MAX_WEIGHT_MULTIPLIER(), 90_000); - assertEq(additionalBondRegistry.CURVE_MULTIPLIER_COOLDOWN(), CURVE_MULTIPLIER_COOLDOWN); + assertEq(additionalBondRegistry.CURVE_MULTIPLIER_STEP(), 100); + assertEq(additionalBondRegistry.CURVE_MULTIPLIER_REDUCTION_COOLDOWN(), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); } } @@ -76,198 +80,255 @@ contract AdditionalBondRegistryInitializeTest is AdditionalBondRegistryBaseTest } function test_initialize_RevertWhen_ZeroAdmin() public { - AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module), CURVE_MULTIPLIER_COOLDOWN); + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); _enableInitializers(address(tp)); vm.expectRevert(IAdditionalBondRegistry.ZeroAdminAddress.selector); - tp.initialize(address(0)); + tp.initialize(address(0), new BoostStep[](0)); } function test_initialize_RevertWhen_DoubleCall() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - additionalBondRegistry.initialize(admin); + additionalBondRegistry.initialize(admin, new BoostStep[](0)); } -} -contract AdditionalBondRegistryAddTierTest is AdditionalBondRegistryBaseTest { - function _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { - vm.prank(admin); - tierId = additionalBondRegistry.addTier(bond, weight); + function test_initialize_RevertWhen_EmptyBoostSteps() public { + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); + _enableInitializers(address(tp)); + vm.expectRevert(IAdditionalBondRegistry.EmptyBoostSteps.selector); + tp.initialize(admin, new BoostStep[](0)); + } + + function test_initialize_SetsBoostSteps() public { + AdditionalBondRegistry tp = new AdditionalBondRegistry(address(module), CURVE_MULTIPLIER_REDUCTION_COOLDOWN); + _enableInitializers(address(tp)); + BoostStep[] memory boostSteps = new BoostStep[](1); + boostSteps[0] = BoostStep({ minCurveMultiplier: 5_000, weightMultiplier: 2_000 }); + tp.initialize(admin, boostSteps); + + assertEq(tp.getBoostSteps().length, 1); + assertEq(tp.getBoostSteps()[0].minCurveMultiplier, 5_000); + assertEq(tp.getBoostSteps()[0].weightMultiplier, 2_000); } +} +contract AdditionalBondRegistrySetBoostStepsTest is AdditionalBondRegistryBaseTest { 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(additionalBondRegistry)); - emit IAdditionalBondRegistry.TierAdded(1, T1_BOND, T1_WEIGHT); - uint256 tierId = _addTier(T1_BOND, T1_WEIGHT); + function _boostSteps() internal pure returns (BoostStep[] memory boostSteps) { + boostSteps = new BoostStep[](2); + boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T1_WEIGHT) }); + boostSteps[1] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T2_WEIGHT) }); + } + + function _setBoostSteps(BoostStep[] memory boostSteps) internal { + vm.prank(admin); + additionalBondRegistry.setBoostSteps(boostSteps); + } + + function test_setBoostSteps() public { + BoostStep[] memory boostSteps = _boostSteps(); + vm.expectEmit(address(additionalBondRegistry)); + emit IAdditionalBondRegistry.BoostStepsSet(boostSteps); + _setBoostSteps(boostSteps); - assertEq(tierId, 1); - assertEq(additionalBondRegistry.getTiersCount(), 1); - TierInfo memory t = additionalBondRegistry.getTierInfo(1); - assertEq(t.curveMultiplier, MAX_BP + T1_BOND); - assertEq(t.weightMultiplier, MAX_BP + T1_WEIGHT); + BoostStep[] memory stored = additionalBondRegistry.getBoostSteps(); + assertEq(stored.length, 2); + assertEq(stored[0].minCurveMultiplier, T1_BOND); + assertEq(stored[0].weightMultiplier, T1_WEIGHT); + assertEq(stored[1].minCurveMultiplier, T2_BOND); + assertEq(stored[1].weightMultiplier, T2_WEIGHT); } - function test_addTier_SecondTier() public { - _addTier(T1_BOND, T1_WEIGHT); - uint256 tierId = _addTier(T2_BOND, T2_WEIGHT); - assertEq(tierId, 2); - assertEq(additionalBondRegistry.getTiersCount(), 2); + function test_setBoostSteps_NotifiesProviderConfigChanged() public { + _setBoostSteps(_boostSteps()); + assertEq(metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(), 1); } - function test_addTier_AllowsZeroCurveMultiplierInc() public { - uint256 tierId = _addTier(0, T1_WEIGHT); - TierInfo memory t = additionalBondRegistry.getTierInfo(tierId); - assertEq(t.curveMultiplier, MAX_BP); - assertEq(t.weightMultiplier, MAX_BP + T1_WEIGHT); + function test_setBoostSteps_Replaces() public { + _setBoostSteps(_boostSteps()); + BoostStep[] memory boostSteps = new BoostStep[](1); + boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T2_WEIGHT) }); + _setBoostSteps(boostSteps); + assertEq(additionalBondRegistry.getBoostSteps().length, 1); } - function test_addTier_AllowsZeroWeightMultiplierInc() public { - uint256 tierId = _addTier(T1_BOND, 0); - TierInfo memory t = additionalBondRegistry.getTierInfo(tierId); - assertEq(t.curveMultiplier, MAX_BP + T1_BOND); - assertEq(t.weightMultiplier, MAX_BP); + function test_setBoostSteps_AllowsZeroIncrement() public { + BoostStep[] memory boostSteps = new BoostStep[](1); + boostSteps[0] = BoostStep({ minCurveMultiplier: 0, weightMultiplier: 0 }); + _setBoostSteps(boostSteps); + assertEq(additionalBondRegistry.getBoostSteps()[0].minCurveMultiplier, 0); } - function test_addTier_AllowsMaxIncrement() public { + function test_setBoostSteps_AllowsMaxIncrement() public { 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.curveMultiplier, MAX_BP + maxCurve); - assertEq(t.weightMultiplier, MAX_BP + maxWeight); + BoostStep[] memory boostSteps = new BoostStep[](1); + boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(maxCurve), weightMultiplier: uint128(maxWeight) }); + _setBoostSteps(boostSteps); + assertEq(additionalBondRegistry.getBoostSteps()[0].minCurveMultiplier, maxCurve); } - function test_addTier_RevertWhen_NotAdmin() public { + function test_setBoostSteps_RevertWhen_NotAdmin() public { vm.expectRevert(); vm.prank(stranger); - additionalBondRegistry.addTier(T1_BOND, T1_WEIGHT); + additionalBondRegistry.setBoostSteps(_boostSteps()); + } + + function test_setBoostSteps_RevertWhen_Empty() public { + vm.expectRevert(IAdditionalBondRegistry.EmptyBoostSteps.selector); + _setBoostSteps(new BoostStep[](0)); } - function test_addTier_RevertWhen_BondMulAboveMax() public { + function test_setBoostSteps_RevertWhen_CurveMulAboveMax() public { uint256 aboveMax = additionalBondRegistry.MAX_CURVE_MULTIPLIER() + 1; + BoostStep[] memory boostSteps = new BoostStep[](1); + boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(aboveMax), weightMultiplier: uint128(T1_WEIGHT) }); vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplier.selector); - _addTier(aboveMax, T1_WEIGHT); + _setBoostSteps(boostSteps); } - function test_addTier_RevertWhen_WeightMulAboveMax() public { + function test_setBoostSteps_RevertWhen_WeightMulAboveMax() public { uint256 aboveMax = additionalBondRegistry.MAX_WEIGHT_MULTIPLIER() + 1; + BoostStep[] memory boostSteps = new BoostStep[](1); + boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(aboveMax) }); + vm.expectRevert(IAdditionalBondRegistry.InvalidWeightMultiplier.selector); + _setBoostSteps(boostSteps); + } + + function test_setBoostSteps_RevertWhen_CurveNotAscending() public { + BoostStep[] memory boostSteps = new BoostStep[](2); + boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T1_WEIGHT) }); + boostSteps[1] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T2_WEIGHT) }); + vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplier.selector); + _setBoostSteps(boostSteps); + } + + function test_setBoostSteps_RevertWhen_WeightNotAscending() public { + BoostStep[] memory boostSteps = new BoostStep[](2); + boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T2_WEIGHT) }); + boostSteps[1] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T1_WEIGHT) }); vm.expectRevert(IAdditionalBondRegistry.InvalidWeightMultiplier.selector); - _addTier(T1_BOND, aboveMax); + _setBoostSteps(boostSteps); } } -contract AdditionalBondRegistrySelectTierBaseTest is AdditionalBondRegistryBaseTest { +contract AdditionalBondRegistryRequestCurveMultiplierBaseTest is AdditionalBondRegistryBaseTest { 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 _addTier(uint256 bond, uint256 weight) internal returns (uint256 tierId) { + function _setBoostSteps() internal { + BoostStep[] memory boostSteps = new BoostStep[](2); + boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T1_WEIGHT) }); + boostSteps[1] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T2_WEIGHT) }); vm.prank(admin); - tierId = additionalBondRegistry.addTier(bond, weight); + additionalBondRegistry.setBoostSteps(boostSteps); } } -contract AdditionalBondRegistrySelectTierTest is AdditionalBondRegistrySelectTierBaseTest { +contract AdditionalBondRegistryRequestCurveMultiplierTest is AdditionalBondRegistryRequestCurveMultiplierBaseTest { function setUp() public override { super.setUp(); - _addTier(T1_BOND, T1_WEIGHT); - _addTier(T2_BOND, T2_WEIGHT); + _setBoostSteps(); } - function test_selectTier_Upgrade_Tier0ToTier1() public { - vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); - emit IAdditionalBondRegistry.TierSelected(0, 1); + function test_requestCurveMultiplier_Upgrade_Tier0ToTier1() public { vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); - OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); - assertEq(s.tierId, 1); - assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 1); assertEq(metaRegistryMock.lastChangedBoostOperatorId(), 0); } - function test_selectTier_Upgrade_Tier1ToTier2() public { + function test_requestCurveMultiplier_Upgrade_Tier1ToTier2() public { vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); - vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); - emit IAdditionalBondRegistry.TierSelected(0, 2); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 2); + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); - OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); - assertEq(s.tierId, 2); - assertEq(s.weightMultiplier, MAX_BP + T2_WEIGHT); assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), 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 { + function test_requestCurveMultiplier_Downgrade_Tier1ToTier0() public { vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); - uint256 expectedCooldown = block.timestamp + CURVE_MULTIPLIER_COOLDOWN; + uint256 expectedCooldown = block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN; vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); - emit IAdditionalBondRegistry.CurveMultiplierCooldownSet(0, expectedCooldown); + emit IAdditionalBondRegistry.CurveMultiplierReductionCooldownSet(0, expectedCooldown); + vm.expectEmit(true, false, false, true, address(additionalBondRegistry)); + emit IAdditionalBondRegistry.CurveMultiplierReductionRequested(0, 0); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 0); + additionalBondRegistry.requestCurveMultiplier(0, 0); - OperatorTierState memory s = additionalBondRegistry.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); + // The bond multiplier stays until apply, so it remains above MAX_BP while the weight already dropped. assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP); assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 2); } - function test_selectTier_Upgrade_ClearsCooldownIfActive() public { + function test_requestCurveMultiplier_Upgrade_ClearsCooldownIfActive() public { vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 0); - assertGt(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); + additionalBondRegistry.requestCurveMultiplier(0, 0); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP); // cooldown active, weight dropped vm.prank(nodeOperatorOwner); vm.expectEmit(true, false, false, false, address(additionalBondRegistry)); - emit IAdditionalBondRegistry.CurveMultiplierCooldownRemoved(0); - additionalBondRegistry.selectTier(0, 2); + emit IAdditionalBondRegistry.CurveMultiplierReductionCooldownRemoved(0); + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); - assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); } - function test_selectTier_RevertWhen_NotOwner() public { + function test_requestCurveMultiplier_WithinStep() public { + // Raising within the same step changes the bond multiplier but not the weight (decoupled). + uint256 within = T1_BOND + additionalBondRegistry.CURVE_MULTIPLIER_STEP(); // 1%-aligned, still first step + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, within); + + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + within); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); + } + + function test_requestCurveMultiplier_RevertWhen_NotOwner() public { vm.expectRevert(IAdditionalBondRegistry.SenderIsNotOperatorOwner.selector); vm.prank(stranger); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); } - function test_selectTier_RevertWhen_InvalidTierId() public { - vm.expectRevert(IAdditionalBondRegistry.InvalidTierId.selector); + function test_requestCurveMultiplier_RevertWhen_CurveMultiplierAboveMax() public { + uint256 aboveMax = additionalBondRegistry.MAX_CURVE_MULTIPLIER() + 1; + vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplier.selector); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 99); + additionalBondRegistry.requestCurveMultiplier(0, aboveMax); } - function test_selectTier_RevertWhen_SameTier() public { - vm.expectRevert(IAdditionalBondRegistry.SameTier.selector); + function test_requestCurveMultiplier_RevertWhen_NotStepAligned() public { + // Not a multiple of CURVE_MULTIPLIER_STEP (1%). + vm.expectRevert(IAdditionalBondRegistry.InvalidCurveMultiplier.selector); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 0); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND + 1); } - function test_selectTier_Upgrade_SucceedsWhenBondCoversScaledRequirement() public { + function test_requestCurveMultiplier_RevertWhen_SameCurveMultiplier() public { + vm.expectRevert(IAdditionalBondRegistry.SameCurveMultiplier.selector); + vm.prank(nodeOperatorOwner); + additionalBondRegistry.requestCurveMultiplier(0, 0); + } + + function test_requestCurveMultiplier_Upgrade_SucceedsWhenBondCoversScaledRequirement() public { uint256 baseRequired = 10 ether; acct.mock_setRequiredBond(0, baseRequired); uint256 scaledRequired = (baseRequired * (MAX_BP + T1_BOND)) / MAX_BP; @@ -275,13 +336,12 @@ contract AdditionalBondRegistrySelectTierTest is AdditionalBondRegistrySelectTie acct.depositETH{ value: scaledRequired }(0); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); - assertEq(additionalBondRegistry.getOperatorTierState(0).tierId, 1); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); } - function test_selectTier_RevertWhen_BondCoversBaseButNotScaledRequirement() public { + function test_requestCurveMultiplier_RevertWhen_BondCoversBaseButNotScaledRequirement() public { uint256 baseRequired = 10 ether; acct.mock_setRequiredBond(0, baseRequired); uint256 scaledRequired = (baseRequired * (MAX_BP + T1_BOND)) / MAX_BP; @@ -289,161 +349,137 @@ contract AdditionalBondRegistrySelectTierTest is AdditionalBondRegistrySelectTie vm.deal(address(this), scaledRequired - 1); acct.depositETH{ value: scaledRequired - 1 }(0); - vm.expectRevert(IAdditionalBondRegistry.InsufficientBondForTier.selector); + vm.expectRevert(IAdditionalBondRegistry.InsufficientBond.selector); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); } - function test_selectTier_RevertWhen_CurveMultiplierCooldownActive() public { + function test_requestCurveMultiplier_LowerAgainResetsCooldown() public { vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 2); + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); // downgrade -> cooldown, pending = T1_BOND - vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierCooldownActive.selector); + // Lowering further during the cooldown is allowed: it just resets the pending target and the cooldown. vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 0); + additionalBondRegistry.requestCurveMultiplier(0, 0); + + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP); // weight follows new pending (0) + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); // bond untouched until applyCurveMultiplier } - 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); + function test_requestCurveMultiplier_LowerToIntermediateResetsCooldown() public { + // A middle step at 7_000: an intermediate value is below the committed multiplier, so it is a lower — + // it resets the pending target and weight but never touches the bond in Accounting. + BoostStep[] memory boostSteps = new BoostStep[](3); + boostSteps[0] = BoostStep({ minCurveMultiplier: uint128(T1_BOND), weightMultiplier: uint128(T1_WEIGHT) }); + boostSteps[1] = BoostStep({ minCurveMultiplier: 7_000, weightMultiplier: 4_000 }); + boostSteps[2] = BoostStep({ minCurveMultiplier: uint128(T2_BOND), weightMultiplier: uint128(T2_WEIGHT) }); + vm.prank(admin); + additionalBondRegistry.setBoostSteps(boostSteps); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 2); + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); // downgrade -> cooldown, pending = T1_BOND - // 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(IAdditionalBondRegistry.CurveMultiplierCooldownActive.selector); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 3); + additionalBondRegistry.requestCurveMultiplier(0, 7_000); // still below committed -> lower, resets - assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); + assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + 4_000); // weight -> middle step + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); // bond untouched } } -contract AdditionalBondRegistryReleaseCurveMultiplierTest is AdditionalBondRegistrySelectTierBaseTest { +contract AdditionalBondRegistryApplyCurveMultiplierTest is AdditionalBondRegistryRequestCurveMultiplierBaseTest { function setUp() public override { super.setUp(); - _addTier(T1_BOND, T1_WEIGHT); - _addTier(T2_BOND, T2_WEIGHT); + _setBoostSteps(); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 0); + additionalBondRegistry.requestCurveMultiplier(0, 0); } function test_applyCurveMultiplier() public { - vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + vm.warp(block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN + 1); vm.expectEmit(true, false, false, false, address(additionalBondRegistry)); - emit IAdditionalBondRegistry.CurveMultiplierCooldownRemoved(0); + emit IAdditionalBondRegistry.CurveMultiplierReductionCooldownRemoved(0); vm.prank(nodeOperatorOwner); additionalBondRegistry.applyCurveMultiplier(0); - assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP); } - function test_applyCurveMultiplier_SettlesToCurrentTierNotDefault() public { + function test_applyCurveMultiplier_SettlesToRequestedNotDefault() public { vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 2); + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); - vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + vm.warp(block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN + 1); vm.prank(nodeOperatorOwner); additionalBondRegistry.applyCurveMultiplier(0); - assertEq(additionalBondRegistry.getOperatorTierState(0).curveMultiplierCooldownUntil, 0); assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T1_BOND); } function test_applyCurveMultiplier_RevertWhen_NotOwner() public { - vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + vm.warp(block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN + 1); vm.expectRevert(IAdditionalBondRegistry.SenderIsNotOperatorOwner.selector); vm.prank(stranger); additionalBondRegistry.applyCurveMultiplier(0); } - function test_applyCurveMultiplier_RevertWhen_NoCurveMultiplierCooldown() public { - vm.warp(block.timestamp + CURVE_MULTIPLIER_COOLDOWN + 1); + function test_applyCurveMultiplier_RevertWhen_NoCurveMultiplierReductionCooldown() public { + vm.warp(block.timestamp + CURVE_MULTIPLIER_REDUCTION_COOLDOWN + 1); vm.prank(nodeOperatorOwner); additionalBondRegistry.applyCurveMultiplier(0); - vm.expectRevert(IAdditionalBondRegistry.NoCurveMultiplierCooldown.selector); + vm.expectRevert(IAdditionalBondRegistry.NoCurveMultiplierReductionCooldown.selector); vm.prank(nodeOperatorOwner); additionalBondRegistry.applyCurveMultiplier(0); } - function test_applyCurveMultiplier_RevertWhen_CurveMultiplierCooldownNotElapsed() public { - vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierCooldownNotElapsed.selector); + function test_applyCurveMultiplier_RevertWhen_CurveMultiplierReductionCooldownNotElapsed() public { + vm.expectRevert(IAdditionalBondRegistry.CurveMultiplierReductionCooldownNotElapsed.selector); vm.prank(nodeOperatorOwner); additionalBondRegistry.applyCurveMultiplier(0); } } -contract AdditionalBondRegistryViewsTest is AdditionalBondRegistrySelectTierBaseTest { +contract AdditionalBondRegistryViewsTest is AdditionalBondRegistryRequestCurveMultiplierBaseTest { function setUp() public override { super.setUp(); - _addTier(T1_BOND, T1_WEIGHT); - _addTier(T2_BOND, T2_WEIGHT); - } - - function test_getTiersCount() public view { - assertEq(additionalBondRegistry.getTiersCount(), 2); + _setBoostSteps(); } - function test_getTierInfo_Tier0() public view { - TierInfo memory t = additionalBondRegistry.getTierInfo(0); - assertEq(t.curveMultiplier, MAX_BP); - assertEq(t.weightMultiplier, MAX_BP); + function test_getBoostSteps() public view { + BoostStep[] memory boostSteps = additionalBondRegistry.getBoostSteps(); + assertEq(boostSteps.length, 2); + assertEq(boostSteps[0].minCurveMultiplier, T1_BOND); + assertEq(boostSteps[0].weightMultiplier, T1_WEIGHT); } - function test_getTierInfo_Tier1() public view { - TierInfo memory t = additionalBondRegistry.getTierInfo(1); - assertEq(t.curveMultiplier, MAX_BP + T1_BOND); - assertEq(t.weightMultiplier, MAX_BP + T1_WEIGHT); - } - - function test_getTierInfo_RevertWhen_InvalidTierId() public { - vm.expectRevert(IAdditionalBondRegistry.InvalidTierId.selector); - additionalBondRegistry.getTierInfo(99); - } - - function test_getOperatorTierState_Default() public view { - OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); - assertEq(s.tierId, 0); - assertEq(s.weightMultiplier, MAX_BP); - assertEq(s.curveMultiplier, MAX_BP); - assertEq(s.curveMultiplierCooldownUntil, 0); + function test_getWeightBoostMultiplierBP_Default() public view { assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP); } - function test_getOperatorTierState_AfterUpgrade() public { + function test_getWeightBoostMultiplierBP_AfterUpgrade() public { vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); - OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); - assertEq(s.tierId, 1); - assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); - assertEq(s.curveMultiplier, MAX_BP + T1_BOND); - assertEq(s.curveMultiplierCooldownUntil, 0); } - function test_getOperatorTierState_AfterDowngrade_CooldownMultiplierDivergesFromTier() public { + function test_getWeightBoostMultiplierBP_DuringDowngradeCooldown() public { vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 2); + additionalBondRegistry.requestCurveMultiplier(0, T2_BOND); vm.prank(nodeOperatorOwner); - additionalBondRegistry.selectTier(0, 1); + additionalBondRegistry.requestCurveMultiplier(0, T1_BOND); - OperatorTierState memory s = additionalBondRegistry.getOperatorTierState(0); - assertEq(s.tierId, 1); - assertEq(s.weightMultiplier, MAX_BP + T1_WEIGHT); + // Weight follows the pending (lower) step while Accounting still holds the higher multiplier. assertEq(additionalBondRegistry.getWeightBoostMultiplierBP(0), MAX_BP + T1_WEIGHT); - assertEq(s.curveMultiplier, MAX_BP + T2_BOND); - assertEq(s.curveMultiplierCooldownUntil, block.timestamp + CURVE_MULTIPLIER_COOLDOWN); + assertEq(acct.getBondCurveMultiplier(0), MAX_BP + T2_BOND); } }