# Security Audit Report - 58 findings
**Generated**: 2026-02-08 14:29:09 UTC
**Model**: gpt-5.3-codex
## Summary
| Severity | Count |
|----------|-------|
| Critical | 4 |
| High | 6 |
| Medium | 23 |
| Low | 21 |
| Informational | 4 |
| Gas | 0 |
| **Total** | **58** |
## Findings
### UPGRADE-01: Unprotected v3 reinitializer enables upgrade-front-run admin takeover / init lock-out
**Severity**: CRITICAL
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- src/CSModule.sol:63-93
```solidity
function initialize(
address admin,
uint8 topUpQueueLimit
) external reinitializer(INITIALIZED_VERSION) {
__BaseModule_init(admin);
_initTopUpQueue(topUpQueueLimit);
}
function finalizeUpgradeV3() external reinitializer(INITIALIZED_VERSION) {
assembly ("memory-safe") {
sstore(__freeSlot1.slot, 0x00)
sstore(__freeSlot2.slot, 0x00)
}
uint256 totalWithdrawnValidators;
unchecked {
for (uint256 i; i < _nodeOperatorsCount; ++i) {
totalWithdrawnValidators += _nodeOperators[i].totalWithdrawnKeys;
}
}
_totalWithdrawnValidators = totalWithdrawnValidators;
}
```
**Description**:
`CSModule.initialize` and `CSModule.finalizeUpgradeV3` are both externally callable `reinitializer(3)` functions with no access control. If a proxy is upgraded to the v3 implementation without an atomic finalize call, an attacker can front-run by calling `initialize(attacker, ...)`, gaining `DEFAULT_ADMIN_ROLE` (and thus the ability to grant themselves privileged roles like `STAKING_ROUTER_ROLE`) and mutate module state. On a freshly deployed but not-yet-initialized proxy, an attacker can call `finalizeUpgradeV3()` first to set the initialized version to 3, permanently preventing `initialize` from ever being executed (bricking the module).
**Recommendation**:
Restrict `finalizeUpgradeV3()` to an already-initialized admin (e.g., `onlyRole(DEFAULT_ADMIN_ROLE)`), and prevent `initialize()` from being callable on upgraded instances (e.g., require no existing `DEFAULT_ADMIN_ROLE` members). Operationally, perform upgrades via an atomic `upgradeToAndCall`/`proxy__upgradeToAndCall` that finalizes in the same transaction to remove the front-run window.
---
### INIT-01: CuratedModule proxy can be admin-taken over by front-running initialize() during deployment
**Severity**: CRITICAL
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- script/curated/DeployBase.s.sol:777-785
```solidity
function _deployProxy(
address admin,
address implementation
) internal returns (address) {
OssifiableProxy proxy = new OssifiableProxy({
implementation_: implementation,
data_: new bytes(0),
admin_: admin
});
return address(proxy);
}
```
**Description**:
CuratedModule.initialize() is publicly callable and grants DEFAULT_ADMIN_ROLE to the provided admin. The curated deployment script deploys/upgrades the CuratedModule proxy with empty setup calldata and only later calls curatedModule.initialize(...), allowing an external actor to front-run that initialize call and permanently seize module admin/roles (and then grant themselves other privileged roles and unpause/control module behavior).
**Recommendation**:
Deploy/upgrade with atomic initialization by passing `data_ = abi.encodeCall(ICuratedModule.initialize,(admin))` to the proxy constructor or using `proxy__upgradeToAndCall` with the initialize calldata; avoid separate public-mempool initialize transactions.
---
### VERSION-01: FeeOracle initializer can be front-run to seize oracle admin and set arbitrary consensus contract/version
**Severity**: CRITICAL
**Confidence**: HIGH
**Category**: proxy
**Locations**:
- src/FeeOracle.sol:60-75
```solidity
function initialize(
address admin,
address consensusContract,
uint256 consensusVersion
) external {
if (admin == address(0)) {
revert ZeroAdminAddress();
}
_grantRole(DEFAULT_ADMIN_ROLE, admin);
BaseOracle._initialize(consensusContract, consensusVersion, 0);
_updateContractVersion(2);
_updateContractVersion(INITIALIZED_VERSION);
}
```
**Description**:
`FeeOracle.initialize` is permissionless and assigns `DEFAULT_ADMIN_ROLE` to an attacker-chosen `admin` on the first call. Because deployment/upgrade flows in this repo perform `proxy__upgradeTo(...)` and only then call `oracle.initialize(...)` in a later transaction, a mempool attacker can initialize the freshly-upgraded proxy first, set a malicious `consensusContract`, and subsequently submit arbitrary oracle report data through the normal `submitReportData` path.
**Recommendation**:
Make initialization atomic with the upgrade by using `OssifiableProxy.proxy__upgradeToAndCall` and/or restrict `initialize` so only the proxy admin (ERC1967 admin slot) can call it during setup.
---
### VERIF-01: Unrestricted `processIncomingConsolidation` lets anyone corrupt key added-balance once verifier has `VERIFIER_ROLE`
**Severity**: CRITICAL
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- src/Verifier.sol:449-460
```solidity
function processIncomingConsolidation(
uint256 nodeOperatorId,
uint256 keyIndex,
uint256 addedBalanceWei
) external whenResumed {
MODULE.increaseKeyAddedBalance(
nodeOperatorId,
keyIndex,
addedBalanceWei
);
// TODO implement
}
```
**Description**:
`Verifier.processIncomingConsolidation` is callable by anyone and forwards arbitrary `(nodeOperatorId, keyIndex, addedBalanceWei)` to `MODULE.increaseKeyAddedBalance`. Since the verifier is expected to hold `VERIFIER_ROLE` on the module (needed for slashing/consolidation flows), any user can inflate `_keyAddedBalances` (capped but still large), which can then drive incorrect penalty computation during withdrawals and force operator deactivation/DoS via uncompensated penalties.
**Recommendation**:
Remove/disable `processIncomingConsolidation` until it is fully implemented with proof verification, or gate it with an explicit role (and ensure only the trusted consolidation reporter can call it).
---
### ADDBAL-01: Anyone can arbitrarily increase per-key added balance via Verifier stub
> **Possible duplicate of**: Unrestricted `processIncomingConsolidation` lets anyone corrupt key added-balance once verifier has `VERIFIER_ROLE`
**Severity**: HIGH
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- src/Verifier.sol:449-460
```solidity
function processIncomingConsolidation(
uint256 nodeOperatorId,
uint256 keyIndex,
uint256 addedBalanceWei
) external whenResumed {
MODULE.increaseKeyAddedBalance(
nodeOperatorId,
keyIndex,
addedBalanceWei
);
// TODO implement
}
```
**Description**:
`Verifier.processIncomingConsolidation` is publicly callable and unconditionally calls `MODULE.increaseKeyAddedBalance` with attacker-supplied `addedBalanceWei`. Because the module trusts the Verifier address via `VERIFIER_ROLE`, any external caller can inflate `keyAddedBalance` (up to the cap) for any non-withdrawn deposited key, which can later force large penalties on withdrawal processing and/or zero-out remaining top-up capacity for the key.
**Recommendation**:
Remove/disable this stub in production, or gate it (e.g., `onlyRole(...)`) until proof verification is implemented; when implemented, require and verify consolidation proofs before calling `increaseKeyAddedBalance`.
---
### ADMIN-01: Deep rewind can make top-up queue (and new deposits) practically unrecoverable due to append-only storage
**Severity**: HIGH
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/CSModule.sol:351-359
```solidity
// TODO: Ensure that after deep rewind we will be able to iterate over the queue without allocating anything and SR will not revert in this case. Add integration test for it
/// @inheritdoc ICSModule
function rewindTopUpQueue(uint256 to) external {
_checkRole(REWIND_TOP_UP_QUEUE_ROLE);
_onlyEnabledTopUpQueue();
_topUpQueue().rewind(to.toUint32());
emit TopUpQueueRewound(to);
_incrementModuleNonce();
}
```
**Description**:
`rewindTopUpQueue` can move the top-up queue `head` arbitrarily far backwards. Because `TopUpQueueLib.Queue.items` is append-only, a deep rewind can inflate `length = items.length - head` to include all historically-processed items; since `limit` is `uint8` (<=255), `capacity()` becomes 0 until the queue is advanced again. This blocks new deposits (CSModule enqueues every newly-deposited key into the top-up queue) and there is no admin fast-forward to recover; advancing an enormous rewound queue may be infeasible in practice (matching the in-code TODO about deep rewinds and StakingRouter behavior).
**Recommendation**:
Add a safety bound in `rewindTopUpQueue` so the post-rewind queue length cannot exceed a small, processable window (e.g., require `items.length - to <= limit` or a dedicated max-rewind parameter), or add an admin fast-forward/prune mechanism; also add the StakingRouter integration test mentioned in the TODO to ensure zero-allocation iterations can advance the queue.
---
### TOPUP-01: CuratedModule top-up flow can exceed per-key cumulative cap, causing keyAddedBalance under-accounting and under-penalization on withdrawal
**Severity**: HIGH
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/CuratedModule.sol:141-176
```solidity
function allocateDeposits(
uint256 maxDepositAmount,
bytes[] calldata pubkeys,
uint256[] calldata keyIndices,
uint256[] calldata operatorIds,
uint256[] calldata topUpLimits
) external returns (uint256[] memory allocations) {
...
// NOTE: StakingRouter is expected to provide per-key top-up limits capped
// by MAX_EFFECTIVE_BALANCE and avoid duplicate (operatorId, keyIndex)
// entries in a single request.
_validateTopUpPublicKeys(pubkeys, keyIndices, operatorIds);
allocations = _allocateTopUps(
maxDepositAmount,
operatorIds,
keyIndices,
topUpLimits
);
_incrementModuleNonce();
}
```
**Description**:
CuratedModule.allocateDeposits uses `topUpLimits` directly (only quantized) and does not cap them by the remaining per-key `keyAddedBalance` headroom. A validator that was previously topped up to the per-key cap can be topped up again after CL penalties reduce its balance; the additional deposits will not be reflected in `_keyAddedBalances` (it saturates), so WithdrawnValidatorLib will compute `minExpectedBalance` from a smaller tracked amount and can undercharge the operator for subsequent losses on withdrawal.
**Recommendation**:
Before distributing allocations, cap `topUpLimits` by remaining per-key added-balance headroom (same approach as `CSModule` via `NodeOperatorOps.capTopUpLimitsByKeyBalance`). Also consider enforcing uniqueness of `(operatorId,keyIndex)` within the request to prevent bypassing per-key limits via duplicates.
---
### WITHDRAW-01: Permissionless keyAddedBalance inflation via Verifier.processIncomingConsolidation
> **Possible duplicate of**: Unrestricted `processIncomingConsolidation` lets anyone corrupt key added-balance once verifier has `VERIFIER_ROLE`
**Severity**: HIGH
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- src/Verifier.sol:448-460
```solidity
function processIncomingConsolidation(
uint256 nodeOperatorId,
uint256 keyIndex,
uint256 addedBalanceWei
) external whenResumed {
MODULE.increaseKeyAddedBalance(
nodeOperatorId,
keyIndex,
addedBalanceWei
);
// TODO implement
}
```
**Description**:
`Verifier.processIncomingConsolidation` is callable by anyone while unpaused and directly calls `MODULE.increaseKeyAddedBalance(...)` without any proof or authorization. An attacker can arbitrarily raise `keyAddedBalance` (up to the module cap) for any deposited, non-withdrawn key, which will later increase the module’s expected exit balance and can force unfair bond penalties and/or node operator deactivation during withdrawal reporting.
**Recommendation**:
Until proper incoming-consolidation proof verification is implemented, make `processIncomingConsolidation` non-callable (e.g., revert unconditionally) or restrict it with a dedicated role/allowlist so arbitrary callers cannot mutate `keyAddedBalance`.
---
### DEPLOY-01: ARTIFACTS_DIR Ignored For Main Deploy Read + No Artifact ChainId Validation (Oracle Miswiring Risk)
**Severity**: HIGH
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/DeployTwoPhaseFrameConfigUpdate.s.sol:44-73
```solidity
artifactDir = vm.envOr(
"ARTIFACTS_DIR",
string(
abi.encodePacked(
"./artifacts/",
chainName,
"/utils/TwoPhaseFrameConfigUpdate/"
)
)
);
string memory mainDeployPath = string(
abi.encodePacked(
"./artifacts/",
chainName,
"/deploy-",
chainName,
".json"
)
);
...
string memory mainDeployJson = vm.readFile(mainDeployPath);
address oracle = vm.parseJsonAddress(mainDeployJson, ".FeeOracle");
if (oracle == address(0)) {
revert InvalidOracleAddress();
}
```
**Description**:
`DeployTwoPhaseFrameConfigUpdateBase.run` reads the FeeOracle from a hardcoded `./artifacts/<chain>/deploy-<chain>.json`, while `ARTIFACTS_DIR` only affects where this script writes its own output. If `ARTIFACTS_DIR` is used to point to the intended artifact set (common in deploy pipelines) but `./artifacts/...` exists and is stale/wrong, the helper can be deployed wired to an unintended on-chain oracle address without detecting the mismatch; additionally, the script does not validate the artifact’s `.ChainId` against `block.chainid`/`expectedChainId` before trusting `.FeeOracle`.
**Recommendation**:
Honor `ARTIFACTS_DIR` (or a dedicated env var) for locating the main deployment artifact too, and validate the artifact’s `.ChainId` equals `block.chainid`/`expectedChainId` before parsing `.FeeOracle` (optionally also validate a `.git-ref`/version field if present).
---
### VERSION-02: finalizeUpgradeV3 is permissionless, allowing griefing/DoS via arbitrary consensusVersion and forced version bump
**Severity**: HIGH
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- src/FeeOracle.sol:77-82
```solidity
function finalizeUpgradeV3(uint256 consensusVersion) external {
_setConsensusVersion(consensusVersion);
_updateContractVersion(INITIALIZED_VERSION);
}
```
**Description**:
`finalizeUpgradeV3` has no access control. After a proxy is upgraded to the v3 implementation while still at contract version 2, any account can call `finalizeUpgradeV3` first, set an arbitrary `consensusVersion`, and bump the stored contract version to 3; this can break report submissions (consensus-version mismatch) and prevent the intended upgrader from finalizing with the correct parameters.
**Recommendation**:
Add access control to `finalizeUpgradeV3` (e.g., `onlyRole(DEFAULT_ADMIN_ROLE)` or a dedicated upgrade role) and execute it atomically with the proxy upgrade via `proxy__upgradeToAndCall`.
---
### VIEW-01: Top-up allocation can become non-callable due to repeated external stake recomputation per operator
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/lib/allocator/CuratedDepositAllocator.sol:346-372
```solidity
IMetaRegistry metaRegistry = ICuratedModule(address(this))
.META_REGISTRY();
// Build global share baseline across all eligible operators (non-zero weight + capacity).
for (uint256 i; i < operatorsCount; ++i) {
uint256 nodeOperatorBalance = nodeOperatorBalances[i];
uint256 capacity = _topUpCapacity(
nodeOperators[i],
nodeOperatorBalance
);
capacitiesByOperatorId[i] = capacity;
if (capacity == 0) continue;
(uint256 weight, uint256 externalStake) = metaRegistry
.getNodeOperatorWeightAndExternalStake(i);
weightsByOperatorId[i] = weight;
if (weight == 0) continue;
uint256 currentStake = nodeOperatorBalance + externalStake;
currentStakeByOperatorId[i] = currentStake;
weightSum += weight;
totalCurrent += currentStake;
}
```
**Description**:
`getDepositsAllocation()` calls `CuratedDepositAllocator.allocateTopUps()`, which builds a global baseline by looping over all operators and calling `META_REGISTRY.getNodeOperatorWeightAndExternalStake(i)` for each. When an operator is in a group with external operators, `MetaRegistry.getNodeOperatorWeightAndExternalStake` recomputes total external stake by iterating the group’s `externalOperators` and performing external calls, so allocation gas can scale roughly with `operatorsCount * externalOperatorsPerGroup`, risking OOG/reverts and making both preview tooling and on-chain top-up allocation unreliable at large configurations.
**Recommendation**:
Avoid recomputing external stake per operator: memoize total external stake per `groupId` for the duration of an allocation call (or cache per-group totals in `MetaRegistry` and update on group changes), and/or add a batched MetaRegistry getter that returns weights and external stake without repeated per-operator external-operator iteration.
---
### SUMMARY-01: Depositable count can over-report bonding-limited capacity after negative stETH rebase
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/CSModule.sol:95-117
```solidity
/// @dev The method does not update depositable keys count for the Node Operators before the queue processing start.
/// Hence, in the rare cases of negative stETH rebase the method might return unbonded keys. This is a trade-off
/// between the gas cost and the correctness of the data. Due to module design, any unbonded keys will be requested
/// to exit by VEBO.
function obtainDepositData(
uint256 depositsCount,
bytes calldata /* depositCalldata */
) external returns (bytes memory publicKeys, bytes memory signatures) {
_checkStakingRouterRole();
...
bool topUpQueueEnabled = _topUpQueueEnabled();
```
**Description**:
`getStakingModuleSummary()` reports cached `_depositableValidatorsCount` (only updated on explicit NO updates), while `obtainDepositData()` does not refresh per-operator depositable counts before consuming the queue. After a negative stETH rebase reduces bond value (shares->ETH), the module can still report/serve keys as depositable even though they are no longer fully bonded (as explicitly noted in-code).
**Recommendation**:
Refresh depositable counts from `Accounting.getUnbondedKeysCount` for the operators being consumed inside `obtainDepositData` (or otherwise enforce a bonding-aware cap at deposit time), so deposits cannot proceed for newly-unbonded keys after a rebase.
---
### ARTIFACT-02: ARTIFACTS_DIR affects output but not input artifact root; output omits key provenance fields
> **Possible duplicate of**: ARTIFACTS_DIR Ignored For Main Deploy Read + No Artifact ChainId Validation (Oracle Miswiring Risk)
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/DeployTwoPhaseFrameConfigUpdate.s.sol:44-68
```solidity
artifactDir = vm.envOr(
"ARTIFACTS_DIR",
string(
abi.encodePacked(
"./artifacts/",
chainName,
"/utils/TwoPhaseFrameConfigUpdate/"
)
)
);
string memory mainDeployPath = string(
abi.encodePacked(
"./artifacts/",
chainName,
"/deploy-",
chainName,
".json"
)
);
if (!vm.exists(mainDeployPath)) {
revert MainDeploymentNotFound(mainDeployPath);
}
string memory mainDeployJson = vm.readFile(mainDeployPath);
```
**Description**:
Even when `ARTIFACTS_DIR` is overridden, the script still reads the FeeOracle from a hard-coded `./artifacts/<chain>/deploy-<chain>.json`. This can cause mixed-root runs (deploying from one artifact set while writing to another), and the saved JSON does not include the resolved `FeeOracle` or `chainid`, making such mismatches harder to detect and increasing the risk of follow-up ops targeting the wrong deployment context.
**Recommendation**:
Derive `mainDeployPath` from the same configured artifacts root (or introduce a separate input-root env var), and persist at least `chainid` and the resolved `FeeOracle` (and optionally `mainDeployPath`) into the output JSON for integrity/provenance.
---
### EXIT-01: Exited-count report lacks existence/bounds/monotonicity checks (can corrupt global vs per-operator exited accounting)
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/lib/NodeOperatorOps.sol:120-161
```solidity
function updateExitedValidatorsCount(
mapping(uint256 => NodeOperator) storage nodeOperators,
uint64 totalExitedValidators,
bytes calldata nodeOperatorIds,
bytes calldata exitedValidatorsCounts
) external returns (uint64) {
uint256 operatorsInReport = ValidatorCountsReport.safeCountOperators(
nodeOperatorIds,
exitedValidatorsCounts
);
for (uint256 i = 0; i < operatorsInReport; ++i) {
(uint256 nodeOperatorId, uint256 exitedValidatorsCount) =
ValidatorCountsReport.next(nodeOperatorIds, exitedValidatorsCounts, i);
NodeOperator storage no = nodeOperators[nodeOperatorId];
uint32 totalExitedKeys = no.totalExitedKeys;
unchecked {
totalExitedValidators =
(totalExitedValidators - totalExitedKeys) +
uint64(exitedValidatorsCount);
}
no.totalExitedKeys = uint32(exitedValidatorsCount);
emit IBaseModule.ExitedSigningKeysCountChanged(nodeOperatorId, exitedValidatorsCount);
}
return totalExitedValidators;
}
```
**Description**:
`NodeOperatorOps.updateExitedValidatorsCount` blindly applies reported exited counts to any `nodeOperatorId` (even if the operator does not exist) and does not enforce basic invariants such as `newExited <= totalDepositedKeys` and `newExited >= oldExited`. It also stores the per-operator value as `uint32` without validating the report value fits, allowing silent truncation and breaking consistency between `_totalExitedValidators` and per-operator `totalExitedKeys`.
**Recommendation**:
Validate `nodeOperatorId` refers to an existing operator (e.g., `no.managerAddress != address(0)`), enforce `exitedValidatorsCount` fits `uint32`, require `exitedValidatorsCount <= no.totalDepositedKeys`, and (for this “safe” updater) require `exitedValidatorsCount >= no.totalExitedKeys` (use a separate unsafe path for decreases).
---
### WEIGHT-01: MetaRegistry weight-update always reports “changed”, enabling permissionless nonce-spam in CuratedModule
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/MetaRegistry.sol:444-459
```solidity
function _refreshOperatorWeight(
uint256 groupId,
uint256 noId
) internal returns (bool changed) {
MarkedUint248 memory shareData = _groupIndex.operatorShareById[noId];
uint256 newWeight = _getLatestEffectiveWeight(noId, shareData.value);
uint256 oldWeight = _setEffectiveWeight(noId, newWeight);
_weightCache.groupEffectiveWeightSum[groupId] =
_weightCache.groupEffectiveWeightSum[groupId] +
newWeight -
oldWeight;
return true;
}
```
**Description**:
`MetaRegistry.onNodeOperatorWeightUpdated()` ultimately calls `_refreshOperatorWeight()` which currently returns `true` unconditionally, even when the effective weight is unchanged. In `CuratedModule._applyDepositableValidatorsCount`, this makes `weightChanged` effectively always true for in-group operators, so any caller can repeatedly call `updateDepositableValidatorsCount(nodeOperatorId)` when `depositableValidatorsCount` is unchanged and still force `_incrementModuleNonce()`, violating the nonce anti-DoS invariant in `IStakingModule` and enabling griefing via continuous `NonceChanged` bumps.
**Recommendation**:
Return `changed = (oldWeight != newWeight)` from `_refreshOperatorWeight` (and propagate it through `onNodeOperatorWeightUpdated`) so CuratedModule only increments nonce on real weight changes; consider additionally rate-limiting or access-controlling nonce-affecting public paths if needed.
---
### WEIGHT-02: Operator group updates can silently invalidate CuratedModule depositability-weight gating until manually refreshed
> **Possible duplicate of**: MetaRegistry weight-update always reports “changed”, enabling permissionless nonce-spam in CuratedModule
**Severity**: MEDIUM
**Confidence**: MEDIUM
**Category**: logic-errors
**Locations**:
- src/MetaRegistry.sol:172-193
```solidity
function createOrUpdateOperatorGroup(
uint256 groupId,
OperatorGroup calldata groupInfo
) external onlyRole(MANAGE_OPERATOR_GROUPS_ROLE) {
if (groupId == CREATE_GROUP_SENTINEL) {
uint256 newGroupId = _groups.length;
_groups.push();
_storeGroup(newGroupId.toUint248(), groupInfo, false);
emit OperatorGroupCreated(newGroupId);
return;
}
if (groupId >= _groups.length) {
revert InvalidOperatorGroupId();
}
_clearGroupMembership(groupId);
_storeGroup(groupId.toUint248(), groupInfo, true);
emit OperatorGroupUpdated(groupId);
}
```
**Description**:
`MetaRegistry.createOrUpdateOperatorGroup` can change operators’ effective weights (including to zero via removal/zero-share), but it does not trigger `CuratedModule.requestFullOperatorWeightsUpdate()` nor any forced recomputation of per-operator `depositableValidatorsCount`. Until someone calls `updateDepositableValidatorsCount` for affected operators, CuratedDepositAllocator will treat zero-weight operators as ineligible while the module may still report them as depositable, and in the extreme case can cause `allocateInitialDeposits` to revert `NotEnoughKeys()` when all depositable capacity sits behind zero weights.
**Recommendation**:
On group create/update, explicitly notify CuratedModule to enter a weights-update cycle (e.g., call `MODULE.requestFullOperatorWeightsUpdate()`), and ensure affected operators’ depositable counts are recomputed (batch refresh) before deposits resume.
---
### MODE-01: Slashed Validator Can Be Finalized Via Regular-Withdrawal Path (Bypassing Slashed-Withdrawal Gating)
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- src/abstract/BaseModule.sol:817-837
```solidity
function _reportWithdrawnValidators(
WithdrawnValidatorInfo[] calldata validatorInfos,
bool slashed
) internal {
...
uint256 pointer = _keyPointer(info.nodeOperatorId, info.keyIndex);
if (_isValidatorWithdrawn[pointer]) {
continue;
}
if (info.isSlashed != slashed) {
revert InvalidWithdrawnValidatorInfo();
}
if (info.isSlashed && !_isValidatorSlashed[pointer]) {
revert SlashingPenaltyIsNotApplicable();
}
...
}
```
**Description**:
`_reportWithdrawnValidators` does not forbid reporting a key via the regular-withdrawal entrypoint when `_isValidatorSlashed[pointer]` is already true. A caller with `REPORT_REGULAR_WITHDRAWN_VALIDATORS_ROLE` can submit `isSlashed=false`, which marks `_isValidatorWithdrawn[pointer]=true` and causes subsequent slashed-withdrawn reporting (and slashing penalty charging) to be skipped due to the early `continue`.
**Recommendation**:
In `_reportWithdrawnValidators`, also reject the regular mode when `_isValidatorSlashed[pointer]` is true (e.g., `if (!slashed && _isValidatorSlashed[pointer]) revert InvalidWithdrawnValidatorInfo();`), or enforce `info.isSlashed == _isValidatorSlashed[pointer]` before marking the key withdrawn.
---
### PENAL-01: Fee Charged Before Penalty Can Incorrectly Trigger Uncovered-Penalty Deactivation
**Severity**: MEDIUM
**Confidence**: MEDIUM
**Category**: logic-errors
**Locations**:
- src/lib/WithdrawnValidatorLib.sol:122-136
```solidity
IAccounting accounting = IBaseModule(address(this)).ACCOUNTING();
if (feeSum > 0) {
accounting.chargeFee(validatorInfo.nodeOperatorId, feeSum);
}
penaltyCovered = true;
if (penaltySum > 0) {
// We still call `penalize` even if there's no bond left, for the lock to be created.
penaltyCovered = accounting.penalize(
validatorInfo.nodeOperatorId,
penaltySum
);
}
```
**Description**:
`WithdrawnValidatorLib._fulfillExitObligations` calls `Accounting.chargeFee` before `Accounting.penalize`, and `BaseModule._reportWithdrawnValidators` uses the returned `penaltyCovered` to decide whether to deactivate the operator. Since `chargeFee` reduces bond first (and does not record unpaid remainder as debt), an operator with bond sufficient to cover `penaltySum` but not `penaltySum + feeSum` can be deactivated even though the penalty itself would have been fully coverable if applied first.
**Recommendation**:
Apply `penalize` before `chargeFee`, or compute/return `penaltyCovered` based on penalty coverage independent of fee confiscation (so unpaid charges cannot cause uncovered-penalty deactivation).
---
### KEYCNT-01: removeKeys can re-increase totalVettedKeys after StakingRouter unvetting
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/abstract/BaseModule.sol:405-441
```solidity
function removeKeys(
uint256 nodeOperatorId,
uint256 startIndex,
uint256 keysCount
) external virtual {
_onlyNodeOperatorManager(nodeOperatorId, msg.sender);
NodeOperator storage no = _nodeOperators[nodeOperatorId];
if (startIndex < no.totalDepositedKeys) {
revert SigningKeysInvalidOffset();
}
uint256 newTotalSigningKeys = SigningKeys.removeKeysSigs({
nodeOperatorId: nodeOperatorId,
startIndex: startIndex,
keysCount: keysCount,
totalKeysCount: no.totalAddedKeys
});
no.totalAddedKeys = uint32(newTotalSigningKeys);
emit TotalSigningKeysCountChanged(nodeOperatorId, newTotalSigningKeys);
no.totalVettedKeys = uint32(newTotalSigningKeys);
emit VettedSigningKeysCountChanged(nodeOperatorId, newTotalSigningKeys);
_updateDepositableValidatorsCount({
nodeOperatorId: nodeOperatorId,
incrementNonceIfUpdated: false
});
_incrementModuleNonce();
}
```
**Description**:
After StakingRouter decreases `totalVettedKeys` via `decreaseVettedSigningKeysCount`, a node operator manager can call `removeKeys` and (as long as `newTotalSigningKeys > oldTotalVettedKeys`) it unconditionally sets `totalVettedKeys = newTotalSigningKeys`, increasing the vetted pointer. This effectively “re-vets” previously-unvetted keys and can raise `depositableValidatorsCount` on `_updateDepositableValidatorsCount`, bypassing SR-driven depositability gating until SR decreases again.
**Recommendation**:
Do not allow `removeKeys` to increase `totalVettedKeys`; clamp it (e.g., `min(oldTotalVettedKeys, newTotalSigningKeys)`) and, if a reset-to-added behavior is required, gate it behind an explicit StakingRouter/DAO-only method.
---
### QUEUE-01: Stale Deactivated-Operator Batches Can Force O(N) Queue Draining in obtainDepositData (Deposit DoS Risk)
**Severity**: MEDIUM
**Confidence**: MEDIUM
**Category**: dos
**Locations**:
- src/CSModule.sol:473-493
```solidity
function _applyDepositableValidatorsCount(
NodeOperator storage no,
uint256 nodeOperatorId,
uint256 newCount,
bool incrementNonceIfUpdated
) internal override returns (bool changed) {
changed = super._applyDepositableValidatorsCount(
no,
nodeOperatorId,
newCount,
incrementNonceIfUpdated
);
DepositQueueOps.enqueueNodeOperatorKeys({
nodeOperators: _nodeOperators,
depositQueues: _depositQueueByPriority,
parametersRegistry: _parametersRegistry(),
accounting: _accounting(),
queueLowestPriority: _queueLowestPriority(),
nodeOperatorId: nodeOperatorId
});
}
```
**Description**:
When `settleGeneralDelayedPenalty` deactivates an operator (forces target limit to 0) and `_updateDepositableValidatorsCount` drives `depositableValidatorsCount` down, `CSModule._applyDepositableValidatorsCount` only calls `DepositQueueOps.enqueueNodeOperatorKeys` (which is a no-op on decreases) and does not proactively prune already-enqueued batches. Those stale batches are only removed lazily during `obtainDepositData`/`cleanDepositQueue`; if a deactivated operator has accumulated many small batches near the queue head, `obtainDepositData` must dequeue them with `keysCount==0` (no progress on `depositsLeft`), potentially running out of gas and reverting, delaying deposits for other operators.
**Recommendation**:
Prevent pathological batch growth and/or stale-batch buildup: merge enqueues into the last batch for the same `(queuePriority, nodeOperatorId)` when possible, and consider a targeted pruning path when `depositableValidatorsCount` decreases (especially to 0) so deactivation cannot leave an unbounded number of zero-depositable batches to be drained during `obtainDepositData`.
---
### EXITCNT-01: Exited counts report lacks bounds/existence checks, allowing permanent corruption via truncation
> **Possible duplicate of**: Exited-count report lacks existence/bounds/monotonicity checks (can corrupt global vs per-operator exited accounting)
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/lib/NodeOperatorOps.sol:120-160
```solidity
function updateExitedValidatorsCount(
mapping(uint256 => NodeOperator) storage nodeOperators,
uint64 totalExitedValidators,
bytes calldata nodeOperatorIds,
bytes calldata exitedValidatorsCounts
) external returns (uint64) {
uint256 operatorsInReport = ValidatorCountsReport.safeCountOperators(
nodeOperatorIds,
exitedValidatorsCounts
);
for (uint256 i = 0; i < operatorsInReport; ++i) {
(uint256 nodeOperatorId, uint256 exitedValidatorsCount) = ValidatorCountsReport.next(
nodeOperatorIds,
exitedValidatorsCounts,
i
);
NodeOperator storage no = nodeOperators[nodeOperatorId];
uint32 totalExitedKeys = no.totalExitedKeys;
unchecked {
totalExitedValidators = (totalExitedValidators - totalExitedKeys) + uint64(exitedValidatorsCount);
}
no.totalExitedKeys = uint32(exitedValidatorsCount);
emit IBaseModule.ExitedSigningKeysCountChanged(nodeOperatorId, exitedValidatorsCount);
}
return totalExitedValidators;
}
```
**Description**:
`BaseModule.updateExitedValidatorsCount` (StakingRouter-gated) forwards a packed report to `NodeOperatorOps.updateExitedValidatorsCount`, which does not validate that the node operator exists nor that `exitedValidatorsCount` fits `uint32` / is `<= totalDepositedKeys` / is monotonic. A single malformed report (e.g., `exitedValidatorsCount > 2**32-1`) will be truncated when stored (`uint32`) but still affects `_totalExitedValidators` via `uint64(exitedValidatorsCount)`, breaking the stated sum invariant and potentially causing persistent exited-count misreporting / downstream DoS in StakingRouter integrations.
**Recommendation**:
In `NodeOperatorOps.updateExitedValidatorsCount`, add `NodeOperatorDoesNotExist()` check and validate `exitedValidatorsCount <= no.totalDepositedKeys` (and ideally `exitedValidatorsCount >= no.totalExitedKeys` to enforce monotonicity); then aggregate using the validated `uint32` value (e.g., `uint64(uint32(exitedValidatorsCount))`).
---
### WC-01: WC change hook can succeed while unused keys remain, leading to later deposit DoS
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/abstract/BaseModule.sol:149-158
```solidity
function onWithdrawalCredentialsChanged() external {
_checkStakingRouterRole();
if (_depositableValidatorsCount > 0) {
revert DepositableKeysWithUnsupportedWithdrawalCredentials();
}
}
```
**Description**:
`BaseModule.onWithdrawalCredentialsChanged` only reverts when `_depositableValidatorsCount > 0`. But `_depositableValidatorsCount` can be driven to 0 by bonding/limit logic (e.g., `unbondedKeys >= nonDeposited` or target-limit capping) even when the module still holds non-deposited vetted pubkey+signature pairs; this lets StakingRouter change withdrawal credentials without discarding them. If those keys later become depositable again, the module will expose them for deposits with signatures produced for the previous withdrawal credentials, causing deposits to revert (blocking deposits until keys are removed/re-added or otherwise invalidated).
**Recommendation**:
Either (a) block WC changes if any non-deposited vetted keys exist (not just currently-depositable ones), or (b) actively discard/disable all unused keys on WC change (e.g., reset vetted pointers/enqueued counts and clear queues, updating global counters accordingly).
---
### ALLOC-01: MetaRegistry group updates can desync CuratedModule depositable counters from weight eligibility (potential obtainDepositData DoS)
> **Possible duplicate of**: MetaRegistry weight-update always reports “changed”, enabling permissionless nonce-spam in CuratedModule
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/MetaRegistry.sol:172-193
```solidity
function createOrUpdateOperatorGroup(
uint256 groupId,
OperatorGroup calldata groupInfo
) external onlyRole(MANAGE_OPERATOR_GROUPS_ROLE) {
if (groupId == CREATE_GROUP_SENTINEL) {
uint256 newGroupId = _groups.length;
_groups.push();
_storeGroup(newGroupId.toUint248(), groupInfo, false);
emit OperatorGroupCreated(newGroupId);
return;
}
if (groupId >= _groups.length) {
revert InvalidOperatorGroupId();
}
_clearGroupMembership(groupId);
_storeGroup(groupId.toUint248(), groupInfo, true);
emit OperatorGroupUpdated(groupId);
}
```
**Description**:
MetaRegistry.createOrUpdateOperatorGroup changes node operator group membership (and thus weight eligibility), but does not trigger any CuratedModule depositable-count refresh/reset. As a result, CuratedModule may keep non-zero per-operator and module-level depositable counters for operators whose weight became 0 (or keep 0 for newly-eligible operators), causing StakingRouter to see depositable keys while CuratedDepositAllocator filters them out and can revert (NotEnoughKeys) when no weight-eligible operators remain.
**Recommendation**:
After group membership changes, proactively sync CuratedModule state: at minimum call `MODULE.updateDepositableValidatorsCount(noId)` for all affected sub-operators (removed and added); alternatively call `MODULE.requestFullOperatorWeightsUpdate()` and require a batch sync before allowing deposits again.
---
### CACHE-02: `getOperatorsWeights` can return stale non-zero weights for operators removed from all groups
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/MetaRegistry.sol:302-313
```solidity
function getOperatorsWeights(
uint256[] calldata nodeOperatorIds
) external view returns (uint256[] memory operatorWeights) {
uint256 count = nodeOperatorIds.length;
operatorWeights = new uint256[](count);
for (uint256 i; i < count; ++i) {
operatorWeights[i] = _weightCache.operatorEffectiveWeight[
nodeOperatorIds[i]
];
}
}
```
**Description**:
`MetaRegistry.getOperatorsWeights` returns the cached `operatorEffectiveWeight` for any requested operator ID without checking whether the operator is currently in a group. Since group membership removal clears only the membership/share indexes (and there is no corresponding delete/reset of `operatorEffectiveWeight` for removed operators), an operator removed from all groups can keep a stale non-zero weight indefinitely, affecting downstream operator-weight–based workflows consuming `CuratedModule.getOperatorsWeights`.
**Recommendation**:
Ensure weights are zeroed when an operator is removed from group membership (and keep group sums consistent), or have `getOperatorsWeights` return 0 for operators with no group membership (e.g., consult `_groupIndex.groupIdByOperatorId`).
---
### HOOK-01: Permissionless bond-curve hook allows continuous nonce bumps (weightChanged always true for grouped operators)
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/CuratedModule.sol:250-375
```solidity
function onNodeOperatorBondCurveUpdated(uint256 nodeOperatorId) external override(IBaseModule) {
_updateDepositableValidatorsCount({
nodeOperatorId: nodeOperatorId,
incrementNonceIfUpdated: true
});
}
function _applyDepositableValidatorsCount(
NodeOperator storage no,
uint256 nodeOperatorId,
uint256 newCount,
bool incrementNonceIfUpdated
) internal override returns (bool) {
bool weightChanged = META_REGISTRY.onNodeOperatorWeightUpdated(nodeOperatorId);
...
bool depositableChanged = super._applyDepositableValidatorsCount(...);
if (!depositableChanged && weightChanged && incrementNonceIfUpdated) {
_incrementModuleNonce();
}
}
```
**Description**:
CuratedModule.onNodeOperatorBondCurveUpdated is externally callable by anyone and triggers depositability recomputation with `incrementNonceIfUpdated=true`. In CuratedModule._applyDepositableValidatorsCount, the module increments its nonce when `weightChanged` is true even if depositable count is unchanged; however MetaRegistry.onNodeOperatorWeightUpdated returns `true` for any operator in a group because `_refreshOperatorWeight` returns `true` unconditionally. As a result, an unprivileged caller can repeatedly call onNodeOperatorBondCurveUpdated for any grouped operator to spam `NonceChanged` and force downstream consumers to continuously resync (explicitly disallowed by the IStakingModule nonce DoS guidance).
**Recommendation**:
Make MetaRegistry.onNodeOperatorWeightUpdated return `true` only when the effective weight actually changes (old != new), and optionally restrict CuratedModule.onNodeOperatorBondCurveUpdated to `msg.sender == address(ACCOUNTING)` to match the expected caller relationship and reduce griefing surface.
---
### INTEG-01: Unvalidated TWG resolution can silently mis-route exit fees and no-op exit requests
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: external-calls
**Locations**:
- src/Ejector.sol:257-266
```solidity
function triggerableWithdrawalsGateway()
public
view
returns (ITriggerableWithdrawalsGateway)
{
return
ITriggerableWithdrawalsGateway(
MODULE.LIDO_LOCATOR().triggerableWithdrawalsGateway()
);
}
```
**Description**:
`Ejector.triggerableWithdrawalsGateway()` blindly casts `MODULE.LIDO_LOCATOR().triggerableWithdrawalsGateway()` to `ITriggerableWithdrawalsGateway`, and all ejection paths forward `msg.value` to `triggerFullWithdrawals(...)`. If the locator ever returns `address(0)` or a non-TWG address that accepts the call (EOA/contract with permissive fallback), the transaction can succeed and emit ejection events while no exit request is actually submitted and any attached ETH is irrecoverably sent to the wrong address.
**Recommendation**:
Validate the resolved gateway address before use (at minimum `!= address(0)` and `code.length > 0`) and revert with a dedicated error if invalid; consider an additional invariant check (eg, allowlisted gateway or role/probe via `staticcall`) if mis-routing must be impossible even under locator misconfiguration.
---
### INIT-02: `finalizeUpgradeV3` is permissionless and allows one-shot consensusVersion sabotage during upgrade window
> **Possible duplicate of**: finalizeUpgradeV3 is permissionless, allowing griefing/DoS via arbitrary consensusVersion and forced version bump
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: proxy
**Locations**:
- src/FeeOracle.sol:77-82
```solidity
function finalizeUpgradeV3(uint256 consensusVersion) external {
_setConsensusVersion(consensusVersion);
_updateContractVersion(INITIALIZED_VERSION);
}
```
**Description**:
After upgrading a v2 proxy to the v3 implementation (while `getContractVersion()==2`), any account can call `finalizeUpgradeV3(consensusVersion)` and choose an arbitrary `consensusVersion`, potentially stalling valid reporting until governance intervenes, and consuming the version bump so the intended finalization call can no longer run.
**Recommendation**:
Restrict `finalizeUpgradeV3` to an admin role (e.g., `DEFAULT_ADMIN_ROLE` / `MANAGE_CONSENSUS_VERSION_ROLE`) and/or require it to be executed via `upgradeToAndCall` in the same transaction as the implementation upgrade.
---
### BOUNDS-01: CurveId Bounds Check Underflows When No Curves Exist
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/lib/BondCurves.sol:34-48
```solidity
function updateBondCurve(
BondCurve.BondCurveStorage storage bondCurvesStorage,
uint256 curveId,
IBondCurve.BondCurveIntervalInput[] calldata intervals
) external {
unchecked {
if (curveId > bondCurvesStorage.bondCurves.length - 1) {
revert IBondCurve.InvalidBondCurveId();
}
}
_check(intervals);
delete bondCurvesStorage.bondCurves[curveId];
_addIntervals(bondCurvesStorage.bondCurves[curveId], intervals);
}
```
**Description**:
`BondCurves.updateBondCurve` performs `bondCurves.length - 1` inside an `unchecked` block. If the curves array is empty, the subtraction underflows to `type(uint256).max`, so the bounds check is bypassed and the function proceeds until it hits an out-of-bounds array access (`delete bondCurves[curveId]`) and reverts with a panic instead of `InvalidBondCurveId` (and can break any flow that expects clean curve-id validation in an empty/just-migrated state).
**Recommendation**:
Replace the check with `uint256 len = bondCurvesStorage.bondCurves.length; if (curveId >= len) revert InvalidBondCurveId();` (no subtraction), which is safe even when `len == 0`.
---
### ADDCM-01: DEFAULT_ADMIN_ROLE is granted to `agent` on Burner/TWG and never revoked
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- script/fork-helpers/SimulateVote.s.sol:92-147
```solidity
function addCuratedModule() external {
initializeFromDeployment();
if (moduleType != ModuleType.Curated) {
revert WrongModuleType();
}
IStakingRouter stakingRouter = IStakingRouter(locator.stakingRouter());
IBurner burner = IBurner(locator.burner());
ITriggerableWithdrawalsGateway twg = ITriggerableWithdrawalsGateway(
locator.triggerableWithdrawalsGateway()
);
address agent = stakingRouter.getRoleMember(
stakingRouter.STAKING_MODULE_MANAGE_ROLE(),
0
);
address curatedAdmin = _prepareAdmin(address(curatedModule));
address burnerAdmin = _prepareAdmin(address(burner));
address twgAdmin = _prepareAdmin(address(twg));
vm.startBroadcast(burnerAdmin);
burner.grantRole(burner.DEFAULT_ADMIN_ROLE(), agent);
vm.stopBroadcast();
vm.startBroadcast(twgAdmin);
twg.grantRole(twg.DEFAULT_ADMIN_ROLE(), agent);
vm.stopBroadcast();
vm.startBroadcast(agent);
...
}
```
**Description**:
`addCuratedModule` elevates `agent` by granting `DEFAULT_ADMIN_ROLE` on `burner` and `twg`, but does not revoke it after performing the required grants. If `agent` is not already the intended long-term admin for these external components, this script introduces a persistent privilege expansion (beyond what’s needed to add+resume the module) that can later be used to grant sensitive roles (e.g., TWG withdrawal-request permissions).
**Recommendation**:
Avoid granting `DEFAULT_ADMIN_ROLE` to `agent` at all (have `burnerAdmin`/`twgAdmin` grant only the needed roles directly), or explicitly revoke `DEFAULT_ADMIN_ROLE` from `agent` after completing the required grants.
---
### ADDCSM-01: addModule picks roleMember(0) as "agent" then permanently grants it Burner/TWG DEFAULT_ADMIN_ROLE
> **Possible duplicate of**: DEFAULT_ADMIN_ROLE is granted to `agent` on Burner/TWG and never revoked
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- script/fork-helpers/SimulateVote.s.sol:36-60
```solidity
IStakingRouter stakingRouter = IStakingRouter(locator.stakingRouter());
IBurner burner = IBurner(locator.burner());
ITriggerableWithdrawalsGateway twg = ITriggerableWithdrawalsGateway(
locator.triggerableWithdrawalsGateway()
);
address agent = stakingRouter.getRoleMember(
stakingRouter.STAKING_MODULE_MANAGE_ROLE(),
0
);
...
vm.startBroadcast(burnerAdmin);
burner.grantRole(burner.DEFAULT_ADMIN_ROLE(), agent);
vm.stopBroadcast();
vm.startBroadcast(twgAdmin);
twg.grantRole(twg.DEFAULT_ADMIN_ROLE(), agent);
vm.stopBroadcast();
```
**Description**:
`SimulateVote.addModule` assumes `getRoleMember(role, 0)` returns the intended governance agent/admin and then grants that address `DEFAULT_ADMIN_ROLE` on `Burner` and `TriggerableWithdrawalsGateway` without any validation or later revocation. If the role member ordering differs (multiple members, past revocations swapping order), the script can end up granting long-lived admin power to an unintended address, enabling arbitrary future role reconfiguration on Burner/TWG.
**Recommendation**:
Avoid deriving privileged actors via `getRoleMember(..., 0)`; use an explicit expected governance address and assert it has expected roles. If elevation is intended to be temporary, revoke `DEFAULT_ADMIN_ROLE` from `agent` after completing the required grants, or have the existing admins grant the minimal required roles directly.
---
### ALLOC_INIT-01: External Stake Mis-Normalized in Initial Deposit Allocation (2048 ETH vs 32 ETH)
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/lib/allocator/CuratedDepositAllocator.sol:258-263
```solidity
uint256 current = no.totalDepositedKeys - no.totalWithdrawnKeys;
if (externalStake > 0) {
current +=
externalStake /
WithdrawnValidatorLib.MAX_EFFECTIVE_BALANCE;
}
```
**Description**:
`allocateInitialDeposits` incorporates `externalStake` into `current` by dividing by `WithdrawnValidatorLib.MAX_EFFECTIVE_BALANCE` (2048 ETH) even though `MetaRegistry` measures external stake as `32 ether` per active external validator. This undercounts external stake by 64x (and often rounds it to zero), causing operators/groups with substantial external stake to appear under-allocated and receive more initial deposits than intended by the weight+external-stake model.
**Recommendation**:
Normalize `externalStake` into the same units as `current` for initial deposits (e.g., divide by `MIN_ACTIVATION_BALANCE`/32 ether), or alternatively switch `current`/`totalCurrent`/`allocationAmount` to consistent wei-denominated stake units for this path.
---
### CONFIG-01: Unvalidated config addresses can bypass legacy-queue safety check and mis-wire deployed implementations
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/csm/DeployCSMImplementationsHoodi.s.sol:28-63
```solidity
function deploy(
string memory deploymentConfigPath,
string memory _gitRef
) external {
gitRef = _gitRef;
string memory deploymentConfigContent = vm.readFile(
deploymentConfigPath
);
DeploymentConfig memory deploymentConfig = parseDeploymentConfig(
deploymentConfigContent
);
csm = CSModule(deploymentConfig.csm);
earlyAdoption = deploymentConfig.earlyAdoption;
accounting = Accounting(deploymentConfig.accounting);
oracle = FeeOracle(deploymentConfig.oracle);
feeDistributor = FeeDistributor(deploymentConfig.feeDistributor);
permissionlessGate = PermissionlessGate(
deploymentConfig.permissionlessGate
);
parametersRegistry = ParametersRegistry(
deploymentConfig.parametersRegistry
);
vettedGateFactory = VettedGateFactory(
deploymentConfig.vettedGateFactory
);
vettedGate = VettedGate(deploymentConfig.vettedGate);
exitPenalties = ExitPenalties(deploymentConfig.exitPenalties);
ejector = Ejector(payable(deploymentConfig.ejector));
strikes = ValidatorStrikes(deploymentConfig.strikes);
hashConsensus = HashConsensus(deploymentConfig.hashConsensus);
verifier = Verifier(deploymentConfig.verifier);
gateSeal = deploymentConfig.gateSeal;
_deploy();
}
```
**Description**:
`DeployCSMImplementationsHoodi.deploy` trusts the JSON config blindly (no `chainId` match, no nonzero/bytecode/proxy sanity checks) before populating `csm/accounting/oracle/...` and calling `_deploy()`. If `deploymentConfig.csm` is accidentally wrong (EOA/unrelated contract), `_deploy()`’s `_ensureLegacyQueueDrained()` reads slot `1` from the wrong address via `vm.load` and can incorrectly conclude the legacy queue is empty, allowing an upgrade to proceed under false preconditions; the same mis-binding also feeds wrong dependency addresses into new implementation constructors, risking permanent privilege/invariant breakage in the resulting wiring.
**Recommendation**:
Before `_deploy()`, assert `deploymentConfig.chainId == block.chainid`, require each bound address is nonzero and has code, and (at least for `csm`/other critical proxies) add a lightweight identity check (e.g., expected proxy pattern via EIP-1967 slots or an expected view/function call) so `_ensureLegacyQueueDrained()` cannot be evaluated against the wrong target.
---
### EJECT-01: ValidatorStrikes.setEjector Allows Miswiring To Ejector Not Bound To This Strikes/Module
**Severity**: MEDIUM
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- src/ValidatorStrikes.sol:81-224
```solidity
function setEjector(
address _ejector
) external onlyRole(DEFAULT_ADMIN_ROLE) {
_setEjector(_ejector);
}
...
function _setEjector(address _ejector) internal {
if (_ejector == address(0)) {
revert ZeroEjectorAddress();
}
ejector = IEjector(_ejector);
emit EjectorSet(_ejector);
}
```
**Description**:
`ValidatorStrikes.setEjector` only checks for non-zero and does not verify that the target ejector is wired back to this `ValidatorStrikes` (e.g., `IEjector(_ejector).STRIKES() == address(this)` and `IEjector(_ejector).MODULE() == MODULE`). If an admin/operator sets an ejector address that is configured for a different strikes/module (or a contract that doesn’t enforce `onlyStrikes`), bad-performer ejection can become triggerable outside the intended proof/threshold path or checked against the wrong module’s state.
**Recommendation**:
In `ValidatorStrikes._setEjector`, require that the ejector’s `STRIKES()` equals `address(this)` and its `MODULE()` equals `MODULE` (and optionally sanity-check `EXIT_PENALTIES.STRIKES() == address(this)` / `EXIT_PENALTIES.MODULE() == MODULE` during initialization/deployment).
---
### ACCESS-01: proposeRewardAddress broadcasts from manager instead of current reward address
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/fork-helpers/NodeOperators.s.sol:92-124
```solidity
modifier broadcastReward(uint256 noId) {
_setUp();
address nodeOperator = module.getNodeOperator(noId).managerAddress;
_setBalance(nodeOperator);
vm.startBroadcast(nodeOperator);
_;
vm.stopBroadcast();
}
function proposeRewardAddress(
uint256 noId,
address rewardAddress
) external broadcastReward(noId) {
module.proposeNodeOperatorRewardAddressChange(noId, rewardAddress);
}
```
**Description**:
`NodeOperators.proposeRewardAddress` uses `broadcastReward(noId)`, but that modifier starts broadcasting from `module.getNodeOperator(noId).managerAddress`. The module requires `msg.sender` to be the current `rewardAddress` for `proposeNodeOperatorRewardAddressChange`, so this wrapper will revert for node operators where manager != reward, preventing the intended two-step reward handover flow via this script.
**Recommendation**:
In `broadcastReward`, broadcast from `module.getNodeOperator(noId).rewardAddress` (or change `proposeRewardAddress` to use a dedicated `broadcastRewardAddress` modifier) so the wrapper matches the module’s authorization expectations.
---
### VIEWS-01: Per-node-operator getter allows out-of-range reads (returns zero struct)
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/abstract/BaseModule.sol:636-640
```solidity
function getNodeOperator(
uint256 nodeOperatorId
) external view returns (NodeOperator memory) {
return _nodeOperators[nodeOperatorId];
}
```
**Description**:
`BaseModule.getNodeOperator(uint256)` returns `_nodeOperators[nodeOperatorId]` without validating `nodeOperatorId < _nodeOperatorsCount`, so callers can read non-existent operators and receive a zeroed struct. This violates the stated invariant of disallowing out-of-range reads and can lead on-chain integrators to treat non-existent operators as valid unless they add their own bounds checks.
**Recommendation**:
Add an existence/bounds check (e.g., `if (nodeOperatorId >= _nodeOperatorsCount) revert NodeOperatorDoesNotExist();`) to prevent out-of-range reads, or document this behavior explicitly and provide a separate checked getter for integrators.
---
### ARTIFACT-01: ARTIFACTS_DIR override can produce unintended output path (missing separator)
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/DeployTwoPhaseFrameConfigUpdate.s.sol:119-123
```solidity
function _deployJsonFilename() internal view returns (string memory) {
return
string(
abi.encodePacked(artifactDir, "deploy-", chainName, ".json")
);
}
```
**Description**:
`_deployJsonFilename()` concatenates `artifactDir` and `"deploy-..."` without inserting a path separator. If `ARTIFACTS_DIR` is set without a trailing slash (e.g., `./out`), the artifact is written to `./outdeploy-<chain>.json` instead of inside the intended directory, increasing the chance operators later act on stale/mismatched artifacts at the expected location.
**Recommendation**:
Normalize `artifactDir` to always end with `/` (or always join with an inserted `/`) and consider reverting if `ARTIFACTS_DIR` is provided in an unexpected form.
---
### MATH-01: Scaled exit penalties round down to whole-ETH steps (systematic undercharge vs exact balance ratio)
**Severity**: LOW
**Confidence**: HIGH
**Category**: arithmetic
**Locations**:
- src/lib/WithdrawnValidatorLib.sol:139-154
```solidity
/// @dev Acts as the numerator to calculate the scaled penalty.
function _getPenaltyMultiplier(
WithdrawnValidatorInfo memory validatorInfo
) internal pure returns (uint256 penaltyMultiplier) {
uint256 exitBalance = validatorInfo.exitBalance;
exitBalance = Math.max(MIN_ACTIVATION_BALANCE, exitBalance);
exitBalance = Math.min(MAX_EFFECTIVE_BALANCE, exitBalance);
penaltyMultiplier = exitBalance / PENALTY_QUOTIENT;
}
function _scalePenaltyByMultiplier(
uint256 penalty,
uint256 multiplier
) internal pure returns (uint256) {
return (penalty * multiplier) / PENALTY_SCALE;
}
```
**Description**:
`_getPenaltyMultiplier` computes `multiplier = clamp(exitBalance, [32e18..2048e18]) / 1 ether`, which floors any sub-1 ETH component of `exitBalance` before scaling `delayFee`/`strikesPenalty`. This makes scaled amounts systematically smaller than proportional-to-wei scaling (by up to ~`basePenalty/32` per scaled component), and is triggered whenever `exitBalance` is not an exact integer ETH amount (common for withdrawals/consolidations).
**Recommendation**:
Compute scaling directly with full precision, e.g. `clamped = min(max(exitBalance, 32 ether), 2048 ether); scaled = Math.mulDiv(penalty, clamped, MIN_ACTIVATION_BALANCE);` (or equivalent), to avoid whole-ETH quantization and reduce rounding bias without overflow risk.
---
### API-01: CuratedModule permissionless recompute can spam module nonce without depositable change
**Severity**: LOW
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/CuratedModule.sol:347-375
```solidity
function _applyDepositableValidatorsCount(
NodeOperator storage no,
uint256 nodeOperatorId,
uint256 newCount,
bool incrementNonceIfUpdated
) internal override returns (bool) {
bool weightChanged = META_REGISTRY.onNodeOperatorWeightUpdated(
nodeOperatorId
);
if (newCount > 0) {
(uint256 weight, ) = META_REGISTRY
.getNodeOperatorWeightAndExternalStake(nodeOperatorId);
if (weight == 0) {
newCount = 0;
}
}
bool depositableChanged = super._applyDepositableValidatorsCount(
no,
nodeOperatorId,
newCount,
incrementNonceIfUpdated
);
if (!depositableChanged && weightChanged && incrementNonceIfUpdated) {
_incrementModuleNonce();
}
}
```
**Description**:
In `CuratedModule`, a permissionless call to `BaseModule.updateDepositableValidatorsCount(nodeOperatorId)` can still increment the module nonce even when `depositableValidatorsCount` remains unchanged. This happens because `_applyDepositableValidatorsCount` increments nonce when `weightChanged` is true, but `META_REGISTRY.onNodeOperatorWeightUpdated(nodeOperatorId)` returns true for any operator that is in a group (even if the effective weight did not actually change), enabling arbitrary nonce churn by any caller.
**Recommendation**:
Make `MetaRegistry.onNodeOperatorWeightUpdated` (and/or its internal refresh helper) return `false` when the effective weight is unchanged, and only bump the module nonce on real changes (depositable count change or weight change).
---
### EXDLQ-01: Exit Delay Threshold and Applicability Can Diverge if ParametersRegistry Addresses Differ
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/abstract/BaseModule.sol:775-800
```solidity
function isValidatorExitDelayPenaltyApplicable(
uint256 nodeOperatorId,
uint256,
/* proofSlotTimestamp */
bytes calldata publicKey,
uint256 eligibleToExitInSec
) external view returns (bool) {
_onlyExistingNodeOperator(nodeOperatorId);
return
_exitPenalties().isValidatorExitDelayPenaltyApplicable(
nodeOperatorId,
publicKey,
eligibleToExitInSec
);
}
function exitDeadlineThreshold(
uint256 nodeOperatorId
) external view returns (uint256) {
_onlyExistingNodeOperator(nodeOperatorId);
return
_parametersRegistry().getAllowedExitDelay(
_getBondCurveId(nodeOperatorId)
);
}
```
**Description**:
`BaseModule.exitDeadlineThreshold()` reads `getAllowedExitDelay(curveId)` from `BaseModule.PARAMETERS_REGISTRY`, while `BaseModule.isValidatorExitDelayPenaltyApplicable()` delegates to `ExitPenalties.isValidatorExitDelayPenaltyApplicable()`, which reads `getAllowedExitDelay(curveId)` from `ExitPenalties.PARAMETERS_REGISTRY`. Since these registries are independent constructor/immutable inputs (and not asserted equal), a mis-deploy/upgrade can make StakingRouter observe inconsistent thresholds vs applicability, potentially causing missed or unintended exit-delay penalties.
**Recommendation**:
Enforce a single source of truth: either (a) assert `EXIT_PENALTIES.PARAMETERS_REGISTRY() == PARAMETERS_REGISTRY` (and similarly for any other shared dependencies) during deployment/initialization, or (b) make `exitDeadlineThreshold()` read from the same registry used by `ExitPenalties` (or have `ExitPenalties` derive its registry from the module).
---
### ORACLE-01: exitRequest Can Encode/Submit Exit For a Different Operator/Validator Than Caller Intends
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/fork-helpers/NodeOperators.s.sol:309-357
```solidity
function exitRequest(
uint256 noId,
uint256 validatorIndex,
bytes calldata validatorPubKey
) external {
...
bytes5 nodeOpId = bytes5(uint40(noId));
bytes8 _validatorIndex = bytes8(uint64(validatorIndex));
...
data = abi.encodePacked(
moduleId,
nodeOpId,
_validatorIndex,
validatorPubKey
);
IVEBO.ReportData memory report = IVEBO.ReportData({
...
dataFormat: 1,
data: data
});
...
vebo.submitConsensusReport(keccak256(abi.encode(report)), reportRefSlot, ...);
...
vebo.submitReportData(report, vebo.getContractVersion());
}
```
**Description**:
`NodeOperators.exitRequest` packs `noId` and `validatorIndex` into fixed-width fields (`uint40`/`uint64`) without range checks and accepts an arbitrary `validatorPubKey` without verifying it matches the module’s stored key at `validatorIndex` for `noId`. In fork simulations, a mistaken (or truncated) input combination will produce a syntactically valid VEBO report payload that targets a different `(nodeOpId, validatorIndex, pubkey)` than the caller believes they are submitting.
**Recommendation**:
Derive the pubkey from on-chain module state (e.g., `module.getSigningKeys(noId, validatorIndex, 1)`) and either drop the `validatorPubKey` argument or `require` it equals the stored key; also add explicit bounds checks before casting to `uint40`/`uint64` to avoid silent truncation.
---
### ORACLE-02: Module-Id Discovery Skips ids[0], Causing exitRequest to Revert on Some Routers
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/fork-helpers/NodeOperators.s.sol:375-385
```solidity
function _getModuleId() internal view returns (uint256) {
uint256[] memory ids = stakingRouter.getStakingModuleIds();
for (uint256 i = ids.length - 1; i > 0; i--) {
IStakingRouter.StakingModule memory moduleInfo = stakingRouter
.getStakingModule(ids[i]);
if (moduleInfo.stakingModuleAddress == address(module)) {
return ids[i];
}
}
revert NodeOperatorsModuleNotFound();
}
```
**Description**:
`_getModuleId` iterates `ids.length-1` down to `1` and never checks `ids[0]`; if the target module is stored at index 0 (including the common `ids.length == 1` case), `exitRequest` reverts with `NodeOperatorsModuleNotFound` and cannot construct the report using the correct module id.
**Recommendation**:
Iterate all entries safely (including index 0) and handle `ids.length == 0` explicitly, e.g. `for (uint256 i = ids.length; i > 0; --i) { uint256 id = ids[i-1]; ... }`.
---
### BOND-01: NodeOperators.addKeys passes wrong `from` address under Foundry broadcast semantics
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/fork-helpers/NodeOperators.s.sol:138-151
```solidity
function addKeys(
uint256 noId,
uint256 keysCount
) external broadcastManager(noId) {
uint256 amount = accounting.getRequiredBondForNextKeys(noId, keysCount);
bytes memory keys = randomBytes(48 * keysCount);
bytes memory signatures = randomBytes(96 * keysCount);
module.addValidatorKeysETH{ value: amount }(
msg.sender,
noId,
keysCount,
keys,
signatures
);
}
```
**Description**:
`NodeOperators.addKeys` calls `BaseModule.addValidatorKeysETH` with `from = msg.sender`, but `vm.startBroadcast(manager)` does not change `msg.sender` inside the script context; it only affects downstream external calls. As a result, the module sees `from != msg.sender` and takes the “gate” branch in `_checkCanAddKeys`, which reverts unless the script runner address happens to equal the node operator manager.
**Recommendation**:
Pass the broadcasted manager address as `from` (e.g., `address manager = module.getNodeOperator(noId).managerAddress;` then call `addValidatorKeysETH(manager, ...)`) so `from` matches the module-level `msg.sender` for direct manager key additions.
---
### PENALTY-01: Penalty Compensation Fork Helper Broadcasts From Wrong Caller (Always Reverts)
**Severity**: LOW
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- script/fork-helpers/NodeOperators.s.sol:298-307
```solidity
function compensateGeneralDelayedPenalty(
uint256 noId,
uint256 amount
) external broadcastStranger {
uint256 lockedBefore = accounting.getActualLockedBond(noId);
module.compensateGeneralDelayedPenalty{ value: amount }(noId);
assertEq(accounting.getActualLockedBond(noId), lockedBefore - amount);
}
```
**Description**:
`NodeOperators.compensateGeneralDelayedPenalty` broadcasts as a random `stranger`, but `BaseModule.compensateGeneralDelayedPenalty` is strictly `onlyNodeOperatorManager`. As a result, the helper cannot actually compensate locked bond for a NO, which can lead operators to be treated as uncompensated (and thus deactivated on settlement) when using these wrappers.
**Recommendation**:
Change the modifier to `broadcastManager(noId)` (or otherwise broadcast as `module.getNodeOperator(noId).managerAddress`) so the wrapper can successfully call `module.compensateGeneralDelayedPenalty`.
---
### PARAM-01: Bond Curve Update Does Not Rebuild Existing Deposit Queue Placement (Priority/MaxDeposits Staleness)
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/CSModule.sol:397-493
```solidity
function onNodeOperatorBondCurveUpdated(
uint256 nodeOperatorId
) external override(IBaseModule) {
_updateDepositableValidatorsCount({
nodeOperatorId: nodeOperatorId,
incrementNonceIfUpdated: true
});
}
function _applyDepositableValidatorsCount(
NodeOperator storage no,
uint256 nodeOperatorId,
uint256 newCount,
bool incrementNonceIfUpdated
) internal override returns (bool changed) {
changed = super._applyDepositableValidatorsCount(
no,
nodeOperatorId,
newCount,
incrementNonceIfUpdated
);
DepositQueueOps.enqueueNodeOperatorKeys({
nodeOperators: _nodeOperators,
depositQueues: _depositQueueByPriority,
parametersRegistry: _parametersRegistry(),
accounting: _accounting(),
queueLowestPriority: _queueLowestPriority(),
nodeOperatorId: nodeOperatorId
});
}
```
**Description**:
`Accounting.setBondCurve` calls `CSModule.onNodeOperatorBondCurveUpdated`, which recomputes `depositableValidatorsCount` but does not migrate or prune already-enqueued batches across priority queues according to the new curve’s `ParametersRegistry.getQueueConfig` (priority/maxDeposits). As a result, pre-existing batches remain in their old queue and can still be consumed by `obtainDepositData`, effectively applying the old curve’s queue routing/priority limits until those batches are drained.
**Recommendation**:
On bond-curve change, explicitly rebuild the operator’s queued state: remove that operator’s outstanding batches from all deposit queues (adjusting `enqueuedCount`), then re-enqueue depositable keys under the current `getQueueConfig` (priority/maxDeposits). Alternatively, enforce the new queue config at consumption time by detecting “wrong-queue” batches and relocating/dropping them safely.
---
### COUNT-01: Permissionless Nonce Griefing Via Always-True `weightChanged` Signal
> **Possible duplicate of**: CuratedModule permissionless recompute can spam module nonce without depositable change
**Severity**: LOW
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/CuratedModule.sol:347-375
```solidity
function _applyDepositableValidatorsCount(
NodeOperator storage no,
uint256 nodeOperatorId,
uint256 newCount,
bool incrementNonceIfUpdated
) internal override returns (bool) {
bool weightChanged = META_REGISTRY.onNodeOperatorWeightUpdated(
nodeOperatorId
);
...
bool depositableChanged = super._applyDepositableValidatorsCount(
no,
nodeOperatorId,
newCount,
incrementNonceIfUpdated
);
if (!depositableChanged && weightChanged && incrementNonceIfUpdated) {
_incrementModuleNonce();
}
}
```
**Description**:
`BaseModule.updateDepositableValidatorsCount()` is externally callable by anyone and reaches `CuratedModule._applyDepositableValidatorsCount`, which increments the module nonce when `weightChanged` is true even if `depositableValidatorsCount` did not change. In `MetaRegistry`, `onNodeOperatorWeightUpdated()` returns true for any operator in a group even when the effective weight is unchanged (because the refresh path returns true unconditionally), enabling arbitrary callers to continuously churn the module nonce without any real deposit-set change, contrary to `IStakingModule.getNonce()`’s non-DoS requirement.
**Recommendation**:
Make `MetaRegistry.onNodeOperatorWeightUpdated()` return `true` only when the operator’s effective weight actually changes (and avoid writing group sums when unchanged), or gate the CuratedModule nonce bump on an actual weight delta instead of the current boolean.
---
### PROCESSOR-01: Deadline Off-By-One Between HashConsensus Slot Check And Processor Timestamp Check
**Severity**: LOW
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/lib/base-oracle/HashConsensus.sol:1238-1246
```solidity
function _submitReportForProcessing(
ConsensusFrame memory frame,
bytes32 report
) internal {
IReportAsyncProcessor(_reportProcessor).submitConsensusReport(
report,
frame.refSlot,
_computeTimestampAtSlot(frame.reportProcessingDeadlineSlot)
);
}
```
**Description**:
`HashConsensus` allows reports while `currentSlot == reportProcessingDeadlineSlot`, but it passes the processor a `deadline` equal to the *start timestamp* of `reportProcessingDeadlineSlot`. `BaseOracle.submitConsensusReport` rejects if `block.timestamp > deadline`, so any consensus reached after the first second of the deadline slot will revert when calling the processor, effectively shortening the reporting window by ~1 slot and making `getConsensusStateForMember().canReport` overly permissive in the final slot.
**Recommendation**:
Align slot-based and timestamp-based deadlines: either pass a timestamp at the start of the *next* slot (e.g., `timestampAtSlot(deadlineSlot + 1)`) or tighten `HashConsensus` checks to disallow submissions during `reportProcessingDeadlineSlot` (and update `canReport` accordingly).
---
### CACHE-01: Incorrect `changed` semantics enables permissionless CuratedModule nonce griefing
> **Possible duplicate of**: CuratedModule permissionless recompute can spam module nonce without depositable change
**Severity**: LOW
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/CuratedModule.sol:347-374
```solidity
function _applyDepositableValidatorsCount(
NodeOperator storage no,
uint256 nodeOperatorId,
uint256 newCount,
bool incrementNonceIfUpdated
) internal override returns (bool) {
bool weightChanged = META_REGISTRY.onNodeOperatorWeightUpdated(
nodeOperatorId
);
...
bool depositableChanged = super._applyDepositableValidatorsCount(
no,
nodeOperatorId,
newCount,
incrementNonceIfUpdated
);
if (!depositableChanged && weightChanged && incrementNonceIfUpdated) {
_incrementModuleNonce();
}
}
```
**Description**:
`MetaRegistry.onNodeOperatorWeightUpdated` ultimately returns `true` for any operator that is in a group, even when the effective weight did not change. Because `CuratedModule.updateDepositableValidatorsCount` is permissionless and increments the module nonce when `weightChanged` is true but `depositableChanged` is false, any account can repeatedly bump the nonce (and emit `NonceChanged`) for grouped operators without changing any real state.
**Recommendation**:
Make `MetaRegistry.onNodeOperatorWeightUpdated` return `true` only when the effective weight (and thus group sum) actually changes (e.g., `oldWeight != newWeight`), and rely on that corrected signal for nonce bumping.
---
### CURVE-01: Empty Curve Table Bypasses Custom Bounds Check, Reverting With Panic
**Severity**: LOW
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/abstract/BondCurve.sol:125-149
```solidity
function _setBondCurve(uint256 nodeOperatorId, uint256 curveId) internal {
BondCurveStorage storage $ = _getBondCurveStorage();
unchecked {
if (curveId > $.bondCurves.length - 1) {
revert InvalidBondCurveId();
}
}
$.operatorBondCurveId[nodeOperatorId] = curveId;
emit BondCurveSet(nodeOperatorId, curveId);
}
function _getCurveInfo(
uint256 curveId
) private view returns (BondCurveData storage) {
BondCurveStorage storage $ = _getBondCurveStorage();
unchecked {
if (curveId > $.bondCurves.length - 1) {
revert InvalidBondCurveId();
}
}
return $.bondCurves[curveId];
}
```
**Description**:
`_setBondCurve`/`_getCurveInfo` use `unchecked` with `bondCurves.length - 1` for bounds checks; when `bondCurves.length == 0`, this underflows and the check does not trigger, leading to an array out-of-bounds panic instead of `InvalidBondCurveId()`. If the curve table is ever empty (e.g., before initialization or if an upgrade/migration path fails to populate the default curve), callers can be unexpectedly DoS'ed and consumers cannot rely on consistent custom-error reverts for invalid/absent curves.
**Recommendation**:
Replace the checks with `if (curveId >= $.bondCurves.length) revert InvalidBondCurveId();` (no `unchecked`) to make `length==0` safe and keep revert behavior consistent; optionally enforce `bondCurves.length > 0` as an invariant during init/upgrade (ensure curveId 0 exists).
---
### HIST-01: Full-withdrawal heuristic (>= 15 ETH) enables post-exit top-up griefing / mis-accounted penalties
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/Verifier.sol:485-543
```solidity
// ...
withdrawalAmount = withdrawal.object.amountWei();
if (withdrawalAmount < 15 ether) {
revert PartialWithdrawal();
}
```
**Description**:
`processHistoricalWithdrawalProof` (via `_processWithdrawalProof`) treats any withdrawal >= 15 ETH as a full withdrawal. As noted in-code, an attacker can wait until a validator’s full withdrawal is swept but unreported, then top-up/deposit and later submit a proof so the module accounts the exit using a non-canonical withdrawal amount, leading to incorrect bond penalty/accounting for that key.
**Recommendation**:
If on-chain mitigation is required, replace the amount-threshold heuristic with an exit-detection rule that cannot be affected by post-exit deposits (or explicitly handle this edge case off-chain, as the inline comment suggests).
---
### NFT-01: ERC721 recovery can be blocked if recoverer is a non-receiver contract (safeTransferFrom to msg.sender)
**Severity**: LOW
**Confidence**: HIGH
**Category**: dos
**Locations**:
- src/lib/AssetRecovererLib.sol:86-89
```solidity
function recoverERC721(address token, uint256 tokenId) external {
IERC721(token).safeTransferFrom(address(this), msg.sender, tokenId);
emit IAssetRecovererLib.ERC721Recovered(token, tokenId, msg.sender);
}
```
**Description**:
`AssetRecovererLib.recoverERC721` hardcodes the recipient to `msg.sender` and uses `safeTransferFrom`, which will revert when the authorized recoverer is a contract that does not implement `onERC721Received` (common for timelocks/governance executors). Since ERC721s can still be sent into the protocol contracts via `transferFrom` (no receiver hook), this can make such NFTs unrecoverable until the recoverer role is moved to an EOA or an ERC721Receiver-capable contract.
**Recommendation**:
Add a recipient parameter (e.g., `recoverERC721To(address recipient, ...)`) and/or use `transferFrom` for ERC721 recovery to avoid receiver-hook DoS when the recoverer is a contract; document that contract recoverers must implement ERC721Receiver/ERC1155Receiver if keeping safe transfers.
---
### ADDCM-02: Unstable role-member selection and implicit admin assumptions can mis-target `agent`/admins and break resume wiring
**Severity**: LOW
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/fork-helpers/SimulateVote.s.sol:104-145
```solidity
address agent = stakingRouter.getRoleMember(
stakingRouter.STAKING_MODULE_MANAGE_ROLE(),
0
);
...
curatedModule.grantRole(curatedModule.RESUME_ROLE(), agent);
curatedModule.resume();
curatedModule.revokeRole(curatedModule.RESUME_ROLE(), agent);
```
**Description**:
The script selects `agent` (and also “admins” via `_prepareAdmin`) using `getRoleMember(role, 0)`, which is not a stable “primary” identity when a role has multiple members. If the index-0 member is not the intended governance agent/admin, the script can grant privileges to an unintended account, and the Curated module resume flow can revert because `agent` is used to `grantRole`/`revokeRole` on `curatedModule` (requires the caller to be the role admin).
**Recommendation**:
Require `getRoleMemberCount(...) == 1` for the roles you treat as singletons, or pass explicit `agent`/admin addresses (env/config) and assert they match expected on-chain role membership before executing grants/resume.
---
### UPGSEQ-01: FeeOracle finalizer called on fixture `oracle` instead of upgraded `deploymentConfig.oracle`
**Severity**: LOW
**Confidence**: MEDIUM
**Category**: logic-errors
**Locations**:
- script/fork-helpers/SimulateVote.s.sol:199-207
```solidity
{
OssifiableProxy oracleProxy = OssifiableProxy(
payable(deploymentConfig.oracle)
);
vm.startBroadcast(_prepareProxyAdmin(address(oracleProxy)));
// 4. Upgrade FeeOracle implementation
oracleProxy.proxy__upgradeTo(deploymentConfig.oracleImpl);
// 5. Finalize FeeOracle v3 upgrade
oracle.finalizeUpgradeV3(deployParams.consensusVersion);
vm.stopBroadcast();
}
```
**Description**:
`SimulateVote.upgrade` upgrades the FeeOracle proxy at `deploymentConfig.oracle`, but calls `finalizeUpgradeV3` on the inherited `oracle` instance from `_setUp()` instead of the config address. If those addresses ever diverge, the script can silently finalize the wrong contract and leave the upgraded oracle unfinalized, risking broken oracle report processing (consensus/contract version) after the upgrade.
**Recommendation**:
Call the finalizer via `FeeOracle(deploymentConfig.oracle).finalizeUpgradeV3(...)` (or assign `oracle = FeeOracle(deploymentConfig.oracle)` after parsing) and optionally assert `address(oracle) == deploymentConfig.oracle` before upgrading/finalizing.
---
### UPGEJT-01: Upgrade Sequencing Temporarily Breaks Strikes-Driven Ejection (TWG Role Granted After Ejector Switch)
**Severity**: LOW
**Confidence**: HIGH
**Category**: dos
**Locations**:
- script/fork-helpers/SimulateVote.s.sol:289-434
```solidity
// 13. Point ValidatorStrikes to the new Ejector
strikes.setEjector(deploymentConfig.ejector);
...
// 40. Revoke TWG full-withdrawal role from old Ejector
twg.revokeRole(twg.ADD_FULL_WITHDRAWAL_REQUEST_ROLE(), oldEjector);
// 41. Grant TWG full-withdrawal role to new Ejector
twg.grantRole(
twg.ADD_FULL_WITHDRAWAL_REQUEST_ROLE(),
deploymentConfig.ejector
);
```
**Description**:
`SimulateVote.upgrade()` points `ValidatorStrikes` to the new `Ejector` before updating Triggerable Withdrawals Gateway (TWG) permissions, and the TWG revoke/grant are emitted as later, separate transactions. During this intermediate state, `processBadPerformanceProof`-driven ejections can revert at the TWG call (and if the sequence is interrupted, the strikes ejection path can remain dead).
**Recommendation**:
Reorder the upgrade sequence to grant TWG `ADD_FULL_WITHDRAWAL_REQUEST_ROLE` to the new ejector before switching `ValidatorStrikes` to it (and revoke the old ejector last), or execute the full wiring atomically in a single governance transaction/script.
---
### REWARD-01: Deploy Script Lacks Sanity Checks for Oracle/Distributor/Accounting Cross-Wiring
**Severity**: LOW
**Confidence**: HIGH
**Category**: access-control
**Locations**:
- script/csm/DeployCSMImplementationsBase.s.sol:57-78
```solidity
Accounting accountingImpl = new Accounting({
lidoLocator: config.lidoLocatorAddress,
module: address(csm),
feeDistributor: address(feeDistributor),
minBondLockPeriod: config.minBondLockPeriod,
maxBondLockPeriod: config.maxBondLockPeriod
});
FeeOracle oracleImpl = new FeeOracle({
feeDistributor: address(feeDistributor),
strikes: address(strikes),
secondsPerSlot: config.secondsPerSlot,
genesisTime: config.clGenesisTime
});
FeeDistributor feeDistributorImpl = new FeeDistributor({
stETH: locator.lido(),
accounting: address(accounting),
oracle: address(oracle)
});
```
**Description**:
`DeployCSMImplementationsHoodi.deploy` passes addresses from an external JSON config into `_deploy()`, which then deploys new implementations whose immutables permanently gate `processOracleReport`/`distributeFees` by `ORACLE`/`ACCOUNTING`. Because `_deploy()` does not verify that the provided `oracle`, `feeDistributor`, and `accounting` addresses are mutually consistent (and bound to the expected stETH), a mis-specified config can deploy implementations that either DoS reports/claims or, worse, bind `FeeDistributor` to an unintended `ACCOUNTING`/`ORACLE` address (enabling an invalid report or claim path).
**Recommendation**:
Before deploying implementations, add explicit checks that current on-chain wiring matches expectations (e.g., `FeeDistributor(feeDistributor).ACCOUNTING()==accounting`, `.ORACLE()==oracle`, `.STETH()==locator.lido()`, `Accounting(accounting).FEE_DISTRIBUTOR()==feeDistributor`, `FeeOracle(oracle).FEE_DISTRIBUTOR()==feeDistributor`), and revert if any mismatch is detected.
---
### LIMITS-01: `NodeOperators.targetLimit` incorrectly asserts target limit persistence when `targetLimitMode == 0`
**Severity**: INFORMATIONAL
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- script/fork-helpers/NodeOperators.s.sol:236-246
```solidity
function targetLimit(
uint256 noId,
uint256 targetLimitMode,
uint256 limit
) external broadcastStakingRouter {
module.updateTargetValidatorsLimits(noId, targetLimitMode, limit);
NodeOperator memory no = module.getNodeOperator(noId);
assertEq(no.targetLimit, limit);
assertEq(no.targetLimitMode, targetLimitMode);
}
```
**Description**:
`NodeOperators.targetLimit` asserts `no.targetLimit == limit` after calling `updateTargetValidatorsLimits`, but the module normalizes `targetLimit` to `0` whenever `targetLimitMode == 0`. Calling the script with `(targetLimitMode=0, limit!=0)` will therefore fail the post-call assertion even though the update is applied (mode set to 0, limit stored as 0), which can mislead operators in fork/broadcast workflows.
**Recommendation**:
In the script, either require `limit == 0` when `targetLimitMode == 0`, or assert against a normalized `expectedLimit` (e.g., `expectedLimit = targetLimitMode == 0 ? 0 : limit`).
---
### VIEW-02: Preview allocation ignores weight-update lock used by stateful top-up allocation
**Severity**: INFORMATIONAL
**Confidence**: HIGH
**Category**: logic-errors
**Locations**:
- src/CuratedModule.sol:316-345
```solidity
function getDepositsAllocation(
uint256 maxDepositAmount
)
external
view
returns (
uint256 allocated,
uint256[] memory operatorIds,
uint256[] memory allocations
)
{
uint256 operatorsCount = _nodeOperatorsCount;
if (maxDepositAmount == 0 || operatorsCount == 0) {
return (0, new uint256[](0), new uint256[](0));
}
uint256[] memory allOperatorIds = new uint256[](operatorsCount);
for (uint256 i; i < operatorsCount; ++i) {
allOperatorIds[i] = i;
}
(allocated, operatorIds, allocations) = CuratedDepositAllocator
.allocateTopUps({
nodeOperators: _nodeOperators,
nodeOperatorBalances: _storage().operatorBalances,
operatorsCount: operatorsCount,
allocationAmount: maxDepositAmount,
operatorIds: allOperatorIds
});
}
```
**Description**:
`allocateDeposits()` (stateful top-ups) reverts while `NodeOperatorWeightsUpdateInProgress`, but `getDepositsAllocation()` does not check this flag and can return a non-zero preview during an update window. This creates a view/state inconsistency that can mislead monitoring/ops tooling about whether top-ups are currently executable.
**Recommendation**:
Either gate `getDepositsAllocation()` with `_requireNodeOperatorWeightsUpToDate()` (or return `(0,[],[])` when updates are in progress), and/or explicitly document that previews may be stale/unactionable until weights are fully refreshed.
---
### DEPLOY-02: Deploy Artifact Omits The Oracle Address Used (Harder To Detect Miswiring Post-Deploy)
**Severity**: INFORMATIONAL
**Confidence**: HIGH
**Category**: other
**Locations**:
- script/DeployTwoPhaseFrameConfigUpdate.s.sol:104-117
```solidity
function _saveDeployJson(
address deployed,
address oracle,
TwoPhaseFrameConfigUpdate.PhasesConfig memory phasesConfig
) internal {
JsonObj memory deployJson = Json.newObj("artifact");
deployJson.set("TwoPhaseFrameConfigUpdate", deployed);
deployJson.set("TwoPhaseFrameConfigUpdateParams", abi.encode(config));
deployJson.set("git-ref", gitRef);
vm.createDir(artifactDir, true);
vm.writeJson(deployJson.str, _deployJsonFilename());
}
```
**Description**:
The script passes `oracle` into `_saveDeployJson` but never writes it to the produced deploy JSON, making it easy to miss that the helper was wired to the wrong FeeOracle when reviewing artifacts after deployment.
**Recommendation**:
Include the parsed `oracle` in the output JSON (e.g., `deployJson.set("FeeOracle", oracle);`) so artifact review can reliably confirm the helper’s wiring.
---
### CONFIG-02: ICS curveId in DeployParams is not validated or updated, so deploy artifacts can record a wrong curveId
**Severity**: INFORMATIONAL
**Confidence**: HIGH
**Category**: other
**Locations**:
- script/csm/DeployBase.s.sol:320-402
```solidity
uint256 identifiedCommunityStakersGateBondCurveId = accounting
.addBondCurve(identifiedCommunityStakersGateBondCurve);
...
vettedGate = VettedGate(
vettedGateFactory.create({
curveId: identifiedCommunityStakersGateBondCurveId,
treeRoot: config.identifiedCommunityStakersGateTreeRoot,
treeCid: config.identifiedCommunityStakersGateTreeCid,
admin: deployer
})
);
```
**Description**:
`DeployParams.identifiedCommunityStakersGateCurveId` is populated by chain-specific constructors, but `DeployBase.run` ignores it and instead derives the ICS curve id from `accounting.addBondCurve(...)`. If curve ordering changes (e.g., via `extraBondCurves`), the deployed gate/registry will use the derived id while the encoded `DeployParams` written to artifacts can still contain a stale/incorrect `identifiedCommunityStakersGateCurveId`, increasing the chance of later governance/ops targeting the wrong curve parameters.
**Recommendation**:
In `run`, either (a) set `config.identifiedCommunityStakersGateCurveId = identifiedCommunityStakersGateBondCurveId` before writing artifacts, and/or (b) assert `config.identifiedCommunityStakersGateCurveId == identifiedCommunityStakersGateBondCurveId` when `identifiedCommunityStakersGateCurveId` is non-zero.
---
## Appendix
### Protocol Dossier
## Architecture & Layers
### Core / Essential Modules
- **`abstract/BaseModule.sol` (shared module core)**
- Responsibilities:
- Node Operator (NO) registry, manager/reward address management, validator key storage/queries.
- `depositableValidatorsCount` computation and global counters (`_totalDepositedValidators`, `_totalExitedValidators`, `_totalWithdrawnValidators`).
- Hooks for `StakingRouter` (`obtainDepositData` implemented in concrete modules, `onRewardsMinted`, `updateTargetValidatorsLimits`, exit-delay hooks).
- Withdrawal/slashing bookkeeping (`_isValidatorWithdrawn`, `_isValidatorSlashed`, `_keyAddedBalances`).
- General delayed penalty lifecycle and “deactivate on uncovered penalty” behavior (`_setTargetLimit(..., FORCED_TARGET_LIMIT_MODE_ID, 0)`).
- Role-gated pausing (`PausableUntil`) and upgradeable access control.
- Owned state:
- NO mapping `mapping(uint256 => NodeOperator) _nodeOperators`, counters, slashed/withdrawn bitmaps, per-key added-balance.
- Deposit queues mapping `mapping(uint256 priority => DepositQueueLib.Queue) _depositQueueByPriority` (used by `CSModule`, unused by `CuratedModule`).
- Security promise:
- Only privileged actors (gates, verifier, staking router, governance roles) can mutate core state; NO manager/owner can only mutate their own keys/addresses within limits.
- **`CSModule.sol` (Community Staking Module, CSM v3)**
- Responsibilities:
- Implements `IStakingModule.obtainDepositData` via **priority deposit queues**.
- Implements `IStakingModuleV2.allocateDeposits` via an optional **top-up queue** (for `0x02` / EIP-7251 style “top-ups”).
- Charges per-key removal fee on `removeKeys` (uses `ParametersRegistry.getKeyRemovalCharge` + `Accounting.chargeFee`).
- Enqueues NO keys into deposit queues whenever `depositableValidatorsCount` changes (via `DepositQueueOps.enqueueNodeOperatorKeys`).
- Owned state:
- `TopUpQueueLib.Queue topUpQueue` (ERC-7201 slot), deposit queues via `BaseModule`.
- Security promise:
- `StakingRouter` is the only caller that can consume keys for deposits/top-ups; NO manager controls adding/removing keys but cannot exceed type-based limits.
- **`CuratedModule.sol` (Curated Module v2, CM v2)**
- Responsibilities:
- Implements `IStakingModule.obtainDepositData` via **greedy weighted allocation** (`CuratedDepositAllocator.allocateDeposits`).
- Implements `IStakingModuleV2.allocateDeposits` for **top-ups** and tracks **per-operator balances** (reported by `StakingRouter` via `updateOperatorBalances`).
- Can “emergency-set” NO addresses via `changeNodeOperatorAddresses` gated by `OPERATOR_ADDRESSES_ADMIN_ROLE`.
- Gates deposits eligibility by `ParametersRegistry.getDepositAllocationWeight(curveId)` (weight `0` makes NO ineligible).
- Owned state:
- `operatorBalances[nodeOperatorId]` in wei (ERC-7201 slot).
- Security promise:
- Allocation is deterministic from on-chain NO state + weights + balances; only `StakingRouter` can request/consume deposits/top-ups.
- **`Accounting.sol` (bond/rewards/penalties + fee splits)**
- Responsibilities:
- Stores NO bond as **stETH shares** (`BondCore`), supports bonding via `depositETH`, `depositStETH`, `depositWstETH`.
- Defines NO “types” via **bond curves** (`BondCurve`), and optional **bond locks** (`BondLock`).
- Applies penalties/charges from the module (`penalize`, `chargeFee`, bond-lock settlement helpers).
- Rewards claiming in `stETH`, `wstETH`, or `unstETH` (`claimRewardsStETH/WstETH/UnstETH`), with optional **fee splits** (`setFeeSplits`, `pullAndSplitFeeRewards`).
- Owned state:
- Per-NO bond shares + total bond shares + per-NO bond debt (`BondCoreStorage`).
- Per-NO curve id (`BondCurveStorage.operatorBondCurveId`) and curve tables.
- Per-NO lock record (`BondLockStorage.bondLock`).
- Fee splits config + pending split shares + optional custom rewards claimer.
- Security promise:
- Only the module can apply penalties/charges; NO owner can claim/split their rewards subject to proofs.
- **`ParametersRegistry.sol` (NO-type parameter registry)**
- Responsibilities:
- Stores defaults plus curve-specific overrides for:
- key removal charge, keys limit, queue config (CSM), reward share, performance leeway/coefficients, strikes params, bad-performance penalty,
- exit-delay parameters (allowed delay, delay fee), max EL withdrawal request fee,
- deposit allocation weight (CM).
- Uses granular roles per parameter group (e.g., `MANAGE_KEYS_LIMIT_ROLE`, `MANAGE_VALIDATOR_EXIT_PARAMETERS_ROLE`).
- Owned state:
- Default values + per-curve marked/interval values.
- Security promise:
- Parameter changes are fully role-gated; “default exists” ensures consistent reads even without per-curve overrides.
- **`FeeDistributor.sol` (rewards distribution vault)**
- Responsibilities:
- Holds module rewards in **stETH shares**; tracks `totalClaimableShares` and per-NO `distributedShares`.
- Oracle-updated Merkle tree root/CIDs via `processOracleReport`.
- Transfers per-NO shares to `Accounting` on claim (`distributeFees`) after Merkle proof validation.
- Sends “rebate” (excess rewards due to variable NO reward share) to `rebateRecipient`.
- Owned state:
- `treeRoot`, `treeCid`, `logCid`, claim/distribution accounting, distribution history ring.
- Security promise:
- Only the configured oracle updates roots and only `Accounting` can distribute per-NO shares; internal accounting never exceeds actual `STETH.sharesOf(address(this))`.
- **`FeeOracle.sol` (report executor)**
- Responsibilities:
- Accepts consensus-backed `ReportData` (via `BaseOracle` / `HashConsensus` integration) and atomically:
- calls `FeeDistributor.processOracleReport(...)`
- calls `ValidatorStrikes.processOracleReport(...)`
- Can be paused/resumed by roles.
- Owned state:
- No funds; relies on ACL + `BaseOracle` consensus state.
- Security promise:
- Only consensus members or `SUBMIT_DATA_ROLE` can submit; report is tied to `refSlot`, `consensusVersion`, and `contractVersion`.
- **`ValidatorStrikes.sol` (strike root + permissionless proofs)**
- Responsibilities:
- Stores strikes Merkle root/CID set by `FeeOracle` (`processOracleReport`).
- Permissionless `processBadPerformanceProof` verifies multiproof leaves for `(nodeOperatorId, pubkey, strikesData[])`.
- If strikes sum reaches threshold (`ParametersRegistry.getStrikesParams(curveId)`), it:
- records strikes penalty in `ExitPenalties.processStrikesReport`
- triggers `Ejector.ejectBadPerformer` (pays EIP-7002/TWG fee from `msg.value`).
- Owned state:
- `treeRoot`, `treeCid`, configured `ejector`.
- Security promise:
- Ejection is only possible with a valid Merkle proof against the current root and only when threshold is met.
- **`Ejector.sol` (exit requests via TWG / EIP-7002 path)**
- Responsibilities:
- NO-owner voluntary exits: `voluntaryEject` (sequential range) / `voluntaryEjectByArray` (arbitrary indices).
- Strike-triggered exit: `ejectBadPerformer` callable only by `ValidatorStrikes`.
- Builds `ValidatorData{stakingModuleId,nodeOperatorId,pubkey}` and calls `TriggerableWithdrawalsGateway.triggerFullWithdrawals`.
- Owned state:
- None beyond roles/pausing; uses module state for checks.
- Security promise:
- Only deposited, non-withdrawn keys can be used; strike ejections are restricted to `STRIKES` contract.
- **`ExitPenalties.sol` (per-validator exit penalty registry)**
- Responsibilities:
- Records (per `(nodeOperatorId,pubkey)`):
- exit delay fee (`processExitDelayReport`)
- strikes penalty (`processStrikesReport`)
- EL withdrawal request fee actually charged (capped by `maxElWithdrawalRequestFee`) on non-voluntary exits (`processTriggeredExit`)
- Provides `getExitPenaltyInfo` and `isValidatorExitDelayPenaltyApplicable`.
- Owned state:
- `_exitPenaltyInfo[keyPointer] -> ExitPenaltyInfo`.
- Security promise:
- Only the module can report exit delay / triggered exits; only strikes can set strikes penalty.
- **`Verifier.sol` (CL proof verifier via EIP-4788 beacon roots)**
- Responsibilities:
- Verifies SL/SSZ proofs against trusted beacon block roots (EIP-4788 `BEACON_ROOTS`) for:
- slashing (`processSlashedProof` → `MODULE.onValidatorSlashed`)
- withdrawals (`processWithdrawalProof`, `processHistoricalWithdrawalProof` → `MODULE.reportRegularWithdrawnValidators`)
- consolidations (`processConsolidation` → `MODULE.reportRegularWithdrawnValidators` with exit balance from balance proof)
- Tracks fork-specific generalized indices (GIndex) and slot boundaries (`FIRST_SUPPORTED_SLOT`, `PIVOT_SLOT`, `CAPELLA_SLOT`).
- Owned state:
- Immutable chain config and GIndices; no per-validator state (module stores outcomes).
- Security promise:
- Module state transitions driven by Verifier are proof-backed against canonical beacon roots.
- **Entry Gates (NO onboarding)**
- `PermissionlessGate.sol`: permissionless NO creation + immediate key upload and bond deposit (ETH/stETH/wstETH).
- `VettedGate.sol`: Merkle-allowlisted onboarding; sets per-gate `curveId` in `Accounting`; optional referral-season mechanics.
- `CuratedGate.sol`: Merkle-allowlisted onboarding for `CuratedModule`; enforces `extendedManagerPermissions = true`, can set curve (or default), and writes metadata to `OperatorsData`.
- Factories: `VettedGateFactory.sol`, `CuratedGateFactory.sol` deploy upgradeable gate instances (`OssifiableProxy`).
### Utility / Support Modules
- **Metadata**: `OperatorsData.sol` caches `StakingRouter` module addresses and stores `(name, description, ownerEditsRestricted)` per `(moduleId,nodeOperatorId)`.
- **Queue/Key libs**: `lib/SigningKeys.sol`, `lib/DepositQueueLib.sol`, `lib/TopUpQueueLib.sol`, and ops wrappers.
- **Penalty/withdrawal libs**: `lib/GeneralPenaltyLib.sol`, `lib/WithdrawnValidatorLib.sol` (applies exit accounting into NO state and interacts with `Accounting`).
- **Allocation libs (CM)**: `lib/allocator/CuratedDepositAllocator.sol` and `DepositAllocatorGreedy.sol`.
- **Common utilities**: `lib/utils/PausableUntil.sol`, `lib/UnstructuredStorage.sol`, `lib/SSZ.sol`, `lib/GIndex.sol`.
---
## Threat Model
- **External attacker (no privileges)**
- Goals:
- Steal/bypass bond or rewards; claim others’ fees; forge ejections; force incorrect slashing/withdrawal reports.
- DoS deposit allocation/queue processing or oracle report execution.
- Grief by consuming TWG exit limits or forcing unfavorable state transitions.
- **Malicious Node Operator / NO manager**
- Goals:
- Add unbonded/invalid keys or manipulate key lifecycle to get deposits without sufficient bond.
- Avoid penalties (exit delay / bad performance / slashing) or shift penalties to protocol.
- Abuse exits (spam/duplicate exit requests) or metadata edits.
- **Privileged operator / keeper / oracle submitter (FeeOracle submitters, consensus members)**
- Goals:
- Post a malicious distribution root to redirect rewards; publish strikes root to force ejections.
- Withhold reports (liveness failure) to delay distribution/penalties/ejections.
- **Compromised governance / admin (role holders across module/oracle/registry)**
- Goals:
- Change ParametersRegistry to disable limits/penalties or bias allocations.
- Reconfigure oracle, gate trees, recovery roles, or pause/resume controls to exfiltrate or freeze funds.
---
## Assets & Units
- **ETH (wei)**
- Used for: bonding via `Accounting.depositETH`, paying TWG exit fees via `Ejector` and `ValidatorStrikes.processBadPerformanceProof`.
- Forced-transfer manipulability: yes (plain ETH), but most logic uses explicit `msg.value`.
- **stETH (rebasable ERC20)**
- Used for: bond deposits/claims and fee distribution; accounting is primarily in **stETH shares**.
- Conversion functions:
- `BondCore` converts ETH-denominated amounts to shares and back via Lido (`getSharesByPooledEth` / `getPooledEthByShares`).
- Forced-transfer manipulability: yes (anyone can transfer stETH to `Accounting`/`FeeDistributor`), which affects `STETH.sharesOf(address(this))` but does not automatically credit per-NO bond or claims unless routed through contract logic.
- **wstETH**
- Used for: optional bond deposit and reward claiming (`depositWstETH`, `claimRewardsWstETH`); internally unwrap/wrap around stETH shares.
- **stETH shares (internal unit)**
- Primary accounting unit for:
- NO bonds (`BondCoreStorage.bondShares[nodeOperatorId]`).
- FeeDistributor claimable/distributed tracking (`totalClaimableShares`, `distributedShares`).
- Rounding direction: delegated to Lido’s share math; callers typically quantize by converting requested ETH amounts into shares.
- **Validator keys**
- Indexed per NO (`keyIndex`), stored as concatenated pubkeys/signatures in `SigningKeys`.
- Counts:
- `totalAddedKeys`, `totalVettedKeys`, `totalDepositedKeys`, `totalWithdrawnKeys`, `depositableValidatorsCount` (per NO).
- Forced-transfer manipulability: N/A (not token-based).
- **CL balance units**
- Verifier uses `gwei`↔`wei` conversion helpers (`gweiToWei`, `amountWei`).
- Curated module operator balances stored in **wei** but reported in **gwei arrays** and converted (`* 1 gwei`) in `CuratedModule.updateOperatorBalances`.
- **Basis points (BP, max 10_000)**
- Used in `ParametersRegistry` for weights/shares/leeways/coefficients.
---
## Time / Oracle Model
- **Fee reporting (rewards + strikes)**
- `FeeOracle.submitReportData(ReportData, contractVersion)` is gated by:
- consensus membership or `SUBMIT_DATA_ROLE`,
- `refSlot` and `consensusVersion` checks (`BaseOracle`),
- contract version checks.
- The report is applied synchronously:
- rewards root/CIDs and share deltas to `FeeDistributor`,
- strikes root/CID to `ValidatorStrikes`.
- Asynchrony window:
- Between report publication and individual NO claims: Merkle root may change; proofs must match current `treeRoot`.
- **CL-proof-based events (EIP-4788 beacon roots)**
- `Verifier` trusts block roots via EIP-4788 `BEACON_ROOTS(timestamp)` and verifies SSZ proofs against state roots.
- Slot boundaries:
- `FIRST_SUPPORTED_SLOT` gates earliest accepted proofs; fork handling via `PIVOT_SLOT`, and historical summaries assumptions via `CAPELLA_SLOT`.
- **Exit delay / exit fees**
- `StakingRouter` reports `eligibleToExitInSec` to the module, which forwards to `ExitPenalties.processExitDelayReport`.
- Exit fee recording is keyed by `(nodeOperatorId, pubkey)` and is “first-write wins” (subsequent reports are ignored once marked).
- **Pausing**
- Many components implement `PausableUntil` and start paused at module initialization (`BaseModule.__BaseModule_init` pauses infinitely). Resume is expected to happen via governance/ops flow.
---
## State Machines
- **Node Operator Onboarding**
- States:
- `Nonexistent` → `Created` (via `BaseModule.createNodeOperator`, role `CREATE_NODE_OPERATOR_ROLE` held by gates).
- `Created` → `HasKeys` (via `addValidatorKeysETH/StETH/WstETH`) for CSM-style onboarding; CM onboarding may start without keys (per `CuratedGate` design/spec).
- Triggers:
- `PermissionlessGate`, `VettedGate`, `CuratedGate` (Merkle-based consumption prevents reusing eligibility).
- Forbidden transitions:
- Adding keys via a gate not recorded as the NO creator (enforced by `OperatorTracker` in `_checkCanAddKeys`).
- Exceeding `ParametersRegistry.getKeysLimit(curveId)` in `_addKeysAndUpdateDepositableValidatorsCount`.
- **Key Lifecycle & Depositability**
- Per-NO counters:
- add keys increases `totalAddedKeys` (and may optimistically bump `totalVettedKeys`).
- deposits (via module `obtainDepositData`) increase `totalDepositedKeys` and decrease `depositableValidatorsCount`.
- withdrawals reporting increases `totalWithdrawnKeys` and marks `_isValidatorWithdrawn`.
- CSM queueing:
- When `depositableValidatorsCount` updates, `CSModule._applyDepositableValidatorsCount` enqueues keys into deposit queues by priority (`DepositQueueOps`).
- `CSModule.obtainDepositData` consumes batches, updates `enqueuedCount`, and (optionally) enqueues each deposited key into the top-up queue.
- CM allocation:
- `CuratedModule.obtainDepositData` allocates deposits across eligible operators by weights and capacities, then updates `operatorBalances += allocation * 32 ether`.
- **Top-Up Allocation (IStakingModuleV2)**
- CSM:
- `CSModule.allocateDeposits` strictly follows `topUpQueue`; allocations are capped by per-key added balance (`capTopUpLimitsByKeyBalance`).
- Queue administration: `setTopUpQueueLimit`, `rewindTopUpQueue` are role-gated.
- CM:
- `CuratedModule.allocateDeposits` validates key list, computes per-operator allocations using `CuratedDepositAllocator.allocateTopUps`, then allocates per key.
- **Rewards Distribution**
- `StakingRouter` mints rewards to module and calls `BaseModule.onRewardsMinted(totalShares)` → module transfers shares to `FeeDistributor`.
- `FeeOracle` publishes `(treeRoot, treeCid, distributed, rebate, refSlot)` to `FeeDistributor`.
- NO claims:
- `Accounting.claimRewards*` pulls claimable shares from `FeeDistributor.distributeFees` using `(nodeOperatorId, cumulativeFeeShares, proof)`.
- Optional split setup via `Accounting.setFeeSplits`.
- **Strikes → Penalty + Ejection**
- Oracle sets strikes root via `FeeOracle` → `ValidatorStrikes.processOracleReport`.
- Anyone can call `ValidatorStrikes.processBadPerformanceProof` with valid multiproof:
- If `sum(strikesData) >= threshold`, then:
- `ExitPenalties.processStrikesReport(nodeOperatorId, pubkey)` records strikes penalty.
- `Ejector.ejectBadPerformer{value: fee}` submits TWG exit request.
- **Withdrawals / Slashings / Consolidations**
- Slashing:
- `Verifier.processSlashedProof` verifies and calls `MODULE.onValidatorSlashed(nodeOperatorId,keyIndex)` (marks `_isValidatorSlashed`).
- Withdrawal (non-slashed):
- `Verifier.processWithdrawalProof` / `processHistoricalWithdrawalProof` verifies and calls `MODULE.reportRegularWithdrawnValidators([{exitBalance, isSlashed=false}])`.
- Consolidation:
- `Verifier.processConsolidation` proves withdrawable + balance at consolidation slot and reports a withdrawn validator with that balance.
- Slashed withdrawals:
- Not handled by `Verifier.processWithdrawalProof` (explicitly rejected); expected to be reported off-chain via module’s slashed-withdrawn reporting path.
---
## Core Invariants
- **Depositability Is Upper-Bounded By Bonding And Limits**
- Scope: `BaseModule` + `Accounting` + `ParametersRegistry` (cross-contract).
- Persona: malicious NO manager, external attacker.
- Failure mode: NO becomes depositable beyond keys limit, target limit, or unbonded key constraints, enabling deposits without sufficient bond or beyond governance-imposed caps.
- **Withdrawn/Slashed Flags Are Single-Assignment Per Key**
- Scope: `BaseModule` storage (`_isValidatorWithdrawn`, `_isValidatorSlashed`).
- Persona: privileged reporter (verifier / withdrawn reporters).
- Failure mode: double-reporting causes double-penalization or inconsistent counters; the module must treat “already withdrawn” as no-op and “slashed-withdrawn requires slashed” as enforced.
- **Uncovered Penalty Deactivates Operator**
- Scope: `BaseModule` (single contract, but impacts staking router behavior).
- Persona: malicious NO, external attacker.
- Failure mode: operator remains depositable after an uncovered penalty, allowing continued deposits into an undercollateralized operator.
- **FeeDistributor Accounting Never Exceeds Its stETH Share Balance**
- Scope: `FeeDistributor` (single contract).
- Persona: oracle submitter, external attacker.
- Failure mode: oracle report inflates `totalClaimableShares` beyond `STETH.sharesOf(FeeDistributor)`, enabling claims of non-existent shares.
- **Strikes-Based Ejection Requires Proof Against Current Root And Threshold**
- Scope: `ValidatorStrikes` + `ExitPenalties` + `Ejector` (cross-contract).
- Persona: external attacker.
- Failure mode: invalid proof triggers ejection or penalty for an honest validator; threshold misapplication triggers premature exit.
---
## Cross-Module Seams
- **StakingRouter ↔ Module boundary**
- `obtainDepositData` and `allocateDeposits` trust `StakingRouter` to call correctly; modules enforce role checks, but data shapes (e.g., per-key `topUpLimits` in CM v2) are assumptions at the seam.
- **Negative rebase / bonding race**
- `CSModule.obtainDepositData` documents that it may return unbonded keys in rare negative rebase scenarios (tradeoff for gas), relying on protocol-side remediation (e.g., exit requests) rather than strict on-chain prevention.
- **Off-chain penalty reporting for slashed withdrawals**
- `Verifier` rejects proofs for withdrawals from slashed validators; slashed-withdrawn reporting is expected via privileged module roles (e.g., EasyTrack-driven paths). This is a trust seam.
- **Parameter changes**
- `ParametersRegistry` updates (keys limits, penalties, weights, exit parameters) can abruptly change operator eligibility and accounting outcomes; invariants depend on correct role assignment and operational discipline.
- **Forced transfers of stETH**
- `Accounting` and `FeeDistributor` rely on `STETH.sharesOf(address(this))` for checks and recovery boundaries; unsolicited transfers can create “excess” shares that are not attributed to any NO unless explicitly handled.
- **Per-validator exit penalty “first-write wins”**
- `ExitPenalties` records delay/fee/strikes penalties only once per `(nodeOperatorId,pubkey)`; upstream callers must ensure correct first report to avoid locking in a suboptimal value.
- **Top-up queue administration (CSM)**
- `setTopUpQueueLimit` and especially `rewindTopUpQueue` are privileged operations that can affect allocation order and liveness; stake/top-up fairness depends on correct use of these controls.
### Focus Areas
**Pausing/Recovery Gating and Upgrade-Safety Readouts** (`pause_recover_version`)
- Scope: Entry via `BaseModule.pauseFor`. Includes role-gated pause/resume controls and the recoverer gate override used by the AssetRecoverer surface, plus initialized-version reporting used for upgrade safety checks. Invariants: only authorized roles can pause/resume or recover assets; pausing must reliably gate state-mutating flows that are `whenResumed` and not leave a path to bypass onboarding/keys operations. Excludes the specific AssetRecoverer recover-method implementations (they live outside BaseModule scope).
- Entry point: BaseModule.pauseFor
- Anchors: BaseModule.pauseFor; BaseModule.resume; BaseModule._onlyRecoverer; BaseModule.getInitializedVersion
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Read APIs: Enumeration, Activity, and Router-Facing Consistency** (`views_enumeration`)
- Scope: Entry via `BaseModule.getNodeOperatorIds`. Includes module-level read APIs used by integrators/routers: NO counts/activity checks, enumeration, per-NO struct reads, module type, and nonce. Invariants: view methods must not revert unexpectedly for valid ranges, must not allow out-of-range reads, and must report counts/activity consistently with the module’s internal NO registry assumptions (including any intentionally-simplified behavior like “all created are active”). Excludes off-chain interpretation of these views and any staking-router logic that consumes them.
- Entry point: BaseModule.getNodeOperatorIds
- Anchors: BaseModule.getNonce; BaseModule.getNodeOperatorsCount; BaseModule.getActiveNodeOperatorsCount; BaseModule.getNodeOperatorIsActive; BaseModule.getNodeOperatorIds; BaseModule.getNodeOperator; BaseModule.getType
- Related anchors: NodeOperatorOps.getNodeOperatorIds
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Top-Up Queue Introspection And Key Fetching** (`csm_topup_queue_read_paths`)
- Scope: Includes read-only paths `CSModule.getTopUpQueue`, `CSModule.getTopUpQueueItem`, and `CSModule.getKeysForTopUp`: correctness of enabled/limit/length/head reporting, index-to-(operatorId,keyIndex) decoding, bounds handling, and ensuring these views cannot be used to trigger unexpected reverts/DoS via malformed queue state. Excludes any privacy assumptions about pubkey exposure (pubkeys are not secret).
- Entry point: CSModule.getKeysForTopUp
- Anchors: CSModule.getKeysForTopUp; CSModule.getTopUpQueue; CSModule.getTopUpQueueItem
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Top-Up Allocation (IStakingModuleV2)
**Deposit Queue Introspection (Pointers And Items)** (`csm_deposit_queue_views`)
- Scope: Includes read-only paths `CSModule.depositQueuePointers` and `CSModule.depositQueueItem`: correctness of head/tail exposure and item decoding, and robustness against out-of-range indices or unexpected queue states (should revert predictably without corrupting state). Excludes queue mutation paths (`obtainDepositData`, `_applyDepositableValidatorsCount`, `cleanDepositQueue`).
- Entry point: CSModule.depositQueueItem
- Anchors: CSModule.depositQueueItem; CSModule.depositQueuePointers
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Module Summary Reporting And Top-Up Capacity Cap** (`csm_summary_depositable_cap`)
- Scope: Includes `CSModule.getStakingModuleSummary` correctness as an external reporting interface: totals (`totalExitedValidators`, `totalDepositedValidators`) and the reported `depositableValidatorsCount`, including the conditional cap by top-up queue capacity when enabled. Excludes how totals are produced (handled by other flows), but includes that the summary must not over-report depositable capacity relative to enforced limits.
- Entry point: CSModule.getStakingModuleSummary
- Anchors: CSModule.getStakingModuleSummary
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits; Uncovered Penalty Deactivates Operator
- State machine relevance: Key Lifecycle & Depositability
**Initialization And Top-Up Mode Enablement** (`csm_initialize_topup_mode`)
- Scope: Includes `CSModule.initialize` as the v3 initializer path: correct reinitialization gating, correct top-up queue enablement semantics (limit==0 permanently disables), and ensuring initial configuration cannot violate depositability bounds or enable unauthorized state mutation. Excludes proxy admin wiring and external role assignment processes (assumed correct governance setup).
- Entry point: CSModule.initialize
- Anchors: CSModule.initialize
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**V3 Finalization: Storage Cleanup And Withdrawn Counter Rebuild** (`csm_finalize_upgrade_v3_withdrawn_rebuild`)
- Scope: Includes `CSModule.finalizeUpgradeV3` upgrade-only behavior: assembly slot cleanup, recomputing `_totalWithdrawnValidators` from per-operator `totalWithdrawnKeys`, and ensuring the rebuilt counter is consistent and cannot overflow/underflow. Excludes all other withdrawal/slashing report paths (they are not in CSModule), but includes that summary counters must remain coherent after upgrade.
- Entry point: CSModule.finalizeUpgradeV3
- Anchors: CSModule.finalizeUpgradeV3
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key
- State machine relevance: Withdrawals / Slashings / Consolidations
**No-Op Balance Updates: Interface Safety And Caller Assumptions** (`csm_update_operator_balances_noop`)
- Scope: Includes `CSModule.updateOperatorBalances` as an externally callable no-op: verify it cannot be abused to create misleading state transitions, to grief by consuming meaningful resources beyond caller-paid gas, or to break StakingRouter expectations around nonce/state changes (it intentionally does not increment nonce). Excludes CuratedModule’s balance-tracking logic (out of scope).
- Entry point: CSModule.updateOperatorBalances
- Anchors: CSModule.updateOperatorBalances
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Top-Up Allocation (IStakingModuleV2)
**Two-Step Manager/Reward Address Changes Cannot Be Hijacked** (`no_addr_change`)
- Scope: Includes only the `NodeOperators` wrappers that propose/confirm manager/reward address changes and their direct calls into the module; validate that the script broadcasts from the intended actor (current manager for propose, proposed address for confirm) and targets the intended node operator id so the effective controller of key/limit operations cannot be unintentionally changed. Excludes any deeper correctness of address-change authorization/ACL inside the module beyond what is exercised by these entry points. Invariant linkage: incorrect address handover can enable violating depositability/bond/limit constraints by granting control to an unintended party.
- Entry point: NodeOperators.proposeManagerAddress
- Anchors: NodeOperators.proposeManagerAddress; NodeOperators.confirmManagerAddress; NodeOperators.proposeRewardAddress; NodeOperators.confirmRewardAddress; BaseModule.getNodeOperator; BaseModule.proposeNodeOperatorManagerAddressChange; BaseModule.confirmNodeOperatorManagerAddressChange; BaseModule.proposeNodeOperatorRewardAddressChange; BaseModule.confirmNodeOperatorRewardAddressChange
- Related anchors: NOAddresses.proposeNodeOperatorManagerAddressChange; NOAddresses.confirmNodeOperatorManagerAddressChange; NOAddresses.proposeNodeOperatorRewardAddressChange; NOAddresses.confirmNodeOperatorRewardAddressChange
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Key Removal Targets Correct Index And (If CSM) Applies Removal Charge Path** (`no_remove_key_fee`)
- Scope: Includes `NodeOperators.removeKey` and the direct `removeKeys` invocation: ensure the wrapper cannot remove the wrong key index/operator and that it is broadcast as the manager. Where the module is `CSModule`, removal also traverses the key removal charge path; validate that the wrapper call meaningfully exercises that path (fee charging + parameter lookup) and cannot bypass it by malformed inputs. Excludes any broader fee configuration or accounting correctness outside the direct call chain initiated here.
- Entry point: NodeOperators.removeKey
- Anchors: NodeOperators.removeKey; BaseModule.removeKeys; CSModule.removeKeys; ParametersRegistry.getKeyRemovalCharge; Accounting.chargeFee
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Target Limit Updates Are Applied To The Intended Operator And Mode** (`no_target_limit_update`)
- Scope: Includes `NodeOperators.targetLimit` and the direct call to `updateTargetValidatorsLimits`, plus post-call reads of `BaseModule.getNodeOperator`. Validate that the wrapper cannot set limits on the wrong operator id or incorrectly assume mode/limit persistence, since target limits directly bound depositability. Excludes policy decisions about what limit/mode values are acceptable; the focus is correct wiring/execution from this script.
- Entry point: NodeOperators.targetLimit
- Anchors: NodeOperators.targetLimit; BaseModule.updateTargetValidatorsLimits; BaseModule.getNodeOperator
- Related anchors: NodeOperatorOps.setTargetLimit
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**StakingRouter-Driven Vetted/Exited Counters Cannot Desync Operator State** (`no_vetted_exited_counters`)
- Scope: Includes `NodeOperators.unvet` and `NodeOperators.exit` wrappers around `decreaseVettedSigningKeysCount` and `updateExitedValidatorsCount`. Validate that the script’s encoding/parameterization and assertions cannot produce inconsistent `totalVettedKeys`/`totalExitedKeys` updates for the wrong operator id, as these counters feed depositability and downstream key lifecycle transitions. Excludes the internal encoding helpers’ implementation details; only their effect at the called anchors matters for this work package.
- Entry point: NodeOperators.unvet
- Anchors: NodeOperators.unvet; NodeOperators.exit; BaseModule.decreaseVettedSigningKeysCount; BaseModule.updateExitedValidatorsCount; BaseModule.getNodeOperator
- Related anchors: NodeOperatorOps.decreaseVettedSigningKeysCount; NodeOperatorOps.updateExitedValidatorsCount
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Member Operational View Correctness (canReport, fastLane, member report)** (`hc-member-consensus-view`)
- Scope: Included: `HashConsensus.getConsensusStateForMember` correctness and consistency with actual submission rules: (a) `isMember`/`isFastLane` flags, (b) `canReport` computation vs current slot, refSlot, deadline, and processing state, (c) member’s last report refSlot and current-frame member report selection, (d) edge cases around fast-lane length and frame boundaries. Excluded: off-chain daemon correctness; this focus area is strictly about on-chain computed guidance being internally consistent and not misleading in ways that cause liveness or safety failures.
- Entry point: HashConsensus.getConsensusStateForMember
- Anchors: HashConsensus.getConsensusStateForMember; HashConsensus.getConsensusState; HashConsensus.getIsFastLaneMember; HashConsensus.getIsMember; HashConsensus.getCurrentFrame; HashConsensus.getFrameConfig; HashConsensus.getChainConfig
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance
- State machine relevance: Rewards Distribution / Strikes → Penalty + Ejection
**Initialization & Admin Wiring** (`cm-init`)
- Scope: In-scope: CuratedModule.initialize reinitialization semantics, admin/role wiring inherited from BaseModule, and CuratedModule’s immutable META_REGISTRY dependency assumptions as they affect all subsequent CuratedModule entry points. Out-of-scope: initializing other contracts (MetaRegistry, Accounting, ParametersRegistry) except where CuratedModule relies on their addresses/roles being correctly configured. Key security question: can CuratedModule be (re)initialized or left in a partially-initialized state that weakens role checks on deposit/top-up/allocation paths, violating depositability limits.
- Entry point: CuratedModule.initialize
- Anchors: CuratedModule.initialize; BaseModule.getInitializedVersion
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Top-Up Allocation Preview: View Consistency and DoS Surface** (`cm-topup-preview`)
- Scope: In-scope: CuratedModule.getDepositsAllocation as a pure preview of operator-level top-up allocation using CuratedDepositAllocator.allocateTopUps, including correctness of operatorId enumeration, edge cases (zero amount, zero operators), and revert/DoS risks from extreme operator counts or balances. Out-of-scope: any state mutation paths (handled elsewhere). Key security question: does the preview match the stateful top-up allocator’s operator-level behavior and remain reliably callable for monitoring/ops tooling.
- Entry point: CuratedModule.getDepositsAllocation
- Anchors: CuratedModule.getDepositsAllocation; CuratedDepositAllocator.allocateTopUps
- Related anchors: MetaRegistry.getNodeOperatorWeightAndExternalStake
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Top-Up Allocation (IStakingModuleV2)
**Emergency Address Changes: Role-Gated Manager/Reward Address Mutation** (`cm-address-admin`)
- Scope: In-scope: CuratedModule.changeNodeOperatorAddresses role gating (OPERATOR_ADDRESSES_ADMIN_ROLE) and the correctness/safety of NOAddresses.changeNodeOperatorAddresses updates to manager and reward addresses (including edge-case addresses like zero or duplicates if permitted). Out-of-scope: normal self-service manager/reward address change flows in BaseModule (propose/confirm) except where emergency changes interact with those state machines. Key security questions: can role checks be bypassed, can address changes brick an operator, and can they enable unauthorized control over operator operations/reward flows.
- Entry point: CuratedModule.changeNodeOperatorAddresses
- Anchors: CuratedModule.changeNodeOperatorAddresses; NOAddresses.changeNodeOperatorAddresses; BaseModule.getNodeOperatorManagementProperties; BaseModule.getNodeOperatorOwner
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Reward Share Interval Data Well-Formedness** (`pr-reward-share-data`)
- Scope: In-scope: `ParametersRegistry.getRewardShareData` return-shape and fallback behavior, ensuring it always returns a non-empty interval set that starts at key number 1 and uses valid BP values, so downstream/off-chain computations that parameterize reward splitting cannot encounter malformed data or unexpected empty responses. Out-of-scope: any specific fee distribution implementation details, except insofar as malformed reward-share inputs could lead to incorrect share accounting expectations. Key invariant targeted: reward distribution accounting safety depends on consistent, well-formed parameter reads feeding the system’s reward-share logic.
- Entry point: ParametersRegistry.getRewardShareData
- Anchors: ParametersRegistry.getRewardShareData
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance
- State machine relevance: Rewards Distribution
**Owner-Gated Bond Curve Claim (Allowlisted)** (`VG_CLAIM_CURVE`)
- Scope: Included: `claimBondCurve` authorization (only node operator owner), existence checks via `MODULE.getNodeOperatorOwner`, and replay-resistance via allowlist consumption for the claiming address. Excluded: Accounting-side validation/side effects of changing bond curve and any consequences for deposits already made.
- Entry point: VettedGate.claimBondCurve
- Anchors: VettedGate.claimBondCurve; BaseModule.getNodeOperatorOwner
- Related anchors: VettedGate.hashLeaf; VettedGate.isConsumed; VettedGate.verifyProof
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Asset Recovery: Recoverer Role Gate and Drains from Gate Address** (`VG_RECOVERY`)
- Scope: Included: enforcement of `RECOVERER_ROLE` via `VettedGate._onlyRecoverer` across the inherited AssetRecoverer public recovery entrypoints (ERC20/ERC721/ERC1155/ETH), and ensuring there is no alternate path to invoke recovery without the role (including through initializer/role misconfiguration). Excluded: token-standard quirks in third-party assets beyond their interaction with recovery calls; module/accounting asset flows (gate is not intended to custody user bond long-term).
- Entry point: VettedGate._onlyRecoverer
- Anchors: VettedGate._onlyRecoverer; AssetRecoverer.recoverERC20; AssetRecoverer.recoverERC721; AssetRecoverer.recoverERC1155; AssetRecoverer.recoverEther; AssetRecovererLib.recoverERC20; AssetRecovererLib.recoverERC721; AssetRecovererLib.recoverERC1155; AssetRecovererLib.recoverEther
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Pausing
**Initialization/Upgrade Gating and Admin Wiring** (`FD_INIT_UPGRADE`)
- Scope: Includes only initialization and upgrade-related entry points: correct one-time (per-version) initialization semantics for `FeeDistributor.initialize` and `FeeDistributor.finalizeUpgradeV3`, correct admin role assignment (DEFAULT_ADMIN_ROLE) at init time, and correct `getInitializedVersion` reporting. Excludes oracle reporting (`processOracleReport`), distribution/claims, and recovery flows except insofar as they depend on admin/role setup.
- Entry point: FeeDistributor.initialize
- Anchors: FeeDistributor.initialize; FeeDistributor.finalizeUpgradeV3; FeeDistributor.getInitializedVersion
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance
- State machine relevance: Rewards Distribution
**Asset Recovery Restrictions and Recoverer Role Checks** (`FD_RECOVERY`)
- Scope: Includes only the token recovery path `FeeDistributor.recoverERC20` and its role gate via `FeeDistributor._onlyRecoverer`: enforce that `RECOVERER_ROLE` is required, and that recovering `STETH` is prohibited (cannot drain the share-balance backing `totalClaimableShares`). Covers downstream library call correctness for ERC20 recovery. Excludes oracle/claim paths and all unrelated AssetRecoverer methods not overridden by FeeDistributor.
- Entry point: FeeDistributor.recoverERC20
- Anchors: FeeDistributor.recoverERC20; FeeDistributor._onlyRecoverer; AssetRecovererLib.recoverERC20
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance
- State machine relevance: Rewards Distribution
**Read-Only Accounting Views (Pending vs Claimable vs History)** (`FD_VIEWS`)
- Scope: Includes only read-only surfaces used by off-chain tooling and other contracts: `pendingSharesToDistribute` (relationship between `STETH.sharesOf(FeeDistributor)` and `totalClaimableShares`) and `getHistoricalDistributionData` (history indexing and data integrity expectations). Excludes all state mutation paths (`processOracleReport`, `distributeFees`, recovery, initialization).
- Entry point: FeeDistributor.pendingSharesToDistribute
- Anchors: FeeDistributor.pendingSharesToDistribute; FeeDistributor.getHistoricalDistributionData
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance
- State machine relevance: Rewards Distribution
**Initialization And Role Wiring** (`meta-init-acl`)
- Scope: Covers `MetaRegistry.initialize` and the resulting AccessControl wiring that gates all subsequent group/weight/metadata administration in MetaRegistry. Includes one-time initialization safety, zero-admin guard, and role separation assumptions for `DEFAULT_ADMIN_ROLE` vs MetaRegistry’s custom roles. Excludes CuratedModule allocation logic except insofar as compromised roles here can later corrupt MetaRegistry weight/group state used to gate depositability.
- Entry point: MetaRegistry.initialize
- Anchors: MetaRegistry.initialize
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Node Operator ID Pagination and Enumeration Correctness** (`noops-enumeration`)
- Scope: Covers NodeOperatorOps.getNodeOperatorIds pagination semantics used for enumerating nodeOperatorIds: empty-return conditions, array sizing, sequential ID generation, and unchecked arithmetic safety. Security question: can incorrect pagination (off-by-one, truncation, overflow) cause systemic monitoring/automation to miss operators, indirectly weakening enforcement/verification of depositability/limits by off-chain actors that rely on correct enumeration. Excludes any on-chain access control and all state mutations.
- Entry point: NodeOperatorOps.getNodeOperatorIds
- Anchors: NodeOperatorOps.getNodeOperatorIds; BaseModule.getNodeOperatorIds
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Asset Recovery Authorization (Recoverer-Only)** (`cg_asset_recovery_acl`)
- Scope: In-scope: authorization correctness of CuratedGate’s recovery surface as enforced by `CuratedGate._onlyRecoverer`, including ensuring all recovery entry points remain strictly gated to `RECOVERER_ROLE` and cannot be invoked via unexpected call paths or role misconfiguration. Out-of-scope: token-standard specific quirks beyond how the recovery functions invoke `_onlyRecoverer`.
- Entry point: CuratedGate._onlyRecoverer
- Anchors: CuratedGate._onlyRecoverer; AssetRecoverer.recoverERC1155; AssetRecoverer.recoverERC20; AssetRecoverer.recoverERC721; AssetRecoverer.recoverEther
- Related anchors: AssetRecovererLib.recoverERC1155; AssetRecovererLib.recoverERC20; AssetRecovererLib.recoverERC721; AssetRecovererLib.recoverEther
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Two-Step Manager Address Handover AuthZ** (`noaddr-mgr-2step`)
- Scope: Covers only the 2-step manager address change flow implemented in the NOAddresses library: proposing (`NOAddresses.proposeNodeOperatorManagerAddressChange`) and confirming (`NOAddresses.confirmNodeOperatorManagerAddressChange`) a new manager address for an existing node operator. Includes: existence check via `managerAddress != 0`, caller authorization (`msg.sender` must be current manager on propose and proposed manager on confirm), replay/grief controls (SameAddress/AlreadyProposed), state transitions on `proposedManagerAddress` and `managerAddress`, and correctness of emitted events and cleared proposal. Excludes: how managerAddress is used by other BaseModule actions (keys, exits, etc.) except as downstream impact if this flow is compromised.
- Entry point: NOAddresses.proposeNodeOperatorManagerAddressChange
- Anchors: NOAddresses.proposeNodeOperatorManagerAddressChange; NOAddresses.confirmNodeOperatorManagerAddressChange; BaseModule.proposeNodeOperatorManagerAddressChange; BaseModule.confirmNodeOperatorManagerAddressChange
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Two-Step Reward Address Handover AuthZ** (`noaddr-reward-2step`)
- Scope: Covers only the 2-step reward address change flow implemented in the NOAddresses library: proposing (`NOAddresses.proposeNodeOperatorRewardAddressChange`) and confirming (`NOAddresses.confirmNodeOperatorRewardAddressChange`) a new reward address for an existing node operator. Includes: existence check via `rewardAddress != 0`, caller authorization (`msg.sender` must be current reward address on propose and proposed reward on confirm), replay/grief controls (SameAddress/AlreadyProposed), state transitions on `proposedRewardAddress` and `rewardAddress`, and event correctness + proposal clearing. Excludes: downstream reward-claim logic in Accounting/FeeDistributor except insofar as reward-address takeover enables unauthorized claiming.
- Entry point: NOAddresses.proposeNodeOperatorRewardAddressChange
- Anchors: NOAddresses.proposeNodeOperatorRewardAddressChange; NOAddresses.confirmNodeOperatorRewardAddressChange; BaseModule.proposeNodeOperatorRewardAddressChange; BaseModule.confirmNodeOperatorRewardAddressChange
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Rewards Distribution
**Reward-Only Manager Reset (No Extended Perms)** (`noaddr-reset-mgr`)
- Scope: Covers only `NOAddresses.resetNodeOperatorManagerAddress`: resetting managerAddress to rewardAddress. Includes: existence check, prohibition when `extendedManagerPermissions` is enabled, caller authorization (must be current reward address), SameAddress guard, and correctness of side effects (managerAddress update, conditional deletion of `proposedManagerAddress`, event emission). Excludes: any other address-change mechanism except as needed to reason about whether pending proposals can/should be cleared here.
- Entry point: NOAddresses.resetNodeOperatorManagerAddress
- Anchors: NOAddresses.resetNodeOperatorManagerAddress; BaseModule.resetNodeOperatorManagerAddress
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Extended-Manager Immediate Reward Address Change** (`noaddr-extmgr-reward-change`)
- Scope: Covers only `NOAddresses.changeNodeOperatorRewardAddress`: one-step reward address change when `extendedManagerPermissions` is enabled. Includes: zero-address rejection, existence check, extended-permissions gating, caller authorization (must be current manager), SameAddress guard, conditional deletion of `proposedRewardAddress`, and event correctness. Excludes: the 2-step reward handover flow except as needed to reason about interactions with pending proposals.
- Entry point: NOAddresses.changeNodeOperatorRewardAddress
- Anchors: NOAddresses.changeNodeOperatorRewardAddress; BaseModule.changeNodeOperatorRewardAddress
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Rewards Distribution
**Oracle Root/CID Update & Wipe Semantics** (`vs_oracle_root`)
- Scope: Entry point `ValidatorStrikes.processOracleReport` only. Validate that strike data publication is correctly and exclusively controlled by `ORACLE` (no bypass of `onlyOracle`), and that `(treeRoot, treeCid)` transitions are coherent and non-malleable: empty-vs-nonempty parity checks, wipe behavior, idempotency, and rejection of mismatched root/CID pairs. Excludes consensus rules and report formation logic inside `FeeOracle` beyond the direct call path. Invariant focus: proofs/ejections must be bound to the current on-chain root and its associated CID.
- Entry point: ValidatorStrikes.processOracleReport
- Anchors: ValidatorStrikes.processOracleReport; FeeOracle.submitReportData; FeeOracle._handleConsensusReport
- Related anchors: FeeDistributor.processOracleReport
- Critical invariants: Strikes-Based Ejection Requires Proof Against Current Root And Threshold
- State machine relevance: Strikes
- Penalty + Ejection
**Initialization, Admin Role, and Ejector Wiring** (`vs_admin_init`)
- Scope: Entry point `ValidatorStrikes.initialize` and the observable upgradeability surface (`getInitializedVersion`). Verify initializer safety (single-use, constructor `_disableInitializers` assumption), correct `DEFAULT_ADMIN_ROLE` assignment, and safe/consistent ejector configuration at init time (non-zero address, emits). Excludes the internal behavior of the configured `IEjector` implementation after it is set. Invariant tie-in: misconfiguration must not enable proof-less or misrouted strikes-based ejection flows.
- Entry point: ValidatorStrikes.initialize
- Anchors: ValidatorStrikes.initialize; ValidatorStrikes.getInitializedVersion
- Related anchors: N/A
- Critical invariants: Strikes-Based Ejection Requires Proof Against Current Root And Threshold
- State machine relevance: Strikes
- Penalty + Ejection
**Expired-Phase Emergency Renounce Safety** (`tpfc_expired_renounce`)
- Scope: In-scope: `TwoPhaseFrameConfigUpdate.renounceRoleWhenExpired` as an emergency escape hatch; verify that it cannot be triggered while both phases are still live (prevent premature loss of ability to complete the intended two-phase update), and that it reliably removes this contract's ability to change the frame config once the procedure is no longer safely executable due to expiry. Out-of-scope: operational choice of calling this function; any third-party role/ACL configuration errors outside this helper's own checks.
- Entry point: TwoPhaseFrameConfigUpdate.renounceRoleWhenExpired
- Anchors: TwoPhaseFrameConfigUpdate.renounceRoleWhenExpired
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance; Strikes-Based Ejection Requires Proof Against Current Root And Threshold
- State machine relevance: Rewards Distribution (oracle report schedule)
**Readiness/Expiry View Accuracy** (`tpfc_introspection_views`)
- Scope: In-scope: view-layer correctness of `getExpirationStatus`, `isReadyForOffsetPhase`, and `isReadyForRestorePhase` in reflecting the same gating conditions that protect execution (executed flag, expiration via current slot, and expected `lastProcessingRefSlot` match; plus restore readiness depending on offset executed). This is primarily an operator-safety surface: incorrect readiness/expiry signals can cause missed windows or attempted executions that induce report-schedule drift. Out-of-scope: any side effects (none expected); external contracts' truthfulness beyond the values they return through the interfaces used.
- Entry point: TwoPhaseFrameConfigUpdate.getExpirationStatus
- Anchors: TwoPhaseFrameConfigUpdate.getExpirationStatus; TwoPhaseFrameConfigUpdate.isReadyForOffsetPhase; TwoPhaseFrameConfigUpdate.isReadyForRestorePhase; BaseOracle.getLastProcessingRefSlot
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance; Strikes-Based Ejection Requires Proof Against Current Root And Threshold
- State machine relevance: Rewards Distribution (oracle report schedule) / Strikes Penalty + Ejection (oracle report schedule)
**Consensus State Observability (Getters)** (`bo_observability_getters`)
- Scope: Entry point: `BaseOracle.getConsensusReport`. Includes correctness of externally observable consensus-processing state derived from BaseOracle storage: (a) `getConsensusReport` returns the last stored report fields and computes `processingStarted` consistently with `getLastProcessingRefSlot`, including post-discard (zero-hash) behavior; and (b) `getLastProcessingRefSlot` is coherent with the report lifecycle (submission, discard, and descendant-started processing). Excludes: any guarantees about report contents or downstream side effects after processing starts.
- Entry point: BaseOracle.getConsensusReport
- Anchors: BaseOracle.getConsensusReport; BaseOracle.getLastProcessingRefSlot; BaseOracle.submitConsensusReport; BaseOracle.discardConsensusReport
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance; Strikes-Based Ejection Requires Proof Against Current Root And Threshold
- State machine relevance: Rewards Distribution
**Penalty Registry Reads: Key Canonicalization and Observability** (`ep_penalty_query`)
- Scope: In-scope: `ExitPenalties.getExitPenaltyInfo` read semantics for the per-validator registry keyed by `_keyPointer(nodeOperatorId, pubkey)`, including (1) consistent key canonicalization expectations for `pubkey` bytes (exact bytes used in writes must be used in reads), (2) default-zero behavior for never-reported keys and how that can be safely consumed by off-chain/other components, and (3) invariants around per-field independence within `ExitPenaltyInfo` (delay/strikes/EL fee marked fields).
Out-of-scope: authorization/policy for who should be able to observe penalty data (this function is intentionally public) and any downstream interpretation logic outside ExitPenalties.
- Entry point: ExitPenalties.getExitPenaltyInfo
- Anchors: ExitPenalties.getExitPenaltyInfo
- Related anchors: N/A
- Critical invariants: Strikes-Based Ejection Requires Proof Against Current Root And Threshold
- State machine relevance: Strikes → Penalty + Ejection
**Upgrade Finalization And Consensus-Version Control Safety** (`fo_upgrade_finalize_v3`)
- Scope: IN: `FeeOracle.finalizeUpgradeV3` behavior and controls: who can call it, how it mutates consensus-version expectations, and how it locks in the new `contractVersion`.
OUT: Other contract upgrades (FeeDistributor/ValidatorStrikes) except where `FeeOracle` finalization ordering or access can break the report pipeline.
Key question: can an untrusted party finalize the upgrade (or choose a malicious `consensusVersion`) and permanently DoS or subvert report acceptance, thereby undermining downstream invariants?
- Entry point: FeeOracle.finalizeUpgradeV3
- Anchors: FeeOracle.finalizeUpgradeV3; BaseOracle.getConsensusVersion; Versioned.getContractVersion
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance; Strikes-Based Ejection Requires Proof Against Current Root And Threshold
- State machine relevance: Rewards Distribution + Strikes → Penalty + Ejection
**Asset Recovery Guardrail And Role Enforcement** (`fo_recovery_acl`)
- Scope: IN: The authorization boundary implemented by `FeeOracle._onlyRecoverer` as it protects inherited `AssetRecoverer` entry points, including delegatecall-style library recovery flows.
OUT: Recoverability policy decisions for other contracts (e.g., FeeDistributor’s additional restriction against recovering stETH) except insofar as FeeOracle’s recovery surface can be abused to grief operations or create misleading assumptions about ‘no funds stored’.
Key question: is recovery strictly limited to the intended role, and can recovery calls be abused (via reentrancy/griefing or misconfiguration) to interfere with oracle report processing safety expectations?
- Entry point: FeeOracle._onlyRecoverer
- Anchors: FeeOracle._onlyRecoverer; AssetRecoverer.recoverEther; AssetRecoverer.recoverERC20; AssetRecoverer.recoverERC721; AssetRecoverer.recoverERC1155; AssetRecovererLib.recoverEther; AssetRecovererLib.recoverERC20; AssetRecovererLib.recoverERC721; AssetRecovererLib.recoverERC1155
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance; Strikes-Based Ejection Requires Proof Against Current Root And Threshold
- State machine relevance: Rewards Distribution
**Asset Recovery Authorization via _onlyRecoverer Override** (`ej-recovery-authz`)
- Scope: Includes only the authorization boundary created by `Ejector._onlyRecoverer` for inherited asset-recovery entry points: ensure only intended role holders can recover ETH/tokens/NFTs from the Ejector address and that recovery cannot be abused to interfere with or spoof the ejection pipeline (e.g., by draining unexpected balances that should not be relied upon for exits). Excludes recovery business logic in `AssetRecoverer`/`AssetRecovererLib` except as exercised through the listed recovery anchors. Key invariants: privileged controls must not enable bypass of the strikes-based ejection trust boundary (only STRIKES can trigger) or undermine the protocol’s intended ejection flow.
- Entry point: Ejector._onlyRecoverer
- Anchors: Ejector._onlyRecoverer; AssetRecoverer.recoverEther; AssetRecoverer.recoverERC20; AssetRecoverer.recoverERC721; AssetRecoverer.recoverERC1155
- Related anchors: AssetRecovererLib.recoverEther; AssetRecovererLib.recoverERC20; AssetRecovererLib.recoverERC721; AssetRecovererLib.recoverERC1155
- Critical invariants: Strikes-Based Ejection Requires Proof Against Current Root And Threshold
- State machine relevance: Strikes → Penalty + Ejection
**ETH Recovery Send Semantics** (`arl_eth_recover`)
- Scope: Includes only `AssetRecovererLib.recoverEther` behavior when invoked via a recoverer wrapper (delegatecall context): full-balance sweep, recipient = `msg.sender`, revert/DoS conditions on failed send, and reentrancy implications of forwarding all gas. Excludes recoverer-role assignment/governance and any non-recovery ETH flows; treats authorization as a strict precondition enforced by the direct wrapper.
- Entry point: AssetRecovererLib.recoverEther
- Anchors: AssetRecovererLib.recoverEther; AssetRecoverer.recoverEther; Accounting._onlyRecoverer; BaseModule._onlyRecoverer; CuratedGate._onlyRecoverer
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Rewards Distribution
**Recovery ACL: RECOVERER_ROLE Guard Correctness** (`pg_recovery_acl`)
- Scope: Covers the PermissionlessGate recovery guard override and its effectiveness at restricting AssetRecoverer recovery entry points. Included: that PermissionlessGate._onlyRecoverer enforces RECOVERER_ROLE consistently for all recovery methods, and that no recovery path can be used to interfere with onboarding assumptions (e.g., by enabling unintended custody/asset extraction from the gate in the presence of forced transfers). Excluded: non-gate recovery logic internals beyond the direct guard->library invocation chain.
- Entry point: PermissionlessGate._onlyRecoverer
- Anchors: PermissionlessGate._onlyRecoverer; AssetRecoverer.recoverEther; AssetRecoverer.recoverERC20; AssetRecoverer.recoverERC721; AssetRecoverer.recoverERC1155; AssetRecovererLib.recoverEther; AssetRecovererLib.recoverERC20; AssetRecovererLib.recoverERC721; AssetRecovererLib.recoverERC1155
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Bond ETH-Equivalent Reporting And Unit Conversion Safety** (`bond-unit-conversions`)
- Scope: Includes BondCore’s shares↔ETH conversion surfaces as observed via `BondCore.getBond` (and its consistency with `BondCore.getBondShares`). Audit question: do conversions and rounding behaviors (including negative-rebase scenarios and pooled-ETH/share rate changes) ever cause materially incorrect bond amounts to be reported/consumed by direct callers, leading to incorrect decisions about bonding sufficiency or claim amounts. Explicitly includes checking unit consistency assumptions in paths that translate user-requested amounts into shares (notably wstETH- and unstETH-related flows) as they affect the underlying bond share state. Excludes: computation of required bond curves/limits themselves (policy), except where they directly depend on BondCore’s reported units.
- Entry point: BondCore.getBond
- Anchors: BondCore.getBond; BondCore.getBondShares; Accounting.getBondSummary; Accounting.getBondSummaryShares; Accounting.getRequiredBondForNextKeys; Accounting.getRequiredBondForNextKeysWstETH; Accounting.getBondAmountByKeysCountWstETH
- Related anchors: BondCore.getBondDebt; BaseModule.getNodeOperatorNonWithdrawnKeys; BondCurve.getBondAmountByKeysCount; BondCurve.getBondCurveId; BondLock.getActualLockedBond
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**ETH Recovery Sweep Semantics** (`ar_eth_sweep`)
- Scope: Covers `AssetRecoverer.recoverEther` and its direct call into `AssetRecovererLib.recoverEther`: verify the recover action is strictly gated by `_onlyRecoverer()` before any external interaction, and that it transfers the full `address(this).balance` to `msg.sender` with correct failure handling. Excludes correctness of `_onlyRecoverer()` implementations in inheriting contracts and any non-`AssetRecoverer` state that might rely on retaining ETH balances.
- Entry point: AssetRecoverer.recoverEther
- Anchors: AssetRecoverer.recoverEther; AssetRecovererLib.recoverEther
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance
- State machine relevance: Rewards Distribution
**ERC721 Recovery (safeTransfer) Hook Surface** (`ar_erc721_recover`)
- Scope: Covers `AssetRecoverer.recoverERC721` and its direct call into `AssetRecovererLib.recoverERC721`: verify `_onlyRecoverer()` is checked before the external token call, and that `safeTransferFrom(address(this), msg.sender, tokenId)` is the only asset movement. Consider reentrancy/side effects via ERC721 receiver hooks (when `msg.sender` is a contract) and via malicious token contracts; confirm `AssetRecoverer` itself has no state that can be corrupted during callbacks.
- Entry point: AssetRecoverer.recoverERC721
- Anchors: AssetRecoverer.recoverERC721; AssetRecovererLib.recoverERC721
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance
- State machine relevance: Rewards Distribution
**ERC1155 Full-Balance Sweep + Receiver Hooks** (`ar_erc1155_recover`)
- Scope: Covers `AssetRecoverer.recoverERC1155` and its direct call into `AssetRecovererLib.recoverERC1155`: verify `_onlyRecoverer()` is checked before any external calls, and that recovery semantics are an all-or-nothing sweep of the contract’s entire balance for `(token, tokenId)` (no partial amount), sent to `msg.sender`. Consider callback/reentrancy surface via ERC1155 receiver hooks when `msg.sender` is a contract, and DoS risks from non-compliant ERC1155 tokens.
- Entry point: AssetRecoverer.recoverERC1155
- Anchors: AssetRecoverer.recoverERC1155; AssetRecovererLib.recoverERC1155
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance
- State machine relevance: Rewards Distribution
**Penalty Cancellation Cannot Over-Release Locked Bond** (`gp_cancel`)
- Scope: Includes only `GeneralPenalty.cancelGeneralDelayedPenalty`: releasing previously locked bond for a node operator, correctness of the emitted cancellation event, and the depositability refresh after unlocking. Excludes: authorization (caller must gate), and Accounting’s internal enforcement of “cannot release more than locked” (but this focus area should explicitly test whether the library+caught-callee composition permits over-release via the `amount` parameter).
Key question: can cancellation be used (intentionally or by parameter confusion) to unlock more than the operator’s active delayed-penalty lock, or to regain depositability beyond bonding/limits due to missing/incorrect refresh?
- Entry point: GeneralPenalty.cancelGeneralDelayedPenalty
- Anchors: GeneralPenalty.cancelGeneralDelayedPenalty; BaseModule.cancelGeneralDelayedPenalty; Accounting.releaseLockedBondETH; BaseModule.updateDepositableValidatorsCount
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Settlement Gating And Skip Semantics Are Safe** (`gp_settle`)
- Scope: Includes only `GeneralPenalty.settleGeneralDelayedPenalty`: the decision logic that checks current locked bond and `maxAmount`, the “skip” behavior (returns `false` without side effects), and the all-or-nothing settlement call and event emission when eligible. Excludes: how the caller iterates node operators / chooses `maxAmount`, and any downstream deactivation/ejection logic not in this library.
Key question: can the `locked > maxAmount` / `locked == 0` gating be abused to indefinitely avoid settlement or to settle an amount larger than intended (e.g., due to race/reentrancy across the external calls), undermining delayed-penalty lifecycle expectations that feed the uncovered-penalty invariant?
- Entry point: GeneralPenalty.settleGeneralDelayedPenalty
- Anchors: GeneralPenalty.settleGeneralDelayedPenalty; BaseModule.settleGeneralDelayedPenalty; BondLock.getActualLockedBond; Accounting.settleLockedBondETH
- Related anchors: NodeOperatorOps.setTargetLimit
- Critical invariants: Uncovered Penalty Deactivates Operator
- State machine relevance: Key Lifecycle & Depositability
**Default Lock Period Bounds And Initialization** (`bondlock_period`)
- Scope: Scope: BondLock’s default lock period as observed via `BondLock.getBondLockPeriod`, including initializer-time setup (`__BondLock_init` -> `_setBondLockPeriod`) and constructor-imposed bounds via `MIN_BOND_LOCK_PERIOD`/`MAX_BOND_LOCK_PERIOD` (and the `type(uint64).max` cap that protects `until` computation in `_lock`). Included: correctness and upgrade-safety of the ERC-7201 storage slot used to persist the period; behavior when `initialize` sets the period. Excluded: role/ACL correctness for changing the period in inheritors (not an anchor here) and any non-BondLock business logic in `Accounting` beyond calling `__BondLock_init`. Key question: can the period be set/left in a state that enables locking logic to revert/overflow or violates the intended bounded time window, indirectly breaking depositability/penalty flows relying on locks?
- Entry point: BondLock.getBondLockPeriod
- Anchors: BondLock.getBondLockPeriod; Accounting.initialize
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Ossification Semantics, Irreversibility, and Observability** (`op-ossification-lock`)
- Scope: In-scope: the definition of “ossified” as admin == address(0), its observability via `proxy__getIsOssified()`, and the safety guarantees that follow: once ossified, admin-only actions must be permanently unavailable and cannot be resurrected by any call path (including via implementation delegatecalls). Validate that ossification via admin-to-zero behaves consistently with authorization checks and with operational tooling that relies on `proxy__getIsOssified()`/`proxy__getAdmin()`. Out-of-scope: governance/process correctness for deciding when to ossify; only the on-chain enforceability is in scope.
- Entry point: OssifiableProxy.proxy__getIsOssified
- Anchors: OssifiableProxy.proxy__getIsOssified
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Initialization Parameter Validation And Atomicity** (`vgf_init_atomicity`)
- Scope: Includes only the atomicity and correctness of initialization triggered by `VettedGateFactory.create`: the proxy must not be left in an uninitialized or partially-initialized state, and `curveId/treeRoot/treeCid/admin` passed via `create` must be validated and applied exactly once through `VettedGate.initialize`. Excludes Merkle proof consumption, referral-season logic, and all onboarding actions beyond the initialization boundary (those belong to `VettedGate` itself).
- Entry point: VettedGateFactory.create
- Anchors: VettedGateFactory.create; VettedGate.initialize; VettedGate.getInitializedVersion
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Initialization Args Integrity (Curve + Merkle Tree)** (`cgf_init_args_integrity`)
- Scope: Includes `CuratedGateFactory.create` pass-through of initialization arguments into `CuratedGate.initialize` (curve selection, Merkle tree root/CID, and role admin) and the atomicity of “deploy proxy then initialize gate” within one transaction (no successful return with an uninitialized or inconsistently-initialized gate). Excludes Merkle tree correctness beyond what is directly implied by the factory-controlled initial values (i.e., does not audit proof verification implementation itself except as a consumer of the initialized `treeRoot/treeCid`). Key security question: can incorrect or attacker-chosen initialization parameters (or partial initialization) allow onboarding outside intended constraints, ultimately threatening bounded depositability assumptions?
- Entry point: CuratedGateFactory.create
- Anchors: CuratedGateFactory.create; CuratedGate.initialize; CuratedGate.verifyProof; CuratedGate.hashLeaf; CuratedGate.isConsumed; CuratedGate.createNodeOperator
- Related anchors: CuratedModule.createNodeOperator
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Penalty Multiplier, Caps, And Scaling Math Safety** (`wvl_penalty_scaling_math`)
- Scope: Includes the penalty-multiplier computation and scaled-fee / scaled-penalty math performed inside `WithdrawnValidatorLib.process`, with emphasis on unit consistency, rounding behavior, and overflow/revert boundaries when scaling `delayFee`/`strikesPenalty` by an exit-balance-derived multiplier capped to `[32 ETH, 2048 ETH]`. Excludes how `ExitPenalties` derives/stores the underlying penalty values (only reads via `getExitPenaltyInfo`).
- Entry point: WithdrawnValidatorLib.process
- Anchors: WithdrawnValidatorLib.process; ExitPenalties.getExitPenaltyInfo
- Related anchors: Accounting.chargeFee; Accounting.penalize
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits; Uncovered Penalty Deactivates Operator
- State machine relevance: Withdrawals / Slashings / Consolidations
**Pubkey Authenticity Check vs Module Storage and Input Validation** (`topup_key_verification`)
- Scope: In scope: `TopUpQueueOps.allocateDeposits`’s verification that each supplied `pubkeys[i]` matches the module’s stored signing key for `(noId,keyIndex)` (including strict length checks and hash-comparison safety), and that invalid/malformed keys cannot be used to obtain allocations for different validators. Out of scope: correctness of signing-key storage management (adds/removals) except to ensure this entry point’s lookup assumptions are safe.
- Entry point: TopUpQueueOps.allocateDeposits
- Anchors: TopUpQueueOps.allocateDeposits; CSModule.allocateDeposits
- Related anchors: NodeOperatorOps.capTopUpLimitsByKeyBalance; NodeOperatorOps.increaseKeyAddedBalancesByAllocations
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Top-Up Allocation (IStakingModuleV2)
**Deployment Artifact Output Integrity** (`deploy_artifact_persistence`)
- Scope: In scope: deploy artifact path derivation and persistence performed by `DeployTwoPhaseFrameConfigUpdateBase.run` (ARTIFACTS_DIR override vs default, chainName-based filename, and values written: deployed address, encoded params, git ref). Out of scope: downstream tooling correctness that consumes these artifacts, except where incorrect/malleable output would plausibly drive a wrong on-chain operational action. Key security question: can artifact output be inconsistent/malleable enough (env override, path construction, or fields written) to cause operators to execute subsequent steps against the wrong deployment or with mismatched parameters?
- Entry point: DeployTwoPhaseFrameConfigUpdateBase.run
- Anchors: DeployTwoPhaseFrameConfigUpdateBase.run
- Related anchors: N/A
- Critical invariants: FeeDistributor Accounting Never Exceeds Its stETH Share Balance
- State machine relevance: Rewards Distribution
**NO Creation: Role Gate, ID Assignment, Nonce Bump** (`no_create_role_nonce`)
- Scope: Covers `BaseModule.createNodeOperator` as the creation choke point: `CREATE_NODE_OPERATOR_ROLE` enforcement, `nodeOperatorId = _nodeOperatorsCount` assignment, `OperatorTracker.recordCreator(nodeOperatorId)` side effects, incrementing `_nodeOperatorsCount`, and `_incrementModuleNonce()` / `NonceChanged` emission. Includes creation-time event surface via downstream calls (NO added + optional referrer). Excludes gate allowlist/merkle eligibility logic, curve assignment, and any key-upload/bonding flows after creation.
- Entry point: src/abstract/BaseModule.sol:BaseModule.createNodeOperator
- Anchors: BaseModule.createNodeOperator; OperatorTracker.recordCreator; BaseModule._incrementModuleNonce; NonceChanged
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**NO Creation: Manager/Reward Defaults and Extended Permissions Flag** (`no_create_props_init`)
- Scope: Covers `NodeOperatorOps.createNodeOperator` initialization semantics invoked by `BaseModule.createNodeOperator`: nonzero `from` requirement, defaulting `managerAddress`/`rewardAddress` to `from` when zero-valued in `managementProperties`, persistence of `extendedManagerPermissions` (only set when true), and creation-time events (`NodeOperatorAdded`, `ReferrerSet`). Excludes how gates choose `from`/`managementProperties` and any later address changes.
- Entry point: src/lib/NodeOperatorOps.sol:NodeOperatorOps.createNodeOperator
- Anchors: NodeOperatorOps.createNodeOperator; IBaseModule.NodeOperatorAdded; IBaseModule.ReferrerSet
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Manager Address: Propose/Confirm Two-Step Rotation** (`mgr_addr_rotation_2step`)
- Scope: Covers the full two-step manager rotation: propose authorization (current `managerAddress` only), existence checks, duplicate/same-address handling, proposed-address storage overwrite semantics, and confirm authorization (only `proposedManagerAddress` can confirm) including deletion of the proposal after confirmation. Excludes manager reset and any reward-address flows.
- Entry point: src/lib/NOAddresses.sol:NOAddresses.proposeNodeOperatorManagerAddressChange
- Anchors: NOAddresses.proposeNodeOperatorManagerAddressChange; NOAddresses.confirmNodeOperatorManagerAddressChange; INOAddresses.NodeOperatorManagerAddressChangeProposed; INOAddresses.NodeOperatorManagerAddressChanged
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Reward Address: Propose/Confirm Two-Step Rotation** (`reward_addr_rotation_2step`)
- Scope: Covers the full two-step reward rotation: propose authorization (current `rewardAddress` only), existence checks, duplicate/same-address handling, proposed-address storage overwrite semantics, and confirm authorization (only `proposedRewardAddress` can confirm) including deletion of the proposal after confirmation. Excludes the extended-permissions direct-change path and all manager-address flows.
- Entry point: src/lib/NOAddresses.sol:NOAddresses.proposeNodeOperatorRewardAddressChange
- Anchors: NOAddresses.proposeNodeOperatorRewardAddressChange; NOAddresses.confirmNodeOperatorRewardAddressChange; INOAddresses.NodeOperatorRewardAddressChangeProposed; INOAddresses.NodeOperatorRewardAddressChanged
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Rewards Distribution
**Extended Permissions: Manager-Only Direct Reward Change** (`mgr_direct_reward_change`)
- Scope: Covers `changeNodeOperatorRewardAddress` when `extendedManagerPermissions` is enabled: gating on `no.extendedManagerPermissions`, caller must equal current `managerAddress`, nonzero `newAddress`, same-address rejection, and the side effect of clearing any outstanding `proposedRewardAddress` to prevent stale-confirm races. Excludes the two-step reward propose/confirm path and all manager-address mutations.
- Entry point: src/lib/NOAddresses.sol:NOAddresses.changeNodeOperatorRewardAddress
- Anchors: NOAddresses.changeNodeOperatorRewardAddress; INOAddresses.NodeOperatorRewardAddressChanged
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Rewards Distribution
**Reward-Driven Manager Reset (Non-Extended Mode Only)** (`reward_reset_manager`)
- Scope: Covers `resetNodeOperatorManagerAddress` recovery path: only callable by `rewardAddress`, forbidden when `extendedManagerPermissions` is true, sets `managerAddress = rewardAddress`, rejects already-equal addresses, and clears any outstanding `proposedManagerAddress`. Excludes the two-step manager propose/confirm flow and all reward-address mutations.
- Entry point: src/lib/NOAddresses.sol:NOAddresses.resetNodeOperatorManagerAddress
- Anchors: NOAddresses.resetNodeOperatorManagerAddress; INOAddresses.NodeOperatorManagerAddressChanged
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**CuratedModule Emergency: Admin Changes Both Addresses** (`cm_emergency_change_both`)
- Scope: Covers the privileged “set both” surface in `CuratedModule.changeNodeOperatorAddresses`: `OPERATOR_ADDRESSES_ADMIN_ROLE` enforcement and delegation into `NOAddresses.changeNodeOperatorAddresses`, which explicitly performs no caller authentication internally. Includes post-condition hazards specific to this path (e.g., whether stale `proposedManagerAddress` / `proposedRewardAddress` should be cleared to avoid unexpected later `confirm*` takeovers after an admin intervention). Excludes any gate onboarding/eligibility logic and all non-CM modules.
- Entry point: src/CuratedModule.sol:CuratedModule.changeNodeOperatorAddresses
- Anchors: CuratedModule.changeNodeOperatorAddresses; NOAddresses.changeNodeOperatorAddresses; CuratedModule.OPERATOR_ADDRESSES_ADMIN_ROLE
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Rewards Distribution
**Authority Views: Owner and Management Properties Semantics** (`authority_views`)
- Scope: Covers authority inference via view methods used by integrators and other module code paths: `getNodeOperatorManagementProperties` correctness and `getNodeOperatorOwner` semantics (owner is `managerAddress` iff `extendedManagerPermissions` is true, else `rewardAddress`). Includes edge cases around nonexistent operators (zero addresses) and ensuring the owner definition matches the intended extended-permissions model relied on elsewhere. Excludes all state-changing address management.
- Entry point: src/abstract/BaseModule.sol:BaseModule.getNodeOperatorOwner
- Anchors: BaseModule.getNodeOperatorManagementProperties; BaseModule.getNodeOperatorOwner
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Rewards Distribution
**Key Upload + Bond Funding Atomicity** (`keys_add_bond`)
- Scope: Entry via `BaseModule.addValidatorKeysETH`. Includes all key-upload entrypoints and their bonding requirements: ETH/stETH/wstETH paths, required-bond calculation, value/permit plumbing, and post-add depositability update triggered by key uploads. Primary invariants: adding keys must not make an operator depositable beyond limits or without sufficient bond; gate-based key upload must be restricted to the recorded creator and single-use as intended. Excludes Accounting internals (share math), and concrete-module deposit consumption/queueing.
- Entry point: BaseModule.addValidatorKeysETH
- Anchors: BaseModule.addValidatorKeysETH; BaseModule.addValidatorKeysStETH; BaseModule.addValidatorKeysWstETH; Accounting.getRequiredBondForNextKeys; Accounting.getRequiredBondForNextKeysWstETH; Accounting.depositETH; Accounting.depositStETH; Accounting.depositWstETH; Accounting.getUnbondedKeysCount; ParametersRegistry.getKeysLimit
- Related anchors: BaseModule.getNodeOperatorNonWithdrawnKeys; BaseModule.updateDepositableValidatorsCount; BondCore.getBond; BondCore.getBondDebt; BondCurve.getBondAmountByKeysCount; BondCurve.getBondCurveId; BondLock.getActualLockedBond; BondCurve.getKeysCountByBondAmount
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Node Operator Onboarding
**Target Limit Mutation Pipeline** (`tgt_limit_mutation`)
- Scope: Covers the staking-router entry `BaseModule.updateTargetValidatorsLimits` and its immediate target-limit mutation path (`_setTargetLimit` -> `NodeOperatorOps.setTargetLimit`). Includes validation of `targetLimitMode`/`targetLimit`, existence checks, idempotency/no-op behavior, and event emission. Key invariant: target limits cannot be set to values/modes that allow depositability beyond intended caps, especially around forced mode semantics. Excludes deposit queue mechanics and any Accounting bond math beyond its effect on depositability recomputation.
- Entry point: src/abstract/BaseModule.sol:updateTargetValidatorsLimits
- Anchors: src/abstract/BaseModule.sol:_setTargetLimit; src/lib/NodeOperatorOps.sol:setTargetLimit; src/interfaces/IStakingModule.sol:FORCED_TARGET_LIMIT_MODE_ID
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits; Uncovered Penalty Deactivates Operator
- State machine relevance: Key Lifecycle & Depositability
**Depositable Recompute Math (Vetted/Deposited/Unbonded/Target)** (`dep_recompute_math`)
- Scope: Covers the pure recomputation logic that `updateTargetValidatorsLimits` relies on: `_updateDepositableValidatorsCount` computation of `newCount` from `totalVettedKeys - totalDepositedKeys`, adjustment for Accounting-reported unbonded keys, and capping by target-limit remaining capacity computed from non-withdrawn deposited keys. Invariants: depositable count is upper-bounded by (vetted minus deposited), target limits, and unbonded-key constraints, and must not underflow/overflow across uint32/uint64 boundaries. Excludes how deposits are allocated/queued (CSM/CM obtainDepositData) and excludes Accounting’s internal bond accounting logic (only the returned unbonded-key counts are in-scope).
- Entry point: src/abstract/BaseModule.sol:_updateDepositableValidatorsCount
- Anchors: src/interfaces/IAccounting.sol:getUnbondedKeysCount; src/Accounting.sol:_getUnbondedKeysCount; src/abstract/BaseModule.sol:getNodeOperatorNonWithdrawnKeys
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits; Withdrawn/Slashed Flags Are Single-Assignment Per Key
- State machine relevance: Key Lifecycle & Depositability
**Global Depositability Counter + Nonce Consistency** (`dep_apply_global_consistency`)
- Scope: Covers how per-operator depositability updates are applied and reflected globally: `_applyDepositableValidatorsCount` updates `NodeOperator.depositableValidatorsCount` (uint32), updates global `_depositableValidatorsCount` (uint64), emits `DepositableSigningKeysCountChanged`, and conditionally increments module nonce. Invariants: global counters remain consistent with per-operator fields (sum semantics) and nonce semantics are correct for downstream router consumers. Excludes module-specific side effects that occur after applying counts (e.g., CSM queue enqueues) and excludes deposit execution paths that directly mutate counters in `obtainDepositData`.
- Entry point: src/abstract/BaseModule.sol:_applyDepositableValidatorsCount
- Anchors: src/abstract/BaseModule.sol:_incrementModuleNonce; src/abstract/BaseModule.sol:getStakingModuleSummary; src/interfaces/IStakingModule.sol:getNonce
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**CuratedModule Depositability Weight Gating** (`curated_weight_gating`)
- Scope: Covers CuratedModule’s override of `_applyDepositableValidatorsCount` that can force `newCount` to zero when deposit allocation weight is zero, and its nonce behavior when weights change without a depositable-count change. Invariants: depositability must not become non-zero when the operator is ineligible by weight, and nonce changes must correctly signal state affecting deposit allocation. Excludes allocator math (`CuratedDepositAllocator`) and excludes `updateOperatorBalances`/bond accounting.
- Entry point: src/CuratedModule.sol:_applyDepositableValidatorsCount
- Anchors: src/CuratedModule.sol:_requireNodeOperatorWeightsUpToDate; src/interfaces/IParametersRegistry.sol:getDepositAllocationWeight
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**StakingRouter Vetted-Count Decrease and Recompute** (`router_vetted_decrease`)
- Scope: Covers staking-router-driven reductions of vetted keys via `BaseModule.decreaseVettedSigningKeysCount` and `NodeOperatorOps.decreaseVettedSigningKeysCount`, including report parsing/length checks, per-operator pointer validation (cannot go below deposited), and the subsequent recomputation trigger via `module.updateDepositableValidatorsCount(nodeOperatorId)` (external self-call). Invariants: depositable remains upper-bounded by vetted-minus-deposited and target/unbonded constraints after router decreases, and updates do not desync global vs per-operator counters. Excludes non-router key add/remove flows and excludes deposit queue mechanics.
- Entry point: src/lib/NodeOperatorOps.sol:decreaseVettedSigningKeysCount
- Anchors: src/abstract/BaseModule.sol:decreaseVettedSigningKeysCount; src/abstract/BaseModule.sol:updateDepositableValidatorsCount; src/lib/ValidatorCountsReport.sol:safeCountOperators
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**StakingRouter Exited-Count Updates (Global/Per-Operator Consistency)** (`router_exited_count_updates`)
- Scope: Covers staking-router-driven exited counter updates via `BaseModule.updateExitedValidatorsCount` and `NodeOperatorOps.updateExitedValidatorsCount`, including report parsing and the update rule for `_totalExitedValidators` vs each operator’s `totalExitedKeys`. Invariants: module-wide exited totals remain consistent with per-operator exited fields and updates must not enable unintended reactivation/cap bypasses via inconsistent lifecycle counters (even if exited is not currently used in `_updateDepositableValidatorsCount`). Excludes depositability math except where it depends on shared counters (deposited/withdrawn) and excludes deposit allocation/queue logic.
- Entry point: src/lib/NodeOperatorOps.sol:updateExitedValidatorsCount
- Anchors: src/abstract/BaseModule.sol:updateExitedValidatorsCount; src/lib/ValidatorCountsReport.sol:next; src/abstract/BaseModule.sol:getStakingModuleSummary
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Uncovered Penalty Forced Deactivation and Reactivation Risks** (`uncovered_penalty_forced_deactivate`)
- Scope: Covers the forced deactivation hook `_onUncompensatedPenalty` which sets forced target limit to zero, and the interplay with later calls to `updateTargetValidatorsLimits` and recomputation that could unintentionally restore depositability. Invariants: an uncovered penalty must deactivate the operator (no reactivation beyond intended caps) and depositability remains upper-bounded under forced limits. Excludes the internal accounting of penalties/bond coverage in `WithdrawnValidatorLib` and excludes slashing/withdrawal proof verification logic (only the resulting deactivation effect is in-scope).
- Entry point: src/abstract/BaseModule.sol:_onUncompensatedPenalty
- Anchors: src/abstract/BaseModule.sol:_reportWithdrawnValidators; src/lib/WithdrawnValidatorLib.sol:process; src/interfaces/IStakingModule.sol:FORCED_TARGET_LIMIT_MODE_ID
- Related anchors: N/A
- Critical invariants: Uncovered Penalty Deactivates Operator; Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Withdrawals / Slashings / Consolidations
**Permissionless Depositable Recompute Endpoint and Nonce Effects** (`permissionless_dep_recompute_endpoint`)
- Scope: Covers the public entry `BaseModule.updateDepositableValidatorsCount(nodeOperatorId)` as an explicit recomputation trigger, including its nonce-increment semantics (`incrementNonceIfUpdated=true`) and any differences from the router-driven recompute used in `updateTargetValidatorsLimits` (which increments nonce unconditionally after the call). Invariants: recomputation cannot create depositability beyond vetted/target/unbonded bounds; nonce changes must not enable griefing or state desync for the staking router (only changes when count changes). Excludes target-limit mutation itself (handled by the target-limit sub-area).
- Entry point: src/abstract/BaseModule.sol:updateDepositableValidatorsCount
- Anchors: src/abstract/BaseModule.sol:_updateDepositableValidatorsCount; src/abstract/BaseModule.sol:_applyDepositableValidatorsCount; src/abstract/BaseModule.sol:updateTargetValidatorsLimits
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Removal Authorization + Deposited-Key Guard** (`rm-auth-deposited-guard`)
- Scope: Covers `BaseModule.removeKeys` preconditions only: caller authorization (`_onlyNodeOperatorManager`) and the deposited-keys protection (`startIndex >= totalDepositedKeys`) that must prevent any mutation/removal of already-deposited validator keys. Includes error-path correctness (`SigningKeysInvalidOffset`) and edge cases around `startIndex == totalDepositedKeys`. Excludes the actual storage mutation algorithm (`SigningKeys.removeKeysSigs`) and any concrete-module removal fees/charges.
- Entry point: BaseModule.removeKeys
- Anchors: BaseModule.removeKeys
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key; Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability; Withdrawals / Slashings / Consolidations
**Key Storage Swap-Delete Range Safety** (`rm-storage-swap-delete`)
- Scope: Covers `SigningKeys.removeKeysSigs` correctness: bounds (`keysCount != 0`, `startIndex + keysCount <= totalKeysCount`), off-by-one safety in the reverse loop, swap-with-last semantics, and storage clearing (5-slot key+sig layout). Key invariant: removal must not allow underflow/overflow in indices, must not corrupt keys outside the removed range, and must not leave stale key/signature words that could be re-read later. Excludes `BaseModule` counter updates and depositability recomputation.
- Entry point: SigningKeys.removeKeysSigs
- Anchors: BaseModule.removeKeys
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key
- State machine relevance: Key Lifecycle & Depositability
**Added/Vetted Counters Sync on Removal** (`rm-counters-vetted-reset`)
- Scope: Covers the counter transitions triggered by `BaseModule.removeKeys`: `totalAddedKeys` and `totalVettedKeys` both being set to `newTotalSigningKeys` after removal, and the security consequences for depositability gating when `totalVettedKeys` had previously been decreased by StakingRouter. Includes interaction boundaries with `decreaseVettedSigningKeysCount` (SR-only decreases) and the invariant that removal must not desync `totalDepositedKeys` vs `totalAddedKeys/totalVettedKeys` or enable bypassing depositability constraints by “re-vetting” previously unvetted keys. Excludes bond/unbonded accounting correctness beyond how it is consumed by depositability computation.
- Entry point: NodeOperatorOps.decreaseVettedSigningKeysCount
- Anchors: BaseModule.decreaseVettedSigningKeysCount; NodeOperatorOps.decreaseVettedSigningKeysCount; BaseModule.removeKeys
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Depositable Count Recompute + Global Counter Updates** (`rm-depositable-recompute-global`)
- Scope: Covers `_updateDepositableValidatorsCount` and `_applyDepositableValidatorsCount` as exercised after `removeKeys`: arithmetic safety (`totalVettedKeys - totalDepositedKeys`, `totalAddedKeys - totalDepositedKeys`, unchecked sections), correct handling of unbonded keys and target limits, and correctness of updating the module-wide `_depositableValidatorsCount` accumulator. Invariant: key removal must not create negative/overflowing counts or inconsistent global totals, and must not leave `depositableValidatorsCount` stale (nonce/update behavior included). Excludes CSModule queue-enqueue side effects and any fee/charge behavior.
- Entry point: BaseModule._updateDepositableValidatorsCount
- Anchors: BaseModule.updateDepositableValidatorsCount; BaseModule.removeKeys
- Related anchors: N/A
- Critical invariants: Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Signing Key Read APIs: Range Checks + OOB Safety** (`read-range-checks`)
- Scope: Covers `BaseModule.getSigningKeys` / `getSigningKeysWithSignatures` guard logic via `_onlyValidIndexRange`: ensuring `startIndex + keysCount <= totalAddedKeys` (and that overflow reverts safely under Solidity 0.8.x), and that zero-length reads behave safely. Also covers the memory-buffer sizing expectations for `SigningKeys.loadKeys`/`loadKeysSigs` as used by these getters. Excludes signature-format validation beyond what `SigningKeys` enforces and excludes downstream business logic that consumes the returned bytes.
- Entry point: BaseModule._onlyValidIndexRange
- Anchors: BaseModule.getSigningKeys; BaseModule.getSigningKeysWithSignatures
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key
- State machine relevance: Key Lifecycle & Depositability
**Withdrawn Reporting Idempotency and Counters** (`wdraw-idem-counters`)
- Scope: Entry via `BaseModule.reportRegularWithdrawnValidators` into `BaseModule._reportWithdrawnValidators`. Includes idempotency on `_isValidatorWithdrawn[pointer]` (skip-without-side-effects), correct single increments of per-NO withdrawn counters (via `WithdrawnValidatorLib.process`) and module-global `_totalWithdrawnValidators`, and module nonce behavior (`anySubmission` gating `_incrementModuleNonce`). Excludes Verifier proof correctness and WithdrawalValidatorLib penalty math beyond its observable effects on BaseModule/NodeOperator state.
- Entry point: src/abstract/BaseModule.sol:_reportWithdrawnValidators
- Anchors: BaseModule.reportRegularWithdrawnValidators; BaseModule._reportWithdrawnValidators; IBaseModule.WithdrawnValidatorInfo; IBaseModule.NonceChanged
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key
- State machine relevance: Withdrawals / Slashings / Consolidations
**Regular vs Slashed Withdrawal Mode Gating** (`wdraw-mode-gating`)
- Scope: Entry via `BaseModule.reportRegularWithdrawnValidators` and the shared internal `BaseModule._reportWithdrawnValidators`. Includes role separation (`REPORT_REGULAR_WITHDRAWN_VALIDATORS_ROLE` vs `REPORT_SLASHED_WITHDRAWN_VALIDATORS_ROLE`), enforcement that `WithdrawnValidatorInfo.isSlashed` matches the chosen reporting mode, and rejection of slashed-withdrawn reports unless `_isValidatorSlashed[pointer]` was previously set. Excludes Verifier proof correctness and off-chain committee decision correctness for slashed-withdrawn inputs.
- Entry point: src/abstract/BaseModule.sol:reportRegularWithdrawnValidators
- Anchors: BaseModule.reportRegularWithdrawnValidators; BaseModule.reportSlashedWithdrawnValidators; BaseModule._reportWithdrawnValidators
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key
- State machine relevance: Withdrawals / Slashings / Consolidations
**Slashing Flag Single-Assignment and Ordering** (`slashing-flag-single-assignment`)
- Scope: Entry via `BaseModule.onValidatorSlashed`. Includes keyIndex bounds (`keyIndex < totalDepositedKeys`), pointer use for `_isValidatorSlashed`, and single-assignment semantics (second report must revert). Also covers ordering interactions with withdrawal reporting (e.g., slashing reported after withdrawal is allowed by this function): ensure no path exists where late slashing can retroactively enable slashed-withdrawn penalties or break withdrawn idempotency/counters. Excludes slashing proof correctness (Verifier).
- Entry point: src/abstract/BaseModule.sol:onValidatorSlashed
- Anchors: BaseModule.onValidatorSlashed; BaseModule._isValidatorSlashed; IBaseModule.ValidatorSlashingReported
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key
- State machine relevance: Withdrawals / Slashings / Consolidations
**Per-Key Added Balance Tracking and Caps** (`key-added-balance-tracking`)
- Scope: Entry via `BaseModule.increaseKeyAddedBalance` into `NodeOperatorOps.increaseKeyAddedBalance`/`_increaseKeyAddedBalance`. Includes verifier-only mutation, prohibition on updating withdrawn keys, `keyIndex < totalDepositedKeys` enforcement, and cap semantics (`MAX_EFFECTIVE_BALANCE - MIN_ACTIVATION_BALANCE`) that bound the `keyAddedBalance` later consumed by withdrawal processing. Excludes top-up allocation correctness except for shared cap/remaining-balance semantics.
- Entry point: src/lib/NodeOperatorOps.sol:increaseKeyAddedBalance
- Anchors: BaseModule.increaseKeyAddedBalance; NodeOperatorOps.increaseKeyAddedBalance; NodeOperatorOps._increaseKeyAddedBalance; IBaseModule.KeyAddedBalanceChanged
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key
- State machine relevance: Withdrawals / Slashings / Consolidations
**Withdrawal Processing Side Effects and External Calls** (`withdrawal-processing-side-effects`)
- Scope: Entry via `BaseModule.reportRegularWithdrawnValidators` into `WithdrawnValidatorLib.process` (called from `BaseModule._reportWithdrawnValidators`). Includes per-NO counter mutation (`no.totalWithdrawnKeys`), dependency reads (`ExitPenalties.getExitPenaltyInfo`), and external calls to `Accounting.chargeFee`/`Accounting.penalize` that determine the `penaltyCovered` signal. Focus is on observable BaseModule/NodeOperator state integrity (atomicity on revert, no double-counting, and correct use of `keyAddedBalance`/`exitBalance`/`slashingPenalty` preconditions), not on Accounting/ExitPenalties internal math beyond their interface-level effects here.
- Entry point: src/lib/WithdrawnValidatorLib.sol:process
- Anchors: WithdrawnValidatorLib.process; WithdrawnValidatorLib._fulfillExitObligations; IAccounting.chargeFee; IAccounting.penalize
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key; Uncovered Penalty Deactivates Operator
- State machine relevance: Withdrawals / Slashings / Consolidations
**Uncovered Penalty Deactivation and Forced Target Limit** (`uncovered-penalty-deactivation`)
- Scope: Entry via `BaseModule.reportRegularWithdrawnValidators` into `BaseModule._onUncompensatedPenalty` (triggered when `WithdrawnValidatorLib.process` returns `penaltyCovered == false`). Includes forced target limit application (`_setTargetLimit(nodeOperatorId, FORCED_TARGET_LIMIT_MODE_ID, 0)`), immediate recomputation of depositable keys, and guarantees that an uncovered penalty makes the operator effectively non-depositable for future allocations. Excludes governance/admin target-limit changes outside this automatic deactivation path.
- Entry point: src/abstract/BaseModule.sol:_onUncompensatedPenalty
- Anchors: BaseModule._onUncompensatedPenalty; BaseModule._setTargetLimit; BaseModule._updateDepositableValidatorsCount; FORCED_TARGET_LIMIT_MODE_ID
- Related anchors: N/A
- Critical invariants: Uncovered Penalty Deactivates Operator; Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Key Pointer Packing and Bounds Assumptions** (`key-pointer-packing-bounds`)
- Scope: Entry via `BaseModule._keyPointer` as used by withdrawal/slashing reporting. Includes verifying the stated bound assumptions (nodeOperatorId/keyIndex effectively limited so `(nodeOperatorId << 128) | keyIndex` is collision-free in practice), and that all call sites enforce existence/index bounds before relying on `_isValidatorWithdrawn/_isValidatorSlashed/_keyAddedBalances` reads/writes in ways that could affect idempotency, prerequisites, or counters. Excludes micro-optimizations of packing itself.
- Entry point: src/abstract/BaseModule.sol:_keyPointer
- Anchors: BaseModule._keyPointer; BaseModule._isValidatorWithdrawn; BaseModule._isValidatorSlashed; BaseModule._keyAddedBalances
- Related anchors: N/A
- Critical invariants: Withdrawn/Slashed Flags Are Single-Assignment Per Key
- State machine relevance: Withdrawals / Slashings / Consolidations
**Batch Settle Loop Semantics** (`gdp_batch_settlement_semantics`)
- Scope: Covers `BaseModule.settleGeneralDelayedPenalty` batch semantics: role gating (`SETTLE_GENERAL_DELAYED_PENALTY_ROLE`), `nodeOperatorIds`/`maxAmounts` length alignment, per-index iteration order, per-NO existence checks (`_onlyExistingNodeOperator`), and `continue` semantics on `settled == false`. Includes call ordering (external settle call first, then deactivation hook, then depositability recompute) and whether any iteration can partially apply effects or skip deactivation for a `settled == true` element. Excludes Accounting internals and any GeneralPenaltyLib accounting math beyond the boolean `settled` outcome and observable module-side effects.
- Entry point: src/abstract/BaseModule.sol:479
- Anchors: BaseModule.settleGeneralDelayedPenalty(uint256[],uint256[])
- Related anchors: N/A
- Critical invariants: Uncovered Penalty Deactivates Operator; Depositability Is Upper-Bounded By Bonding And Limits
- State machine relevance: Key Lifecycle & Depositability
**Settlement Gate And MaxAmounts** (`gdp_settlement_gate_conditions`)
- Scope: Covers the settlement eligibility gate used by `BaseModule.settleGeneralDelayedPenalty`: `GeneralPenalty.settleGeneralDelayedPenalty(nodeOperatorId,maxAmount)` behavior when `locked == 0`, `locked > maxAmount`, and `0 < locked <= maxAmount`, plus the resulting observable side effects (accounting call + `GeneralDelayedPenaltySettled` event + `bool` return). Excludes the internal correctness of `Accounting.getActualLockedBond`/`settleLockedBondETH` and any economic assumptions about locked amounts.
- Entry point: src/lib/GeneralPenaltyLib.sol:86
- Anchors: GeneralPenalty.settleGeneralDelayedPenalty(uint256,uint256)
- Related anchors: N/A
- Critical invariants: Uncovered Penalty Deactivates Operator
- State machine relevance: Key Lifecycle & Depositability