diff --git a/script/curated/DeployBase.s.sol b/script/curated/DeployBase.s.sol index d8c12c614..f289f1cef 100644 --- a/script/curated/DeployBase.s.sol +++ b/script/curated/DeployBase.s.sol @@ -18,6 +18,7 @@ import { ParametersRegistry } from "../../src/ParametersRegistry.sol"; import { ExitPenalties } from "../../src/ExitPenalties.sol"; import { MetaRegistry } from "../../src/MetaRegistry.sol"; import { AdditionalBondRegistry } from "../../src/AdditionalBondRegistry.sol"; +import { NodeOperatorStrikes } from "../../src/NodeOperatorStrikes.sol"; import { BoostStep } from "../../src/interfaces/IAdditionalBondRegistry.sol"; import { CuratedGate } from "../../src/CuratedGate.sol"; import { MerkleGateFactory } from "../../src/MerkleGateFactory.sol"; @@ -30,6 +31,7 @@ import { IParametersRegistry } from "../../src/interfaces/IParametersRegistry.so import { IBondCurve } from "../../src/interfaces/IBondCurve.sol"; import { IMetaRegistry } from "../../src/interfaces/IMetaRegistry.sol"; import { IWeightBoostProvider } from "../../src/interfaces/IWeightBoostProvider.sol"; +import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { JsonObj, Json } from "../utils/Json.sol"; import { Dummy } from "../utils/Dummy.sol"; @@ -133,6 +135,9 @@ struct CuratedDeployParams { address secondAdminAddress; // AdditionalBondRegistry AdditionalBondRegistryConfig additionalBondRegistryConfig; + // NodeOperatorStrikes + address strikesCommittee; + StrikeThreshold[] strikesThresholds; } abstract contract DeployBase is Script { @@ -156,6 +161,7 @@ abstract contract DeployBase is Script { ParametersRegistry public parametersRegistry; MetaRegistry public metaRegistry; AdditionalBondRegistry public additionalBondRegistry; + NodeOperatorStrikes public nodeOperatorStrikes; MerkleGateFactory public curatedGateFactory; address[] public curatedGateInstances; address internal curatedGateImpl; @@ -245,6 +251,7 @@ abstract contract DeployBase is Script { oracle = FeeOracle(_deployProxy(deployer, address(dummyImpl))); metaRegistry = MetaRegistry(_deployProxy(deployer, address(dummyImpl))); additionalBondRegistry = AdditionalBondRegistry(_deployProxy(deployer, address(dummyImpl))); + nodeOperatorStrikes = NodeOperatorStrikes(_deployProxy(deployer, address(dummyImpl))); FeeDistributor feeDistributorImpl = new FeeDistributor({ stETH: locator.lido(), @@ -354,12 +361,28 @@ abstract contract DeployBase is Script { additionalBondRegistryProxy.proxy__changeAdmin(config.proxyAdmin); } + NodeOperatorStrikes nodeOperatorStrikesImpl = new NodeOperatorStrikes({ module: address(curatedModule) }); + + { + OssifiableProxy nodeOperatorStrikesProxy = OssifiableProxy(payable(address(nodeOperatorStrikes))); + nodeOperatorStrikesProxy.proxy__upgradeToAndCall( + address(nodeOperatorStrikesImpl), + abi.encodeCall(NodeOperatorStrikes.initialize, (deployer, config.strikesThresholds)) + ); + nodeOperatorStrikesProxy.proxy__changeAdmin(config.proxyAdmin); + } + accounting.grantRole(accounting.MANAGE_BOND_CURVES_ROLE(), address(deployer)); accounting.grantRole(accounting.SET_BOND_CURVE_MULTIPLIER_ROLE(), address(additionalBondRegistry)); metaRegistry.addWeightBoostProvider( IWeightBoostProvider(address(additionalBondRegistry)), IMetaRegistry.WeightBoostProviderMode.NodeOperator ); + metaRegistry.addWeightBoostProvider( + IWeightBoostProvider(address(nodeOperatorStrikes)), + IMetaRegistry.WeightBoostProviderMode.NodeOperator + ); + nodeOperatorStrikes.grantRole(nodeOperatorStrikes.STRIKES_COMMITTEE_ROLE(), config.strikesCommittee); metaRegistry.grantRole(metaRegistry.SET_BOND_CURVE_WEIGHT_ROLE(), deployer); for (uint256 i = 0; i < gatesCount; i++) { @@ -560,6 +583,9 @@ abstract contract DeployBase is Script { additionalBondRegistry.grantRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), config.aragonAgent); additionalBondRegistry.revokeRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), deployer); + nodeOperatorStrikes.grantRole(nodeOperatorStrikes.DEFAULT_ADMIN_ROLE(), config.aragonAgent); + nodeOperatorStrikes.revokeRole(nodeOperatorStrikes.DEFAULT_ADMIN_ROLE(), deployer); + verifier.grantRole(verifier.DEFAULT_ADMIN_ROLE(), config.aragonAgent); verifier.revokeRole(verifier.DEFAULT_ADMIN_ROLE(), deployer); @@ -586,6 +612,8 @@ abstract contract DeployBase is Script { deployJson.set("MetaRegistryImpl", address(metaRegistryImpl)); deployJson.set("AdditionalBondRegistry", address(additionalBondRegistry)); deployJson.set("AdditionalBondRegistryImpl", address(additionalBondRegistryImpl)); + deployJson.set("NodeOperatorStrikes", address(nodeOperatorStrikes)); + deployJson.set("NodeOperatorStrikesImpl", address(nodeOperatorStrikesImpl)); deployJson.set("ParametersRegistry", address(parametersRegistry)); deployJson.set("ParametersRegistryImpl", address(parametersRegistryImpl)); deployJson.set("Accounting", address(accounting)); @@ -682,6 +710,7 @@ abstract contract DeployBase is Script { parametersRegistry.grantRole(parametersRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); metaRegistry.grantRole(metaRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); additionalBondRegistry.grantRole(additionalBondRegistry.DEFAULT_ADMIN_ROLE(), config.secondAdminAddress); + nodeOperatorStrikes.grantRole(nodeOperatorStrikes.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 099b8bbf1..c001bb4d4 100644 --- a/script/curated/DeployHoodi.s.sol +++ b/script/curated/DeployHoodi.s.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.33; import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; +import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployHoodi is DeployBase { @@ -205,6 +206,14 @@ contract DeployHoodi is DeployBase { config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); + // NodeOperatorStrikes + config.strikesCommittee = 0x84DffcfB232594975C608DE92544Ff239a24c9E9; // CMC on Hoodi + // TODO: finalize strike weight-reduction thresholds + config.strikesThresholds.push(StrikeThreshold({ minCount: 2, reductionBP: 2_500 })); + config.strikesThresholds.push(StrikeThreshold({ minCount: 3, reductionBP: 5_000 })); + config.strikesThresholds.push(StrikeThreshold({ minCount: 4, reductionBP: 7_500 })); + config.strikesThresholds.push(StrikeThreshold({ minCount: 5, reductionBP: 10_000 })); + _setUp(); } } diff --git a/script/curated/DeployLocalDevNet.s.sol b/script/curated/DeployLocalDevNet.s.sol index d7a47d1c9..691b4494c 100644 --- a/script/curated/DeployLocalDevNet.s.sol +++ b/script/curated/DeployLocalDevNet.s.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.33; import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; +import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; import { BaseOracle } from "../../src/lib/base-oracle/BaseOracle.sol"; import { HashConsensus } from "../../src/lib/base-oracle/HashConsensus.sol"; @@ -193,6 +194,13 @@ contract DeployLocalDevNet is DeployBase { config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); + // NodeOperatorStrikes + config.strikesCommittee = vm.envAddress("CSM_FIRST_ADMIN_ADDRESS"); // Dev team EOA + config.strikesThresholds.push(StrikeThreshold({ minCount: 2, reductionBP: 2_500 })); + config.strikesThresholds.push(StrikeThreshold({ minCount: 3, reductionBP: 5_000 })); + config.strikesThresholds.push(StrikeThreshold({ minCount: 4, reductionBP: 7_500 })); + config.strikesThresholds.push(StrikeThreshold({ minCount: 5, reductionBP: 10_000 })); + _setUp(); } diff --git a/script/curated/DeployMainnet.s.sol b/script/curated/DeployMainnet.s.sol index 40d4eeadf..013753996 100644 --- a/script/curated/DeployMainnet.s.sol +++ b/script/curated/DeployMainnet.s.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.33; import { DeployBase, CuratedGateConfig, AdditionalBondRegistryConfig } from "./DeployBase.s.sol"; +import { StrikeThreshold } from "../../src/interfaces/INodeOperatorStrikes.sol"; import { GIndices } from "../constants/GIndices.sol"; contract DeployMainnet is DeployBase { @@ -204,6 +205,14 @@ contract DeployMainnet is DeployBase { config.additionalBondRegistryConfig.boostSteps.push([uint256(5_000), 2_000]); config.additionalBondRegistryConfig.boostSteps.push([uint256(10_000), 8_000]); + // NodeOperatorStrikes + config.strikesCommittee = 0x2570e0b22AD904501dfB0d49575991ACB801dD91; // CMC https://docs.lido.fi/multisigs/committees#220-curated-module-committee-cmc + // TODO: finalize strike weight-reduction thresholds + config.strikesThresholds.push(StrikeThreshold({ minCount: 2, reductionBP: 2_500 })); + config.strikesThresholds.push(StrikeThreshold({ minCount: 3, reductionBP: 5_000 })); + config.strikesThresholds.push(StrikeThreshold({ minCount: 4, reductionBP: 7_500 })); + config.strikesThresholds.push(StrikeThreshold({ minCount: 5, reductionBP: 10_000 })); + _setUp(); } } diff --git a/src/NodeOperatorStrikes.sol b/src/NodeOperatorStrikes.sol new file mode 100644 index 000000000..84aaa3527 --- /dev/null +++ b/src/NodeOperatorStrikes.sol @@ -0,0 +1,235 @@ +// 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 { ICuratedModule } from "./interfaces/ICuratedModule.sol"; +import { IMetaRegistry } from "./interfaces/IMetaRegistry.sol"; +import { INodeOperatorStrikes, StrikeInput, Strike, StrikeThreshold } from "./interfaces/INodeOperatorStrikes.sol"; +import { IWeightBoostProvider } from "./interfaces/IWeightBoostProvider.sol"; +import { MAX_BP } from "./lib/Constants.sol"; + +/// @notice Committee-issued, operator-level strikes that cumulatively reduce +/// a Node Operator's allocation weight. Strikes persist until removed; +/// removal is permissionless once a strike's lifetime elapses. +contract NodeOperatorStrikes is INodeOperatorStrikes, Initializable, AccessControlEnumerableUpgradeable { + struct OperatorStrikes { + uint64 lastId; + uint256[] activeIds; + mapping(uint256 strikeId => Strike) strikes; + } + + /// @custom:storage-location erc7201:NodeOperatorStrikes + struct NodeOperatorStrikesStorage { + StrikeThreshold[] thresholds; + mapping(uint256 nodeOperatorId => OperatorStrikes) operatorStrikes; + } + + bytes32 public constant STRIKES_COMMITTEE_ROLE = keccak256("STRIKES_COMMITTEE_ROLE"); + + uint256 public constant MAX_THRESHOLDS = 16; + uint256 public constant MAX_DESCRIPTION_LENGTH = 1024; + + ICuratedModule public immutable MODULE; + IMetaRegistry public immutable META_REGISTRY; + + // keccak256(abi.encode(uint256(keccak256("NodeOperatorStrikes")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant NODE_OPERATOR_STRIKES_STORAGE_LOCATION = + 0x510f8e4bbf34090117edc1d950679ffb8abd223dc216175d997628968b892400; + + /// @param module CuratedModule proxy address. + constructor(address module) { + if (module == address(0)) revert ZeroModuleAddress(); + + MODULE = ICuratedModule(module); + META_REGISTRY = ICuratedModule(module).META_REGISTRY(); + + _disableInitializers(); + } + + /// @inheritdoc INodeOperatorStrikes + function initialize(address admin, StrikeThreshold[] calldata thresholds) external initializer { + if (admin == address(0)) revert ZeroAdminAddress(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _setStrikeThresholds(thresholds); + } + + /// @inheritdoc INodeOperatorStrikes + function issueStrike( + StrikeInput calldata input + ) external onlyRole(STRIKES_COMMITTEE_ROLE) returns (uint256 strikeId) { + _onlyExistingOperator(input.nodeOperatorId); + + uint256 descLength = bytes(input.description).length; + if (descLength == 0 || descLength > MAX_DESCRIPTION_LENGTH) revert InvalidDescription(); + + uint256 lifetime = input.lifetime; + if (lifetime == 0) revert ZeroLifetime(); + uint256 expiry = block.timestamp + lifetime; + if (expiry > type(uint64).max) revert LifetimeTooLong(); + + OperatorStrikes storage rec = _storage().operatorStrikes[input.nodeOperatorId]; + strikeId = ++rec.lastId; + rec.activeIds.push(strikeId); + rec.strikes[strikeId] = Strike({ + id: uint64(strikeId), + expiry: uint64(expiry), + category: input.category, + description: input.description + }); + + emit StrikeIssued({ + nodeOperatorId: input.nodeOperatorId, + strikeId: strikeId, + category: input.category, + expiry: expiry, + description: input.description + }); + + META_REGISTRY.notifyWeightBoostChanged(input.nodeOperatorId); + } + + /// @inheritdoc INodeOperatorStrikes + function removeStrike(uint256 nodeOperatorId, uint256 strikeId) external onlyRole(STRIKES_COMMITTEE_ROLE) { + OperatorStrikes storage rec = _storage().operatorStrikes[nodeOperatorId]; + _removeStrike(rec, nodeOperatorId, _activeIndex(rec, strikeId), strikeId); + + META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + } + + /// @inheritdoc INodeOperatorStrikes + function removeExpiredStrikes(uint256 nodeOperatorId) external { + OperatorStrikes storage rec = _storage().operatorStrikes[nodeOperatorId]; + uint256[] storage activeIds = rec.activeIds; + + // Back-to-front so swap-pop never skips an id. + bool removed; + uint256 i = activeIds.length; + while (i > 0) { + --i; + uint256 strikeId = activeIds[i]; + if (rec.strikes[strikeId].expiry > block.timestamp) continue; + _removeStrike(rec, nodeOperatorId, i, strikeId); + removed = true; + } + + if (removed) META_REGISTRY.notifyWeightBoostChanged(nodeOperatorId); + } + + /// @inheritdoc INodeOperatorStrikes + function setStrikeThresholds(StrikeThreshold[] calldata thresholds) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setStrikeThresholds(thresholds); + META_REGISTRY.notifyWeightBoostProviderConfigChanged(); + } + + /// @inheritdoc IWeightBoostProvider + /// @dev Counts strikes regardless of expiry: an expired one keeps reducing the weight until removed. + function getWeightBoostMultiplierBP(uint256 nodeOperatorId) external view returns (uint256 multiplierBP) { + multiplierBP = MAX_BP; + + NodeOperatorStrikesStorage storage $ = _storage(); + uint256 count = $.operatorStrikes[nodeOperatorId].activeIds.length; + + StrikeThreshold[] storage thresholds = $.thresholds; + uint256 len = thresholds.length; + for (uint256 i; i < len; ++i) { + if (count < thresholds[i].minCount) break; // thresholds ascend by minCount + multiplierBP = MAX_BP - thresholds[i].reductionBP; + } + } + + /// @inheritdoc INodeOperatorStrikes + function getActiveStrikesCount(uint256 nodeOperatorId) external view returns (uint256 count) { + return _storage().operatorStrikes[nodeOperatorId].activeIds.length; + } + + /// @inheritdoc INodeOperatorStrikes + function getStrike(uint256 nodeOperatorId, uint256 strikeId) external view returns (Strike memory strike) { + strike = _storage().operatorStrikes[nodeOperatorId].strikes[strikeId]; + // expiry == 0 means removed or never issued. + if (strike.expiry == 0) revert StrikeNotExist(); + } + + /// @inheritdoc INodeOperatorStrikes + function getStrikes(uint256 nodeOperatorId) external view returns (Strike[] memory strikes) { + OperatorStrikes storage rec = _storage().operatorStrikes[nodeOperatorId]; + uint256[] storage activeIds = rec.activeIds; + uint256 len = activeIds.length; + + strikes = new Strike[](len); + for (uint256 i; i < len; ++i) { + strikes[i] = rec.strikes[activeIds[i]]; + } + } + + /// @inheritdoc INodeOperatorStrikes + function getStrikeThresholds() external view returns (StrikeThreshold[] memory thresholds) { + return _storage().thresholds; + } + + /// @dev Swap-pops the id, deletes the record, emits. Caller refreshes the weight (once per batch). + function _removeStrike( + OperatorStrikes storage rec, + uint256 nodeOperatorId, + uint256 idx, + uint256 strikeId + ) internal { + uint256[] storage activeIds = rec.activeIds; + uint256 lastIdx = activeIds.length - 1; + if (idx != lastIdx) { + activeIds[idx] = activeIds[lastIdx]; + } + activeIds.pop(); + delete rec.strikes[strikeId]; + + emit StrikeRemoved(nodeOperatorId, strikeId, msg.sender); + } + + function _setStrikeThresholds(StrikeThreshold[] calldata thresholds) internal { + _validateStrikeThresholds(thresholds); + + NodeOperatorStrikesStorage storage $ = _storage(); + delete $.thresholds; + for (uint256 i; i < thresholds.length; ++i) { + $.thresholds.push(thresholds[i]); + } + + emit StrikeThresholdsSet(thresholds); + } + + function _onlyExistingOperator(uint256 nodeOperatorId) internal view { + if (nodeOperatorId >= MODULE.getNodeOperatorsCount()) revert NodeOperatorDoesNotExist(); + } + + /// @dev Index of `strikeId` in `activeIds`; reverts `StrikeNotExist` if absent. + function _activeIndex(OperatorStrikes storage rec, uint256 strikeId) internal view returns (uint256) { + uint256[] storage activeIds = rec.activeIds; + uint256 len = activeIds.length; + for (uint256 i; i < len; ++i) { + if (activeIds[i] == strikeId) return i; + } + revert StrikeNotExist(); + } + + function _validateStrikeThresholds(StrikeThreshold[] calldata thresholds) internal pure { + uint256 len = thresholds.length; + if (len == 0 || len > MAX_THRESHOLDS) revert InvalidStrikeThresholds(); + if (thresholds[0].minCount == 0) revert InvalidStrikeThresholds(); + if (thresholds[0].reductionBP > MAX_BP) revert InvalidStrikeThresholds(); + + for (uint256 i = 1; i < len; ++i) { + if (thresholds[i].minCount <= thresholds[i - 1].minCount) revert InvalidStrikeThresholds(); + if (thresholds[i].reductionBP <= thresholds[i - 1].reductionBP) revert InvalidStrikeThresholds(); + if (thresholds[i].reductionBP > MAX_BP) revert InvalidStrikeThresholds(); + } + } + + function _storage() internal pure returns (NodeOperatorStrikesStorage storage $) { + assembly ("memory-safe") { + $.slot := NODE_OPERATOR_STRIKES_STORAGE_LOCATION + } + } +} diff --git a/src/interfaces/INodeOperatorStrikes.sol b/src/interfaces/INodeOperatorStrikes.sol new file mode 100644 index 000000000..9e0b895b7 --- /dev/null +++ b/src/interfaces/INodeOperatorStrikes.sol @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.33; + +import { ICuratedModule } from "./ICuratedModule.sol"; +import { IMetaRegistry } from "./IMetaRegistry.sol"; +import { IWeightBoostProvider } from "./IWeightBoostProvider.sol"; + +/// @dev Payload describing a strike to issue. +struct StrikeInput { + uint256 nodeOperatorId; + bytes32 category; + uint256 lifetime; + string description; +} + +/// @dev Stored strike record (kept in a mapping by id, so the struct can grow without migration). +/// A live strike has `expiry != 0`; a removed or never-issued one reads as zeroed. +struct Strike { + uint64 id; + uint64 expiry; + bytes32 category; + string description; +} + +/// @dev Cumulative weight reduction step. At `minCount` active strikes the operator's weight is +/// reduced by `reductionBP` basis points (effective multiplier = MAX_BP - reductionBP). +struct StrikeThreshold { + uint256 minCount; + uint256 reductionBP; +} + +/// @notice Committee-issued strikes act as a weight-reduction provider consumed by MetaRegistry. +interface INodeOperatorStrikes is IWeightBoostProvider { + event StrikeIssued( + uint256 indexed nodeOperatorId, + uint256 indexed strikeId, + bytes32 indexed category, + uint256 expiry, + string description + ); + event StrikeRemoved(uint256 indexed nodeOperatorId, uint256 indexed strikeId, address indexed remover); + event StrikeThresholdsSet(StrikeThreshold[] thresholds); + + error ZeroModuleAddress(); + error ZeroAdminAddress(); + error NodeOperatorDoesNotExist(); + error StrikeNotExist(); + error ZeroLifetime(); + error LifetimeTooLong(); + error InvalidStrikeThresholds(); + error InvalidDescription(); + + /// @notice Role allowed to issue and remove strikes. + function STRIKES_COMMITTEE_ROLE() external view returns (bytes32); + + /// @notice Maximum number of weight-reduction thresholds. + function MAX_THRESHOLDS() external view returns (uint256); + + /// @notice Maximum byte length of a strike description. + function MAX_DESCRIPTION_LENGTH() external view returns (uint256); + + /// @notice Curated module used to check operator existence. + function MODULE() external view returns (ICuratedModule); + + /// @notice MetaRegistry called back via `notifyWeightBoostChanged` on every strike change. + function META_REGISTRY() external view returns (IMetaRegistry); + + /// @notice Initialize the contract. + /// @param admin Address to receive DEFAULT_ADMIN_ROLE. + /// @param thresholds Initial weight-reduction thresholds. + function initialize(address admin, StrikeThreshold[] calldata thresholds) external; + + /// @notice Issue a strike against a Node Operator (callable by STRIKES_COMMITTEE_ROLE). + /// @param input Strike payload. + /// @return strikeId ID assigned to the new strike. + function issueStrike(StrikeInput calldata input) external returns (uint256 strikeId); + + /// @notice Remove any strike (callable by STRIKES_COMMITTEE_ROLE). For permissionless cleanup of + /// expired strikes use `removeExpiredStrikes`. + /// @param nodeOperatorId ID of the Node Operator. + /// @param strikeId ID of the strike to remove. + function removeStrike(uint256 nodeOperatorId, uint256 strikeId) external; + + /// @notice Permissionlessly remove all of a Node Operator's strikes whose lifetime has elapsed. + /// No-op if none are expired. + /// @param nodeOperatorId ID of the Node Operator. + function removeExpiredStrikes(uint256 nodeOperatorId) external; + + /// @notice Set the global weight-reduction thresholds (callable by DEFAULT_ADMIN_ROLE). + /// @dev Notifies MetaRegistry of the config change so affected operator weights are refreshed. + /// @param thresholds Step function mapping active strike count to weight reduction. + function setStrikeThresholds(StrikeThreshold[] calldata thresholds) external; + + /// @notice Number of active (non-removed) strikes of a Node Operator. + /// @param nodeOperatorId ID of the Node Operator. + function getActiveStrikesCount(uint256 nodeOperatorId) external view returns (uint256 count); + + /// @notice Return a single strike record. Reverts with `StrikeNotExist` if removed or never issued. + /// @param nodeOperatorId ID of the Node Operator. + /// @param strikeId ID of the strike. + function getStrike(uint256 nodeOperatorId, uint256 strikeId) external view returns (Strike memory strike); + + /// @notice Return all of a Node Operator's active (non-removed) strikes. + /// @param nodeOperatorId ID of the Node Operator. + function getStrikes(uint256 nodeOperatorId) external view returns (Strike[] memory strikes); + + /// @notice Return the configured global weight-reduction thresholds. + function getStrikeThresholds() external view returns (StrikeThreshold[] memory thresholds); +} diff --git a/test/unit/NodeOperatorStrikes.t.sol b/test/unit/NodeOperatorStrikes.t.sol new file mode 100644 index 000000000..5c84c7ce0 --- /dev/null +++ b/test/unit/NodeOperatorStrikes.t.sol @@ -0,0 +1,611 @@ +// 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 { NodeOperatorStrikes } from "src/NodeOperatorStrikes.sol"; +import { INodeOperatorStrikes, StrikeInput, Strike, StrikeThreshold } from "src/interfaces/INodeOperatorStrikes.sol"; + +import { CuratedMock } from "../helpers/mocks/CuratedMock.sol"; +import { MetaRegistryMock } from "../helpers/mocks/MetaRegistryMock.sol"; +import { Utilities } from "../helpers/Utilities.sol"; +import { Fixtures } from "../helpers/Fixtures.sol"; + +contract NodeOperatorStrikesBaseTest is Test, Utilities, Fixtures { + CuratedMock public module; + MetaRegistryMock public metaRegistryMock; + NodeOperatorStrikes public strikes; + + address public admin; + address public committee; + address public stranger; + + uint256 internal constant MAX_BP = 10_000; + uint256 internal constant LIFETIME = 30 days; + bytes32 internal constant CATEGORY = keccak256("performance"); + uint256 internal constant NO_ID = 0; + string internal constant DESCRIPTION = "Operator missed attestations for two consecutive frames"; + + function setUp() public virtual { + admin = nextAddress("ADMIN"); + committee = nextAddress("COMMITTEE"); + stranger = nextAddress("STRANGER"); + + module = new CuratedMock(); + module.mock_setNodeOperatorsCount(3); + + metaRegistryMock = new MetaRegistryMock(); + module.mock_setMetaRegistry(address(metaRegistryMock)); + + strikes = new NodeOperatorStrikes({ module: address(module) }); + _enableInitializers(address(strikes)); + strikes.initialize(admin, _exampleThresholds()); + + bytes32 committeeRole = strikes.STRIKES_COMMITTEE_ROLE(); + vm.prank(admin); + strikes.grantRole(committeeRole, committee); + } + + function _input( + uint256 nodeOperatorId, + bytes32 category, + uint256 lifetime + ) internal pure returns (StrikeInput memory) { + return + StrikeInput({ + nodeOperatorId: nodeOperatorId, + category: category, + lifetime: lifetime, + description: DESCRIPTION + }); + } + + function _exampleThresholds() internal pure returns (StrikeThreshold[] memory thresholds) { + thresholds = new StrikeThreshold[](4); + thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: 2_500 }); + thresholds[1] = StrikeThreshold({ minCount: 3, reductionBP: 5_000 }); + thresholds[2] = StrikeThreshold({ minCount: 4, reductionBP: 7_500 }); + thresholds[3] = StrikeThreshold({ minCount: 5, reductionBP: 10_000 }); + } + + function _setExampleThresholds() internal { + vm.prank(admin); + strikes.setStrikeThresholds(_exampleThresholds()); + } + + function _issue(uint256 nodeOperatorId) internal returns (uint256 strikeId) { + vm.prank(committee); + strikeId = strikes.issueStrike(_input(nodeOperatorId, CATEGORY, LIFETIME)); + } + + function _expectNoStrike(uint256 nodeOperatorId, uint256 strikeId) internal { + vm.expectRevert(INodeOperatorStrikes.StrikeNotExist.selector); + strikes.getStrike(nodeOperatorId, strikeId); + } +} + +contract NodeOperatorStrikesConstructorTest is NodeOperatorStrikesBaseTest { + function test_constructor_SetsImmutables() public view { + assertEq(address(strikes.MODULE()), address(module)); + assertEq(address(strikes.META_REGISTRY()), address(metaRegistryMock)); + } + + function test_constructor_RevertWhen_ZeroModule() public { + vm.expectRevert(INodeOperatorStrikes.ZeroModuleAddress.selector); + new NodeOperatorStrikes(address(0)); + } +} + +contract NodeOperatorStrikesInitializeTest is NodeOperatorStrikesBaseTest { + function test_initialize_SetsAdmin() public view { + assertTrue(strikes.hasRole(strikes.DEFAULT_ADMIN_ROLE(), admin)); + } + + function test_initialize_SetsThresholds() public { + NodeOperatorStrikes s = new NodeOperatorStrikes(address(module)); + _enableInitializers(address(s)); + s.initialize(admin, _exampleThresholds()); + + StrikeThreshold[] memory stored = s.getStrikeThresholds(); + assertEq(stored.length, 4); + assertEq(stored[0].minCount, 2); + assertEq(stored[3].reductionBP, 10_000); + } + + function test_initialize_RevertWhen_ZeroAdmin() public { + NodeOperatorStrikes s = new NodeOperatorStrikes(address(module)); + _enableInitializers(address(s)); + vm.expectRevert(INodeOperatorStrikes.ZeroAdminAddress.selector); + s.initialize(address(0), new StrikeThreshold[](0)); + } + + function test_initialize_RevertWhen_InvalidThresholds() public { + NodeOperatorStrikes s = new NodeOperatorStrikes(address(module)); + _enableInitializers(address(s)); + + StrikeThreshold[] memory bad = new StrikeThreshold[](1); + bad[0] = StrikeThreshold({ minCount: 0, reductionBP: 1_000 }); // minCount 0 is invalid + vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + s.initialize(admin, bad); + } + + function test_initialize_RevertWhen_EmptyThresholds() public { + NodeOperatorStrikes s = new NodeOperatorStrikes(address(module)); + _enableInitializers(address(s)); + + vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + s.initialize(admin, new StrikeThreshold[](0)); + } + + function test_initialize_RevertWhen_DoubleCall() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + strikes.initialize(admin, new StrikeThreshold[](0)); + } +} + +contract NodeOperatorStrikesIssueTest is NodeOperatorStrikesBaseTest { + function test_issueStrike_StoresAndRefreshes() public { + uint256 expiry = block.timestamp + LIFETIME; + + vm.expectEmit(true, true, true, true, address(strikes)); + emit INodeOperatorStrikes.StrikeIssued(NO_ID, 1, CATEGORY, expiry, DESCRIPTION); + + vm.prank(committee); + uint256 strikeId = strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME)); + + assertEq(strikeId, 1); + assertEq(strikes.getActiveStrikesCount(NO_ID), 1); + + Strike memory s = strikes.getStrike(NO_ID, 1); + assertEq(s.id, 1); + assertEq(s.expiry, expiry); + assertEq(s.category, CATEGORY); + assertEq(s.description, DESCRIPTION); + + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), 1); + assertEq(metaRegistryMock.lastChangedBoostOperatorId(), NO_ID); + } + + function test_issueStrike_AssignsSequentialIds() public { + assertEq(_issue(NO_ID), 1); + assertEq(_issue(NO_ID), 2); + assertEq(_issue(NO_ID), 3); + assertEq(strikes.getActiveStrikesCount(NO_ID), 3); + } + + function test_issueStrike_RevertWhen_NotCommittee() public { + expectRoleRevert(stranger, strikes.STRIKES_COMMITTEE_ROLE()); + vm.prank(stranger); + strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME)); + } + + function test_issueStrike_RevertWhen_OperatorDoesNotExist() public { + vm.expectRevert(INodeOperatorStrikes.NodeOperatorDoesNotExist.selector); + vm.prank(committee); + strikes.issueStrike(_input(3, CATEGORY, LIFETIME)); // count == 3, so id 3 doesn't exist + } + + function test_issueStrike_RevertWhen_ZeroLifetime() public { + vm.expectRevert(INodeOperatorStrikes.ZeroLifetime.selector); + vm.prank(committee); + strikes.issueStrike(_input(NO_ID, CATEGORY, 0)); + } + + function test_issueStrike_RevertWhen_LifetimeOverflowsUint64() public { + uint256 hugeLifetime = type(uint64).max; // block.timestamp + this overflows uint64 + vm.expectRevert(INodeOperatorStrikes.LifetimeTooLong.selector); + vm.prank(committee); + strikes.issueStrike(_input(NO_ID, CATEGORY, hugeLifetime)); + } + + function test_issueStrike_AllowsMaxLengthDescription() public { + uint256 maxLen = strikes.MAX_DESCRIPTION_LENGTH(); + StrikeInput memory input = _input(NO_ID, CATEGORY, LIFETIME); + input.description = string(new bytes(maxLen)); + + vm.prank(committee); + uint256 id = strikes.issueStrike(input); + assertEq(bytes(strikes.getStrike(NO_ID, id).description).length, maxLen); + } + + function test_issueStrike_RevertWhen_DescriptionTooLong() public { + StrikeInput memory input = _input(NO_ID, CATEGORY, LIFETIME); + input.description = string(new bytes(strikes.MAX_DESCRIPTION_LENGTH() + 1)); + + vm.expectRevert(INodeOperatorStrikes.InvalidDescription.selector); + vm.prank(committee); + strikes.issueStrike(input); + } + + function test_issueStrike_RevertWhen_EmptyDescription() public { + StrikeInput memory input = _input(NO_ID, CATEGORY, LIFETIME); + input.description = ""; + + vm.expectRevert(INodeOperatorStrikes.InvalidDescription.selector); + vm.prank(committee); + strikes.issueStrike(input); + } +} + +contract NodeOperatorStrikesRemoveTest is NodeOperatorStrikesBaseTest { + function test_removeStrike_RemovesAndRefreshes() public { + uint256 id = _issue(NO_ID); + uint256 refreshesBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + vm.expectEmit(true, true, true, true, address(strikes)); + emit INodeOperatorStrikes.StrikeRemoved(NO_ID, id, committee); + + vm.prank(committee); + strikes.removeStrike(NO_ID, id); + + assertEq(strikes.getActiveStrikesCount(NO_ID), 0); + _expectNoStrike(NO_ID, id); // removed + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), refreshesBefore + 1); + } + + function test_removeStrike_RevertWhen_NotCommittee() public { + uint256 id = _issue(NO_ID); + bytes32 role = strikes.STRIKES_COMMITTEE_ROLE(); + uint256 expiry = strikes.getStrike(NO_ID, id).expiry; + + // Even after the lifetime elapses removeStrike stays committee-only; + // permissionless cleanup goes through removeExpiredStrikes. + vm.warp(expiry); + + expectRoleRevert(stranger, role); + vm.prank(stranger); + strikes.removeStrike(NO_ID, id); + } + + function test_removeStrike_RevertWhen_NonExistent() public { + vm.expectRevert(INodeOperatorStrikes.StrikeNotExist.selector); + vm.prank(committee); + strikes.removeStrike(NO_ID, 1); + } + + function test_removeStrike_RevertWhen_AlreadyRemoved() public { + uint256 id = _issue(NO_ID); + vm.prank(committee); + strikes.removeStrike(NO_ID, id); + + vm.expectRevert(INodeOperatorStrikes.StrikeNotExist.selector); + vm.prank(committee); + strikes.removeStrike(NO_ID, id); + } + + function test_strikeIds_MonotonicWithGapsAndExpiredInActive() public { + _issue(NO_ID); // id 1 + uint256 id2 = _issue(NO_ID); // id 2 + _issue(NO_ID); // id 3 + _issue(NO_ID); // id 4 + + // Remove id 2 early (committee): id 4 swaps into its slot. + vm.prank(committee); + strikes.removeStrike(NO_ID, id2); + + // New strikes keep incrementing past the gap — id 2 is never reused. + uint256 id5 = _issue(NO_ID); + assertEq(id5, 5); + assertEq(strikes.getActiveStrikesCount(NO_ID), 4); // 1, 3, 4, 5 + + // After lifetime elapses expired strikes stay in active until explicitly removed. + vm.warp(block.timestamp + LIFETIME); + assertEq(strikes.getActiveStrikesCount(NO_ID), 4); + + // Gap at id 2 reverts; all others are individually reachable. + assertEq(strikes.getStrike(NO_ID, 1).id, 1); + _expectNoStrike(NO_ID, 2); // gap + assertEq(strikes.getStrike(NO_ID, 3).id, 3); + assertEq(strikes.getStrike(NO_ID, 4).id, 4); + assertEq(strikes.getStrike(NO_ID, 5).id, 5); + + // Sum of active ids is a duplicate-free witness. + Strike[] memory active = strikes.getStrikes(NO_ID); + assertEq(active.length, 4); + uint256 idSum; + for (uint256 i; i < active.length; ++i) idSum += active[i].id; + assertEq(idSum, 1 + 3 + 4 + 5); + } + + function test_removeStrike_SwapPopResolvesById() public { + _issue(NO_ID); // id 1 + uint256 id2 = _issue(NO_ID); + uint256 id3 = _issue(NO_ID); + + // Remove the first strike: the last one (id3) is swapped into its slot. + vm.prank(committee); + strikes.removeStrike(NO_ID, 1); + + // The swapped strike must still resolve by its id and stay removable. + assertEq(strikes.getActiveStrikesCount(NO_ID), 2); + assertEq(strikes.getStrike(NO_ID, id3).id, id3); + assertEq(strikes.getStrike(NO_ID, id2).id, id2); + + vm.prank(committee); + strikes.removeStrike(NO_ID, id3); + + assertEq(strikes.getActiveStrikesCount(NO_ID), 1); + assertEq(strikes.getStrike(NO_ID, id2).id, id2); + _expectNoStrike(NO_ID, id3); // removed + } +} + +contract NodeOperatorStrikesRemoveExpiredTest is NodeOperatorStrikesBaseTest { + /// @dev Issues four strikes: id1/id3 short-lived (LIFETIME), id2/id4 long-lived (2x LIFETIME). + function _issueMixed() internal returns (uint256 id1, uint256 id2, uint256 id3, uint256 id4) { + vm.startPrank(committee); + id1 = strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME)); + id2 = strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME * 2)); + id3 = strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME)); + id4 = strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME * 2)); + vm.stopPrank(); + } + + function test_removeExpiredStrikes_AlternatingSurvivorsStayResolvable() public { + // Alternate expiring/surviving so back-to-front swap-pop must shuffle survivors repeatedly. + vm.startPrank(committee); + strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME)); // id1 expire + uint256 id2 = strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME * 2)); // keep + strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME)); // id3 expire + uint256 id4 = strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME * 2)); // keep + strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME)); // id5 expire + uint256 id6 = strikes.issueStrike(_input(NO_ID, CATEGORY, LIFETIME * 2)); // keep + vm.stopPrank(); + + vm.warp(block.timestamp + LIFETIME); + vm.prank(stranger); + strikes.removeExpiredStrikes(NO_ID); + + assertEq(strikes.getActiveStrikesCount(NO_ID), 3); + // Every survivor still resolves by its id (index stayed consistent through the shuffles). + assertEq(strikes.getStrike(NO_ID, id2).id, id2); + assertEq(strikes.getStrike(NO_ID, id4).id, id4); + assertEq(strikes.getStrike(NO_ID, id6).id, id6); + _expectNoStrike(NO_ID, 1); + _expectNoStrike(NO_ID, 3); + _expectNoStrike(NO_ID, 5); + } + + function test_removeExpiredStrikes_RemovesOnlyExpired() public { + (uint256 id1, uint256 id2, uint256 id3, uint256 id4) = _issueMixed(); + + vm.warp(block.timestamp + LIFETIME); // id1, id3 expired; id2, id4 still active + uint256 refreshesBefore = metaRegistryMock.notifyWeightBoostChangedCallCount(); + + vm.prank(stranger); // permissionless + strikes.removeExpiredStrikes(NO_ID); + + assertEq(strikes.getActiveStrikesCount(NO_ID), 2); + _expectNoStrike(NO_ID, id1); + _expectNoStrike(NO_ID, id3); + assertEq(strikes.getStrike(NO_ID, id2).id, id2); + assertEq(strikes.getStrike(NO_ID, id4).id, id4); + assertEq(metaRegistryMock.notifyWeightBoostChangedCallCount(), refreshesBefore + 1); // refreshed once + } + + function test_removeExpiredStrikes_SurvivorStaysRemovable() public { + (, uint256 id2, , uint256 id4) = _issueMixed(); + + vm.warp(block.timestamp + LIFETIME); + vm.prank(stranger); + strikes.removeExpiredStrikes(NO_ID); + + // A survivor stays removable by the committee (its slot was swapped during cleanup). + vm.prank(committee); + strikes.removeStrike(NO_ID, id2); + assertEq(strikes.getActiveStrikesCount(NO_ID), 1); + assertEq(strikes.getStrike(NO_ID, id4).id, id4); + } + + function test_removeExpiredStrikes_AllExpired() public { + _issue(NO_ID); + _issue(NO_ID); + _issue(NO_ID); + + vm.warp(block.timestamp + LIFETIME); + vm.prank(stranger); + strikes.removeExpiredStrikes(NO_ID); + + assertEq(strikes.getActiveStrikesCount(NO_ID), 0); + } + + function test_removeExpiredStrikes_NoopWhenNoneExpired() public { + _issue(NO_ID); + _issue(NO_ID); + uint256 refreshesBefore = metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(); + + vm.prank(stranger); + strikes.removeExpiredStrikes(NO_ID); + + assertEq(strikes.getActiveStrikesCount(NO_ID), 2); + assertEq(metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(), refreshesBefore); // no refresh + } +} + +contract NodeOperatorStrikesThresholdsTest is NodeOperatorStrikesBaseTest { + function test_setStrikeThresholds_RoundTrip() public { + StrikeThreshold[] memory thresholds = _exampleThresholds(); + + vm.expectEmit(false, false, false, true, address(strikes)); + emit INodeOperatorStrikes.StrikeThresholdsSet(thresholds); + + vm.prank(admin); + strikes.setStrikeThresholds(thresholds); + + StrikeThreshold[] memory stored = strikes.getStrikeThresholds(); + assertEq(stored.length, 4); + assertEq(stored[0].minCount, 2); + assertEq(stored[0].reductionBP, 2_500); + assertEq(stored[3].minCount, 5); + assertEq(stored[3].reductionBP, 10_000); + + // The config change is pushed to MetaRegistry so cached weights get refreshed. + assertEq(metaRegistryMock.notifyWeightBoostProviderConfigChangedCallCount(), 1); + } + + function test_setStrikeThresholds_Replaces() public { + _setExampleThresholds(); + + StrikeThreshold[] memory next = new StrikeThreshold[](1); + next[0] = StrikeThreshold({ minCount: 1, reductionBP: 1_000 }); + vm.prank(admin); + strikes.setStrikeThresholds(next); + + StrikeThreshold[] memory stored = strikes.getStrikeThresholds(); + assertEq(stored.length, 1); + assertEq(stored[0].minCount, 1); + assertEq(stored[0].reductionBP, 1_000); + } + + function test_setStrikeThresholds_RevertWhen_NotAdmin() public { + bytes32 adminRole = strikes.DEFAULT_ADMIN_ROLE(); + expectRoleRevert(stranger, adminRole); + vm.prank(stranger); + strikes.setStrikeThresholds(_exampleThresholds()); + } + + function test_setStrikeThresholds_RevertWhen_Empty() public { + StrikeThreshold[] memory thresholds = new StrikeThreshold[](0); + vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + vm.prank(admin); + strikes.setStrikeThresholds(thresholds); + } + + function test_setStrikeThresholds_RevertWhen_FirstMinCountZero() public { + StrikeThreshold[] memory thresholds = new StrikeThreshold[](1); + thresholds[0] = StrikeThreshold({ minCount: 0, reductionBP: 1_000 }); + vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + vm.prank(admin); + strikes.setStrikeThresholds(thresholds); + } + + function test_setStrikeThresholds_RevertWhen_MinCountNotAscending() public { + StrikeThreshold[] memory thresholds = new StrikeThreshold[](2); + thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: 2_500 }); + thresholds[1] = StrikeThreshold({ minCount: 2, reductionBP: 5_000 }); + vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + vm.prank(admin); + strikes.setStrikeThresholds(thresholds); + } + + function test_setStrikeThresholds_RevertWhen_ReductionDecreasing() public { + StrikeThreshold[] memory thresholds = new StrikeThreshold[](2); + thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: 5_000 }); + thresholds[1] = StrikeThreshold({ minCount: 3, reductionBP: 2_500 }); + vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + vm.prank(admin); + strikes.setStrikeThresholds(thresholds); + } + + function test_setStrikeThresholds_RevertWhen_ReductionEqual() public { + StrikeThreshold[] memory thresholds = new StrikeThreshold[](2); + thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: 2_500 }); + thresholds[1] = StrikeThreshold({ minCount: 3, reductionBP: 2_500 }); // equal -> redundant band + vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + vm.prank(admin); + strikes.setStrikeThresholds(thresholds); + } + + function test_setStrikeThresholds_RevertWhen_ReductionAboveMaxBp() public { + StrikeThreshold[] memory thresholds = new StrikeThreshold[](1); + thresholds[0] = StrikeThreshold({ minCount: 2, reductionBP: MAX_BP + 1 }); + vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + vm.prank(admin); + strikes.setStrikeThresholds(thresholds); + } + + function test_setStrikeThresholds_RevertWhen_TooMany() public { + uint256 n = strikes.MAX_THRESHOLDS() + 1; + StrikeThreshold[] memory thresholds = new StrikeThreshold[](n); + for (uint256 i; i < n; ++i) { + thresholds[i] = StrikeThreshold({ minCount: i + 1, reductionBP: 0 }); + } + vm.expectRevert(INodeOperatorStrikes.InvalidStrikeThresholds.selector); + vm.prank(admin); + strikes.setStrikeThresholds(thresholds); + } +} + +contract NodeOperatorStrikesWeightMultiplierTest is NodeOperatorStrikesBaseTest { + function test_getWeightBoostMultiplierBP_StepFunction() public { + // 0 strikes -> full weight. + assertEq(strikes.getWeightBoostMultiplierBP(NO_ID), MAX_BP); + + _issue(NO_ID); // 1 -> full weight + assertEq(strikes.getWeightBoostMultiplierBP(NO_ID), MAX_BP); + + _issue(NO_ID); // 2 -> 75% + assertEq(strikes.getWeightBoostMultiplierBP(NO_ID), 7_500); + + _issue(NO_ID); // 3 -> 50% + assertEq(strikes.getWeightBoostMultiplierBP(NO_ID), 5_000); + + _issue(NO_ID); // 4 -> 25% + assertEq(strikes.getWeightBoostMultiplierBP(NO_ID), 2_500); + + _issue(NO_ID); // 5 -> 0% + assertEq(strikes.getWeightBoostMultiplierBP(NO_ID), 0); + + _issue(NO_ID); // 6 -> still 0% (clamped to last band) + assertEq(strikes.getWeightBoostMultiplierBP(NO_ID), 0); + } + + function test_getWeightBoostMultiplierBP_IncreasesAfterRemoval() public { + uint256 id1 = _issue(NO_ID); + _issue(NO_ID); + _issue(NO_ID); // 3 active -> 50% + assertEq(strikes.getWeightBoostMultiplierBP(NO_ID), 5_000); + + vm.prank(committee); + strikes.removeStrike(NO_ID, id1); // 2 active -> 75% + assertEq(strikes.getWeightBoostMultiplierBP(NO_ID), 7_500); + } + + function test_getStrikes_ExcludesRemoved() public { + uint256 id1 = _issue(NO_ID); + uint256 id2 = _issue(NO_ID); + uint256 id3 = _issue(NO_ID); + + // Remove the middle strike to check removed slots are skipped, not zero-padded. + vm.prank(committee); + strikes.removeStrike(NO_ID, id2); + + Strike[] memory active = strikes.getStrikes(NO_ID); + assertEq(active.length, 2); + assertEq(active[0].id, id1); + assertEq(active[1].id, id3); + } + + function test_getStrikes() public { + uint256 t = block.timestamp; + bytes32 catA = keccak256("late-attestations"); + bytes32 catB = keccak256("missed-proposal"); + + vm.startPrank(committee); + uint256 idA = strikes.issueStrike( + StrikeInput({ nodeOperatorId: NO_ID, category: catA, lifetime: LIFETIME, description: "first" }) + ); + uint256 idB = strikes.issueStrike( + StrikeInput({ nodeOperatorId: NO_ID, category: catB, lifetime: LIFETIME * 2, description: "second" }) + ); + vm.stopPrank(); + + Strike[] memory active = strikes.getStrikes(NO_ID); + assertEq(active.length, 2); + + // Each record carries its own distinct fields. + assertEq(active[0].id, idA); + assertEq(active[0].category, catA); + assertEq(uint256(active[0].expiry), t + LIFETIME); + assertEq(active[0].description, "first"); + + assertEq(active[1].id, idB); + assertEq(active[1].category, catB); + assertEq(uint256(active[1].expiry), t + LIFETIME * 2); + assertEq(active[1].description, "second"); + } +}