Try   HackMD

CSM Gates and Extensions

This doc is closed!

Pls comment on and refer to https://hackmd.io/@lido/csm-v2-internal

We all know that custom-tailored clothes tend to be comfier than the mass-market ones. The same goes for Ethereum validation with LST protocols. Community Staking Module (CSM) has already proven to be a market-leading solution for various Ethereum stakers, starting from home stakers and ending with professional Node Operators. Such a diverse set of participants requires some tailored solutions.

Goals

  • Provide separate entry points for the different types of stakers in CSM, like:
    • Unidentified permissionless participants;
    • Identified solo, home, and community stakers;
    • Identified professional Node Operators;
    • Stakers utilizing specific technologies for the validator operation (ex. DVT);
    • Other distinguishable types;
  • Allow for seamless CSM integrations with third parties willing to attract their users as CSM Node Operators;

General description

CSModule.sol currently has several methods for creating Node Operators. During the Early Adoption (EA) period, these methods allow only members of the EA List to join. Once the EA period is over, these methods become permissionless. This approach is extremely limited in terms of customization. Hence, the concept of Gates and Extensions is proposed.

To implement this concept Node Operator creation methods in CSModule.sol should be permissioned. Only members of the corresponding role (CREATE_NODE_OPERATOR_ROLE) can call these methods. These role members are what we call Gates and Extensions.

Gates

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Gates are smart contracts that allow users to join CSM. There are two types of gates:

  • Permissionless
  • Permissioned

Gate can assign a custom Node Operator type (defined by bondCurveId) to the operators created using it. A permissioned gate can allow existing Node Operators to prove they are eligible to get the corresponding Node Operator type and claim one (corresponding bondCurveId).

Gates examples and code snippets

Permissionless gate

This is a stateless contract that proxies addNodeOperator* calls with no additional functionalities. It keeps the invariant with mandatory keys uploading.

PermissionlessGate.sol
import { ICSAccounting } from "./interfaces/ICSAccounting.sol";
import { ICSModule, NodeOperatorManagementProperties } from "./interfaces/ICSModule.sol";

contract PermissionlessGate {
    /// @dev Curve ID is the default bond curve ID from the accounting contract
    ///      No need to set it explicitly
    uint256 public immutable CURVE_ID;

    ICSModule public immutable CSM;

    constructor(address csm) {
        CSM = ICSModule(csm);
        CURVE_ID = CSM.accounting().DEFAULT_BOND_CURVE_ID();
    }

    function addNodeOperatorETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        address referrer
    ) external payable returns (uint256 nodeOperatorId) {
        nodeOperatorId = CSM.createNodeOperator(
            msg.sender,
            managementProperties,
            referrer
        );

        CSM.addValidatorKeysETH{ value: msg.value }({
            from: msg.sender,
            nodeOperatorId: nodeOperatorId,
            keysCount: keysCount,
            publicKeys: publicKeys,
            signatures: signatures
        });
    }

    function addNodeOperatorStETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        address referrer
    ) external returns (uint256 nodeOperatorId) {  ...  }

    function addNodeOperatorWstETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        address referrer
    ) external returns (uint256 nodeOperatorId) {  ...  }
}
Vetted Gate

A gate that allows to join only vetted addresses using Merkle Tree. Additionally, it sets a special bond curve for the Node Operator.

VettedGate.sol
import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import { ICSModule, NodeOperatorManagementProperties, NodeOperator } from "./interfaces/ICSModule.sol";
import { ICSAccounting } from "./interfaces/ICSAccounting.sol";

