Lido, as part of the community, thinks of Ethereum as a credibly neutral home for applications and their users. The mission of Lido on Ethereum is to provide a secure and accessible staking platform and contribute to Ethereum's decentralization. Lido is firmly committed to diversifying the validator set, which should reduce the risk of downtime or censorship while retaining network performance.
With this goal, we, the dev team, present Staking Router, a major protocol update, moving the operator registry to a modular architecture. This design would allow for experiments with different approaches and principles for building validator sets, including solo and DVT validators, without significant changes to the underlying protocol.
The Staking Router brings Lido's vision of creating a platform for stakers, developers, and node operators to collaborate and advance the future of decentralized staking to reality. We invite teams and individual contributors to build with us.
AragonApp
is a base utility contract from Aragon;Lido
before being deposited to DepositContract
;DepositContract
is the official contract for validator deposits;DepositSecurityModule
is a utility contract for verifying deposit parameters;Lido
is a core Lido contract that stores the protocol state, accepts user submissions, and includes the stETH token;NodeOperatorsRegistry
, each node operator has an index, a human-readable name, an associated address on Ethereum, and a list of signing keys.NodeOperatorsRegistry
is a Lido contract that manages a curated node operators' list, stores their signing keys, and keeps track of the number of active/stopped validators for each node operator;NodeOperatorsRegistry
consisting of the validator's public key and signature, which are submitted to DepositContract
. Lido does not store validators' private keys;Lido
and representing the user's share in pooled ether under the protocol control;Currently, at the smart-contract level, Lido on Ethereum supports the curated validator set only. All node operators apply through the DAO vetting process and, if approved, are added to NodeOperatorRegistry, a smart contract that manages node operators, stores signing keys, and distributes the stake.
At this time, Lido seeks to diversify the validator set and is already actively experimenting with DVT partners such as SSV and Obol, and has plans for onboarding community validators. In this connection, the Lido protocol needs to be more flexible and able to support new validator subsets, which is challenging under the constraints of the existing monolithic architecture. It is, therefore, proposed to move to a modular structure such that each validator subset is encapsulated into a separate smart contract and integrated into the protocol.
It makes sense to impose certain limits on newly joined modules and increase them as the relevant infrastructure matures. For example, the DVT module may start with some safe and low limit (e.g., 1%) of the total stake in Lido, and if it performs well, this limit might be increased.
Moreover, the modular design will allow the DAO to specify fee settings for each validator subset independently because different ways of distributing stake can call for different fee structures.
This approach will allow the Lido on Ethereum protocol to incorporate an aggregator strategy for its validator set. Anyone is welcome to build on this platform, diversifying both the contributor community and the underlying validator technologies. The role of Lido DAO is to choose the optimal proportion between subsets to maximize the protocol's decentralization, profitability, and sustainability.
Before diving into the StakingRouter
design, it is worthwhile to establish an understanding of the existing architecture and processes. This knowledge will serve as a jumping point for the StakingRouter
design exploration.
The diagram below illustrates the main processes in Lido:
A user submits ether to the Lido
contract in return for a share of the total Lido stETH pool. The pool grows by earning consensus and execution layer rewards and shrinks due to slashing penalties. As such, the same user share will respectively appreciate or depreciate in terms of stETH amount. Before being deposited, user-submitted funds accumulate on the Lido
contract's balance. In technical terms, we refer to this ether as buffered or in the deposit buffer.
Lido, however, does not perform the deposit as soon as the buffer reaches 32 ether. This would be prohibitively expensive. Instead, a special off-chain bot monitors the network gas price and performs a batch deposit in a single transaction paid for by the protocol whenever the buffer reaches the size that justifies the cost of the transaction.
Among the core mechanics of the Lido protocol is synchronizing the total validator balances with the total supply of stETH. However, because the execution and consensus layers are separate, the protocol employs a set of oracles to reflect balance updates onto the Lido
contract. Typically, the oracles report balance changes daily, and Lido accordingly updates the total supply of stETH.
Users receive 90% of the total rewards earned by validators because Lido takes a 10% cut as the protocol fee, which is then split fifty-fifty between the treasury and node operators. In technical terms, Lido
mints shares in the amount that reduces holder rewards by 10% and distributes the shares between the treasury and node operators.
On the protocol level, Lido interacts with node operators via NodeOperatorsRegistry
. This contract manages the list of node operators, stores their signing keys, and decides which keys will be used for the next batch deposit based on which node operators have the least active validators. Once voted in, the node operator is added to NodeOperatorsRegistry
. Then, the operator can add signing keys to the same contract. When the keys are submitted to DepositContract
, the respective validators are placed in the activation queue. Once activated, they start participating in the consensus.
Although there are no restrictions on how many keys a node operator can upload, Lido imposes staking limits on each node operator. Thus, a node operator has only certain amount of approved keys and cannot start more validators than what the current limit allows. These limits are not static and may be raised via Aragon Voting or EasyTrack.
Each deposit to DepositContract
must come with withdrawal credentials (WC), allowing the sender to withdraw the funds and potential rewards in the future, once withdrawals are enabled. All Lido deposits are submitted with the Lido-approved WC to ensure that only Lido can withdraw user funds.
In October 2021, a critical vulnerability was reported to the Lido bug bounty program. Because DepositContract
associates the validator's key with the first valid deposit, this exploit allowed a node operator to pre-submit a minimal deposit of 1 ether with their own WC, thus, making any subsequent deposits with the same key withdrawable only with the initial WC.
As per LIP-5: Mitigations for deposit front-running vulnerability, a deposit security scheme was introduced. Before submitting a batch deposit, the guardian committee ensures there were no malicious pre-deposits and signs a message containing the deposit parameters:
depositRoot
from DepositContract
, the Merkle root of all deposits;keyOpsIndex
from NodeOperatorsRegistry
, a nonce of key operations;The depositor bot submits these messages to DepositSecurityContract
, which verifies the guardian signatures and confirms the block with the specified number has the specified hash.
The main idea of StakingRouter
is to modularize the validator set at the smart-contract level. This design encapsulates different validator subsets into separate pluggable contracts called modules. These modules manage node operators, store their signing keys (or their hashes in case the keys are stored elsewhere, e.g., an off-chain database), and distribute the stake and rewards between them. StakingRouter
is a contract that acts as a top-level controller that oversees the operation across the modules.
The implementation may drastically differ from one module to another depending on the underlying validator technology, however, in order to be able to communicate with them, StakingRouter
requires all modules to implement a specific interface. For example, each module must provide a way to retrieve validators' key information, regardless of how the module stores it.
When designing the specification, the development team had in mind several possible validator subsets that could be connected to StakingRouter
:
Curated
, the DAO-curated node operators, equivalent to the existing NodeOperatorsRegistry
;Community
, permissionless node operators on a bond basis with an optional mechanic of effectively lowering bond requirements based on reputation;DVT
, DVT-enabled validators (with optional bonds) such as Obol's Distributed Validator Clusters or SSV nodes; andOffchain
or L2
, upgrade for Curated
subset, which allows reducing gas cost by pushing validators' keys storing to off-chain or layer 2.Staking Router's purpose is to orchestrate deposits and, eventually, withdrawals through different modules in a way that satisfies the stake distribution desired by the DAO.
Initially, the Lido DAO will set a target share expressed as a percentage for each module's maximum fraction of active validators. Over time, as modules mature and develop higher capacity, the DAO will increase their target shares to compile a validator set that maximizes capital efficiency and, at the same time, is both sufficiently safe to delegate more user funds to and diverse and censorship-resistant to act as the part of the blockchain validation operations.
Along with the allocation algorithm discussed further, another way to control the distribution of active validators between modules is selecting from which module to eject validators once withdrawals are enabled. Next year, Lido plans to launch protocol-level withdrawals, but this topic is out of the scope of this proposal.
Much like right now, the rewards will be distributed across all modules without regard for performance, meaning the modules will receive rewards in proportion to the number of validators they have running and fee settings. The modular design of StakingRouter
allows the DAO to set the treasury fee independently for each module which implies different fee structures for different modules.
The main difference in the deposit process is the introduction of the balancing strategy, which we call the stake allocation algorithm. Whereas currently, NodeOperatorsRegistry
is the only consumer of the buffered ether, StakingRouter
will have to split incoming ether between several modules. The main goal here is reserving enough ether for all modules and, at the same time, reducing the amount of idle ether to maximize capital efficiency.
Another feature for this upgrade is the migration of NodeOperatorsRegistry
to meet the StakingRouter
module requirements. The existing registry contains a lot of information about node operators and moving this state may be complicated and expensive both in terms of gas consumption and development.
Now we want to talk about some of the problems of this update and propose solutions.
In the existing architecture, the Lido
contract serves as a temporary storage, or a buffer, for user-submitted ether. Whenever the buffer becomes large enough to justify the cost of the transaction, the depositor bot performs a batch deposit bundled with the keys provided by NodeOperatorsRegistry
. The key selection is based on whichever node operators have the fewest active validators.
With this upgrade, the deposit buffer still remains on Lido
, however, during the deposit procedure some of the ether will be transferred to StakingRouter
which calculates the amount of ether allocated to the module and performs the deposits. We call this process stake allocation.
One of the main jobs of StakingRouter
is to manage the relative module sizing. To achieve this, the DAO imposes a target share on each newly joined module, expressed as the percentage of its active validators to those in total across all modules. At the same time, this target acts as the module's quota that skews stake allocation in favor of the underdeposited module.
To illustrate this mechanism, we will consider the following example. The table below presents information about the modules at the start of an allocation round.
Module | Target share (%) | Сurrent share (%) | Active validators | Ready-to-deposit keys |
---|---|---|---|---|
Community |
10% | 7% | 7000 | 3000 |
DVT |
20% | 18% | 18,000 | 5000 |
Curated |
70% | 75% | 75,000 | 20,000 |
Now, let's say that there is 320,000 ether in the deposit buffer, enough to onboard 10,000 new validators. Starting from the smallest module, the allocation algorithm reserves sufficient ether to whichever of the below conditions comes first:
If two or more modules are equal in size and still have ready unused keys and some room until the target share, deposit ether is allocated evenly between them until reaching the capacity or there is no more ether to allocate.
The procedure of allocation is as follows:
StakingRouter
calculates the maximum active validators for each module based on the target shares. Including the buffered ether, there is 110,000 validators worth of stake in the protocol. Therefore, the flat caps of the Community
, DVT
and Curated
modules, with their respective target shares of 10%, 20% and 70%, is 11,000, 22,000 and 77,000 validators.StakingRouter
starts with the Community
module, as it has the least stake. With its 7000 already active validators plus the 3000 ready validators, the module will total to 10,000 validators which is well within the module limit. As such, 96,000 ether (or in other words 3000 validators worth of ether) is reserved for the module.StakingRouter
reserves only 128,000 ether (or 4000 validators worth of ether) for the DVT
module, pushing the module to its maximum of 22,000 active validators and leaving 1,000 idling until the next allocation round.Curated
module. 32,000 remains unaccomodated and will spill over to the next deposit.The table below demonstrated the state after the allocation.
Module | Target share (%) | Сurrent share (%) | Active validators | Ready-to-deposit keys |
---|---|---|---|---|
Community |
10% | 9% | 10,000 | 0 |
DVT |
20% | 20% | 22,000 | 1000 |
Curated |
70% | 70% | 77,000 | 18,000 |
With this mechanic in mind, we have come up with the following allocation algorithms.
The allocation algorithm should,
Listed below are the general assumptions for all algorithms:
StakingRouter
requires exactly 32 ether to deposit.The static bucket algorithm implements the allocation logic precisely as the target-share mechanic prescribes. This means that the buffered ether is split according to the target shares into separate reserves, so-called buckets. These buckets signify a module's priority access to the buffer up to the assigned allocation.
Each module is entitled only to its respective bucket. This guarantees a reserve of ether for each module to cover validators up to the module's maximum capacity or up to the target share. The downside to this approach is if the module, for some reason, cannot make use of its reserve, the ether stays idle, thus, decreasing capital efficiency.
Since each staking module is autonomous and responsible for sending a transaction that would result in a deposit to the DepositContract
, idle ether may accumulate in the event of a module failure or due to an aggressive gas spending economy strategy by the module.
This approach builds on top of the first one, as it uses the same allocation logic. However, the difference is that the module's guaranteed reserve expires over a set period, allowing other modules to take any unused ether. Note that the module does not lose access to its reserve but no longer has a priority and will have to race with other modules.
In each round, the module is given some time to make use of the entire reserved allocation. However, starting from a certain point in the round, the guaranteed reserve will gradually expire, for example, in four increments of 25%.
The diagram below illustrates an example of a module's guaranteed reserve expiring in four increments of 25% starting from the middle point of the round.
Thus, rounds are delineated by time. As such, there may be multiple allocations during a single round, and the deallocation mechanic works in terms of the current, not initial, reserve.
We plan to limit one round to 24 hours which is why it may make sense to attach the round start to oracle report time. However, as oracles do not guarantee a report every day, it may be safer to use real-world time, e.g., start each round at 12 am UTC+0.
This approach provides a good balance between providing a sufficient supply for all modules and minimizing idle ether.
We are leaning towards Option A because it offers a straightforward solution which is enough for the initial iteration of StakingRouter
. In future, we intend to make the algorithm more sophisticated and able to allocate ether dynamically based on the module demand.
Currently, the Lido
contract is responsible for the distribution of rewards between treasury and node operators. We propose to shift this responsibility to StakingRouter
because of the following reasons:
Lido
contract approaching the maximum contract bytecode size, andStakingRouter
having a direct line of communication with modules.This means that the oracle will still report the rebase delta to Lido
but StakingRouter
will provide the amount of shares to mint. While it does make sense from the design perspective to move minting to StakingRouter
, we are strongly inclined to leave the mintShares
function on Lido
to avoid security risks associated with having minting on a separate contract.
As we've mentioned earlier, the treasury cut will be set individually for each module in StakingRouter
that gives the flexibility to integrate modules without imposing Lido's current 5% treasury fee.
mint
function on the Lido
contract to avoid security risks of having the mint function on a separate contract;Lido
due to the contract bytecode size limits;Lido
and StakingRouter
; andWe have identified three possible variations of the rewards distribution process. All of them are quite similar and any one of these options is not marginally better than the others. Nevertheless, by presenting these options, we document our thinking process behind the decision.
This option presents the following steps of rewards distribution:
Lido
requests the number of shares to mint from StakingRouter
;Lido
mints new shares for StakingRouter
;Lido
retrieves the module-shares table from StakingRouter
;Lido
retrieves the Lido treasury shares table from StakingRouter
;Lido
transfers the minted shares from StakingRouter
to treasury.Lido
transfers the minted shares from StakingRouter
to each module.%%{
init: {
"theme": "forest"
}
}%%
sequenceDiagram
title Option 1
participant Oracle
participant Lido
participant StakingRouter
participant LidoTreasury
participant Curated
participant DVTRegistry
Oracle ->> Lido: reports delta
Note over StakingRouter: updates module-to-shares table
Lido ->> StakingRouter: gets delta
Lido ->> StakingRouter: prints shares
Lido ->> StakingRouter: gets module-to-shares
Lido ->> LidoTreasury: transfers shares
Lido ->> Curated: transfers shares
Lido ->> DVTRegistry: transfers shares
Lido
;Lido
;Lido
;StakingRouter
and are then transferred to node operators, instead of minting the shares to node operators at once.This option presents the following steps of rewards distribution:
Lido
requests the number of shares to mint from StakingRouter
;Lido
mints new shares for StakingRouter
;Lido
calls distibuteFee
function on StakingRouter
; andStakingRouter
transfers the minted shares to treasury and to each module.%%{
init: {
"theme": "forest"
}
}%%
sequenceDiagram
title Option 2
participant Oracle
participant Lido
participant StakingRouter
participant LidoTreasury
participant Curated
participant DVTRegistry
Oracle ->> Lido: reports delta
Note over StakingRouter: updates module-to-shares table
Lido ->> StakingRouter: gets delta
Lido ->> StakingRouter: prints shares
Lido ->> StakingRouter: calls `distributeFee()`
StakingRouter ->> LidoTreasury: transfer shares
StakingRouter ->> Curated: transfers shares
StakingRouter ->> DVTRegistry: transfers shares
Lido
;Lido
;Lido
;StakingRouter
and are then transferred to modules, instead of minting the shares to modules at once.This option presents the following steps of rewards distribution:
Lido
requests the module-to-shares table from StakingRouter
;Lido
requests the treasury shares from StakingRouter
;Lido
mints the shares to treasury.Lido
mints the shares to each module.%%{
init: {
"theme": "forest"
}
}%%
sequenceDiagram
title Option 3
participant Oracle
participant Lido
participant StakingRouter
participant LidoTreasury
participant Curated
participant DVTRegistry
Oracle ->> Lido: reports delta
Note over StakingRouter: updates module-to-shares table
Lido ->> StakingRouter: gets module-to-shares table
Lido ->> LidoTreasury: mint shares
Lido ->> Curated: mint shares
Lido ->> DVTRegistry: mint shares
Lido
;Lido
.We propose to move forward with Option C because it presents a straightforward distribution logic with minimal steps.
With this upgrade, the existing NodeOperatorsRegistry
becomes the Curated
module. This implies that NodeOperatorsRegistry
must implement the module interface. Nevertheless, StakingRouter
does not dictate the underlying design of the module; thus, the architecture of the registry remains as it is and will only be expanded according to the module specification.
NodeOperatorsRegistry
interface to comply with module specificationsBecause NodeOperatorsRegistry
is a proxy contract, we have the option of changing the implementation with one that complies with module specifications and preserve the state on the proxy. The upgrade implies only modifications to the existing NodeOperatorsRegistry
codebase to meet the module interface requirements. However, the Solidity version is quite outdated (v0.4.24
), and as the contract inherits from AragonApp
, it results in slightly inefficient gas consumption for daily transactions.
NodeOperatorsRegistry
This option implies moving the registry to a new codebase using an up-to-date Solidity version but leaving the state on NodeOperatorsRegistry
. Any state reading and writing calls will be communicated through the new contract to the old NodeOperatorsRegistry
. Even though the newer Solidity version provides better gas optimization, the operation gas expenses will likely still be increased due to call delegation.
In this approach, we migrate to a new registry codebase using an up-to-date Solidity version and move the state in a single expensive transaction.
We propose to move forward with Option A with plans for a complete migration in future.
Each deposit to DepositContract
must come with withdrawal credentials (WC), allowing the sender to withdraw the funds and potential rewards in the future, once withdrawals are enabled. All Lido deposits are submitted with the Lido-approved WC to ensure that only Lido can withdraw user funds.
In October 2021, a critical vulnerability was reported to the Lido bug bounty program. Because DepositContract
associates the validator's key with the first valid deposit, this exploit allowed a node operator to pre-submit a minimal deposit of 1 ether with their own WC, thus, making any subsequent deposits with the same key withdrawable only with the initial WC.
As per LIP-5: Mitigations for deposit front-running vulnerability, a deposit security scheme was introduced. Before submitting a batch deposit, the guardian committee ensures there were no malicious pre-deposits and signs a message containing the deposit parameters:
depositRoot
from DepositContract
, the global Merkle root;keyOpsIndex
from NodeOperatorsRegistry
, a nonce of key operations;The depositor bot submits these messages to DepositSecurityContract
, which verifies the guardian signatures and confirms the specified block has the specified hash. After that, the contract submits the deposits by calling the deposit function on Lido
.
keysOpIndex
is a nonce incremented on state transitions in NodeOperatorsRegistry
. This nonce allows the guardian committee to refer to a specific state in NodeOperatorsRegistry
when performing a deposit security check. Thus, DepositSecurityModule
knows that the guardian signatures are valid only for the specific state of NodeOperatorsRegistry
, if the keyOpsIndex
in the committee-signed message does not match the one in the registry, the deposit will not occur.
In the existing design, keyOpIndex
in NodeOperatorsRegistry
is incremented when:
Lido
retrieves keys for deposits by calling the assignNextSigningKeys
function.With the StakingRouter
update, DepositSecurityModule
will need to verify deposits from multiple modules, each with their keysOpIndex
. In this regard, we have identified two approaches.
This approach implies designing a system with only one index required for the security check. For example, StakingRouter
stores a counter that increments on any key operation in any module. If the check fails, deposits are stopped across all modules. The obvious flaw with this approach is that DepositySecurityModule
cannot identify the source of the malicious deposit and has to halt deposits for all modules.
The sequence diagram below illustrates this process.
%%{
init: {
"theme": "forest"
}
}%%
sequenceDiagram
title DSM
participant DSMBot
participant DepositSecurityModule
participant StakingRouter
participant Curated
participant Community
participant DVT
DSMBot->>DepositSecurityModule: deposit(depositRoot, <br />keyOpIndex, <br />blockNumber, <br />blockHash)
DepositSecurityModule->>StakingRouter: getCommonKeyOpIndex()
StakingRouter->>Curated: getKeyOpIndex()
Curated->>StakingRouter: index=10
StakingRouter->>Community: getKeyOpIndex()
Community->>StakingRouter: index=20
StakingRouter->>DVT: getKeyOpIndex()
DVT->>StakingRouter: index=12
StakingRouter->>DepositSecurityModule: index==42
DepositSecurityModule->>DepositSecurityModule: check(keyOpIndex)
Note right of DepositSecurityModule: check failed
DepositSecurityModule->>DepositSecurityModule: pause()
In this approach, each module tracks its own index and DepositSecurityModule
performs multiple checks, one for each module. This allows the protocol to halt deposits independently for one module in the case of a failed check. This approach excludes any race conditions because once the module deposit is performed, the deposit Merkle root updates, and the subsequent check will be carried out against the new root.
This approach is illustrated below.
%%{
init: {
"theme": "forest"
}
}%%
sequenceDiagram
title DepositSecurityModule
participant DSMBot
participant DepositSecurityModule
participant Curated
DSMBot->>DepositSecurityModule: deposit(depositRoot, <br />keyOpIndex, <br />blockNumber, <br />blockHash)
DepositSecurityModule->>Curated: getKeyOpIndex()
Curated->>DepositSecurityModule: index=42
DepositSecurityModule->>DepositSecurityModule: check(keyOpIndex)
Note right of DepositSecurityModule: check failed
DepositSecurityModule->>Curated: pause()
We propose to move forward with Option B due to the flexibility it provides.
Up to this point, we have discussed design decisions that required our immediate attention. We want to dedicate this section to some of the features we intend to focus on in the next iteration.
We have identified three directions that we will be working in within the next iteration of Staking Router:
We also expect other feature requests from contributors and stakeholders.
Community and DVT modules would likely require operators to submit bonds as collateral. A bond makes up a part of the DepositContract
deposit, with the rest taken from the Lido pool. For example, to start participating, a node operator may submit only ten ether, and 22 ether will be provided from Lido.
Before full-featured bonds are implemented, modules that require this feature could convert bonds to stETH. The downside of this approach is that converted bonds could shrink under mass-slashing events. However, the original bond semantics can be achieved by handling such cases off-chain with the slashing insurance fund, assuming that the volume of bonds is relatively small compared to the protocol TVL. We have plans to rework the accounting model to support a true bond implementation which will enable the protocol preserve the bond and translate rewards and penalties based on individual validator performance.
Before that time comes, we can take two possible directions with stETH-based bonds:
Each module will have a target share of stake to work towards. The balancing algorithms explained in Problem 1 account for these shares when allocating buffered ether between modules. Another way to balance the stake distribution between modules is through withdrawals. As such, the StakingRouter
can employ a strategy for ejecting active validators to bring modules closer to the target shares desired by the DAO.
Staking Router will need to provide support for L2 modules. Although we anticipate some complications with interoperability, it will be possible to platform these modules due to the minimal interfacing requirements. In our design process, we tried to keep module requirements as loose as possible. As such, the only critical information each module must provide is the number of total, active, stopped, and exited (once withdrawals are enabled) keys.