Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: automated ejector #146

Merged
merged 16 commits into from
Apr 11, 2024
170 changes: 170 additions & 0 deletions src/EjectionManager.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.9;

import {OwnableUpgradeable} from "@openzeppelin-upgrades/contracts/access/OwnableUpgradeable.sol";
import {IEjectionManager} from "./interfaces/IEjectionManager.sol";
import {IRegistryCoordinator} from "./interfaces/IRegistryCoordinator.sol";
import {IStakeRegistry} from "./interfaces/IStakeRegistry.sol";
import {BitmapUtils} from "./libraries/BitmapUtils.sol";

/**
* @title Used for automated ejection of operators from the RegistryCoordinator
* @author Layr Labs, Inc.
*/
contract EjectionManager is IEjectionManager, OwnableUpgradeable{

/// @notice The basis point denominator for the ejectable stake percent
uint16 internal constant BIPS_DENOMINATOR = 10000;

IRegistryCoordinator public immutable registryCoordinator;
IStakeRegistry public immutable stakeRegistry;

/// @notice Address permissioned to eject operators under a ratelimit
address public ejector;

/// @notice Keeps track of the total stake ejected for a quorum within a time delta
mapping(uint8 => StakeEjection[]) public stakeEjectedForQuorum;
/// @notice Ratelimit parameters for each quorum
mapping(uint8 => QuorumEjectionParams) public quorumEjectionParams;

constructor(
IRegistryCoordinator _registryCoordinator,
IStakeRegistry _stakeRegistry
) {
registryCoordinator = _registryCoordinator;
stakeRegistry = _stakeRegistry;

_disableInitializers();
}

function initialize(
address _owner,
address _ejector,
QuorumEjectionParams[] memory _quorumEjectionParams
) external initializer {
_transferOwnership(_owner);
_setEjector(_ejector);

for(uint8 i = 0; i < _quorumEjectionParams.length; i++) {
_setQuorumEjectionParams(i, _quorumEjectionParams[i]);
}
}

/**
* @notice Ejects operators from the AVSs registryCoordinator under a ratelimit
* @dev This function will eject as many operators as possible without reverting
* @param _operatorIds The ids of the operators to eject for each quorum
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's assumed in this parameter by this function?
From the impl below it looks: 1) the caller provides all operators eligible for ejection, but delegate to this function for rate limiting; 2) the operators in each quorum are ordered by priority to eject (in case not all can be ejected, it'll just eject a leading prefix of the array)
Might be good to document the assumptions so the caller knows how to pass the params.

*/
function ejectOperators(bytes32[][] memory _operatorIds) external {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i feel like ejectOperators(uint8 quorumNumber, bytes32[] calldata _operatorIds) is more in line with our style

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you caught me red handed. now i realize why i wanted that is we want to eject from multiple quorums at the same time

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could do that outside of the contract too, but ok

require(msg.sender == ejector || msg.sender == owner(), "Ejector: Only owner or ejector can eject");

for(uint i = 0; i < _operatorIds.length; ++i) {
uint8 quorumNumber = uint8(i);

_cleanOldEjections(quorumNumber);
uint256 amountEjectable = _amountEjectableForQuorum(quorumNumber);
uint256 stakeForEjection;

bool broke;
for(uint8 j = 0; j < _operatorIds[i].length; ++j) {
uint256 operatorStake = stakeRegistry.getCurrentStake(_operatorIds[i][j], quorumNumber);

if(
msg.sender == ejector &&
quorumEjectionParams[quorumNumber].rateLimitWindow > 0 &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this means that no limit will be enforced for new quorums until a limit is explicitly set on this contract for the quorum?
possibly a bit of a "footgun"?
please correct me if I'm reading this wrong though.

Copy link
Contributor Author

@0x0aa0 0x0aa0 Apr 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is correct. My thinking here is that when adding a new quorum, while it is finding its footing we will not want to run automated ejection but reserve the ability to eject without ratelimit in that period. Once the set is more stable the quorum params can be set and offchain can be turned on to run regularly.

stakeForEjection + operatorStake > amountEjectable
){
stakeEjectedForQuorum[quorumNumber].push(StakeEjection({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we pushing and not continuing here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we do not want to revert if the stake of the ops in the input is greater than the ratelimit. Here we checkpoint the stake ejected inside the ratelimit and then end execution

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh my bad, i completely misread this

timestamp: block.timestamp,
stakeEjected: stakeForEjection
}));
broke = true;
break;
}

try registryCoordinator.ejectOperator(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why would we not revert if this failed? why would this fail?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would fail if the operator leaves the set before ejection is called

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah gpgp

registryCoordinator.getOperatorFromId(_operatorIds[i][j]),
abi.encodePacked(quorumNumber)
) {
stakeForEjection += operatorStake;
emit OperatorEjected(_operatorIds[i][j], quorumNumber);
} catch (bytes memory err) {
emit FailedOperatorEjection(_operatorIds[i][j], quorumNumber, err);
}
}

if(!broke){
stakeEjectedForQuorum[quorumNumber].push(StakeEjection({
timestamp: block.timestamp,
stakeEjected: stakeForEjection
}));
}

}
}

/**
* @notice Sets the ratelimit parameters for a quorum
* @param _quorumNumber The quorum number to set the ratelimit parameters for
* @param _quorumEjectionParams The quorum bitmaps for each respective operator
*/
function setQuorumEjectionParams(uint8 _quorumNumber, QuorumEjectionParams memory _quorumEjectionParams) external onlyOwner() {
_setQuorumEjectionParams(_quorumNumber, _quorumEjectionParams);
}

/**
* @notice Sets the address permissioned to eject operators
* @param _ejector The address to permission
*/
function setEjector(address _ejector) external onlyOwner() {
_setEjector(_ejector);
}

///@dev internal function to set the quorum ejection params
function _setQuorumEjectionParams(uint8 _quorumNumber, QuorumEjectionParams memory _quorumEjectionParams) internal {
quorumEjectionParams[_quorumNumber] = _quorumEjectionParams;
emit QuorumEjectionParamsSet(_quorumNumber, _quorumEjectionParams.rateLimitWindow, _quorumEjectionParams.ejectableStakePercent);
}

///@dev internal function to set the ejector
function _setEjector(address _ejector) internal {
emit EjectorUpdated(ejector, _ejector);
ejector = _ejector;
}

/**
* @dev Removes stale ejections for a quorums history
* @param _quorumNumber The quorum number to clean ejections for
*/
function _cleanOldEjections(uint8 _quorumNumber) internal {
uint256 cutoffTime = block.timestamp - quorumEjectionParams[_quorumNumber].rateLimitWindow;
uint256 index = 0;
StakeEjection[] storage stakeEjections = stakeEjectedForQuorum[_quorumNumber];
while (index < stakeEjections.length && stakeEjections[index].timestamp < cutoffTime) {
index++;
}
if (index > 0) {
for (uint256 i = index; i < stakeEjections.length; ++i) {
stakeEjections[i - index] = stakeEjections[i];
}
for (uint256 i = 0; i < index; ++i) {
stakeEjections.pop();
}
}
}

/**
* @notice Returns the amount of stake that can be ejected for a quorum
* @dev This function only returns a valid amount after _cleanOldEjections has been called
* @param _quorumNumber The quorum number to view ejectable stake for
*/
function _amountEjectableForQuorum(uint8 _quorumNumber) internal view returns (uint256) {
uint256 totalEjected = 0;
for (uint256 i = 0; i < stakeEjectedForQuorum[_quorumNumber].length; i++) {
totalEjected += stakeEjectedForQuorum[_quorumNumber][i].stakeEjected;
}
uint256 totalEjectable = quorumEjectionParams[_quorumNumber].ejectableStakePercent * stakeRegistry.getCurrentTotalStake(_quorumNumber) / BIPS_DENOMINATOR;
return totalEjectable - totalEjected;
0x0aa0 marked this conversation as resolved.
Show resolved Hide resolved
}

}
55 changes: 55 additions & 0 deletions src/interfaces/IEjectionManager.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;

