# EigenLayer Integration Upgrade Plan ## Purpose This document outlines the proposed plan to upgrade Taiyi's existing EigenLayer integration to the Eigenlayer's latest version ELIP-2, which unlocks operator sets, slashing capabilities, and other new features. The doc aims to provide background knowledge and proposed solution to both internal and external stakeholders for reviews and discussions. ## Background ### Taiyi Today > **Note**: This section summarizes the dual AVS design from the [official Luban documentation on AVS Middleware](https://docs.luban.wtf/learn/architecture/on_chain_components/avs_middleware_contracts). > **Note**: In the Taiyi upgrade, Gateway AVS is renamed to Underwriting AVS to better reflect its role in the preconfirmation system. Thoughout this document, we will refer to the Underwriting AVS as Gateway AVS. Luban's architecture employs a dual AVS design built on top of EigenLayer. This approach separates responsibilities between two distinct AVS contracts: **ValidatorAVS** and **UnderwritingAVS**. Both inherit from a common abstract base contract called **EigenLayerMiddleware**. ```mermaid classDiagram class EigenLayerMiddleware { <<abstract>> } class TaiyiProposerRegistry { } class UnderwriterAVS { } class ValidatorAVS { } EigenLayerMiddleware <|-- UnderwriterAVS : inherits EigenLayerMiddleware <|-- ValidatorAVS : inherits TaiyiProposerRegistry --> UnderwriterAVS : manages TaiyiProposerRegistry --> ValidatorAVS : manages UnderwriterAVS --> ValidatorAVS : coordinates rewards ``` #### 1. Operator Opt-in Process The system provides a flexible operator registration process that allows operators to participate in one or both AVS roles: ```solidity // Step 1: Register in EigenLayer as operator delegationManager.registerAsOperator(...) // Step 2: Register in the appropriate AVS(s) // For Validator role only: validatorAVS.registerOperatorToAVS(operator, operatorSignature) // For Underwriting role (with or without Validator role): gatewayAVS.registerOperatorToAVSWithPubKey(operator, operatorSignature, operatorBLSPubKey) // If also registering for Validator role, add: validatorAVS.registerOperatorToAVS(operator, operatorSignature) ``` Operators can choose to participate in one or both roles. The BLS public key is required only for operators providing Underwriting services, as it's used to sign preconfirmation commitments. Registering for both roles maximizes potential rewards. Under the hood, each registration adds the operator to the respective AVS in the EigenLayer AVS Directory and also registers them in the TaiyiProposerRegistry with the appropriate service type. #### 2. Validator Opt-in Process Validators can opt into the Luban system through the ValidatorAVS contract: ```solidity function _registerValidators( bytes[] calldata valPubKeys, address podOwner, bytes calldata delegatedGatewayPubKey ) ``` The process works as follows: 1. **EigenPod Verification**: Validates that the validator has an active EigenPod within EigenLayer. 2. **Delegation to Underwriting**: The validator must delegate to a registered Underwriting operator by providing their BLS public key (`delegatedGatewayPubKey`). 3. **Operator Registration Check**: Verifies that the operator delegated by the pod owner is registered in both Underwriting and Validator AVSs. 4. **Validator Status Check**: Confirms each validator's BLS public key is active within EigenLayer. 5. **Registration Event**: Emits a `ValidatorOperatorRegistered` event, which is captured by off-chain services to handle delegation to relays. This process enables validators to delegate their blocks to underwriting operators for preconfirmation services. #### 3. Slashing Mechanism The slashing mechanism leverages zkVM (SP1) for interactive and non-interactive fraud proofs: ```solidity contract TaiyiNonInteractiveChallenger is ITaiyiNonInteractiveChallenger, Ownable { address public verifierGateway; bytes32 public nonInteractiveFraudProofVKey; // ... function proveAType( PreconfRequestAType calldata preconfRequestAType, bytes calldata signature, bytes calldata proofValues, bytes calldata proofBytes ) external payable { // Verify the proof using SP1 zkVM ISP1Verifier(verifierGateway).verifyProof( nonInteractiveFraudProofVKey, proofValues, proofBytes ); } } ``` ### Latest Changes to EigenLayer #### 1. Operator Opt-in The [ELIP-2](https://github.com/eigenfoundation/ELIPs/blob/main/ELIPs/ELIP-002.md) upgrade introduces a new operator opt-in process via `AllocationManager`. • **Register as Operator**: Operators register in the EigenLayer protocol by calling `delegationManager.registerAsOperator()`, providing delegation approval settings and metadata. • **Allocate Stake**: Operators allocate their stake to specific operator sets using `allocationManager.modifyAllocations()`, where stake becomes slashable after a configurable delay period. • **Register for Sets**: After the allocation delay, operators register for their selected operator sets via `allocationManager.registerForOperatorSets()`, immediately making allocated stake slashable. • **Update State**: For some AVSs (not applicable to Taiyi), operators may need to update their state in relevant registries using `stakeRegistry.updateOperatorsForQuorum()`. #### 2. Slashing The ELIP-2 upgrade introduces a more sophisticated slashing framework that Taiyi will leverage to enhance security guarantees. The new slashing mechanism consists of two main components: the VetoSlasher and InstantSlasher contracts. The slashing process: 1. The AVS initiates slashing by calling the [slashing contract](https://github.com/Layr-Labs/eigenlayer-middleware/blob/dev/src/slashers/base/SlasherBase.sol) with 'IAllocationManager.SlashingParams' ```solidity struct SlashingParams { address operator; uint32 operatorSetId; IStrategy[] strategies; uint256[] wadsToSlash; string description; } ``` 2. For VetoSlasher, the request is queued for the delay period and can be vetoed 3. If not vetoed, or for InstantSlasher, the slashing is executed through `AllocationManager.slashOperator()` 4. For each strategy being slashed: - The operator's allocated magnitude to the operator set is reduced - The operator's maximum and encumbered magnitudes are decreased proportionally - Any pending deallocations are adjusted proportionally - The slashed stake is burned through the DelegationManager ## Taiyi after ELIP-2 ### Todo No. 1: Update Operator Sets In the new EigenLayer architecture, the first step in upgrading from dual AVSs to a single AVS is to create dedicated operator sets. Rather than having two separate AVS contracts (ValidatorAVS and UnderwritingAVS), Taiyi will now define two operator sets within a single AVS using the `createOperatorSets` function in the AllocationManager: ```solidity /** * @notice Parameters used by an AVS to create new operator sets * @param operatorSetId the id of the operator set to create * @param strategies the strategies to add as slashable to the operator set */ struct CreateSetParams { uint32 operatorSetId; IStrategy[] strategies; } /** * @notice Allows an AVS to create new operator sets, defining strategies that the operator set uses */ function createOperatorSets( address avs, CreateSetParams[] calldata params ) external checkCanCall(avs) ``` <details> <summary><b>Example: Creating Validator and Underwriting Operator Sets</b> (click to expand)</summary> ```solidity // Step 1: Register Taiyi AVS metadata (required before creating operator sets) allocationManager.updateAVSMetadataURI( address(taiyiServiceManager), // The single AVS contract address "ipfs://QmTaiyiMetadataURI" // Metadata URI with operator set details ); // Step 2: Create Validator and Underwriting operator sets CreateSetParams[] memory setParams = new CreateSetParams[](2); // Define Validator Operator Set (ID: 1) setParams[0] = CreateSetParams({ operatorSetId: 1, // Validator set ID strategies: getValidatorStrategies() // Array of IStrategy addresses for Validator set }); // Define Underwriting Operator Set (ID: 2) setParams[1] = CreateSetParams({ operatorSetId: 2, // Underwriting set ID strategies: getUnderwritingStrategies() // Array of IStrategy addresses for Underwriting set }); // Create both operator sets in a single transaction allocationManager.createOperatorSets( address(taiyiServiceManager), // AVS address setParams ); ``` </details> Additionally, `SlashingRegistryCoordinator`, which implements [`IAVSRegistrar`](https://github.com/Layr-Labs/eigenlayer-contracts/blob/dev/src/contracts/interfaces/IAVSRegistrar.sol) will be implemented to handle callbacks from the `AllocationManager.registerForOperatorSets()`. Setting the `SlashingRegistryCoordinator` is done by `AllocationManager.setAvsRegistrar()`. ```solidity /** * @notice Called by an AVS to configure the address that is called when an operator registers * or is deregistered from the AVS's operator sets. If not set (or set to 0), defaults * to the AVS's address. * @param registrar the new registrar address */ function setAVSRegistrar( address avs, IAVSRegistrar registrar ) external checkCanCall(avs) ``` ### Todo No. 2: Validate Opt-in Process The entry point of Validator registration will stay on the `EigenlayerMiddleware` contract via a dedicated `registerValidator` function, but URC's `Registry` will be called inside of the `registerValidator` function. One thing worth noting is that `collateralGwei` will need to be set to minimum allowable amount(0.1 ETH) as Taiyi still uses EigenLayer's for colalteral management. This is for two reasons: 1. **Asset Support**: URC only supports native ETH as collateral, but EigenLayer supports diverse assets like stETH, cbETH, and rETH. 2. **Cross-Protocol Double-Slashing Prevention**: By setting `collateralGwei` to minimum amount(0.1 ETH) in URC and handling actual slashing at the EigenLayer level, we avoid the risk of operators being slashed twice for the same violation. Additionally, the validator registration function's delegation message should be changed to URC's `Delegation` data type ```solidity import {Delegation} from '@urc/src/ISlasher.sol' // Old function _registerValidators( bytes[] calldata valPubKeys, address podOwner, bytes calldata delegatedGatewayPubKey ) internal override // New function _registerValidators( bytes[] calldata valPubKeys, address podOwner, Delegation calldata delegation ) internal override ``` ### Todo No. 3: Slashing The following diagram illustrates the architectural relationships between the various components involved in Taiyi's slashing system: >**Note**: VetoSlasher requires a *submission-first-then-approval/veto-later* interactive process that is out of the flow below. For InstantSlasher, the entry point for slashing request would be URCRegistry, whereas VetoSlasher's entry point would be itself, which is a seperate process that needs to be triggered before the main thread below. ```mermaid classDiagram %% URC Components class URCRegistry { +slashCommitmentFromOptIn() } %% Bridge Component class TaiyiSlasher { +slashFromOptIn() } %% EigenLayer Components class SlasherBase { <<abstract>> } class ISlasher { <<interface>> +slashFromOptIn() } class InstantSlasher { +initiateSlashing() } class VetoSlasher { +initiateSlashing() +executeSlashing() +vetoSlashing() +getSlashingStatus() } class IAllocationManager { +slashOperator() } %% Inheritance Relationships SlasherBase <|-- TaiyiSlasher : inherits ISlasher <|.. TaiyiSlasher : implements %% Functional Relationships URCRegistry --> TaiyiSlasher : calls TaiyiSlasher --> InstantSlasher : verify proof TaiyiSlasher --> VetoSlasher : verify proof InstantSlasher --> IAllocationManager : if success, slash operator VetoSlasher --> IAllocationManager : if success, slash operator %% Notes class TaiyiSlasher { Bridge between URC and EigenLayer } class URCRegistry { Handles fraud proof verification } class InstantSlasher { No veto period } class VetoSlasher { Includes veto period } class IAllocationManager { Executes actual slashing on strategies } ``` <details> <summary><b>Example: TaiyiSlasher Contract Implementation</b> (click to expand)</summary> ```solidity import { SlasherBase } from "@eigenlayer/eigenlayer-middleware/src/slashers/base/SlasherBase.sol"; import { ISlasher } from "@urc/src/interfaces/ISlasher.sol"; contract TaiyiSlasher is SlasherBase, ISlasher { struct SlashingConfig { uint32 operatorSetId; // Which operator set to slash uint256 slashAmount; // Amount to slash (in WAD) bool isInstantSlashing; // Whether to use instant slashing } /// @notice Slash an operator for a given commitment /// @dev The URC will call this function to slash a registered operator if supplied with a valid commitment and evidence /// @param commitment The commitment message /// @param evidence Arbitrary evidence for the slashing /// @param challenger The address of the challenger /// @return slashAmountGwei The amount of Gwei slashed function slashFromOptIn( Commitment calldata commitment, bytes calldata evidence, address challenger ) external returns (uint256 slashAmountGwei) { // Step 1: Determine which restaking protocol this is targeting // Evidence format: abi.encode(RestakingProtocol, violationType, additionalData) (uint8 restakingProtocol, bytes32 violationType, bytes memory additionalData) = abi.decode(evidence, (uint8, bytes32, bytes)); // Step 2: Get the slashing configuration for this violation type SlashingConfig memory config = slashingConfigs[violationType]; if (config.operatorSetId == 0) { revert("Unknown violation type"); } // Step 3: Extract operator address from commitment address operator = _extractOperatorFromCommitment(commitment); // Prepare slashing parameters IAllocationManager.SlashingParams memory params = _prepareSlashingParams( operator, config.operatorSetId, config.slashAmount, string(abi.encodePacked("URC slash: ", violationType)) ); // Step 4: Execute slashing through appropriate slasher bool slashingExecuted = false; if (config.isInstantSlashing) { // Non-interactive proof (immediate slashing) // The instantSlasher returns true if slashing should be executed directly bool shouldExecuteDirectly = instantSlasher.initiateSlashing(params); if (shouldExecuteDirectly) { // Call slashOperator directly only if instantSlasher indicates we should allocationManager.slashOperator(params); slashingExecuted = true; } } else { // Interactive proof (with veto period) // Check if a slashing request is already in progress uint256 slashingId = vetoSlasher.getSlashingIdForOperator(operator, config.operatorSetId); if (slashingId != 0) { // Existing slashing request found, check its status VetoSlasher.SlashingStatus status = vetoSlasher.getSlashingStatus(slashingId); if (status == VetoSlasher.SlashingStatus.Vetoed) { revert("Slashing request has been vetoed"); } else if (status == VetoSlasher.SlashingStatus.Pending) { // Check if the veto period has passed uint256 createdAt = vetoSlasher.getSlashingCreatedAt(slashingId); uint256 vetoPeriod = vetoSlasher.getVetoPeriod(); if (block.timestamp < createdAt + vetoPeriod) { revert("Veto period has not yet passed"); } // Veto period has passed, proceed with the slashing bool shouldExecuteDirectly = vetoSlasher.executeSlashing(slashingId); if (shouldExecuteDirectly) { // Call slashOperator directly only if vetoSlasher indicates we should allocationManager.slashOperator(params); slashingExecuted = true; } } } else { // No existing request, create a new one bool shouldExecuteDirectly = vetoSlasher.initiateSlashing(params); if (shouldExecuteDirectly) { allocationManager.slashOperator(params); slashingExecuted = true; } } } // Log the slashing outcome emit SlashingResult(operator, config.operatorSetId, slashingExecuted); // Step 5: Return 0 for slashAmountGwei // This is because collateral management is handled by EigenLayer // The URC only supports ETH as collateral, but EigenLayer supports ERC20 tokens return 0; } // Helper function to extract operator address from commitment function _extractOperatorFromCommitment(Commitment calldata commitment) internal view returns (address) { // Implementation depends on how operator address is encoded in the commitment // For example, could be directly included in the payload or derived from it // This is a placeholder for the actual implementation return address(uint160(uint256(keccak256(commitment.payload)))); } // Helper function to prepare slashing parameters function _prepareSlashingParams( address operator, uint32 operatorSetId, uint256 slashAmount, string memory description ) internal view returns (IAllocationManager.SlashingParams memory) { // Get operators strategies IStrategy[] memory strategies = _getOperatorStrategies(operator, operatorSetId); uint256[] memory wadsToSlash = new uint256[](strategies.length); // Calculate proportional slashing for each strategy for (uint256 i = 0; i < strategies.length; i++) { wadsToSlash[i] = slashAmount; } return IAllocationManager.SlashingParams({ operator: operator, operatorSetId: operatorSetId, strategies: strategies, wadsToSlash: wadsToSlash, description: description }); } // Implementation-specific functions would be here function _getOperatorStrategies(address operator, uint32 operatorSetId) internal view returns (IStrategy[] memory) { // Implementation would get the strategies for a specific operator and set } } ``` </details> #### Multi-Restaking Protocol Compatibility The `slashFromOptIn` function is designed to work with multiple restaking protocols, not just EigenLayer. The evidence parameter contains crucial routing information: ```solidity (uint8 restakingProtocol, bytes32 violationType, bytes memory additionalData) = abi.decode(evidence, (uint8, bytes32, bytes)); ``` This design allows: - Operator-set specific violation handling via the `violationType` parameter - Additional protocol-specific data via the `additionalData` field ## Open Questions ### Operator Set Creation In the sample implementation, operator set creation is done on [`SlashingRegistryCoordinator`](https://github.com/Layr-Labs/eigenlayer-middleware/blob/4d63f27247587607beb67f96fdabec4b2c1321ef/src/SlashingRegistryCoordinator.sol#L825). Since Taiyi will not track quorum, what are the issues with calling `IAllocationManager.createOperatorSets()` directly for operator set creation? ### EigenPod vs Regular Node Registration To participate in Taiyi, validators need to show that 1. they are indeed ETH validators 2. the registering operator indeed owns, or has been authorized to, register validator keys 3. there are slashable collateral #### For EigenPod owner or its delegated operator, this is established through: 1. Proving a validator is authentic (ETH validator) In EigenLayer, verification happens through the verifyWithdrawalCredentials function in the EigenPod contract: ```solidity function verifyWithdrawalCredentials( uint64 beaconTimestamp, BeaconChainProofs.StateRootProof calldata stateRootProof, uint40[] calldata validatorIndices, bytes[] calldata validatorFieldsProofs, bytes32[][] calldata validatorFields ) external onlyOwnerOrProofSubmitter { ... } ``` Source: [EigenPod.sol Lines 242](https://github.com/Layr-Labs/eigenlayer-contracts/blob/816e66f7da205e9858ffdb4fdf9700024fd45766/src/contracts/pods/EigenPod.sol#L242) This function proves a validator exists on the beacon chain by: - Verifying the validator against the beacon state root - Confirming the validator's credentials via Merkle proof - Checking the validator is active on the beacon chain 2. Proving ownership/authorization of validator keys EigenLayer verifies pod ownership through withdrawal credentials verification: ```solidity // From _verifyWithdrawalCredentials function require( validatorFields.getWithdrawalCredentials() == bytes32(_podWithdrawalCredentials()) || validatorFields.getWithdrawalCredentials() == bytes32(_podCompoundingWithdrawalCredentials()), WithdrawalCredentialsNotForEigenPod() ); ``` Source: [EigenPod.sol Lines 438](https://github.com/Layr-Labs/eigenlayer-contracts/blob/816e66f7da205e9858ffdb4fdf9700024fd45766/src/contracts/pods/EigenPod.sol#L438) This verifies that: - The validator's withdrawal credentials point to the EigenPod contract Only the EigenPod owner (or delegated proof submitter) can provide proofs The withdrawal address configuration proves key ownership For delegated operators, they could show the delegation via `DelgationManager` contract thereafter. 3. Confirming collateral backing EigenLayer ensures validators have collateral through: ```solidity // The validator must have sufficient stake uint64 restakedBalanceGwei = validatorFields.getEffectiveBalanceGwei(); ``` Source: [EigenPod.sol Line 502](https://github.com/Layr-Labs/eigenlayer-contracts/blob/816e66f7da205e9858ffdb4fdf9700024fd45766/src/contracts/pods/EigenPod.sol#L502) Collateral is proven by verifying the validator's effective balance in Gwei #### For an operator who simply runs node infra for liquid staking protocol, this is established through: 1. Currently this is not handled directly by the URC or Taiyi. However, there're no clear incentive for operator to spam registration with fake validator credentials. 2. URC handles this optmistically via the [`slashRegistration` function](https://github.com/eth-fabric/urc/blob/34bda6ea5029f6cee336dd96c0524e3e35cc6c9f/src/Registry.sol#L215). 3. this is handled during operator registration. During operator registration, `AllocationManager` would ask operator to allocate stake for the target operator set. This implies that slashing a validator in fact is slashing its operator's stake, which could be either native ETH or ERC20 tokens like stETH. First I want to confirm that my understanding above is correct. Secondarily, I wanto explore if there are more elegant ways to handle validator registration.