contract VettedGate {
    uint256 public immutable CURVE_ID;

    ICSModule public immutable CSM;

    /// @dev Root of the eligible members Merkle Tree
    bytes32 public treeRoot;

    mapping(address => bool) internal _consumedAddresses;

    constructor(
        bytes32 _treeRoot,
        uint256 curveId,
        address csm,
        address admin
    ) {
        CSM = ICSModule(csm);
        CURVE_ID = curveId;
        _setTreeRoot(_treeRoot);
    }

    /// @dev checks the eligibility for the msg.sender firstly
    ///      sets the bond curve. It requires `SET_BOND_CURVE` role granted on the CSM side
    ///      then, upload keys with bond requirement from the given curve
    function addNodeOperatorETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        bytes32[] calldata proof,
        address referrer
    ) external payable returns (uint256 nodeOperatorId) {
        _consume(proof);

        nodeOperatorId = CSM.createNodeOperator(
            msg.sender,
            managementProperties,
            referrer
        );
        CSM.setBondCurve(nodeOperatorId, CURVE_ID);
        CSM.addValidatorKeysETH{ value: msg.value }({
            from: msg.sender,
            nodeOperatorId: nodeOperatorId,
            keysCount: keysCount,
            publicKeys: publicKeys,
            signatures: signatures
        });
    }

    function addNodeOperatorStETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        bytes32[] calldata proof,
        address referrer
    ) external returns (uint256 nodeOperatorId) {  ...  }

    function addNodeOperatorWstETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        bytes32[] calldata proof,
        address referrer
    ) external returns (uint256 nodeOperatorId) {  ...  }

    /// @dev claim bond curve for the eligible Node Operator
    ///      checks that msg.sender is a secure address of the Node Operator
    function claimBondCurve(
        uint256 nodeOperatorId,
        bytes32[] calldata proof
    ) external {
        NodeOperator memory nodeOperator = CSM.getNodeOperator(nodeOperatorId);
        address nodeOperatorAddress = nodeOperator.extendedManagerPermissions
            ? nodeOperator.managerAddress
            : nodeOperator.rewardAddress;
        if (nodeOperatorAddress != msg.sender) revert NotAllowedToClaim();
        _consume(proof);

        CSM.setBondCurve(nodeOperatorId, CURVE_ID);
    }

    function isConsumed(address member) public view returns (bool) {
        return _consumedAddresses[member];
    }

    function verifyProof(
        address member,
        bytes32[] calldata proof
    ) public view returns (bool) {
        return MerkleProof.verifyCalldata(proof, treeRoot, hashLeaf(member));
    }

    function hashLeaf(address member) public pure returns (bytes32) {
        return keccak256(bytes.concat(keccak256(abi.encode(member))));
    }

    function _consume(bytes32[] calldata proof) internal {
        if (isConsumed(msg.sender)) revert AlreadyConsumed();
        if (!verifyProof(msg.sender, proof)) revert InvalidProof();
        _consumedAddresses[msg.sender] = true;
        emit Consumed(msg.sender);
    }

    function _setTreeRoot(bytes32 _treeRoot) internal {
        treeRoot = _treeRoot;
        emit TreeRootSet(_treeRoot);
    }
}

Extensions

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Similar to gates, extensions are smart contracts that allow Node Operators to join CSM. Extensions also abstract Node Operator management in some form. An extension might assign itself as a CSM Node Operator manager and/or reward address to do that. This allows an extension to implement custom principles of Node Operator management, like taking a reward share from the Node Operator rewards or allowing only verified validator keys to be uploaded.

A good but not exhaustive example of the extension is a DVT-powered extension. Since DVT assumes the collective operation of a single validator by the cluster participants, a DVT-powered extension can manage individual cluster participants and share rewards. The other possible aspect of a DVT-powered extension can be on-chain key validation, ensuring that only keys created via DKG can be uploaded to CSM via this extension.

Extensions examples and code snippets