import {IRegistryCoordinator} from "./IRegistryCoordinator.sol";
import {IStakeRegistry} from "./IStakeRegistry.sol";

/**
* @title Interface for a contract that ejects operators from an AVSs RegistryCoordinator
* @author Layr Labs, Inc.
*/
interface IEjectionManager {

/// @notice A quorum's ratelimit parameters
struct QuorumEjectionParams {
uint32 rateLimitWindow; // Time delta to track ejection over
uint16 ejectableStakePercent; // Max stake to be ejectable per time delta
}

/// @notice A stake ejection event
struct StakeEjection {
uint256 timestamp; // Timestamp of the ejection
uint256 stakeEjected; // Amount of stake ejected at the timestamp
}

///@notice Emitted when the ejector address is set
event EjectorUpdated(address previousAddress, address newAddress);
///@notice Emitted when an operator is ejected
event OperatorEjected(bytes32 operatorId, uint8 quorumNumber);
///@notice Emitted when an operator ejection fails
event FailedOperatorEjection(bytes32 operatorId, uint8 quorumNumber, bytes err);
///@notice Emitted when the ratelimit parameters for a quorum are set
event QuorumEjectionParamsSet(uint8 quorumNumber, uint32 rateLimitWindow, uint16 ejectableStakePercent);

/**
* @notice Ejects operators from the AVSs registryCoordinator under a ratelimit
* @param _operatorIds The ids of the operators to eject for each quorum
*/
function ejectOperators(bytes32[][] memory _operatorIds) external;

/**
* @notice Sets the ratelimit parameters for a quorum
* @param _quorumNumber The quorum number to set the ratelimit parameters for
* @param _quorumEjectionParams The quorum bitmaps for each respective operator
*/
function setQuorumEjectionParams(uint8 _quorumNumber, QuorumEjectionParams memory _quorumEjectionParams) external;

/**
* @notice Sets the address permissioned to eject operators
* @param _ejector The address to permission
*/
function setEjector(address _ejector) external;



}
Loading