Staking modules code development — starter guide

Staking modules code development — starter guide

Group
Module Development

This document is a WIP guide for staking module code development, beginning with setting up testing forks and mocks. While the idea is to have a comprehensive guide eventually, for now this is a barebones starter guide. That said, there’re valuable insights into the development process, testing procedures, and essential protocol scenarios, so let’s dig in.

Development

Start by creating a dedicated repository for your staking module. Choose your preferred framework for the project, with recommendations including Foundry, Hardhat, or Ape.

Ensure that the code complies with the IStakingModule interface to enable seamless integration with the Lido protocol and StakingRouter.

Testing

It is highly advisable to use the mainnet fork at specific blocks when conducting tests. The rationale behind this recommendation lies in the intricate nature of the protocol, which can introduce complexities, and in the potential challenges that may arise when resorting to custom mock simulations. These custom mocks, while useful, might not fully encapsulate all the essential aspects of protocol processing, potentially leading to incomplete or inaccurate testing results.

An optimal starting point for testing is to initiate a fork from the block following the voting process for the Lido v2 update, marked at block 17266005. This allows you to establish a suitable testing environment where you can adjust the protocol state to meet your specific testing conditions.

If you require testing interactions with stETH/wstETH in select scenarios, it is acceptable to employ mock simulations. The Lido protocol adheres to ERC standards for tokens in these interactions, devoid of any unique or specialized considerations. As a general guideline, it is recommended to utilize mainnet values for state variables in your mock simulations (such as totalShares and totalSupply).

You can access specific examples of mock simulations through the provided repository link.

If you need access to the repository linked above, just give a shout to dgusakov on Discord

Main protocol scenarios

In the context of the protocol, there are a few key scenarios that affect how your staking module works. Extensive explanations for these scenarios are available in Lido protocol documentation.

Rebase and Fee distribution

The distribution of protocol fees to staking modules occurs at regular intervals, currently set on a daily basis. This process unfolds following the transmission of data by the AccountingOracle via bots.

AccountingOracle is equipped with a distinct method that is invoked on the Lido contract, dedicated to the handling of reports. Within this process, a critical step is the distribution of fees. This happens after withdrawals are completed and excess shares are burned, but before the token rebase process.

The precise value of the fees is dependent on the protocol's daily income (CL and EL Rewards) and is computed based on the fee structure.

The fee structure is set independently in each module. This structure comprises two fundamental components: the module fee and the treasury fee, both expressed as percentages (basis points). For example, a module fee of 5% (500 basis points) is partitioned among node operators associated with the module, while an additional 5% (500 basis points) treasury fee is sent to the treasury.

Deposit

The deposit process involves the submission of batches of 32 ether deposits along with their corresponding validator keys to the official DepositContract. It is crucial to understand that each staking module autonomously manages its deposits. Consequently, each batch deposit is inherently linked to keys originating exclusively from the same module.

The deposit operation is a sequence of contract calls, initiated by an off-chain software component referred to as the depositor bot.

Exits

When the protocol needs validators in the module to withdraw, the process begins with the ValidatorsExitBusOracle contract. Once a quorum of bots submits reports, the contract generates events that indicate which validators are exiting.

As AccountingOracle reports data about validators who have already exited, the protocol updates the module's internal state, particularly the count of exited validators. The additional part of the report contains detailed information about these exited validators of each node operator.

Examples

Simulating rebase (using mocks)

Occasionally, it is necessary to simulate a rebase to evaluate your module's handling of this process. In such instances, mock simulations prove to be invaluable tools.

// SPDX-FileCopyrightText: 2023 Lido <info@lido.fi>
// SPDX-License-Identifier: GPL-3.0

pragma solidity 0.8.21;

import "forge-std/Test.sol";

import { StETHMock } from "../helpers/mocks/StETHMock.sol";

contract StETHRebaseTest is Test {

    StETHMock public stETH;

    function setUp() public {
        // 1. Use mock with initial supply value for stETH contract to have possibility
        //    to change total pooled ether (total supply) and total shares
        stETH = new StETHMock({ _totalPooledEther: 100 ether });
        // 2. Mint shares to some address to make positive share rate
        stETH.mintShares(msg.sender, 90 * 1e18);
        // 3. Write your module init code here
        YourModule stakingModule = new YourModule();
    }

    function test_PositiveRebase() public {
        // Change total pooled ether (total supply) to make positive rebase
        stETH.addTotalPooledEther(10 ether);
        // Check your module state after rebase
        assertEq(...);
    }

    function test_NegativeRebase() public {
        // Change total pooled ether (total supply) to make negative rebase
        stETH.subTotalPooledEther(10 ether);
        // Check your module state after rebase
        assertEq(...);
    }
}

Simulating fee distribution (using a fork)

Simulating fee distribution may be required to verify the accurate functioning of your module. This process involves simulating the AccountingOracle report. Actual transactions can be referenced for obtaining necessary values for testing. For instance, the data used in the following test was extracted from the initial accounting report following the v2 upgrade.

// SPDX-FileCopyrightText: 2023 Lido <info@lido.fi>
// SPDX-License-Identifier: GPL-3.0

pragma solidity 0.8.21;

import "forge-std/Test.sol";

import { ILido } from "../../src/interfaces/ILido.sol";
import { ILidoLocator } from "../../src/interfaces/ILidoLocator.sol";
import { IStakingRouter } from "../../src/interfaces/IStakingRouter.sol";

