Try   HackMD

Dual Governance specification

A version from 2024-04-04.


Dual Governance (DG) is a governance subsystem that sits between the Lido DAO, represented by various voting systems, and the protocol contracts it manages. It protects protocol users from hostile actions by the DAO by allowing to cooperate and block any in-scope governance decision until either the DAO cancels this decision or users' (w)stETH is completely withdrawn to ETH.

This document provides the system description on the code architecture level. A detailed description on the mechanism level can be found in the Dual Governance mechanism design document which should be considered an integral part of this specification.

System overview

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 →

The system is composed of the following main contracts:

  • DualGovernance.sol is a singleton that provides an interface for submitting governance proposals and scheduling their execution, as well as managing the list of supported proposers (DAO voting systems). Implements a state machine tracking the current global governance state which, in turn, determines whether proposal submission and execution is currently allowed.
  • EmergencyProtectedTimelock.sol is a singleton that stores submitted proposals and provides an interface for their execution. In addition, it implements an optional temporary protection from a zero-day vulnerability in the dual governance contracts following the initial deployment or upgrade of the system. The protection is implemented as a timelock on proposal execution combined with two emergency committees that have the right to cooperate and disable the dual governance.
  • Executor.sol contract instances make calls resulting from governance proposals' execution. Every protocol permission or role protected by the DG, as well as the permission to manage this role/permission, should be assigned exclusively to one of the instances of this contract (in contrast with being assigned directly to a DAO voting system).
  • Escrow.sol is a contract that can hold stETH, wstETH, withdrawal NFTs, and plain ETH. It can exist in two states, each serving a different purpose: either an oracle for users' opposition to DAO proposals or an immutable and ungoverned accumulator for the ETH withdrawn as a result of the rage quit.
  • GateSealBreaker.sol is a singleton that allows anyone to unpause the protocol contracts that were put into an emergency pause by the GateSeal emergency protection mechanism, given that the minimum pause duration has passed and that the DAO execution is not currently blocked by the DG system.

Proposal flow

The system supports multiple DAO voting systems, represented in the dual governance as proposers. A proposer is an address that has the right to submit sets of EVM calls (proposals) to be made by a dual governance's executor contract. Each proposer has a single associated executor, though multiple proposers can share the same executor, so the system supports multiple executors and the relation between proposers and executors is many-to-one.

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 →

The general proposal flow is the following:

  1. A proposer submits a proposal, i.e. a set of EVM calls (represented by an array of ExecutorCall structs) to be issued by the proposer's associated executor contract, by calling the DualGovernance.submitProposal function.
  2. This starts a dynamic timelock period that allows stakers to oppose the DAO, potentially leaving the protocol before the timelock elapses.
  3. By the end of the dynamic timelock period, the proposal is either canceled by the DAO or executable.
    • If it's canceled, it cannot be scheduled for execution. However, any proposer is free to submit a new proposal with the same set of calls.
    • Otherwise, anyone can schedule the proposal for execution by calling the DualGovernance.scheduleProposal function, with the execution flow that follows being dependent on the deployment mode.
  4. The proposal's execution results in the proposal's EVM calls being issued by the executor contract associated with the proposer.

Dynamic timelock

Each submitted proposal requires a minimum timelock before it can be scheduled for execution.

At any time, including while a proposal's timelock is lasting, stakers can signal their opposition to the DAO by locking their (w)stETH or withdrawal NFTs (wNFTs) into the signalling escrow contract. If the opposition exceeds some minimum threshold, the global governance state gets changed, blocking any DAO execution and thus effectively extending the timelock of all pending (i.e. submitted but not scheduled for execution) proposals.

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 →

At any time, the DAO can cancel all pending proposals by calling the DualGovernance.cancelAllPendingProposals function.

By the time the dynamic timelock described above elapses, one of the following outcomes is possible:

  • The DAO was not opposed by stakers (the happy path scenario).
  • The DAO was opposed by stakers and canceled all pending proposals (the two-sided de-escalation scenario).
  • The DAO was opposed by stakers and didn't cancel pending proposals, forcing the stakers to leave via the rage quit process, or canceled the proposals but some stakers still left (the rage quit scenario).
  • The DAO was opposed by stakers and didn't cancel pending proposals but the total stake opposing the DAO was too small to trigger the rage quit (the failed escalation scenario).

Proposal execution and deployment modes

The proposal execution flow comes after the dynamic timelock elapses and the proposal is scheduled for execution. The system can function in two deployment modes which affect the flow.

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 →

Regular deployment mode

In the regular deployment mode, the emergency protection delay is set to zero and all calls from scheduled proposals are immediately executable by anyone via calling the EmergencyProtectedTimelock.execute function.

Protected deployment mode

The protected deployment mode is a temporary mode designed to be active during an initial period after the deployment or upgrade of the DG contracts. In this mode, scheduled proposals cannot be executed immediately; instead, before calling EmergencyProtectedTimelock.execute, one has to wait until an emergency protection timelock elapses since the proposal scheduling time.

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 →

In this mode, an emergency activation committee has the one-off and time-limited right to activate an adversarial emergency mode if they see a scheduled proposal that was created or altered due to a vulnerability in the DG contracts or if governance execution is prevented by such a vulnerability. Once the emergency mode is activated, the emergency activation committee is disabled, i.e. loses the ability to activate the emergency mode again. If the emergency activation committee doesn't activate the emergency mode within the duration of the emergency protection duration since the committee was configured by the DAO, it gets automatically disabled as well.

The emergency mode lasts up to the emergency mode max duration counting from the moment of its activation. While it's active, 1) only the emergency execution committee has the right to execute scheduled proposals, and 2) the same committee has the one-off right to disable the DG subsystem, i.e. disconnect executor contracts from the DG contracts and reconnect them to the Lido DAO Voting/Agent contract. The latter also disables the emergency mode and the emergency execution committee, so any proposal can be executed by the DAO without cooperation from any other actors.

