06 Fee Model
Overview
Lido charges a protocol fee on staking rewards, not directly on the principal deposited by users. The fee is split between node operators, staking modules, and the DAO treasury. The rate can be changed through Lido DAO governance.
[!NOTE] The protocol fee is usually represented as a portion of the fee for staking rewards; on the Router side, the final total rate depends on the sum of the
module fee,treasury feeand theiractive validatorsweights of each module. The current common governance configuration is 10%, but from a mechanism perspective, the Router aggregation result is not a hard-coded constant in the code.
This means that what users receive is the net staking income after deducting the protocol fee; the protocol fee will be distributed to relevant parties through the issuance of additional shares/stETH.
- module fee
The fees allocated to the staking module compensate the module and its node operators for validator operations, infrastructure maintenance, and day-to-day operations.
- treasury fee
Fees allocated to the Lido DAO Treasury are used for protocol governance, development, risk management, incident response, and other DAO expenses.
So the fee structure of a single module is: total module rate = module fee + treasury fee.
Calculation Formula
Calculated as the module's active-validator share multiplied by the fee rate set for that module
- The module’s validator weight in the protocol
Proportion of module validators = module active validators / full protocol active validators
- Module fee actually received
Module reward share = module validator share × module fee
- Module share allocated to Treasury
The Treasury share corresponding to this module = Proportion of module validators × treasury fee
- Aggregate total protocol fee
totalFee = sum of all modules (module fee share + treasury fee share)
uint256 stakingModuleValidatorsShare =
(stakingModulesCache[i].activeValidatorsCount * precisionPoints) / totalActiveValidators;
address recipient =
address(stakingModulesCache[i].stakingModuleAddress);
uint96 stakingModuleFee =
uint96((stakingModuleValidatorsShare * stakingModulesCache[i].stakingModuleFee) / TOTAL_BASIS_POINTS);
if (stakingModulesCache[i].status != StakingModuleStatus.Stopped) {
stakingModuleFees[rewardedStakingModulesCount] = stakingModuleFee;
}
totalFee +=
uint96((stakingModuleValidatorsShare * stakingModulesCache[i].treasuryFee) / TOTAL_BASIS_POINTS)
+ stakingModuleFee;
Example 🌰
Assume that there are 1000 active validators in the entire protocol, among which:
Module A: 500 active validators, accounting for 50%
Module B: 300 active validators, accounting for 30%
Module C: 200 active validators, accounting for 20%
[!NOTE]
If the module is
activeValidatorsCount = 0, the module will not receive module fee, nor will it contribute part of the module income to the total fee distribution of this round.The Stopped module will not get its own module fee, but its corresponding part of the fee will still be included in
totalFee.
Suppose further:
A's module fee = 8%, treasury fee = 2%
B’s module fee = 6%, treasury fee = 3%
C's module fee = 10%, treasury fee = 2%
So:
Module A
Module revenue = 50% × 8% = 4% Treasury income = 50% × 2% = 1%
Module B
Module revenue = 30% × 6% = 1.8% Treasury income = 30% × 3% = 0.9%
Module C
Module revenue = 20% × 10% = 2% Treasury income = 20% × 2% = 0.4%
The final total protocol fee is: 4% + 1% + 1.8% + 0.9% + 2% + 0.4% = 10.1%
activeValidators and exitedValidators Synchronization
As mentioned in the previous example, when the Router calculates the fee allocation, the weight of the module depends on its activeValidators amount.
module weight = activeValidators / totalActiveValidators
activeValidators =
totalDepositedValidators
- max(moduleSummaryExited, routerExited)
The exitedValidators here is not a single source, but relies on the status of the Router/ Module exited data after synchronization is completed. Therefore, it is coupled with the exited validators reporting chain of AccountingOracle, but it is not the same synchronization chain as the triggering of ExitBus itself. exitedValidators The synchronization process is as follows:
Oracle reports module exited count
After counting the validator status from the consensus layer, report the exit number of a certain module
↓
Router updates module level exited ledger
1. Verify that the number of exited cannot be reduced
2. Verify that the number of exited cannot exceed deposited
3.Update
↓
Module updates node operator exited details
Router will:
a. Check encoding format
b. Forward exit information to the module
↓
Inside the module there will be:
c. Update the exited validators of each node operator
d. Re-summarize the number of module exited
↓
Finish hook confirms that the module summary status is consistent
1. Traverse all modules
2. Read exitedValidators in module summary
3. Compare with exitedValidators recorded by Router
↓
Confirm synchronization moduleSummaryExited == routerExited
Router exited General Ledger
Module exited details
Synchronization has been completed
↓
Router/Lido subsequent logic reads the final status
The final module weight depends on active validators, and the calculation of this value is related to the routerExited, moduleSummaryExited synchronization results of the above process.
Give an example 🌰
Assume two modules:
Module A deposited = 1000Module B deposited = 1000initial:
A active = 1000B active = 1000weight = 50% / 50%If Oracle reports:
routerExited = 200moduleSummaryExited = 150Router uses: when calculating active validators:
active = deposited - max(routerExited, moduleSummaryExited)get:
A active = 800B active = 1000New fee weight:
A = 800 / 1800B = 1000 / 1800Therefore, in the next round of rewards-fee distribution, the fee weight of Module A will decrease, and the fee weight of Module B will increase relatively.
In summary, synchronizing exitedValidators changes the amount of activeValidators, which in turn affects the reward-fee split across modules and the treasury's income composition.
Core synchronization interface:
- Module level total update
updateExitedValidatorsCountByStakingModule(
uint256[] calldata _stakingModuleIds,
uint256[] calldata _exitedValidatorsCounts
)
- nodeOperator level update
reportStakingModuleExitedValidatorsCountByNodeOperator(
uint256 _stakingModuleId,
bytes calldata _nodeOperatorIds,
bytes calldata _exitedValidatorsCounts
)
- All node operators report finish after completion
onValidatorsCountsByNodeOperatorReportingFinished()
- Module closing hook
module.onExitedAndStuckValidatorsCountsUpdated()
- Urgent fix
unsafeSetExitedValidatorsCount(...)
Calling relationship
Oracle submit report
↓
Lido.handleOracleReport
↓
Calculate _totalRewards
↓
Lido._distributeFee
↓
StakingRouter.getStakingRewardsDistribution
↓
get modulesFees / totalFee
↓
Calculate sharesMintedAsFees
↓
_mintShares(address(this), sharesMintedAsFees)
↓
_transferModuleRewards
↓
_transferTreasuryRewards
↓
router.reportRewardsMinted
[!NOTE] router.reportRewardsMinted does not participate in this round of rate calculation. Instead, after the allocation is completed, the
totalSharescorresponding to each module will be notified synchronously to the module for internal accounting or subsequent processing of the module.
Call the handleOracleReport function in the Lido contract and execute to step 7. _processRewards internally calls the router contract to call getStakingRewardsDistribution to calculate and return the current round of modulesFees, totalFee and precisionPoints based on the current status of staking modules. The key data returned is as follows:
modulesFees = [4%, 2%, 3%]
treasuryFee = 1%
totalFee = 10%
Subsequently used for mint and allocation of fee shares, the steps to calculate mint shares are as follows:
Step 1 Define initial state
-
The total ETH in the Lido contract before the reward is generated: preTotalPooledEther = E
-
Total Share in the Lido contract before rewards are generated: preTotalShares = S
-
Generated in this round: totalRewards = R
Step 2 Update the amount of ETH after the reward
- After Oracle reports the reward, the total ETH of the protocol becomes: newTotalPooledEther = E + R
Step 3 Calculate the fee receivable under the agreement
- Protocol fee ratio: f = totalFee / precisionPoints
Step 4 Calculate the share price after mint
-
Let: sharesMintedAsFees = x
-
After mint shares: totalShares = S + x
-
Generated in this round: totalRewards = R
-
The share price is: sharePrice = (E + R) / (S + x)
-
The value of shares of protocol new mint is: value = x * sharePrice
-
Substitute sharePrice: value = x * (E + R) / (S + x)
-
Get the formula:
x = \frac{R f S}{E + R - R f}
- $E$: preTotalPooledEther
- $S$: preTotalShares
- $R$: totalRewards
- $f$:fee ratio
- $x$:sharesMintedAsFees
The protocol hopes that the value of the shares from the new mint will be exactly equal to the fee ETH receivable by the protocol. The protocol will not directly deduct rewards ETH from the pool, but dilute and distribute the equity "equal to the protocol fee" to modules and treasury by minting new shares.
uint256 totalPooledEtherWithRewards = _preTotalPooledEther.add(_totalRewards);
sharesMintedAsFees =
_totalRewards.mul(rewardsDistribution.totalFee).mul(_preTotalShares).div(
totalPooledEtherWithRewards.mul(
rewardsDistribution.precisionPoints
).sub(_totalRewards.mul(rewardsDistribution.totalFee))
);