contract FeeDistributionTest is Test {
    uint256 networkFork;

    ILido public lido;
    ILidoLocator public locator;
    IStakingRouter public stakingRouter;

    address internal agent;

    function setUp() public {
        // 1. Provide RPC url from your mainnet EL node
        networkFork = vm.createFork("<RPC_URL>", 17266005);
        vm.selectFork(networkFork);

        // 2. Use LidoLocator mainnet address to get StakingRouter address
        locator = ILidoLocator(vm.parseAddress('0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb'));
        stakingRouter = IStakingRouter(payable(locator.stakingRouter()));
        lido = ILido(locator.lido());

        // 3. Write your module init code here
        YourModule stakingModule = new YourModule();

        // 4. Pretend to be the agent who can interact with StakingRouter
        agent = stakingRouter.getRoleMember(
            stakingRouter.DEFAULT_ADMIN_ROLE(),
            0
        );
        vm.startPrank(agent);
        stakingRouter.grantRole(
            stakingRouter.STAKING_MODULE_MANAGE_ROLE(),
            agent
        );

        // 5. Add your module to StakingRouter
        //    _stakingModuleFee = 500 means that your module will receive 5% of the fee
        //    _treasuryFee = 500 means that treasury will receive 5% of the fee
        stakingRouter.addStakingModule({
            _name: "your-staking-module-v1",
            _stakingModuleAddress: stakingModule,
            _targetShare: 10000,
            _stakingModuleFee: 500,
            _treasuryFee: 500
        });
        vm.stopPrank();
    }

    function test_DistributeFee() public {
        // 6. Pretend to be the address of AccountingOracle contract to call Lido.handleOracleReport
        vm.startPrank(locator.accountingOracle());
        // 7. Call handleOracleReport to simulate Accounting report
        lido.handleOracleReport({
          // Oracle timings
          _reportTimestamp: GENESIS_TIME + 6451199 * 12,
          _timeElapsed: 6451199 - 0, // 0 - previous accounting report slot
          // CL values
          _clValidators: 196087,
          _clBalance: 6276095756977027 * 1e9, // gwei to wei
          // EL values
          _withdrawalVaultBalance: 277712792775539582008297,
          _elRewardsVaultBalance: 579806686319271377574,
          _sharesRequestedToBurn: 0,
          // Decision about withdrawals processing
          _withdrawalFinalizationBatches = [],
          _simulatedShareRate = 0
        });
        // 8. Check your module state after fee distribution
        assertEq(...);
    }
}

Simulating a deposit (using a fork)

In certain cases, it becomes essential to simulate a deposit to assess how your module correctly processes it. This involves simulating a deposit call on Lido contract.

// SPDX-FileCopyrightText: 2023 Lido <info@lido.fi>
// SPDX-License-Identifier: GPL-3.0

pragma solidity 0.8.21;

import "forge-std/Test.sol";

import { ILido } from "../../src/interfaces/ILido.sol";
import { ILidoLocator } from "../../src/interfaces/ILidoLocator.sol";
import { IStakingRouter } from "../../src/interfaces/IStakingRouter.sol";

contract DepositTest is Test {
    uint256 networkFork;

    ILido public lido;
    ILidoLocator public locator;
    IStakingRouter public stakingRouter;

    address internal agent;

    function setUp() public {
        // 1. Provide RPC url from your mainnet EL node
        networkFork = vm.createFork("<RPC_URL>", 17266005);
        vm.selectFork(networkFork);

        // 2. Use LidoLocator mainnet address to get StakingRouter address
        locator = ILidoLocator(vm.parseAddress('0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb'));
        stakingRouter = IStakingRouter(payable(locator.stakingRouter()));
        lido = ILido(locator.lido());

        // 3. Write your module init code here
        YourModule stakingModule = new YourModule();

        // 4. Pretend to be the agent who can interact with StakingRouter
        agent = stakingRouter.getRoleMember(
            stakingRouter.DEFAULT_ADMIN_ROLE(),
            0
        );
        vm.startPrank(agent);
        stakingRouter.grantRole(
            stakingRouter.STAKING_MODULE_MANAGE_ROLE(),
            agent
        );

        // 5. Add your module to StakingRouter
        //    _targetShare = 10000 means 100%. A relative hard cap on deposits within protocol.
        stakingRouter.addStakingModule({
            _name: "your-staking-module-v1",
            _stakingModuleAddress: stakingModule,
            _targetShare: 10000,
            _stakingModuleFee: 500,
            _treasuryFee: 500
        });
        vm.stopPrank();
    }

    function test_deposit() public {
        // 6. Get your staking module id
        uint256 stakingModuleId = stakingRouter.getStakingModulesCount();
        // 7. Pretend to be the address of DepositContract contract to call Lido.deposit
        vm.startPrank(locator.depositContract());
        // 8. Call deposit to simulate deposit
        lido.deposit({
           _maxDepositsCount: 10,
           _stakingModuleId: stakingModuleId,
           _depositCalldata: 0x000
        });
        // 9. Check your module state after fee distribution
        assertEq(...);
    }
}

Simulating exits (using a fork)

To be extended 🔜

Actual tests examples

For concrete examples of tests, please visit the repository here.

To access the repository linked above, please contact dgusakov on Discord