If the emergency execution committee doesn't disable the DG until the emergency mode max duration elapses, anyone gets the right to deactivate the emergency mode, switching the system back to the protected mode and disabling the emergency committee.

Note: the protected deployment mode and emergency mode are only designed to protect from a vulnerability in the DG contracts and assume the honest and operational DAO. The system is not designed to handle a situation when there's a vulnerability in the DG contracts AND the DAO is captured/malicious or otherwise dysfunctional.

Governance state

The DG system implements a state machine tracking the global governance state defining which governance actions are currently possible. The state is global since it affects all non-executed proposals and all system actors.

The state machine is specified in the Dual Governance mechanism design document. The possible states are:

  • Normal allows proposal submission and scheduling for execution.
  • VetoSignalling only allows proposal submission.
    • VetoSignallingDeactivation sub-state (doesn't deactivate the parent state upon entry) doesn't allow proposal submission or scheduling for execution.
  • VetoCooldown only allows scheduling already submitted proposals for execution.
  • RageQuit only allows proposal submission.

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 →

Possible state transitions:

  • NormalVetoSignalling
  • VetoSignallingRageQuit
  • VetoSignallingDeactivation sub-state entry and exit (while the parent VetoSignalling state is active)
  • VetoSignallingDeactivationVetoCooldown
  • VetoCooldownNormal
  • VetoCooldownVetoSignalling
  • RageQuitVetoCooldown
  • RageQuitVetoSignalling

These transitions are enabled by three processes (see the mechanism design document for more details):

  1. Rage quit support changing due to stakers locking and unlocking their tokens into/out of the veto signalling escrow or stETH total supply changing;
  2. Protocol withdrawals processing (in the RageQuit state);
  3. Time passing.

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 →

Rage quit

Rage quit is a global process of withdrawing stETH and wstETH locked in the signalling escrow and waiting until all these withdrawals, as well as any withdrawals represented by withdrawal NFTs that were locked into the signalling escrow prior to the process started, are finished.

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 →

In the governance state machine, the rage quit process is represented by the RageQuit global state. While this state is active, no proposal can be scheduled for execution. Thus, rage quit contributes to dynamic timelocks of all pending proposals.

At any time, only one instance of the rage quit process can be active.

From the stakers' point of view, opposition to the DAO and the rage quit process can be described by the following diagram:

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 →

Tiebreaker committee

The mechanism design allows for a deadlock where the system is stuck in the RageQuit state while protocol withdrawals are paused or dysfunctional and require a DAO vote to resume, and includes a third-party arbiter Tiebreaker committee for resolving it.

The committee gains the power to bypass the DG dynamic timelock and execute pending proposals under the specific conditions of the deadlock. The detailed Tiebreaker mechanism design can be found in the Dual Governance mechanism design overview document.

The Tiebreaker committee is represented in the system by its address which can be configured via the admin executor calling the DualGovernance.setTiebreakerCommittee function.

While the deadlock conditions are met, the tiebreaker committee address is allowed to approve execution of any pending proposal by calling DualGovernance.tiebreakerApproveProposal so that its execution can be scheduled after the tiebreaker execution timelock passes by calling DualGovernance.tiebreakerScheduleProposal.

Administrative actions

The dual governance system supports a set of administrative actions, including:

Each of these actions can only be performed by a designated admin executor contract (set by a configuration option), meaning that:

  1. It has to be proposed by one of the proposers associated with this executor. Such proposers are called admin proposers.
  2. It has to go through the dual governance execution flow with stakers having the power to object.

Common types

Struct: ExecutorCall

struct ExecutorCall {
    address target;
    uint96 value;
    bytes payload;
}

Encodes an EVM call from an executor contract to the target address with the specified value and the calldata being set to payload.

Contract: DualGovernance.sol

The main entry point to the dual governance system.

  • Provides an interface for submitting and cancelling governance proposals and implements a dynamic timelock on scheduling their execution.
  • Manages the list of supported proposers (DAO voting systems).
  • Implements a state machine tracking the current global governance state which, in turn, determines whether proposal submission and execution is currently allowed.
  • Deploys and tracks the Escrow contract instances. Tracks the current signalling escrow.

This contract is a singleton, meaning that any DG deployment includes exectly one instance of this contract.

Enum: DualGovernance.State

enum State {
    Normal,
    VetoSignalling,
    VetoSignallingDeactivation,
    VetoCooldown,
    RageQuit
}

Encodes the current global governance state, affecting the set of actions allowed for each of the system's actors.

Function: DualGovernance.submitProposal

function submitProposal(ExecutorCall[] calls)
  returns (uint256 proposalId)

Instructs the EmergencyProtectedTimelock singleton instance to register a new governance proposal composed of one or more EVM calls to be made by an executor contract currently associated with the proposer address calling this function. Starts a dynamic timelock on scheduling the proposal for execution.

See: EmergencyProtectedTimelock.submit.

Returns

The id of the successfully registered proposal.

Preconditions

  • The calling address MUST be registered as a proposer.
  • The current governance state MUST be either of: Normal, VetoSignalling, RageQuit.

Triggers a transition of the current governance state (if one is possible) before checking the preconditions.

Function: DualGovernance.scheduleProposal

function scheduleProposal(uint256 proposalId)

Instructs the EmergencyProtectedTimelock singleton instance to schedule the proposal with id proposalId for execution.

Preconditions

  • The proposal with the given id MUST be already submitted.
  • The proposal MUST NOT be scheduled.
  • The proposal's dynamic timelock MUST have elapsed.
  • The proposal MUST NOT be canceled.
  • The current governance state MUST be either Normal or VetoCooldown.

Triggers a transition of the current governance state (if one is possible) before checking the preconditions.

Function: DualGovernance.tiebreakerApproveProposal

function tiebreakerApproveProposal(uint256 proposalId)

Marks the proposal with id proposalId as approved by the Tiebreaker committee, given that the DG system is in a deadlock.

Preconditions

  • MUST be called by the Tiebreaker committee address.
  • Either the Tiebreaker Condition A or the Tiebreaker Condition B MUST be met (see the mechanism design document).
  • The proposal MUST be already submitted.
  • The proposal MUST NOT be canceled.
  • The proposal with the specified id MUST NOT be already approved by the Tiebreaker committee.

Triggers a transition of the current governance state (if one is possible) before checking the preconditions.

Function: DualGovernance.tiebreakerScheduleProposal

function tiebreakerScheduleProposal(uint256 proposalId)

Instructs the EmergencyProtectedTimelock singleton instance to schedule the proposal with the id proposalId for execution, bypassing the proposal dynamic timelock and given that the proposal was previously approved by the Tiebreaker committee and that the tiebreaker execution timelock has elapsed.

Preconditions

  • Either the Tiebreaker Condition A or the Tiebreaker Condition B MUST be met (see the mechanism design document).
  • The proposal MUST be already submitted.
  • The proposal MUST NOT be canceled.
  • The proposal with the specified id MUST be approved by the Tiebreaker committee.
  • The current block timestamp MUST be at least TIEBREAKER_EXECUTION_TIMELOCK seconds greater than the timestamp of the block in which the proposal was approved by the Tiebreaker committee.

Triggers a transition of the current governance state (if one is possible) before checking the preconditions.

Function: DualGovernance.cancelAllPendingProposals

function cancelAllPendingProposals()

Cancels all currently submitted and non-executed proposals. If a proposal was submitted but not scheduled, it becomes unschedulable. If a proposal was scheduled, it becomes unexecutable.

Triggers a transition of the current governance state, if one is possible.

Preconditions

Function: DualGovernance.registerProposer

function registerProposer(address proposer, address executor)

Registers the proposer address in the system as a valid proposer and associates it with the executor contract address (which is expected to be an instance of Executor.sol) as an executor.

Preconditions

  • MUST be called by the admin executor contract (see Config.sol).
  • The proposer address MUST NOT be already registered in the system.
  • The executor instance SHOULD be owned by the EmergencyProtectedTimelock singleton instance.

Function: DualGovernance.unregisterProposer

function unregisterProposer(address proposer)

Removes the registered proposer address from the list of valid proposers and dissociates it with the executor contract address.

Preconditions

  • MUST be called by the admin executor contract.
  • The proposer address MUST be registered in the system as proposer.

Function: DualGovernance.setTiebreakerCommittee

function setTiebreakerCommittee(address newTiebreaker)

Updates the address of the Tiebreaker committee.

Preconditions

  • MUST be called by the admin executor contract.

Function: DualGovernance.activateNextState

function activateNextState()

Triggers a transition of the global governance state, if one is possible; does nothing otherwise.

Contract: Executor.sol

Issues calls resulting from governance proposals' execution. Every protocol permission or role protected by the DG, as well as the permission to manage this role/permission, should be assigned exclusively to the instances of this contract.

The system supports multiple instances of this contract, but all instances SHOULD be owned by the EmergencyProtectedTimelock singleton instance.

Function: execute

function execute(address target, uint256 value, bytes payload)
  payable returns (bytes result)

Issues a EVM call to the target address with the payload calldata, optionally sending value wei ETH.

Reverts if the call was unsuccessful.

Returns

The result of the call.

Preconditions

Contract: Escrow.sol

The Escrow contract serves as an accumulator of users' (w)stETH, withdrawal NFTs, and ETH. It has two internal states and serves a different purpose depending on its state:

  • The initial state is the SignallingEscrow state. In this state, the contract serves as an oracle for users' opposition to DAO proposals. It allows users to lock and unlock (unlocking is permitted only for the caller after the SignallingEscrowMinLockTime duration has passed since their last funds locking operation) stETH, wstETH, and withdrawal NFTs, potentially changing the global governance state. The SignallingEscrowMinLockTime duration, measured in hours, safeguards against manipulating the dual governance state through instant lock/unlock actions within the Escrow contract instance.
  • The final state is the RageQuitEscrow state. In this state, the contract serves as an immutable and ungoverned accumulator for the ETH withdrawn as a result of the rage quit and enforces a timelock on reclaiming this ETH by users.

The DualGovernance contract tracks the current signalling escrow contract using the DualGovernance.signallingEscrow pointer. Upon the initial deployment of the system, an instance of Escrow is deployed in the SignallingEscrow state by the DualGovernance contract and the DualGovernance.signallingEscrow pointer is set to this contract.

Each time the governance enters the global RageQuit state, two things happen simultaneously:

  1. The Escrow instance currently stored in the DualGovernance.signallingEscrow pointer changes its state from SignallingEscrow to RageQuitEscrow. This is the only possible (and thus irreversible) state transition.
  2. The DualGovernance contract deploys a new instance of Escrow in the SignallingEscrow state and resets the DualGovernance.signallingEscrow pointer to this newly-deployed contract.

At any point in time, there can be only one instance of the contract in the SignallingEscrow state (so the contract in this state is a singleton) but multiple instances of the contract in the RageQuitEscrow state.

After the Escrow instance transitions into the RageQuitEscrow state, all locked stETH and wstETH tokens are meant to be converted into withdrawal NFTs using the permissionless Escrow.requestNextWithdrawalsBatch() function.

Once all funds locked in the Escrow instance are converted into withdrawal NFTs, finalized, and claimed, the main rage quit phase concludes, and the RageQuitExtensionDelay period begins.

The purpose of the RageQuitExtensionDelay phase is to provide sufficient time to participants who locked withdrawal NFTs to claim them before Lido DAO's proposal execution is unblocked. As soon as a withdrawal NFT is claimed, the user's ETH is no longer affected by any code controlled by the DAO.

When the RageQuitExtensionDelay period elapses, the DualGovernance.activateNextState() function exits the RageQuit state and initiates the RageQuitEthClaimTimelock. Throughout this timelock, tokens remain locked within the Escrow instance and are inaccessible for withdrawal. Once the timelock expires, participants in the rage quit process can retrieve their ETH by withdrawing it from the Escrow instance.

The duration of the RageQuitEthClaimTimelock is dynamic and varies based on the number of "continuous" rage quits. A pair of rage quits is considered continuous when DualGovernance has not transitioned to the Normal or VetoCooldown state between them.

Function: Escrow.lockStETH

function lockStETH(uint256 amount)

Transfers the specified amount of stETH from the caller's (i.e., msg.sender) account into the SignallingEscrow instance of the Escrow contract.

The total rage quit support is updated proportionally to the number of shares corresponding to the locked stETH (see the Escrow.getRageQuitSupport() function for the details). For the correct rage quit support calculation, the function updates the number of locked stETH shares in the protocol as follows:

uint256 amountInShares = stETH.getSharesByPooledEther(amount);

_vetoersLockedAssets[msg.sender].stETHShares += amountInShares;
_totalStEthSharesLocked += amountInShares;

The rage quit support will be dynamically updated to reflect changes in the stETH balance due to protocol rewards or validators slashing.

Finally, calls the DualGovernance.activateNextState() function. This action may transit the Escrow instance from the SignallingEscrow state into the RageQuitEscrow state.

Preconditions

  • The Escrow instance MUST be in the SignallingEscrow state.
  • The caller MUST have an allowance set on the stETH token for the Escrow instance equal to or greater than the locked amount.
  • The locked amount MUST NOT exceed the caller's stETH balance.

Function: Escrow.unlockStETH

function unlockStETH()

Allows the caller (i.e. msg.sender) to unlock the previously locked stETH in the SignallingEscrow instance of the Escrow contract. The locked stETH balance may change due to protocol rewards or validators slashing, potentially altering the original locked amount. The total unlocked stETH equals the sum of all previously locked stETH by the caller, accounting for any changes during the locking period.

For the correct rage quit support calculation, the function updates the number of locked stETH shares in the protocol as follows:

_totalStEthSharesLocked -= _vetoersLockedAssets[msg.sender].stETHShares;
_vetoersLockedAssets[msg.sender].stETHShares = 0;

Additionally, the function triggers the DualGovernance.activateNextState() function at the beginning and end of the execution.

Preconditions

  • The Escrow instance MUST be in the SignallingEscrow state.
  • The caller MUST have a non-zero amount of previously locked stETH in the Escrow instance using the Escrow.lockStETH function.
  • At least the duration of the SignallingEscrowMinLockTime MUST have passed since the caller last invoked any of the methods Escrow.lockStETH, Escrow.lockWstETH, or Escrow.lockUnstETH.

Function: Escrow.lockWstETH

function lockWstETH(uint256 amount)

Transfers the specified amount of wstETH from the caller's (i.e., msg.sender) account into the SignallingEscrow instance of the Escrow contract.

The total rage quit support is updated proportionally to the amount of locked wstETH (see the Escrow.getRageQuitSupport() function for the details). For the correct rage quit support calculation, the function updates the number of locked wstETH in the protocol as follows:

_vetoersLockedAssets[msg.sender].wstETHShares += amount;
_totalStEthSharesLocked += amount;

Finally, calls the DualGovernance.activateNextState() function. This action may transit the Escrow instance from the SignallingEscrow state into the RageQuitEscrow state.

Preconditions

  • The Escrow instance MUST be in the SignallingEscrow state.
  • The caller MUST have an allowance set on the wstETH token for the Escrow instance equal to or greater than the locked amount.
  • The locked amount MUST NOT exceed the caller's wstETH balance.

Function: Escrow.unlockWstETH

function unlockWstETH()

Allows the caller (i.e. msg.sender) to unlock previously locked wstETH from the SignallingEscrow instance of the Escrow contract. The total unlocked wstETH equals the sum of all previously locked wstETH by the caller.

For the correct rage quit support calculation, the function updates the number of locked wstETH shares in the protocol as follows:

_totalStEthSharesLocked -= _vetoersLockedAssets[msg.sender].wstETHShares;
_vetoersLockedAssets[msg.sender].wstETHShares = 0;

Additionally, the function triggers the DualGovernance.activateNextState() function at the beginning and end of the execution.

Preconditions

  • The Escrow instance MUST be in the SignallingEscrow state.
  • The caller MUST have a non-zero amount of previously locked wstETH in the Escrow instance using the Escrow.lockWstETH function.
  • At least the duration of the SignallingEscrowMinLockTime MUST have passed since the caller last invoked any of the methods Escrow.lockStETH, Escrow.lockWstETH, or Escrow.lockUnstETH.

Function: Escrow.lockUnstETH

function lockUnstETH(uint256[] unstETHIds)

Transfers the WIthdrawal NFTs with ids contained in the unstETHIds from the caller's (i.e. msg.sender) account into the SignallingEscrow instance of the Escrow contract.

To correctly calculate the rage quit support (see the Escrow.getRageQuitSupport() function for the details), updates the number of locked Withdrawal NFT shares in the protocol for each withdrawal NFT in the unstETHIds, as follows:

uint256 amountOfShares = WithdrawalRequest[id].amountOfShares;

_vetoersLockedAssets[msg.sender].withdrawalNFTShares += amountOfShares;
_totalWithdrawlNFTSharesLocked += amountOfShares;

Finally, calls the DualGovernance.activateNextState() function. This action may transition the Escrow instance from the SignallingEscrow state into the RageQuitEscrow state.

Preconditions

  • The Escrow instance MUST be in the SignallingEscrow state.
  • The caller MUST be the owner of all withdrawal NFTs with the given ids.
  • The caller MUST grant permission to the SignallingEscrow instance to transfer tokens with the given ids (approve() or setApprovalForAll()).
  • The passed ids MUST NOT contain the finalized or claimed Withdrawal NFTs.
  • The passed ids MUST NOT contain duplicates.

Function: Escrow.unlockUnstETH

function unlockUnstETH(uint256[] unstETHIds)

Allows the caller (i.e. msg.sender) to unlock a set of previously locked Withdrawal NFTs with ids unstETHIds from the SignallingEscrow instance of the Escrow contract.

To correctly calculate the rage quit support (see the Escrow.getRageQuitSupport() function for details), updates the number of locked Withdrawal NFT shares in the protocol for each withdrawal NFT in the unstETHIds, as follows:

  • If the Withdrawal NFT was marked as finalized (see the Escrow.markUnstETHFinalized() function for details):
uint256 amountOfShares = WithdrawalRequest[id].amountOfShares;
uint256 claimableAmount = _getClaimableEther(id);

_totalWithdrawlNFTSharesLocked -= amountOfShares;
_totalFinalizedWithdrawlNFTSharesLocked -= amountOfShares;
_totalFinalizedWithdrawlNFTAmountLocked -= claimableAmount;

_vetoersLockedAssets[msg.sender].withdrawalNFTShares -= amountOfShares;
_vetoersLockedAssets[msg.sender].finalizedWithdrawalNFTShares -= amountOfShares;
_vetoersLockedAssets[msg.sender].finalizedWithdrawalNFTAmount -= claimableAmount;
  • if the Withdrawal NFT wasn't marked as finalized:
uint256 amountOfShares = WithdrawalRequest[id].amountOfShares;

_totalWithdrawlNFTSharesLocked -= amountOfShares;
_vetoersLockedAssets[msg.sender].withdrawalNFTShares -= amountOfShares;

Additionally, the function triggers the DualGovernance.activateNextState() function at the beginning and end of the execution.

Preconditions

  • The Escrow instance MUST be in the SignallingEscrow state.
  • Each provided Withdrawal NFT MUST have been previously locked by the caller.
  • At least the duration of the SignallingEscrowMinLockTime MUST have passed since the caller last invoked any of the methods Escrow.lockStETH, Escrow.lockWstETH, or Escrow.lockUnstETH.

Function Escrow.markUnstETHFinalized

function markUnstETHFinalized(uint256[] unstETHIds, uint256[] hints)

Marks the provided Withdrawal NFTs with ids unstETHIds as finalized to accurately calculate their rage quit support.

The finalization of the Withdrawal NFT leads to the following events:

  • The value of the Withdrawal NFT is no longer affected by stETH token rebases.
  • The total supply of stETH is adjusted based on the value of the finalized Withdrawal NFT.

As both of these events affect the rage quit support value, this function updates the number of finalized Withdrawal NFTs for the correct rage quit support accounting.

For each Withdrawal NFT in the unstETHIds:

uint256 claimableAmount = _getClaimableEther(id);
uint256 amountOfShares = WithdrawalRequest[id].amountOfShares;

_totalFinalizedWithdrawlNFTSharesLocked += amountOfShares;
_totalFinalizedWithdrawlNFTAmountLocked += claimableAmount;

_vetoersLockedAssets[msg.sender].finalizedWithdrawalNFTShares += amountOfShares;
_vetoersLockedAssets[msg.sender].finalizedWithdrawalNFTAmount += claimableAmount;

Withdrawal NFTs belonging to any of the following categories are excluded from the rage quit support update:

  • Claimed or unfinalized Withdrawal NFTs
  • Withdrawal NFTs already marked as finalized
  • Withdrawal NFTs not locked in the Escrow instance

Preconditions

  • The Escrow instance MUST be in the SignallingEscrow state.

Function Escrow.getRageQuitSupport()

function getRageQuitSupport() view returns (uint256)

Calculates and returns the total rage quit support as a percentage of the stETH total supply locked in the instance of the Escrow contract. It considers contributions from stETH, wstETH, and non-finalized Withdrawal NFTs while adjusting for the impact of locked finalized Withdrawal NFTs.

The returned value represents the total rage quit support expressed as a percentage with a precision of 16 decimals. It is computed using the following formula:

uint256 rebaseableAmount = stETH.getPooledEthByShares(
	_totalStEthSharesLocked + 
	_totalWstEthSharesLocked +
	_totalWithdrawalNFTSharesLocked -
	_totalFinalizedWithdrawalNFTSharesLocked
);

return 10 ** 18 * (
    rebaseableAmount + _totalFinalizedWithdrawalNFTAmountLocked
) / (
    stETH.totalSupply() + _totalFinalizedWithdrawalNFTAmountLocked
);

Function Escrow.startRageQuit

function startRageQuit()

Transits the Escrow instance from the SignallingEscrow state to the RageQuitEscrow state. Following this transition, locked funds become unwithdrawable and are accessible to users only as plain ETH after the completion of the full RageQuit process, including the RageQuitExtensionDelay and RageQuitEthClaimTimelock stages.

As the initial step of transitioning to the RageQuitEscrow state, all locked wstETH is converted into stETH, and the maximum stETH allowance is granted to the WithdrawalQueue contract for the upcoming creation of Withdrawal NFTs.

Preconditions

  • Method MUST be called by the DualGovernance contract.
  • The Escrow instance MUST be in the SignallingEscrow state.

Function Escrow.requestNextWithdrawalsBatch

function requestNextWithdrawalsBatch(uint256 maxWithdrawalRequestsCount)

Transfers stETH held in the RageQuitEscrow instance into the WithdrawalQueue. The function may be invoked multiple times until all stETH is converted into Withdrawal NFTs. For each Withdrawal NFT, the owner is set to Escrow contract instance. Each call creates up to maxWithdrawalRequestsCount withdrawal requests, where each withdrawal request size equals WithdrawalQueue.MAX_STETH_WITHDRAWAL_AMOUNT(), except for potentially the last batch, which may have a smaller size.

Upon execution, the function updates the count of withdrawal requests generated by all invocations. When the remaining stETH balance on the contract falls below WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(), the generation of withdrawal batches is concluded, and subsequent function calls will revert.

Preconditions

  • The Escrow instance MUST be in the RageQuitEscrow state.
  • The maxWithdrawalRequestsCount MUST be greater than 0
  • The generation of WithdrawalRequest batches MUST not be concluded

Function Escrow.claimNextWithdrawalsBatch

function claimNextWithdrawalsBatch(uint256[] withdrawalRequestIds, uint256[] hints)

Allows users to claim finalized Withdrawal NFTs generated by the Escrow.requestNextWithdrawalsBatch() function. Tracks the total amount of claimed ETH updating the _totalClaimedEthAmount variable. Upon claiming the last batch, the RageQuitExtensionDelay period commences.

Preconditions

  • The Escrow instance MUST be in the RageQuitEscrow state.
  • The withdrawalRequestIds array MUST contain only the ids of finalized but unclaimed withdrawal requests generated by the Escrow.requestNextWithdrawalsBatch() function.

Function Escrow.claimUnstETH

function claimUnstETH(uint256[] unstETHIds, uint256[] hints)

Allows users to claim the ETH associated with finalized Withdrawal NFTs with ids unstETHIds locked in the Escrow contract. Upon calling this function, the claimed ETH is transferred to the Escrow contract instance.

To safeguard the ETH associated with Withdrawal NFTs, this function should be invoked when the Escrow is in the RageQuitEscrow state and before the RageQuitExtensionDelay period ends. The ETH corresponding to unclaimed Withdrawal NFTs after this period ends would still be controlled by the code potentially afftected by pending and future DAO decisions.

Preconditions

  • The Escrow instance MUST be in the RageQuitEscrow state.
  • The provided unstETHIds MUST only contain finalized but unclaimed withdrawal requests with the owner set to msg.sender.

Function Escrow.isRageQuitFinalized

function isRageQuitFinalized() view returns (bool)

Returns whether the rage quit process has been finalized. The rage quit process is considered finalized when all the following conditions are met:

  • The Escrow instance is in the RageQuitEscrow state.
  • All withdrawal request batches have been claimed.
  • The duration of the RageQuitExtensionDelay has elapsed.

Function Escrow.withdrawStEthAsEth

function withdrawStEthAsEth()

Allows the caller (i.e. msg.sender) to withdraw all stETH they have previouusly locked into Escrow contract instance (while it was in the SignallingEscrow state) as plain ETH, given that the RageQuit process is completed and that the RageQuitEthClaimTimelock has elapsed. Upon execution, the function transfers ETH to the caller's account and marks the corresponding stETH as withdrawn for the caller.

The amount of ETH sent to the caller is determined by the proportion of the user's stETH shares compared to the total amount of locked stETH and wstETH shares in the Escrow instance, calculated as follows:

return _totalClaimedEthAmount * _vetoersLockedAssets[msg.sender].stETHShares
    / (_totalStEthSharesLocked + _totalWstEthSharesLocked);

Preconditions

  • The Escrow instance MUST be in the RageQuitEscrow state.
  • The rage quit process MUST be completed, including the expiration of the RageQuitExtensionDelay duration.
  • The RageQuitEthClaimTimelock period MUST be elapsed after the expiration of the RageQuitExtensionDelay duration.
  • The caller MUST have a non-zero amount of stETH to withdraw.
  • The caller MUST NOT have previously withdrawn stETH.

Function Escrow.withdrawWstEthAsEth

function withdrawWstEthAsEth() external

Allows the caller (i.e. msg.sender) to withdraw all wstETH they have previouusly locked into Escrow contract instance (while it was in the SignallingEscrow state) as plain ETH, given that the RageQuit process is completed and that the RageQuitEthClaimTimelock has elapsed. Upon execution, the function transfers ETH to the caller's account and marks the corresponding wstETH as withdrawn for the caller.

The amount of ETH sent to the caller is determined by the proportion of the user's wstETH funds compared to the total amount of locked stETH and wstETH shares in the Escrow instance, calculated as follows:

return _totalClaimedEthAmount * 
  _vetoersLockedAssets[msg.sender].wstETHShares /
  (_totalStEthSharesLocked + _totalWstEthSharesLocked);

Preconditions

  • The Escrow instance MUST be in the RageQuitEscrow state.
  • The rage quit process MUST be completed, including the expiration of the RageQuitExtensionDelay duration.
  • The RageQuitEthClaimTimelock period MUST be elapsed after the expiration of the RageQuitExtensionDelay duration.
  • The caller MUST have a non-zero amount of wstETH to withdraw.
  • The caller MUST NOT have previously withdrawn wstETH.

Function Escrow.withdrawUnstETHAsEth

function withdrawUnstETHAsEth(uint256[] unstETHIds)

Allows the caller (i.e. msg.sender) to withdraw the claimed ETH from the Withdrawal NFTs with ids unstETHIds locked by the caller in the Escrow contract while the latter was in the SignallingEscrow state. Upon execution, all ETH previously claimed from the NFTs is transferred to the caller's account, and the NFTs are marked as withdrawn.

Preconditions

  • The Escrow instance MUST be in the RageQuitEscrow state.
  • The rage quit process MUST be completed, including the expiration of the RageQuitExtensionDelay duration.
  • The RageQuitEthClaimTimelock period MUST be elapsed after the expiration of the RageQuitExtensionDelay duration.
  • The caller MUST be set as the owner of the provided NFTs.
  • Each Withdrawal NFT MUST have been claimed using the Escrow.claimUnstETH() function.
  • Withdrawal NFTs must not have been withdrawn previously.

Contract: EmergencyProtectedTimelock.sol

EmergencyProtectedTimelock is the singleton instance storing proposals approved by DAO voting systems and submitted to the Dual Governance. It allows for setting up time-bound Emergency Activation Committee and Emergency Execution Committee, acting as safeguards for the case of zero-day vulnerability in Dual Governance contracts.

For a proposal to be executed, the following steps have to be performed in order:

  1. The proposal must be submitted using the EmergencyProtectedTimelock.submit function.
  2. The configured post-submit timelock must elapse.
  3. The proposal must be scheduled using the EmergencyProtectedTimelock.schedule function.
  4. The configured emergency protection timelock must elapse (can be zero, see below).
  5. The proposal must be executed using the EmergencyProtectedTimelock.execute function.

The contract only allows proposal submission and scheduling by the governance address. Normally, this address points to the DualGovernance singleton instance. Proposal execution is permissionless, unless Emergency Mode is activated.

If the Emergency Committees are set up and active, the governance proposal gets a separate emergency protection timelock between submitting and scheduling. This additional timelock is implemented in the EmergencyProtectedTimelock contract to protect from zero-day vulnerability in the logic of DualGovenance.sol and other core DG contracts. If the Emergency Committees aren't set, the proposal flow is the same, but the timelock duration is zero.

Emergency Activation Committee, while active, can enable the Emergency Mode. This mode prohibits anyone but the Emergency Execution Committee from executing proposals. It also allows the Emergency Execution Committee to reset the governance, effectively disabling the Dual Governance subsystem.

The governance reset entails the following steps:

  1. Clearing both the Emergency Activation and Execution Committees from the EmergencyProtectedTimelock.
  2. Cancelling all proposals that have not been executed.
  3. Setting the governance address to a pre-configured Emergency Governance address. In the simplest scenario, this would be the Lido DAO Aragon Voting contract.

Function: EmergencyProtectedTimelock.submit

function submit(address executor, ExecutorCall[] calls)
  returns (uint256 proposalId)

Registers a new governance proposal composed of one or more EVM calls to be made by the executor contract.

Returns

The ID of the successfully registered proposal.

Preconditions

  • MUST be called by the governance address.

Function: EmergencyProtectedTimelock.schedule

function schedule(uint256 proposalId)

Preconditions

  • MUST be called by the governance address.
  • The proposal MUST be already submitted.
  • The post-submit timelock MUST already elapse since the moment the proposal was submitted.

Function: EmergencyProtectedTimelock.execute

function execute(uint256 proposalId)

Instructs the executor contract associated with the proposal to issue the proposal's calls.

Preconditions

  • Emergency mode MUST NOT be active.
  • The proposal MUST be already submitted & scheduled for execution.
  • The emergency protection delay MUST already elapse since the moment the proposal was scheduled.

Function: EmergencyProtectedTimelock.cancelAllNonExecutedProposals

function cancelAllNonExecutedProposals()

Cancels all non-executed proposal, making them forever non-executable.

Preconditions

  • MUST be called by the governance address.

Function: EmergencyProtectedTimelock.activateEmergencyMode

function activateEmergencyMode()

Activates the Emergency Mode.

Preconditions

  • MUST be called by the Emergency Activation Committee address.
  • The Emergency Mode MUST NOT be active.

Function: EmergencyProtectedTimelock.emergencyExecute

function emergencyExecute(uint256 proposalId)

Executes the scheduled proposal, bypassing the post-schedule delay.

Preconditions

  • MUST be called by the Emergency Execution Committee address.
  • The Emergency Mode MUST be active.

Function: EmergencyProtectedTimelock.deactivateEmergencyMode

function deactivateEmergencyMode()

Deactivates the Emergency Activation and Emergency Execution Committees (setting their addresses to 0x00), cancels all unexecuted proposals, and disables the Protected deployment mode.

Preconditions

  • The Emergency Mode MUST be active.
  • If the Emergency Mode was activated less than the emergency mode max duration ago, MUST be called by the Admin Executor address.

Function: EmergencyProtectedTimelock.emergencyReset

function emergencyReset()

Resets the governance address to the EMERGENCY_GOVERNANCE value defined in the configuration, cancels all unexecuted proposals, and disables the Protected deployment mode.

Preconditions

  • The Emergency Mode MUST be active.
  • MUST be called by the Emergency Execution Committee address.

Admin functions

The contract has the interface for managing the configuration related to emergency protection (setEmergencyProtection) and general system wiring (transferExecutorOwnership, setGovernance). These functions MUST be called by the Admin Executor address, basically routing any such changes through the Dual Governance mechanics.

Contract: GateSealBreaker.sol

In the Lido protocol, specific critical components (WithdrawalQueue and ValidatorsExitBus) are safeguarded by the GateSeal contract instance. According to the gate seals documentation:

"A GateSeal is a contract that allows the designated account to instantly put a set of contracts on pause (i.e. seal) for a limited duration. This will give the Lido DAO the time to come up with a solution, hold a vote, implement changes, etc.".

However, the effectiveness of this approach is contingent upon the predictability of the DAO's solution adoption timeframe. With the dual governance system, proposal execution may experience significant delays based on the current state of the DualGovernance contract. There's a risk that GateSeal's pause period may expire before the Lido DAO can implement the necessary fixes.

To address this compatibility challenge between gate seals and dual governance, the GateSealBreaker contract is introduced. The GateSealBreaker enables the trustless unpause of contracts sealed by a GateSeal instance, but only under specific conditions:

  • The minimum delay defined in the GateSeal contract has elapsed.
  • Proposal execution is allowed within the dual governance system.

For seamless integration with the DualGovernance and GateSealBreaker contracts, the GateSeal instance will be configured as follows:

  • MAX_SEAL_DURATION_SECONDS and SEAL_DURATION_SECONDS are set to type(uint256).max, what equivalent to PAUSE_INFINITELY, for the PausableUntil.sol contract.
  • MIN_SEAL_DURATION_SECONDS is set to a finite duration, allowing the Lido DAO sufficient time to respond and adopt proposals when the DualGovernance contract is in the Normal state.

With such settings, the GateSeal instance seals the contracts indefinitely. However, anyone can initiate the process of "breaking the seal" by calling the GateSealBreaker.startRelease(address gateSeal) function, provided both requirements are met:

  • The MIN_SEAL_DURATION_SECONDS has elapsed since the committee activated the GateSeal.
  • The DualGovernance is currently in the Normal or VetoCooldown state, allowing proposals scheduling.

The GateSealBreaker.startRelease() function can be called only once for each activated GateSeal contract registered in the GateSealBreaker. This function effectively begins the countdown to release the seal, starting the RELEASE_DELAY.

During the RELEASE_DELAY, the sealed contracts remain paused, providing the Lido DAO time to schedule proposals within the dual governance system (the scheduling is allowed, which is guaranteed by the governance state precondition of the GateSealBreaker.startRelease function).

Upon completion of the RELEASE_DELAY, the GateSealBreaker.enactRelease(address gateSeal) function can be called to unpause the sealed contracts. This function is trustless and may only be called once. It does not revert even if some or all attempts to unpause the sealed contracts fail.

Function GateSealBreaker.registerGateSeal

function registerGateSeal(IGateSeal gateSeal)

This function should be invoked by the Lido DAO during the setup of the GateSeal instance. Upon registration in the contract, an activated GateSeal instance becomes eligible for release using the startRelease()/enactRelease() methods.

Preconditions

  • MUST be called by the contract owner (supposed to be set to Lido DAO).
  • The GateSeal instance being registered MUST NOT have been previously registered.

Function GateSealBreaker.startRelease

function startRelease(IGateSeal gateSeal)

Initiates the release process for the activated GateSeal instance registered in the contract. Records the release initiation timestamp and starts the RELEASE_DELAY period for the specific gateSeal.

Preconditions

  • The specified gateSeal MUST be registered in the contract.
  • The gateSeal MUST be activated by the gate seal committee.
  • The MIN_SEAL_DURATION_SECONDS MUST have passed since the activation of the gateSeal.
  • The gateSeal MUST NOT be already released.
  • The DualGovernance contract MUST be in either the Normal or VetoCooldown state.

Function GateSealBreaker.enactRelease

function enactRelease(IGateSeal gateSeal)

Unpauses all contracts sealed by the specified gateSeal once the RELEASE_DELAY has elapsed since the release initiation.

Retrieves all sealed contracts via the GateSeal.sealed_sealables() view function and calls IPausableUntil(sealable).resume() for each sealed contract.

If any call to a sealable, including the resume() call, fails during the execution, the transaction WILL NOT revert but will emit the ErrorWhileResuming(sealable, lowLevelError) event for each contract that failed to unpause.

Preconditions

  • The GateSealBreaker.startRelease() function MUST be called for the specified gateSeal.
  • The RELEASE_DELAY for the specified gateSeal MUST have elapsed since the release initiation.
  • The GateSealBreaker contract SHOULD have been granted rights to unpause the sealed contracts.

Contract: Configuration.sol

Configuration.sol is the smart contract encompassing all the constants in the Dual Governance design & providing the interfaces for getting access to them. It implements interfaces IAdminExecutorConfiguration, ITimelockConfiguration, IDualGovernanceConfiguration covering for relevant "parameters domains".

Upgrade flow description

In designing the dual governance system, ensuring seamless updates while maintaining the contracts' immutability was a primary consideration. To achieve this, the system was divided into three key components: DualGovernance, EmergencyProtectedTimelock, and Executor.

When updates are necessary only for the DualGovernance contract logic, the EmergencyProtectedTimelock and Executor components remain unchanged. This simplifies the process, as it only requires deploying a new version of the DualGovernance. This approach preserves proposal history and avoids the complexities of redeploying executors or transferring rights from previous instances.

During the deployment of a new dual governance version, the Lido DAO will likely launch it under the protection of the emergency committee, similar to the initial launch (see Proposal execution and deployment modes for the details). The EmergencyProtectedTimelock allows for the reassembly and reactivation of emergency protection at any time, even if the previous committee's duration has not yet concluded.

A typical proposal to update the dual governance system to a new version will likely contain the following steps:

  1. Set the governance variable in the EmergencyProtectedTimelock instance to the new version of the DualGovernance contract.
  2. Update the implementation of the Configuration proxy contract if necessary.
  3. Configure emergency protection settings in the EmergencyProtectedTimelock contract, including the address of the committee, the duration of emergency protection, and the duration of the emergency mode.

For more significant updates involving changes to the EmergencyProtectedTimelock or Proposals mechanics, new versions of both the DualGovernance and EmergencyProtectedTimelock contracts are deployed. While this adds more steps to maintain the proposal history, such as tracking old and new versions of the Timelocks, it also eliminates the need to migrate permissions or rights from executors. The transferExecutorOwnership() function of the EmergencyProtectedTimelock facilitates the assignment of executors to the newly deployed contract.