diff --git a/foundry.toml b/foundry.toml index 21a872b3..847da51c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,7 +1,7 @@ [profile.default] evm_version = "osaka" optimizer = true -optimizer_runs = 200 +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 27316016..bd76be93 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -17,6 +17,7 @@ import { Verifier } from "../../src/Verifier.sol"; import { ParametersRegistry } from "../../src/ParametersRegistry.sol"; import { ExitPenalties } from "../../src/ExitPenalties.sol"; import { MetaRegistry } from "../../src/MetaRegistry.sol"; +import { AdditionalBondRegistry } from "../../src/AdditionalBondRegistry.sol"; import { CuratedGate } from "../../src/CuratedGate.sol"; import { MerkleGateFactory } from "../../src/MerkleGateFactory.sol"; @@ -58,6 +59,10 @@ struct CuratedGateConfig { GateCurveParams params; } +struct AdditionalBondRegistryConfig { + uint256 curveMultiplierCooldown; +} + struct CuratedDeployParams { // Lido addresses address lidoLocatorAddress; @@ -121,6 +126,8 @@ struct CuratedDeployParams { address resealManager; // Testnet stuff address secondAdminAddress; + // AdditionalBondRegistry + AdditionalBondRegistryConfig additionalBondRegistryConfig; } abstract contract DeployBase is Script { @@ -143,6 +150,7 @@ abstract contract DeployBase is Script { HashConsensus public hashConsensus; ParametersRegistry public parametersRegistry; MetaRegistry public metaRegistry; + AdditionalBondRegistry public additionalBondRegistry; MerkleGateFactory public curatedGateFactory; address[] public curatedGateInstances; address internal curatedGateImpl; @@ -231,6 +239,7 @@ abstract contract DeployBase is Script { accounting = Accounting(_deployProxy(deployer, address(dummyImpl))); oracle = FeeOracle(_deployProxy(deployer, address(dummyImpl))); metaRegistry = MetaRegistry(_deployProxy(deployer, address(dummyImpl))); + additionalBondRegistry = AdditionalBondRegistry(_deployProxy(deployer, address(dummyImpl))); FeeDistributor feeDistributorImpl = new FeeDistributor({ stETH: locator.lido(), @@ -309,7 +318,10 @@ abstract contract DeployBase is Script { moduleProxy.proxy__changeAdmin(config.proxyAdmin); } - MetaRegistry metaRegistryImpl = new MetaRegistry(address(curatedModule)); + MetaRegistry metaRegistryImpl = new MetaRegistry({ + module: address(curatedModule), + additionalBondRegistry: address(additionalBondRegistry) + }); { OssifiableProxy metaRegistryProxy = OssifiableProxy(payable(address(metaRegistry))); @@ -320,7 +332,22 @@ abstract contract DeployBase is Script { metaRegistryProxy.proxy__changeAdmin(config.proxyAdmin); } + AdditionalBondRegistry additionalBondRegistryImpl = new AdditionalBondRegistry({ + module: address(curatedModule), + curveMultiplierCooldown: config.additionalBondRegistryConfig.curveMultiplierCooldown + }); + + { + OssifiableProxy additionalBondRegistryProxy = OssifiableProxy(payable(address(additionalBondRegistry))); + additionalBondRegistryProxy.proxy__upgradeToAndCall( + address(additionalBondRegistryImpl), + abi.encodeCall(AdditionalBondRegistry.initialize, (deployer)) + ); + additionalBondRegistryProxy.proxy__changeAdmin(config.proxyAdmin); + } + accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); + accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), address(additionalBondRegistry)); metaRegistry.grantRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); for (uint256 i = 0; i < gatesCount; i++) { @@ -520,6 +547,9 @@ abstract contract DeployBase is Script { metaRegistry.grantRole(metaRegistry.DEFAULT_ADMIN_ROLE(), config.aragonAgent); metaRegistry.revokeRole(metaRegistry.DEFAULT_ADMIN_ROLE(), deployer); + additionalBondRegistry.grantRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), config.aragonAgent); + additionalBondRegistry.revokeRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), deployer); + verifier.grantRole(verifier.DEFAULT_ADMIN_ROLE(), config.aragonAgent); verifier.revokeRole(verifier.DEFAULT_ADMIN_ROLE(), deployer); @@ -544,6 +574,8 @@ abstract contract DeployBase is Script { deployJson.set("CuratedModuleImpl", address(curatedModuleImpl)); deployJson.set("MetaRegistry", address(metaRegistry)); deployJson.set("MetaRegistryImpl", address(metaRegistryImpl)); + deployJson.set("AdditionalBondRegistry", address(additionalBondRegistry)); + deployJson.set("AdditionalBondRegistryImpl", address(additionalBondRegistryImpl)); deployJson.set("ParametersRegistry", address(parametersRegistry)); deployJson.set("ParametersRegistryImpl", address(parametersRegistryImpl)); deployJson.set("Accounting", address(accounting)); @@ -639,6 +671,7 @@ abstract contract DeployBase is Script { hashConsensus.grantRole(hashConsensus.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); parametersRegistry.grantRole(parametersRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); metaRegistry.grantRole(metaRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); + additionalBondRegistry.grantRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); for (uint256 i = 0; i < curatedGateInstances.length; i++) { CuratedGate gate = CuratedGate(curatedGateInstances[i]); gate.grantRole(gate.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); diff --git a/script/curated/DeployHoodi.s.sol b/script/curated/DeployHoodi.s.sol index 018fb29f..f2a7f312 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployHoodi is DeployBase { @@ -198,6 +198,10 @@ contract DeployHoodi is DeployBase { config.resealManager = 0x05172CbCDb7307228F781436b327679e4DAE166B; config.secondAdminAddress = 0x4AF43Ee34a6fcD1fEcA1e1F832124C763561dA53; // Dev team EOA + + // CurveMultiplier + config.additionalBondRegistryConfig.curveMultiplierCooldown = 7 days; + _setUp(); } } diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index fd44e813..29ed47c9 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployLocalDevNet is DeployBase { @@ -31,6 +31,7 @@ contract DeployLocalDevNet is DeployBase { config.gIFirstWithdrawal = GIndices.FIRST_WITHDRAWAL_ELECTRA; config.gIFirstValidator = GIndices.FIRST_VALIDATOR_ELECTRA; config.gIFirstHistoricalSummary = GIndices.FIRST_HISTORICAL_SUMMARY_ELECTRA; // prettier-ignore + config.gIFirstBalanceNode = GIndices.FIRST_BALANCE_NODE_ELECTRA; config.verifierFirstSupportedSlot = vm.envUint("DEVNET_ELECTRA_EPOCH") * config.slotsPerEpoch; config.capellaSlot = vm.envUint("DEVNET_CAPELLA_EPOCH") * config.slotsPerEpoch; config.minWithdrawalRatio = 9950; @@ -186,6 +187,9 @@ contract DeployLocalDevNet is DeployBase { config.secondAdminAddress = vm.envOr("CSM_SECOND_ADMIN_ADDRESS", address(0)); + // CurveMultiplier + config.additionalBondRegistryConfig.curveMultiplierCooldown = 1 days; + _setUp(); } } diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index 8be37162..6c30fda9 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.33; -import { DeployBase, CuratedGateConfig } from "./DeployBase.s.sol"; +import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployMainnet is DeployBase { @@ -193,6 +193,10 @@ contract DeployMainnet is DeployBase { // DG config.resealManager = 0x7914b5a1539b97Bd0bbd155757F25FD79A522d24; + + // CurveMultiplier + config.additionalBondRegistryConfig.curveMultiplierCooldown = 7 days; + _setUp(); } } diff --git a/src/Accounting.sol b/src/Accounting.sol index 935e56db..c6af0752 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); @@ -405,6 +410,15 @@ contract Accounting is return _sharesByEth(getRequiredBondForNextKeys(nodeOperatorId, additionalKeys)); } + /// @inheritdoc IAccounting + function getRequiredBondForNextKeysWstETH( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 multiplier + ) external view returns (uint256) { + return _sharesByEth(getRequiredBondForNextKeys(nodeOperatorId, additionalKeys, multiplier)); + } + /// @inheritdoc IAccounting function getClaimableBondShares(uint256 nodeOperatorId) external view returns (uint256) { return _getClaimableBondShares(nodeOperatorId); @@ -431,28 +445,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); } @@ -526,18 +562,19 @@ contract Accounting is return Math.saturatingSub(currentShares, requiredShares); } - function _getRequiredBond(uint256 nodeOperatorId, uint256 additionalKeys) internal view returns (uint256) { - uint256 curveId = BondCurve.getBondCurveId(nodeOperatorId); - uint256 nonWithdrawnKeys = MODULE.getNodeOperatorNonWithdrawnKeys(nodeOperatorId); - uint256 requiredBondForKeys = BondCurve.getBondAmountByKeysCount(nonWithdrawnKeys + additionalKeys, curveId); - uint256 lockedBond = BondLock.getLockedBond(nodeOperatorId); - uint256 bondDebt = BondCore.getBondDebt(nodeOperatorId); - - return requiredBondForKeys + lockedBond + bondDebt; - } - - function _getRequiredBondShares(uint256 nodeOperatorId, uint256 additionalKeys) internal view returns (uint256) { - return _sharesByEth(_getRequiredBond(nodeOperatorId, additionalKeys)); + function _getRequiredBond( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 mul + ) internal view returns (uint256) { + return + BondCurve.getBondAmountByKeysCount( + MODULE.getNodeOperatorNonWithdrawnKeys(nodeOperatorId) + additionalKeys, + BondCurve.getBondCurveId(nodeOperatorId), + mul + ) + + BondLock.getLockedBond(nodeOperatorId) + + BondCore.getBondDebt(nodeOperatorId); } /// @dev Unbonded stands for the amount of keys not fully covered with bond @@ -564,7 +601,8 @@ contract Accounting is // Should be sufficient for ~ 40 years uint256 bondedKeys = BondCurve.getKeysCountByBondAmount( currentBond + 10 wei, - BondCurve.getBondCurveId(nodeOperatorId) + BondCurve.getBondCurveId(nodeOperatorId), + BondCurve.getBondCurveMultiplier(nodeOperatorId) ); return Math.saturatingSub(nonWithdrawnKeys, bondedKeys); } diff --git a/src/AdditionalBondRegistry.sol b/src/AdditionalBondRegistry.sol new file mode 100644 index 00000000..0b2f8fa4 --- /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 f8c49650..a9c8268d 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 5d37d151..08ecd113 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 2f765aea..c235dbe8 100644 --- a/src/abstract/FeeSplits.sol +++ b/src/abstract/FeeSplits.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.33; import { IFeeSplits } from "../interfaces/IFeeSplits.sol"; +import { MAX_BP } from "../lib/Constants.sol"; /// @dev Fee split mechanics abstract contract /// @@ -25,7 +26,6 @@ abstract contract FeeSplits is IFeeSplits { bytes32 private constant FEE_SPLITS_STORAGE_LOCATION = 0xac5584dcb35bfb1b3f4187762b10cb284ff937e63b5eb675e2e8e8876c7ee000; - uint256 internal constant MAX_BP = 10_000; uint256 public constant MAX_FEE_SPLITS = 10; /// @inheritdoc IFeeSplits diff --git a/src/interfaces/IAccounting.sol b/src/interfaces/IAccounting.sol index 33bb28d1..a8d86921 100644 --- a/src/interfaces/IAccounting.sol +++ b/src/interfaces/IAccounting.sol @@ -47,6 +47,8 @@ interface IAccounting is IBondCore, IBondCurve, IBondLock, IFeeSplits, IAssetRec function SET_BOND_CURVE_ROLE() external view returns (bytes32); + function SET_BOND_CURVE_MULTIPLIER_ROLE() external view returns (bytes32); + function MODULE() external view returns (IBaseModule); function FEE_DISTRIBUTOR() external view returns (IFeeDistributor); @@ -115,6 +117,17 @@ interface IAccounting is IBondCore, IBondCurve, IBondLock, IFeeSplits, IAssetRec /// @return Required bond amount in ETH function getRequiredBondForNextKeys(uint256 nodeOperatorId, uint256 additionalKeys) external view returns (uint256); + /// @notice Get the required bond in ETH (inc. missed and excess) at the given curve multiplier for the given Node Operator to upload new deposit data. + /// @param nodeOperatorId ID of the Node Operator + /// @param additionalKeys Number of new keys to add + /// @param multiplier Full curve multiplier in basis points (>= MAX_BP; MAX_BP = no scaling). + /// @return Required bond amount in ETH + function getRequiredBondForNextKeys( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 multiplier + ) external view returns (uint256); + /// @notice Get the bond amount in wstETH required for the `keysCount` keys for the given bond curve /// @param keysCount Keys count to calculate the required bond amount /// @param curveId Id of the curve to perform calculations against @@ -130,6 +143,17 @@ interface IAccounting is IBondCore, IBondCurve, IBondLock, IFeeSplits, IAssetRec uint256 additionalKeys ) external view returns (uint256); + /// @notice Get the required bond in wstETH (inc. missed and excess) at the given curve multiplier for the given Node Operator to upload new keys. + /// @param nodeOperatorId ID of the Node Operator + /// @param additionalKeys Number of new keys to add + /// @param multiplier Full curve multiplier in basis points (>= MAX_BP; MAX_BP = no scaling). + /// @return Required bond in wstETH + function getRequiredBondForNextKeysWstETH( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 multiplier + ) external view returns (uint256); + /// @notice Get the number of the unbonded keys /// @param nodeOperatorId ID of the Node Operator /// @return Unbonded keys count @@ -317,6 +341,13 @@ interface IAccounting is IBondCore, IBondCurve, IBondLock, IFeeSplits, IAssetRec /// @param curveId ID of the bond curve to set function setBondCurve(uint256 nodeOperatorId, uint256 curveId) external; + /// @notice Set the bond curve multiplier increment (above MAX_BP) for the given Node Operator. + /// Pass 0 to reset to the default (no scaling). + /// @dev Triggers a deposit info update so key pointers stay consistent. + /// @param nodeOperatorId ID of the Node Operator + /// @param multiplier Bond curve multiplier increment above MAX_BP in basis points (0 = no scaling) + function setBondCurveMultiplier(uint256 nodeOperatorId, uint256 multiplier) external; + /// @notice Penalize bond by burning stETH shares of the given Node Operator /// @dev Penalty application has a priority over the locked bond. /// Method call can result in the remaining bond being lower than the locked bond. diff --git a/src/interfaces/IAdditionalBondRegistry.sol b/src/interfaces/IAdditionalBondRegistry.sol new file mode 100644 index 00000000..6b37e59d --- /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 fcfeaff2..883d191e 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 55ea72be..99f235b0 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 49d9d1cc..10e93cfc 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 00000000..694f04ed --- /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 e564e8c3..090cc9cb 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 a3d371df..7873df0d 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -33,6 +33,7 @@ import { Verifier } from "src/Verifier.sol"; import { CuratedModule } from "src/CuratedModule.sol"; import { MetaRegistry } from "src/MetaRegistry.sol"; import { IMetaRegistry } from "src/interfaces/IMetaRegistry.sol"; +import { AdditionalBondRegistry } from "src/AdditionalBondRegistry.sol"; import { ICuratedModule } from "src/interfaces/ICuratedModule.sol"; import { CuratedGate } from "src/CuratedGate.sol"; import { DeployParams } from "script/csm/DeployBase.s.sol"; @@ -246,6 +247,8 @@ contract DeploymentHelpers is Test { address hashConsensus; address metaRegistry; address metaRegistryImpl; + address additionalBondRegistry; + address additionalBondRegistryImpl; address curatedGateFactory; address curatedGateImpl; address[] curatedGates; @@ -424,6 +427,12 @@ contract DeploymentHelpers is Test { deploymentConfig.metaRegistryImpl = vm.parseJsonAddress(config, ".MetaRegistryImpl"); vm.label(deploymentConfig.metaRegistryImpl, "metaRegistryImpl"); + deploymentConfig.additionalBondRegistry = vm.parseJsonAddress(config, ".AdditionalBondRegistry"); + vm.label(deploymentConfig.additionalBondRegistry, "additionalBondRegistry"); + + deploymentConfig.additionalBondRegistryImpl = vm.parseJsonAddress(config, ".AdditionalBondRegistryImpl"); + vm.label(deploymentConfig.additionalBondRegistryImpl, "additionalBondRegistryImpl"); + if (vm.keyExistsJson(config, ".CuratedGateFactory")) { deploymentConfig.curatedGateFactory = vm.parseJsonAddress(config, ".CuratedGateFactory"); } @@ -553,6 +562,9 @@ contract DeploymentHelpers is Test { // Testnet stuff dst.secondAdminAddress = src.secondAdminAddress; + + // AdditionalBondRegistry + dst.additionalBondRegistryConfig = src.additionalBondRegistryConfig; } function parseCommonDeployParams(string memory config) internal view returns (CommonDeployParams memory params) { @@ -839,6 +851,8 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { CuratedModule public curatedModule; CuratedModule public curatedModuleImpl; MetaRegistry public metaRegistry; + AdditionalBondRegistry public additionalBondRegistry; + AdditionalBondRegistry public additionalBondRegistryImpl; CuratedGate public curatedGateImpl; address[] public curatedGates; @@ -952,6 +966,8 @@ abstract contract DeploymentFixturesBase is StdCheats, DeploymentHelpers { burner = IBurner(locator.burner()); metaRegistry = MetaRegistry(deploymentConfig.metaRegistry); + additionalBondRegistry = AdditionalBondRegistry(deploymentConfig.additionalBondRegistry); + additionalBondRegistryImpl = AdditionalBondRegistry(deploymentConfig.additionalBondRegistryImpl); curatedGateImpl = CuratedGate(deploymentConfig.curatedGateImpl); curatedGates = deploymentConfig.curatedGates; } diff --git a/test/helpers/mocks/AccountingMock.sol b/test/helpers/mocks/AccountingMock.sol index 2e50fe84..73d147dc 100644 --- a/test/helpers/mocks/AccountingMock.sol +++ b/test/helpers/mocks/AccountingMock.sol @@ -25,6 +25,8 @@ contract AccountingMock { mapping(uint256 nodeOperatorId => IBondLock.BondLockData) bondLock; mapping(uint256 nodeOperatorId => uint256) bond; + mapping(uint256 nodeOperatorId => uint256) curveMultiplier; + mapping(uint256 nodeOperatorId => uint256) requiredBondAtMul; mapping(uint256 nodeOperatorId => uint256 bondCurveId) operatorBondCurveId; uint256[] bondCurves; @@ -42,6 +44,30 @@ contract AccountingMock { FEE_DISTRIBUTOR = IFeeDistributor(_feeDistributor); } + function setBondCurveMultiplier(uint256 nodeOperatorId, uint256 multiplier) external { + curveMultiplier[nodeOperatorId] = multiplier; + } + + function getBondCurveMultiplier(uint256 nodeOperatorId) external view returns (uint256) { + return 10_000 + curveMultiplier[nodeOperatorId]; + } + + /// @dev Sets the base required bond (at identity multiplier MAX_BP). The 3-arg `getRequiredBondForNextKeys` + /// scales it by the requested multiplier and subtracts the current bond, mirroring real Accounting. + function mock_setRequiredBond(uint256 nodeOperatorId, uint256 amount) external { + requiredBondAtMul[nodeOperatorId] = amount; + } + + function getRequiredBondForNextKeys( + uint256 nodeOperatorId, + uint256 /* additionalKeys */, + uint256 multiplier + ) public view returns (uint256) { + uint256 totalRequired = (requiredBondAtMul[nodeOperatorId] * multiplier) / 10_000; + uint256 current = getBond(nodeOperatorId); + return totalRequired > current ? totalRequired - current : 0; + } + function setModule(IBaseModule _module) external { MODULE = _module; } @@ -243,6 +269,14 @@ contract AccountingMock { return wstETH.getWstETHByStETH(getRequiredBondForNextKeys(nodeOperatorId, additionalKeys)); } + function getRequiredBondForNextKeysWstETH( + uint256 nodeOperatorId, + uint256 additionalKeys, + uint256 multiplier + ) external view returns (uint256) { + return wstETH.getWstETHByStETH(getRequiredBondForNextKeys(nodeOperatorId, additionalKeys, multiplier)); + } + function getLockedBond(uint256 nodeOperatorId) public view returns (uint256) { return bondLock[nodeOperatorId].amount; } diff --git a/test/helpers/mocks/AdditionalBondRegistryMock.sol b/test/helpers/mocks/AdditionalBondRegistryMock.sol new file mode 100644 index 00000000..daa600cc --- /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 bc1e0465..3f924b36 100644 --- a/test/helpers/mocks/MetaRegistryMock.sol +++ b/test/helpers/mocks/MetaRegistryMock.sol @@ -6,7 +6,15 @@ pragma solidity 0.8.33; import { IMetaRegistry, OperatorMetadata } from "src/interfaces/IMetaRegistry.sol"; contract MetaRegistryMock { + uint256 public refreshOperatorWeightCallCount; + uint256 public lastRefreshedOperatorId; + function setOperatorMetadataAsAdmin(uint256 nodeOperatorId, OperatorMetadata calldata metadata) external { emit IMetaRegistry.OperatorMetadataSet({ nodeOperatorId: nodeOperatorId, metadata: metadata }); } + + function refreshOperatorWeight(uint256 nodeOperatorId) external { + refreshOperatorWeightCallCount++; + lastRefreshedOperatorId = nodeOperatorId; + } } diff --git a/test/unit/Accounting/BondCalculations.t.sol b/test/unit/Accounting/BondCalculations.t.sol index eae03fcf..aed81c82 100644 --- a/test/unit/Accounting/BondCalculations.t.sol +++ b/test/unit/Accounting/BondCalculations.t.sol @@ -7,6 +7,7 @@ import { stdError } from "forge-std/Test.sol"; import { BaseTest, BondStateBaseTest, GetRequiredBondBaseTest, GetRequiredBondForKeysBaseTest, RewardsBaseTest } from "./_Base.t.sol"; import { Accounting } from "src/Accounting.sol"; +import { MAX_BP } from "src/lib/Constants.sol"; import { IBondCurve } from "src/interfaces/IBondCurve.sol"; import { IBondLock } from "src/interfaces/IBondLock.sol"; import { IBaseModule } from "src/interfaces/IBaseModule.sol"; @@ -162,6 +163,21 @@ contract ClaimableBondTest is RewardsBaseTest { ); } + function test_WithMultiplier() public override { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 claimableBondShares = accounting.getClaimableBondShares(0); + + assertApproxEqAbs( + claimableBondShares, + stETH.getSharesByPooledEth(2 ether), + 1 wei, + "claimable bond shares should be the excess over the scaled requirement" + ); + } + function test_WithOneWithdrawnValidator() public override { _operator({ ongoing: 16, withdrawn: 1 }); _deposit({ bond: 32 ether }); @@ -682,6 +698,12 @@ contract GetRequiredETHBondTest is GetRequiredBondBaseTest { assertEq(accounting.getRequiredBondForNextKeys(0, 0), 18 ether); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _multiplier({ multiplier: 15_000 }); + assertEq(accounting.getRequiredBondForNextKeys(0, 0), 48 ether); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); assertEq(accounting.getRequiredBondForNextKeys(0, 0), 30 ether); @@ -781,6 +803,13 @@ contract GetRequiredWstETHBondTest is GetRequiredBondBaseTest { assertEq(accounting.getRequiredBondForNextKeysWstETH(0, 0), wstETH.getWstETHByStETH(required - current)); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _multiplier({ multiplier: 15_000 }); + (uint256 current, uint256 required) = accounting.getBondSummary(0); + assertEq(accounting.getRequiredBondForNextKeysWstETH(0, 0), wstETH.getWstETHByStETH(required - current)); + } + function test_WithCurveAndLocked() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 0 }); _curve(curveWithDiscount); @@ -896,6 +925,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 { @@ -939,6 +1071,14 @@ contract GetBondSummaryTest is BondStateBaseTest { assertEq(required, 18 ether); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _multiplier({ multiplier: 15_000 }); + (uint256 current, uint256 required) = accounting.getBondSummary(0); + assertEq(current, 0 ether); + assertEq(required, 48 ether); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); (uint256 current, uint256 required) = accounting.getBondSummary(0); @@ -1045,6 +1185,14 @@ contract GetBondSummarySharesTest is BondStateBaseTest { assertEq(required, stETH.getSharesByPooledEth(18 ether)); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _multiplier({ multiplier: 15_000 }); + (uint256 current, uint256 required) = accounting.getBondSummaryShares(0); + assertEq(current, 0 ether); + assertEq(required, stETH.getSharesByPooledEth(48 ether)); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); (uint256 current, uint256 required) = accounting.getBondSummaryShares(0); @@ -1188,6 +1336,22 @@ contract ClaimableRewardsAndBondSharesTest is RewardsBaseTest { ); } + function test_WithMultiplier() public override { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _rewards({ fee: 0.1 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 claimableBondShares = accounting.getClaimableRewardsAndBondShares(0, leaf.shares, leaf.proof); + + assertApproxEqAbs( + claimableBondShares, + stETH.getSharesByPooledEth(2.1 ether), + 1 wei, + "claimable bond shares should be the excess over the scaled requirement + rewards" + ); + } + function test_WithOneWithdrawnValidator() public override { _operator({ ongoing: 16, withdrawn: 1 }); _deposit({ bond: 32 ether }); diff --git a/test/unit/Accounting/ClaimRewards.t.sol b/test/unit/Accounting/ClaimRewards.t.sol index 43572b67..68e802d5 100644 --- a/test/unit/Accounting/ClaimRewards.t.sol +++ b/test/unit/Accounting/ClaimRewards.t.sol @@ -87,6 +87,33 @@ contract ClaimStETHRewardsTest is ClaimRewardsBaseTest { assertEq(accounting.getBondShares(0), stETH.sharesOf(address(accounting))); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _rewards({ fee: 0.1 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 bondSharesBefore = accounting.getBondShares(0); + vm.prank(user); + accounting.claimRewardsStETH(leaf.nodeOperatorId, UINT256_MAX, leaf.shares, leaf.proof); + uint256 bondSharesAfter = accounting.getBondShares(0); + + assertApproxEqAbs( + stETH.balanceOf(rewardAddress), + stETHAsFee + 2 ether, + 1 wei, + "reward address balance should be the fee reward plus the excess over the scaled requirement" + ); + assertApproxEqAbs( + bondSharesAfter, + bondSharesBefore - stETH.getSharesByPooledEth(2 ether), + 1 wei, + "bond shares should drop by the claimed excess" + ); + assertEq(stETH.sharesOf(address(accounting)), bondSharesAfter); + assertEq(accounting.totalBondShares(), bondSharesAfter); + } + function test_WithCurveAndLocked() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 0 }); _deposit({ bond: 32 ether }); @@ -568,6 +595,34 @@ contract ClaimWstETHRewardsTest is ClaimRewardsBaseTest { assertEq(wstETH.balanceOf(rewardAddress), 0); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _rewards({ fee: 0.1 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 bondSharesBefore = accounting.getBondShares(0); + vm.prank(user); + accounting.claimRewardsWstETH(leaf.nodeOperatorId, UINT256_MAX, leaf.shares, leaf.proof); + uint256 bondSharesAfter = accounting.getBondShares(0); + + assertApproxEqAbs( + wstETH.balanceOf(rewardAddress), + wstETHAsFee + stETH.getSharesByPooledEth(2 ether), + 1 wei, + "reward address balance should be the fee reward plus the excess over the scaled requirement" + ); + assertApproxEqAbs( + bondSharesAfter, + bondSharesBefore - stETH.getSharesByPooledEth(2 ether), + 1 wei, + "bond shares should drop by the claimed excess" + ); + assertEq(wstETH.balanceOf(address(accounting)), 0, "bond manager wstETH balance should be 0"); + assertEq(stETH.sharesOf(address(accounting)), bondSharesAfter); + assertEq(accounting.totalBondShares(), bondSharesAfter); + } + function test_WithCurveAndLocked() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 0 }); _deposit({ bond: 32 ether }); @@ -1045,6 +1100,27 @@ contract ClaimRewardsUnstETHTest is ClaimRewardsBaseTest { assertEq(requestId, 0); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 50 ether }); + _rewards({ fee: 0.1 ether }); + _multiplier({ multiplier: 15_000 }); + + uint256 bondSharesBefore = accounting.getBondShares(0); + vm.prank(user); + accounting.claimRewardsUnstETH(leaf.nodeOperatorId, UINT256_MAX, leaf.shares, leaf.proof); + uint256 bondSharesAfter = accounting.getBondShares(0); + + assertApproxEqAbs( + bondSharesAfter, + bondSharesBefore - stETH.getSharesByPooledEth(2 ether), + 1 wei, + "bond shares should drop by the withdrawn excess" + ); + assertEq(stETH.sharesOf(rewardAddress), 0, "reward address shares should be 0"); + assertEq(accounting.totalBondShares(), bondSharesAfter, "total bond shares should be equal to after"); + } + function test_WithCurveAndLocked() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 0 }); _deposit({ bond: 32 ether }); diff --git a/test/unit/Accounting/UnbondedKeys.t.sol b/test/unit/Accounting/UnbondedKeys.t.sol index 3be0980e..7015cc91 100644 --- a/test/unit/Accounting/UnbondedKeys.t.sol +++ b/test/unit/Accounting/UnbondedKeys.t.sol @@ -44,6 +44,13 @@ contract GetUnbondedKeysCountTest is BondStateBaseTest { assertEq(accounting.getUnbondedKeysCount(0), 7); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 11.5 ether }); + _multiplier({ multiplier: 15_000 }); + assertEq(accounting.getUnbondedKeysCount(0), 13); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); _deposit({ bond: 11.5 ether }); @@ -158,6 +165,13 @@ contract GetUnbondedKeysCountToEjectTest is BondStateBaseTest { assertEq(accounting.getUnbondedKeysCountToEject(0), 6); } + function test_WithMultiplier() public override assertInvariants { + _operator({ ongoing: 16, withdrawn: 0 }); + _deposit({ bond: 11.5 ether }); + _multiplier({ multiplier: 15_000 }); + assertEq(accounting.getUnbondedKeysCountToEject(0), 13); + } + function test_WithOneWithdrawnValidator() public override assertInvariants { _operator({ ongoing: 16, withdrawn: 1 }); _deposit({ bond: 11.5 ether }); diff --git a/test/unit/Accounting/_Base.t.sol b/test/unit/Accounting/_Base.t.sol index 1b9102ea..c3d41d66 100644 --- a/test/unit/Accounting/_Base.t.sol +++ b/test/unit/Accounting/_Base.t.sol @@ -10,6 +10,7 @@ import { IStakingModule } from "src/interfaces/IStakingModule.sol"; import { IBondCurve } from "src/interfaces/IBondCurve.sol"; import { Accounting } from "src/Accounting.sol"; +import { MAX_BP } from "src/lib/Constants.sol"; import { Stub } from "../../helpers/mocks/Stub.sol"; import { LidoMock } from "../../helpers/mocks/LidoMock.sol"; @@ -170,6 +171,7 @@ contract BaseTest is AccountingFixtures { accounting.grantRole(accounting.RESUME_ROLE(), admin); accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), admin); accounting.grantRole(accounting.SET_BOND_CURVE_ROLE(), admin); + accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), admin); vm.stopPrank(); } @@ -205,6 +207,10 @@ abstract contract BondAmountModifiersTest { // 2 keys -> 3 ether + 1 ether // n keys -> 2 + (n - 1) * 1 ether + 1 ether function test_WithCurveAndLocked() public virtual; + + // bond curve scaled by an effective multiplier (e.g. 1.5x): + // n keys -> (2 + (n - 1) * 2) ether * multiplier / MAX_BP + function test_WithMultiplier() public virtual; } abstract contract BondStateBaseTest is BondAmountModifiersTest, BaseTest { @@ -242,6 +248,13 @@ abstract contract BondStateBaseTest is BondAmountModifiersTest, BaseTest { accounting.penalize(0, bondBefore + amount); } + // @dev Sets the operator's bond curve multiplier. `multiplier` is the effective value in + // basis points (>= MAX_BP); stored as the increment above MAX_BP. + function _multiplier(uint256 multiplier) internal virtual { + vm.prank(admin); + accounting.setBondCurveMultiplier(0, multiplier - MAX_BP); + } + function test_WithOneWithdrawnValidator() public virtual; function test_WithBond() public virtual; diff --git a/test/unit/AdditionalBondRegistry.t.sol b/test/unit/AdditionalBondRegistry.t.sol new file mode 100644 index 00000000..6fd9301c --- /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 94e7d6e3..447243b4 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 5f477d4f..38526b0d 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); } }