ExtensionExample.sol
contract ExtensionExample {
    /// @dev An extension requires to save the real Node Operator's addresses to operate with them
    /// An extension should has corresponding methods to change these addresses. It can be the same system as in the CSM itself, or something specific
    struct ExtensionNodeOperator {
        uint64 nodeOperatorId;
        address managerAddress;
        address rewardAddress;
    }

    mapping(uint64 => ExtensionNodeOperator) public extensionNodeOperators;

    /// @dev Id of the bond curve to be assigned for the eligible members
    uint256 public immutable CURVE_ID;

    /// @dev Address of the Community Staking Module
    ICSModule public immutable CSM;

    constructor(
        uint256 curveId,
        address csm,
        uint256 extension_share_bp
    ) {
        CSM = ICSModule(csm);
        CURVE_ID = curveId;
    }

    /// @dev The main difference is that the contract itself acts as a node operator
    function addNodeOperatorETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        bytes32[] calldata proof,
        address referrer
    ) external payable returns (uint256 nodeOperatorId) {
        nodeOperatorId = CSM.createNodeOperator(
            address(this),
            managementProperties,
            referrer
        );

        extensionNodeOperators[nodeOperatorId] = ExtensionNodeOperator({
            nodeOperatorId: uint64(nodeOperatorId),
            managerAddress: msg.sender,
            rewardAddress: msg.sender
        });

        CSM.setBondCurve(nodeOperatorId, CURVE_ID);
        CSM.addValidatorKeysETH{ value: msg.value }({
            from: address(this),
            nodeOperatorId: nodeOperatorId,
            keysCount: keysCount,
            publicKeys: publicKeys,
            signatures: signatures
        });
    }

    function addNodeOperatorStETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        bytes32[] calldata proof,
        address referrer
    ) external returns (uint256 nodeOperatorId) { ... }

    function addNodeOperatorWstETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        bytes32[] calldata proof,
        address referrer
    ) external returns (uint256 nodeOperatorId) { ... }


    function claimRewardsStETH(uint64 nodeOperatorId,
        uint256 stETHAmount,
        uint256 cumulativeFeeShares,
        bytes32[] memory rewardsProof
    ) external {
        ExtensionNodeOperator storage extensionNodeOperator = extensionNodeOperators[nodeOperatorId];
        if (extensionNodeOperator.managerAddress != msg.sender) revert NotAuthorized();

        /// pull rewards to calculate exact number of given shares
        uint256 distributedShares = CSM.accounting().pullFeeRewards(
            nodeOperatorId,
            cumulativeFeeShares,
            rewardsProof
        );

        uint256 claimedShares = CSM.claimRewardsStETH(
            nodeOperatorId,
            stETHAmount,
            0,
            new bytes32[](0)
        );

        // Do whatever extension want to do with distributed shares, e.g. save some on the contract balance
        // Then transfer the remaining to the node operator's reward address
    }

    /// @dev all the other CSM methods that are allowed only for NO's addresses
    ///       should be exposed here as well, such as:
    ///      - upload more keys
    ///      - removeKeys
    ///      - compensateELRewardsStealingPenalty
}

Effects on CSM Interactions

Node Operator creation will have an interface similar to the current one. However, the contract to be called will be different. By default, it is assumed that CSM will have two native gates:

  • Permissionless gate;
  • Identified Home stakers gate;

Permissionless gate will have a simplified interface for Node Operator creation (without proof argument):

    function addNodeOperatorETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        address referrer
    ) external payable returns (uint256 nodeOperatorId);
    
    function addNodeOperatorStETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        address referrer
    ) external returns (uint256 nodeOperatorId);
    
    function addNodeOperatorWstETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        address referrer
    ) external returns (uint256 nodeOperatorId);

Identified Home stakers gate will inherit existing CSM interface for Node Operators creation:

    function addNodeOperatorETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        bytes32[] calldata proof,
        address referrer
    ) external payable returns (uint256 nodeOperatorId);
    
    function addNodeOperatorStETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        bytes32[] calldata proof,
        address referrer
    ) external returns (uint256 nodeOperatorId);
    
    function addNodeOperatorWstETH(
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        NodeOperatorManagementProperties calldata managementProperties,
        ICSAccounting.PermitInput calldata permit,
        bytes32[] calldata proof,
        address referrer
    ) external returns (uint256 nodeOperatorId);

In addition to the Node Operator creation, the Identified Home stakers gate will also provide a method for the existing CSM Node Operators to claim beneficial bond curve:

    function claimBondCurve(
        uint256 nodeOperatorId,
        bytes32[] calldata proof
    ) external;

All other interactions with CSM will remain intact except for the minor changes in the interface of the addKeys methods (from argument added):

    function addValidatorKeysETH(
        address from,
        uint256 nodeOperatorId,
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures
    ) external payable;
    
    function addValidatorKeysStETH(
        address from,
        uint256 nodeOperatorId,
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        ICSAccounting.PermitInput calldata permit
    ) external;
    
    function addValidatorKeysWstETH(
        address from,
        uint256 nodeOperatorId,
        uint256 keysCount,
        bytes calldata publicKeys,
        bytes calldata signatures,
        ICSAccounting.PermitInput calldata permit
    ) external;