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.
dgusakov
on DiscordMain 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.
dgusakov
on Discord