From 00f9c7af8a2a6e702addc18bedfa694414355887 Mon Sep 17 00:00:00 2001 From: chasingrainbows Date: Mon, 14 Sep 2026 17:03:48 +0300 Subject: [PATCH 1/8] docs: address the second round of EDF docs review - ejector allowlist must hold delegate EOAs, old accounts stay ~7 days, only a build with execute() unwrapping works - oracle manual: deploy DelegationContract and configure the daemon before the vote - operator guide: real council daemon behaviour with an empty DELEGATION_CONTRACT_ADDRESS, oracle 8.1.0 log text, trace-level alerts and delegate code polling, minimum versions, funding required before the vote - custody policy: minimum 48 h cooldown, delegate must be a plain EOA and never sign EIP-7702 - deposit security manual: DSM v5 signed message and the direct call path for pause and unvet - protocol levers: fix the DSM address, add the proposed v5 one - tooling: bump council daemon to 4.1.2 and depositor bot to 5.7.0 --- .../curated-module/exits/tooling-setup.md | 5 +- docs/guides/deposit-security-manual.md | 2 +- docs/guides/edf/edf-operator-guide.md | 69 +++++++++++++++---- docs/guides/edf/edf-rotation-and-incidents.md | 4 +- .../key-custody-policy-for-edf-operators.md | 16 +++++ docs/guides/oracle-operator-manual.md | 2 +- docs/guides/protocol-levers.md | 2 +- docs/guides/tooling.md | 20 +++--- docs/guides/validator-ejector-guide.md | 8 ++- 9 files changed, 100 insertions(+), 28 deletions(-) diff --git a/docs/guides/curated-module/exits/tooling-setup.md b/docs/guides/curated-module/exits/tooling-setup.md index 58d389db4..220121c9b 100644 --- a/docs/guides/curated-module/exits/tooling-setup.md +++ b/docs/guides/curated-module/exits/tooling-setup.md @@ -39,7 +39,10 @@ ID of the [StakingRouter](https://github.com/lidofinance/core/blob/master/contra Currently, it has only one module ([NodeOperatorsRegistry](https://github.com/lidofinance/core/blob/master/contracts/0.4.24/nos/NodeOperatorsRegistry.sol)), it's id is `1`. ### Oracle Allowlist -The oracle members are retrievable from the HashConsensus (for the Validator Exit Bus Oracle ) contract on-chain, directly from the contract using Etherscan. + +After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, the oracle seats are held by `DelegationContract`s, and `getMembers()` on `HashConsensus` returns those contract addresses. The Ejector verifies the signer of the report transaction, so `ORACLE_ADDRESSES_ALLOWLIST` must contain the members' **delegate EOAs**, not the `DelegationContract` addresses. Take each `DelegationContract` from the [Lido Oracle members page](/holders/lido-oracle) and read its `getDelegate()`, keep the previous member EOAs for about 7 days after the vote, and run an Ejector build that unwraps `execute(address,bytes)`. See the [ORACLE_ADDRESSES_ALLOWLIST](/guides/validator-ejector-guide#oracle_addresses_allowlist) section of the Ejector guide for the details. + +Before the vote, the oracle members are retrievable from the HashConsensus (for the Validator Exit Bus Oracle) contract on-chain, directly from the contract using Etherscan. | network | Contract Call | | -------- | ------------- | | Mainnet | [getMembers()](https://etherscan.io/address/0x7FaDB6358950c5fAA66Cb5EB8eE5147De3df355a#readContract#F16) | diff --git a/docs/guides/deposit-security-manual.md b/docs/guides/deposit-security-manual.md index 397bc60da..7e4164cfd 100644 --- a/docs/guides/deposit-security-manual.md +++ b/docs/guides/deposit-security-manual.md @@ -34,7 +34,7 @@ After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framew ### Members responsibilities -Each member must prepare a hot key to sign the pair `(depositRoot, keysOpIndex)`. The address added to the smart contract is the member's `DelegationContract` (before the LIP-37 vote: the member's EOA), and the hot key is its delegate. The `DepositSecurityModule` verifies guardian signatures through ERC-1271, so a signature is valid only while the signing key is the active delegate of the contract. Also, members have to run `DSC Daemon` that monitors the validators’ public keys in the `DepositContract` and in all Staking Modules. The daemon must have access to the delegate’s private key to be able to perform ECDSA signing. See the [EDF Operator Guide](/guides/edf/edf-operator-guide) for the setup. +Each member must prepare a hot key to sign the deposit attestation message. In `DepositSecurityModule` v5 the signed message is `(prefix, guardian, blockNumber, blockHash, depositRoot, stakingModuleId, nonce)`, where `guardian` is the member's `DelegationContract` address; the pause and unvet messages include the guardian address in the same way. The address added to the smart contract is the member's `DelegationContract` (before the LIP-37 vote: the member's EOA), and the hot key is its delegate. The `DepositSecurityModule` verifies guardian signatures through ERC-1271, so a signature is valid only while the signing key is the active delegate of the contract. `pauseDeposits` and `unvetSigningKeys` also accept a direct call: when the sender is a guardian, the signature argument is ignored. Under EDF this is the delegate calling `execute()` on its `DelegationContract`, which makes the contract the sender; the council daemon uses this path to pause deposits and to unvet keys, and it also broadcasts the signed message so that anyone can relay it. Also, members have to run `DSC Daemon` that monitors the validators’ public keys in the `DepositContract` and in all Staking Modules. The daemon must have access to the delegate’s private key to be able to perform ECDSA signing. See the [EDF Operator Guide](/guides/edf/edf-operator-guide) for the setup. ## Preparation steps diff --git a/docs/guides/edf/edf-operator-guide.md b/docs/guides/edf/edf-operator-guide.md index 379736dd0..ea2ac9a05 100644 --- a/docs/guides/edf/edf-operator-guide.md +++ b/docs/guides/edf/edf-operator-guide.md @@ -43,7 +43,7 @@ Read the [Key Custody Policy](./key-custody-policy-for-edf-operators.md) before anything. Two of its values are irreversible: - the **owner address** — your multisig (step 0.2); -- the **cooldown** — **48 hours = `172800` seconds**. +- the **cooldown** — the policy requires **at least 48 hours**; use **`172800` seconds** (48 hours). ### 0.2. Prepare the owner multisig @@ -177,7 +177,16 @@ Route these to a phone. - `execute()` calls to targets your daemon never calls, or to an EOA; - non-zero `msg.value` forwarded through `execute()`; -- direct transactions from the delegate EOA that your daemon did not send. +- direct transactions from the delegate EOA that your daemon did not send; +- code on the delegate account: poll `eth_getCode` (EXTCODESIZE) of your delegate EOA and alert + when the result is not empty. + +`execute()` emits no event, so the first two alerts cannot come from log or event monitoring. They +need trace-level monitoring: internal transactions from a `trace_` / `debug_` RPC, or the +**Internal Transactions** feed of your `DelegationContract` on Etherscan. The code check needs +polling too: no event marks that transition. A delegate with code (for example after an EIP-7702 +authorization) changes how your `DelegationContract` verifies signatures, see +[section 4 of the custody policy](./key-custody-policy-for-edf-operators.md#4-delegate-hot-key-custody). ### 1.5. Publish your addresses @@ -209,6 +218,20 @@ true yet, finish that step first. --- +## Minimum software versions + +Run these versions or newer **before the vote**. Older releases do not know the `DelegationContract` +and stop working at the moment the vote is enacted. + +| Component | Minimum version | Notes | +| --- | --- | --- | +| [lido-oracle](https://github.com/lidofinance/lido-oracle/releases) | 8.1.0 | post-audit EDF release | +| [lido-council-daemon](https://github.com/lidofinance/lido-council-daemon/releases) | 4.1.2 | first release with DSM v5 support | +| [depositor-bot](https://github.com/lidofinance/depositor-bot/releases) | 5.7.0 | depositor seat only; older releases stop at DSM v5 | +| [validator-ejector](https://github.com/lidofinance/validator-ejector/releases) | a build that unwraps `execute(address,bytes)` | run by node operators, not by seat holders. Today only the [2.2.0 pre-release](https://github.com/lidofinance/validator-ejector/releases/tag/2.2.0) has it; there is **no stable release** with this support yet. The 2.1.0 stable release rejects every report sent through a `DelegationContract`. See the [Ejector guide](/guides/validator-ejector-guide#oracle_addresses_allowlist). | + +--- + ## Part 2 — Configure the Lido Oracle > Follow **Part 2** if you run the Lido Oracle, **Part 3** if you run the Council daemon. @@ -229,9 +252,10 @@ true yet, finish that step first. MEMBER_PRIV_KEY_2=0xnewdelegatekey # new - takes over after the vote ``` -2. **Fund the delegate EOA.** Send 50% of the current balance of your old member EOA to the new - delegate EOA (the address returned by `getDelegate()`). Both keys must be able to pay for gas: the - old one until the vote, the new one after it. +2. **Fund the delegate EOA — required before the vote.** Send 50% of the current balance of your + old member EOA to the new delegate EOA (the address returned by `getDelegate()`). Both keys must + be able to pay for gas: the old one until the vote, the new one after it. A delegate with no ETH + cannot send reports from the moment the vote is enacted. 3. **Restart the oracle.** @@ -243,7 +267,13 @@ At startup: - `Delegation contract is a member, but its current delegate matches none of the configured accounts.` — fix the config. - `None of the configured accounts is an active member.` — fix the config. -- `Provided Account is not part of Oracle's members and has no submit role.` — fix the config. +- `Reporting address is not a HashConsensus member and has no submit role at this block. The member + list probably changed since the signer was resolved; it will be re-resolved on the next cycle.` — + fix the config. + +None of these messages stops the oracle. It keeps running in dry mode, sends no transactions, and +re-resolves the member list and the delegate on every cycle, so it recovers by itself once the +config or the on-chain state is right. ### 2.3. Report your oracle setup in the operators' chat @@ -296,7 +326,7 @@ unset or wrong — fix the config. | Variable | Value | | --- | --- | - | `DELEGATION_CONTRACT_ADDRESS` | Your `DelegationContract` address. Config validation **fails at startup** if it is empty or not a valid address — even while the DSM is still on v4. | + | `DELEGATION_CONTRACT_ADDRESS` | Your `DelegationContract` address. An **empty value passes startup validation**, and the daemon runs normally while the DSM is on v4, so a missing variable is not caught at startup. Set it before the vote and check for the `EDF preflight passed` log line (step 3.2). | | `WALLET_PRIVATE_KEY` / `WALLET_PRIVATE_KEY_FILE` | **The old key** — your existing guardian EOA. Used while the DSM is on v4. | | `WALLET_PRIVATE_KEY_2` / `WALLET_PRIVATE_KEY_2_FILE` | **The new key** — the delegate of your `DelegationContract`. | @@ -306,10 +336,12 @@ unset or wrong — fix the config. WALLET_PRIVATE_KEY_2=0xnewdelegatekey # new - takes over at DSM v5 ``` -2. **Fund the delegate EOA.** Send 50% of the current balance of your old guardian EOA to the new - delegate EOA (the address returned by `getDelegate()`). Both keys must be able to pay for gas: the - old one until DSM v5, the new one after it. Do the same on the DataBus chain (Gnosis): the delegate - EOA needs xDAI there to send Data Bus messages. +2. **Fund the delegate EOA — required before the vote.** Send 50% of the current balance of your + old guardian EOA to the new delegate EOA (the address returned by `getDelegate()`). Both keys must + be able to pay for gas: the old one until DSM v5, the new one after it. Do the same on the DataBus + chain (Gnosis): the delegate EOA needs xDAI there to send Data Bus messages. A delegate with no + funds cannot pause deposits, unvet keys, or send Data Bus messages from the moment DSM v5 is + enacted. 3. **Restart the daemon.** @@ -325,11 +357,24 @@ Guardian execution mode: edf dsmVersion: 5 ``` +Before the vote, while the DSM is still on v4, the daemon runs in `legacy-eoa` mode and checks the +EDF configuration once at startup, without blocking: + +- `EDF preflight passed` — the contract is found, it is not terminated, and its delegate matches one + of the configured keys. +- `EDF setup is not ready` with a `reason` field — fix the config. This is a warning only: the daemon + keeps running on DSM v4 as if nothing is wrong. + +This warning is the only signal you get before the vote. If you miss it, the daemon fails at the +moment the vote enacts DSM v5: a running daemon does not crash, it logs +`Guardian cycle processing error` on every cycle and signs nothing, so the guardian goes silent. A +restart at that point exits with code 1 and one of the errors below. + Errors you may hit, and what they mean: | Error | Meaning | | --- | --- | -| `DELEGATION_CONTRACT_ADDRESS is required for DSM version 5` | Variable not set. | +| `DELEGATION_CONTRACT_ADDRESS is required for DSM version 5` | Variable not set, and the DSM is already on v5. | | `No contract code at DELEGATION_CONTRACT_ADDRESS 0x…` | Wrong address, or wrong network. | | `DelegationContract 0x… is terminated` | Someone called `terminate()`. The seat is permanently dead. | | `DelegationContract 0x… has no active delegate` | The delegate was revoked, or never set. Expected right after an emergency revocation. | diff --git a/docs/guides/edf/edf-rotation-and-incidents.md b/docs/guides/edf/edf-rotation-and-incidents.md index 5308a9512..f90268389 100644 --- a/docs/guides/edf/edf-rotation-and-incidents.md +++ b/docs/guides/edf/edf-rotation-and-incidents.md @@ -44,7 +44,9 @@ is unknown. 1. **Generate** the new key on the target host (step 1.1 of the guide applies). 2. **Announce** at least **1 day** ahead on the research forum and in the operators' channel. Oracle operators: also send the new delegate address to node operators for their - `ORACLE_ADDRESSES_ALLOWLIST`. + `ORACLE_ADDRESSES_ALLOWLIST`. Node operators must add the new delegate before `activeFrom` and + keep the old one for about 7 days after the switch, until its reports leave the Ejector's + lookback window (`BLOCKS_PRELOAD`, 50000 blocks by default). 3. **Stage it in the daemon**, keeping the current key in place: - **Oracle:** set `MEMBER_PRIV_KEY_2` to the new key. Restart once. - **Council:** set `WALLET_PRIVATE_KEY_2` to the new key, keeping `WALLET_PRIVATE_KEY` as it diff --git a/docs/guides/edf/key-custody-policy-for-edf-operators.md b/docs/guides/edf/key-custody-policy-for-edf-operators.md index bd0285f60..0a883dc01 100644 --- a/docs/guides/edf/key-custody-policy-for-edf-operators.md +++ b/docs/guides/edf/key-custody-policy-for-edf-operators.md @@ -72,6 +72,10 @@ Treat the owner setup as a long-lived commitment and get it right before deploym - **Suspected compromise of the signer’s computer, or coercion.** Within **24 hours**. - **Routine device replacement, or a signer who cannot be reached out of hours.** Within **5 business days**. +6. **The cooldown MUST be at least 48 hours.** + + The `cooldown` passed to `DelegationFactory.deploy()` MUST be at least **48 hours (172800 seconds)**. The cooldown is the only window in which the owner can react to a hostile `DelegateNominated`, and it is fixed at deployment. Neither the contract nor the factory bounds this value, so it is checked at admission: a `DelegationContract` with a shorter cooldown is not accepted for a seat. + --- @@ -98,6 +102,14 @@ Treat the owner setup as a long-lived commitment and get it right before deploym Each hot key MUST be responsible only for the single activity it was assigned to perform (day-to-day protocol operation). It MUST NOT be used for any other purpose. +5. **The delegate MUST be a plain externally owned account.** + + The delegate address MUST have empty code for the whole time it is the delegate. It MUST NOT be a smart contract, a smart-contract wallet, or an account with an EIP-7702 delegation designator. The `DelegationContract` reads the code length of the delegate to choose between ECDSA recovery and an ERC-1271 call, so code installed on the delegate changes which signatures the seat accepts, while `getDelegate()` still returns the expected address and no event is emitted. Operators SHOULD poll the code of the delegate account and alert when it is not empty (see §7). + +6. **The delegate key MUST never sign an EIP-7702 authorization.** + + An EIP-7702 authorization installs code on the delegate account and breaks the rule above. If such an authorization has been signed, treat it as a §6.1 event and revoke the delegate. + --- @@ -230,6 +242,10 @@ Alongside Lido’s protocol-wide monitoring, each operator SHOULD independently - Unexpected `execute()` targets, including EOA destinations - Unexpected non-zero `msg.value` forwarded through `execute()` - Transactions from the delegate EOA itself + - `execute()` emits no event, so the first two checks need trace-level monitoring (internal transactions), not log or event monitoring +- **Code on the delegate account** + - Poll the code of the delegate address (`eth_getCode` / EXTCODESIZE) and alert when it is not empty + - No event marks this transition, so polling is the only detector (see §4.5) ### Emergency contact diff --git a/docs/guides/oracle-operator-manual.md b/docs/guides/oracle-operator-manual.md index a7ec5c38c..6c9382ca3 100644 --- a/docs/guides/oracle-operator-manual.md +++ b/docs/guides/oracle-operator-manual.md @@ -13,7 +13,7 @@ Due to the lack of native communication between these two networks, Lido employs 6. [**Optional**] Add alerts to Oracle's Prometheus metrics. 7. In case of mainnet, share your address and intention to join the Oracle set with the public. You need to publish it on Twitter and also write a message with a Twitter link under the Onboarding post on [the Research forum](https://research.lido.fi/). You need to publish it on Twitter and also write a message with a twitter link under the Onboarding post on [the Research forum](https://research.lido.fi/). 8. Propose your Oracle's Ethereum address to the Lido team to vote on adding your address to the Oracle Members. -9. After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, the seat is held by a `DelegationContract` instead of an EOA: deploy it and configure the daemon as described in the [EDF Operator Guide](/guides/edf/edf-operator-guide). +9. Before the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, deploy your `DelegationContract` and configure the daemon as described in the [EDF Operator Guide](/guides/edf/edf-operator-guide). The vote then moves the seat from your EOA to that contract. A seat that is not prepared before the vote produces no reports from the moment the vote is enacted until the operator finishes the setup. ## Intro diff --git a/docs/guides/protocol-levers.md b/docs/guides/protocol-levers.md index a61eb7a46..0e9035eee 100644 --- a/docs/guides/protocol-levers.md +++ b/docs/guides/protocol-levers.md @@ -105,7 +105,7 @@ Key levers on [StakingRouter](/contracts/staking-router/) ([`0xFdDf38947aFB03C62 | Module registry | `addStakingModule()`, `updateStakingModule()`, `setStakingModuleStatus()` | `STAKING_MODULE_MANAGE_ROLE` | StakingRouter | Aragon Agent | Aragon Agent ([`0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c`](https://etherscan.io/address/0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c)) | | Module fees | `setStakingModuleFees()` | `STAKING_MODULE_MANAGE_ROLE` | StakingRouter | Aragon Agent | Aragon Agent ([`0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c`](https://etherscan.io/address/0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c)) | | Withdrawal credentials | `setWithdrawalCredentials()` | `MANAGE_WITHDRAWAL_CREDENTIALS_ROLE` | StakingRouter | Aragon Agent | Unassigned | -| Module unvetting | `decreaseStakingModuleVettedKeysCountByNodeOperator()` | `STAKING_MODULE_UNVETTING_ROLE` | StakingRouter | Aragon Agent | [DepositSecurityModule](/contracts/deposit-security-module/) ([`0xfFA96D84dEF2EA035c7AB153D8B991128e3d72fD`](https://etherscan.io/address/0xfFA96D84dEF2EA035c7AB153D8B991128e3d72fD)) | +| Module unvetting | `decreaseStakingModuleVettedKeysCountByNodeOperator()` | `STAKING_MODULE_UNVETTING_ROLE` | StakingRouter | Aragon Agent | [DepositSecurityModule](/contracts/deposit-security-module/) ([`0xF573E9E3de1f86B085417ab294f56E7920B4e9Be`](https://etherscan.io/address/0xF573E9E3de1f86B085417ab294f56E7920B4e9Be)), \[[proposed](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746/25)\] DSM v5 ([`0x39BB5d491e98A44D1bfe8047A737a81E296a63E0`](https://etherscan.io/address/0x39BB5d491e98A44D1bfe8047A737a81E296a63E0)) | ### Active staking modules diff --git a/docs/guides/tooling.md b/docs/guides/tooling.md index 52884c909..99345e3b3 100644 --- a/docs/guides/tooling.md +++ b/docs/guides/tooling.md @@ -35,22 +35,22 @@ Daemon service which loads LidoOracle events for validator exits and sends out e The Lido Council Daemon monitors deposit contract keys. -- **Version**: 4.0.4 -- **Docker image**: sha256:8e419905599b55cf37dc51f667468e7a24c34e7b5bade17e7f08691e98dbdb02, [lidofinance/lido-council-daemon@sha256-8e419905599b55cf37dc51f667468e7a24c34e7b5bade17e7f08691e98dbdb02](https://hub.docker.com/layers/lidofinance/lido-council-daemon/4.0.4/images/sha256-8e419905599b55cf37dc51f667468e7a24c34e7b5bade17e7f08691e98dbdb02) -- **Commit hash**: [lidofinance/lido-council-daemon@b02577f](https://github.com/lidofinance/lido-council-daemon/commit/b02577ff193ea8fa96f5c16025292d044ebd70f3) -- **Last update date**: 7 July, 2026 -- [**Repository**](https://github.com/lidofinance/lido-council-daemon/tree/4.0.4) +- **Version**: 4.1.2 +- **Docker image**: sha256:4c204661e0c930be50a0d42155342c2988f1b024d1d8896250197c4256347aa7, [lidofinance/lido-council-daemon@sha256-4c204661e0c930be50a0d42155342c2988f1b024d1d8896250197c4256347aa7](https://hub.docker.com/layers/lidofinance/lido-council-daemon/4.1.2/images/sha256-4c204661e0c930be50a0d42155342c2988f1b024d1d8896250197c4256347aa7) +- **Commit hash**: [lidofinance/lido-council-daemon@d3bc5e8](https://github.com/lidofinance/lido-council-daemon/commit/d3bc5e8fe968293530f3ec976c30230d98f671de) +- **Last update date**: 7 September, 2026 +- [**Repository**](https://github.com/lidofinance/lido-council-daemon/tree/4.1.2) - [**Documentation**](/guides/deposit-security-manual) ## Depositor Bot Bot that submits deposit transactions to the Lido protocol once the Deposit Security Committee quorum is reached. -- **Version**: 5.6.0 -- **Docker image**: sha256:a8fc015713cf4680bf2d2692de7a295ac99d00d29bb154860c285a44e63e0c32, [lidofinance/depositor-bot@sha256-a8fc015713cf4680bf2d2692de7a295ac99d00d29bb154860c285a44e63e0c32](https://hub.docker.com/layers/lidofinance/depositor-bot/5.6.0/images/sha256-a8fc015713cf4680bf2d2692de7a295ac99d00d29bb154860c285a44e63e0c32) -- **Commit hash**: [lidofinance/depositor-bot@ccb788e](https://github.com/lidofinance/depositor-bot/commit/ccb788e041cf7a95ff5f9a1894bb67fd5393124c) -- **Last update date**: 24 July, 2026 -- [**Repository**](https://github.com/lidofinance/depositor-bot/tree/5.6.0) +- **Version**: 5.7.0 +- **Docker image**: sha256:5289b2a070190adcdf70d7cc54885235faaa8ac015c4e57977ebfc4160ee59ae, [lidofinance/depositor-bot@sha256-5289b2a070190adcdf70d7cc54885235faaa8ac015c4e57977ebfc4160ee59ae](https://hub.docker.com/layers/lidofinance/depositor-bot/5.7.0/images/sha256-5289b2a070190adcdf70d7cc54885235faaa8ac015c4e57977ebfc4160ee59ae) +- **Commit hash**: [lidofinance/depositor-bot@b5ea173](https://github.com/lidofinance/depositor-bot/commit/b5ea173eb86c27bf164c5f6ca8bc862be869736e) +- **Last update date**: 8 September, 2026 +- [**Repository**](https://github.com/lidofinance/depositor-bot/tree/5.7.0) - [**Documentation**](/guides/depositor-bot) ## Reward Distribution Bot diff --git a/docs/guides/validator-ejector-guide.md b/docs/guides/validator-ejector-guide.md index 61aecbcc5..41196a551 100644 --- a/docs/guides/validator-ejector-guide.md +++ b/docs/guides/validator-ejector-guide.md @@ -196,7 +196,13 @@ On the endpoint, JSON will be POSTed with the following structure: JSON array of Lido Oracle addresses, from which only report transactions will be accepted. -You can get a list from Etherscan on [Hoodi](https://hoodi.etherscan.io/address/0x32EC59a78abaca3f91527aeB2008925D5AaC1eFC#readContract#F16) or [Mainnet](https://etherscan.io/address/0xD624B08C83bAECF0807Dd2c6880C3154a5F0B288#readContract#F16) +After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, every oracle seat is held by a `DelegationContract` under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). The member's **delegate EOA** sends each report through `DelegationContract.execute(address,bytes)`, and the Ejector verifies a report by recovering the signer of that transaction. This changes what the allowlist must contain: + +- The allowlist must contain the **delegate EOAs** of the oracle members, not the `DelegationContract` addresses. After the vote, `getMembers()` on `HashConsensus` returns the `DelegationContract` addresses, so do not copy that list as is. Take each `DelegationContract` from the [Lido Oracle members page](/holders/lido-oracle) and read its `getDelegate()` on Etherscan, or use the delegate addresses that oracle operators publish in the LIP-37 forum thread and in rotation announcements (see [EDF Rotation and Incidents](/guides/edf/edf-rotation-and-incidents)). +- Keep the previous member EOAs in the allowlist until their reports leave the lookback window: about 7 days at the default `BLOCKS_PRELOAD` of 50000 blocks. The same rule applies to every later delegate rotation: add the new delegate before it becomes active and remove the old one about 7 days later. +- Only an Ejector build that unwraps `execute(address,bytes)` can verify reports after the vote. Today that is the [2.2.0 pre-release](https://github.com/lidofinance/validator-ejector/releases/tag/2.2.0); there is no stable release with this support yet. The 2.1.0 stable release rejects every report sent through a `DelegationContract` and skips the exit request. + +Before the vote, you can get the list from Etherscan on [Hoodi](https://hoodi.etherscan.io/address/0x30308CD8844fb2DB3ec4D056F1d475a802DCA07c#readContract#F16) or [Mainnet](https://etherscan.io/address/0x7FaDB6358950c5fAA66Cb5EB8eE5147De3df355a#readContract#F16) (`HashConsensus` of the Validators Exit Bus Oracle). Format: From 3d8269960dd28c6944a774926e83d276f594df68 Mon Sep 17 00:00:00 2001 From: chasingrainbows Date: Mon, 14 Sep 2026 18:28:40 +0300 Subject: [PATCH 2/8] fix: tighten the wording of the second EDF review round --- .../curated-module/exits/tooling-setup.md | 3 +- docs/guides/deposit-security-manual.md | 6 +- docs/guides/edf/edf-operator-guide.md | 64 ++++++++----------- docs/guides/edf/edf-rotation-and-incidents.md | 4 +- .../key-custody-policy-for-edf-operators.md | 8 +-- docs/guides/oracle-operator-manual.md | 2 +- docs/guides/validator-ejector-guide.md | 8 +-- 7 files changed, 46 insertions(+), 49 deletions(-) diff --git a/docs/guides/curated-module/exits/tooling-setup.md b/docs/guides/curated-module/exits/tooling-setup.md index 220121c9b..5e48ad65b 100644 --- a/docs/guides/curated-module/exits/tooling-setup.md +++ b/docs/guides/curated-module/exits/tooling-setup.md @@ -40,9 +40,10 @@ Currently, it has only one module ([NodeOperatorsRegistry](https://github.com/li ### Oracle Allowlist -After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, the oracle seats are held by `DelegationContract`s, and `getMembers()` on `HashConsensus` returns those contract addresses. The Ejector verifies the signer of the report transaction, so `ORACLE_ADDRESSES_ALLOWLIST` must contain the members' **delegate EOAs**, not the `DelegationContract` addresses. Take each `DelegationContract` from the [Lido Oracle members page](/holders/lido-oracle) and read its `getDelegate()`, keep the previous member EOAs for about 7 days after the vote, and run an Ejector build that unwraps `execute(address,bytes)`. See the [ORACLE_ADDRESSES_ALLOWLIST](/guides/validator-ejector-guide#oracle_addresses_allowlist) section of the Ejector guide for the details. +After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, `getMembers()` on `HashConsensus` returns `DelegationContract` addresses. `ORACLE_ADDRESSES_ALLOWLIST` must contain the members' **delegate EOAs** instead. See [ORACLE_ADDRESSES_ALLOWLIST](/guides/validator-ejector-guide#oracle_addresses_allowlist) in the Ejector guide. Before the vote, the oracle members are retrievable from the HashConsensus (for the Validator Exit Bus Oracle) contract on-chain, directly from the contract using Etherscan. + | network | Contract Call | | -------- | ------------- | | Mainnet | [getMembers()](https://etherscan.io/address/0x7FaDB6358950c5fAA66Cb5EB8eE5147De3df355a#readContract#F16) | diff --git a/docs/guides/deposit-security-manual.md b/docs/guides/deposit-security-manual.md index 7e4164cfd..b853cfad7 100644 --- a/docs/guides/deposit-security-manual.md +++ b/docs/guides/deposit-security-manual.md @@ -34,7 +34,11 @@ After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framew ### Members responsibilities -Each member must prepare a hot key to sign the deposit attestation message. In `DepositSecurityModule` v5 the signed message is `(prefix, guardian, blockNumber, blockHash, depositRoot, stakingModuleId, nonce)`, where `guardian` is the member's `DelegationContract` address; the pause and unvet messages include the guardian address in the same way. The address added to the smart contract is the member's `DelegationContract` (before the LIP-37 vote: the member's EOA), and the hot key is its delegate. The `DepositSecurityModule` verifies guardian signatures through ERC-1271, so a signature is valid only while the signing key is the active delegate of the contract. `pauseDeposits` and `unvetSigningKeys` also accept a direct call: when the sender is a guardian, the signature argument is ignored. Under EDF this is the delegate calling `execute()` on its `DelegationContract`, which makes the contract the sender; the council daemon uses this path to pause deposits and to unvet keys, and it also broadcasts the signed message so that anyone can relay it. Also, members have to run `DSC Daemon` that monitors the validators’ public keys in the `DepositContract` and in all Staking Modules. The daemon must have access to the delegate’s private key to be able to perform ECDSA signing. See the [EDF Operator Guide](/guides/edf/edf-operator-guide) for the setup. +Each member must prepare a hot key to sign the deposit attestation message. In `DepositSecurityModule` v5 the signed message is `(prefix, guardian, blockNumber, blockHash, depositRoot, stakingModuleId, nonce)`, where `guardian` is the member's `DelegationContract` address. The pause and unvet messages include the guardian address in the same way. The address added to the smart contract is the member's `DelegationContract` (before the LIP-37 vote: the member's EOA), and the hot key is its delegate. The `DepositSecurityModule` verifies guardian signatures through ERC-1271, so a signature is valid only while the signing key is the active delegate of the contract. + +`pauseDeposits` and `unvetSigningKeys` can also be called directly by a guardian; then the signature argument is ignored. Under EDF the delegate calls `execute()` on its `DelegationContract`, so the contract is the sender. The council daemon uses this path to pause deposits and unvet keys, and also broadcasts the signed message so that anyone can relay it. + +Members also have to run `DSC Daemon` that monitors the validators’ public keys in the `DepositContract` and in all Staking Modules. The daemon must have access to the delegate’s private key to be able to perform ECDSA signing. See the [EDF Operator Guide](/guides/edf/edf-operator-guide) for the setup. ## Preparation steps diff --git a/docs/guides/edf/edf-operator-guide.md b/docs/guides/edf/edf-operator-guide.md index ea2ac9a05..49a787f21 100644 --- a/docs/guides/edf/edf-operator-guide.md +++ b/docs/guides/edf/edf-operator-guide.md @@ -43,7 +43,7 @@ Read the [Key Custody Policy](./key-custody-policy-for-edf-operators.md) before anything. Two of its values are irreversible: - the **owner address** — your multisig (step 0.2); -- the **cooldown** — the policy requires **at least 48 hours**; use **`172800` seconds** (48 hours). +- the **cooldown** — **48 hours = `172800` seconds**, the policy minimum. ### 0.2. Prepare the owner multisig @@ -178,15 +178,12 @@ Route these to a phone. - `execute()` calls to targets your daemon never calls, or to an EOA; - non-zero `msg.value` forwarded through `execute()`; - direct transactions from the delegate EOA that your daemon did not send; -- code on the delegate account: poll `eth_getCode` (EXTCODESIZE) of your delegate EOA and alert - when the result is not empty. +- non-empty code on the delegate EOA (poll `eth_getCode`; see + [section 4 of the custody policy](./key-custody-policy-for-edf-operators.md#4-delegate-hot-key-custody)). -`execute()` emits no event, so the first two alerts cannot come from log or event monitoring. They -need trace-level monitoring: internal transactions from a `trace_` / `debug_` RPC, or the -**Internal Transactions** feed of your `DelegationContract` on Etherscan. The code check needs -polling too: no event marks that transition. A delegate with code (for example after an EIP-7702 -authorization) changes how your `DelegationContract` verifies signatures, see -[section 4 of the custody policy](./key-custody-policy-for-edf-operators.md#4-delegate-hot-key-custody). +`execute()` emits no events, so the first two alerts need trace-level monitoring: internal +transactions from a `trace_` / `debug_` RPC, or the **Internal Transactions** tab of your +`DelegationContract` on Etherscan. ### 1.5. Publish your addresses @@ -220,15 +217,15 @@ true yet, finish that step first. ## Minimum software versions -Run these versions or newer **before the vote**. Older releases do not know the `DelegationContract` -and stop working at the moment the vote is enacted. +Run these versions or newer **before the vote**. Older releases stop working when the vote is +enacted. -| Component | Minimum version | Notes | -| --- | --- | --- | -| [lido-oracle](https://github.com/lidofinance/lido-oracle/releases) | 8.1.0 | post-audit EDF release | -| [lido-council-daemon](https://github.com/lidofinance/lido-council-daemon/releases) | 4.1.2 | first release with DSM v5 support | -| [depositor-bot](https://github.com/lidofinance/depositor-bot/releases) | 5.7.0 | depositor seat only; older releases stop at DSM v5 | -| [validator-ejector](https://github.com/lidofinance/validator-ejector/releases) | a build that unwraps `execute(address,bytes)` | run by node operators, not by seat holders. Today only the [2.2.0 pre-release](https://github.com/lidofinance/validator-ejector/releases/tag/2.2.0) has it; there is **no stable release** with this support yet. The 2.1.0 stable release rejects every report sent through a `DelegationContract`. See the [Ejector guide](/guides/validator-ejector-guide#oracle_addresses_allowlist). | +| Component | Minimum version | +| --- | --- | +| [lido-oracle](https://github.com/lidofinance/lido-oracle/releases) | 8.1.0 | +| [lido-council-daemon](https://github.com/lidofinance/lido-council-daemon/releases) | 4.1.2 | +| [depositor-bot](https://github.com/lidofinance/depositor-bot/releases) | 5.7.0 | +| [validator-ejector](https://github.com/lidofinance/validator-ejector/releases) | 2.2.0 | --- @@ -254,8 +251,7 @@ and stop working at the moment the vote is enacted. 2. **Fund the delegate EOA — required before the vote.** Send 50% of the current balance of your old member EOA to the new delegate EOA (the address returned by `getDelegate()`). Both keys must - be able to pay for gas: the old one until the vote, the new one after it. A delegate with no ETH - cannot send reports from the moment the vote is enacted. + be able to pay for gas: the old one until the vote, the new one after it. 3. **Restart the oracle.** @@ -271,9 +267,8 @@ At startup: list probably changed since the signer was resolved; it will be re-resolved on the next cycle.` — fix the config. -None of these messages stops the oracle. It keeps running in dry mode, sends no transactions, and -re-resolves the member list and the delegate on every cycle, so it recovers by itself once the -config or the on-chain state is right. +The oracle does not stop on these errors: it runs in dry mode and re-checks the config every +cycle, so no restart is needed after the fix. ### 2.3. Report your oracle setup in the operators' chat @@ -326,7 +321,7 @@ unset or wrong — fix the config. | Variable | Value | | --- | --- | - | `DELEGATION_CONTRACT_ADDRESS` | Your `DelegationContract` address. An **empty value passes startup validation**, and the daemon runs normally while the DSM is on v4, so a missing variable is not caught at startup. Set it before the vote and check for the `EDF preflight passed` log line (step 3.2). | + | `DELEGATION_CONTRACT_ADDRESS` | Your `DelegationContract` address. An **empty value is not rejected at startup** while the DSM is on v4. Set it before the vote and check for `EDF preflight passed` in the logs (step 3.2). | | `WALLET_PRIVATE_KEY` / `WALLET_PRIVATE_KEY_FILE` | **The old key** — your existing guardian EOA. Used while the DSM is on v4. | | `WALLET_PRIVATE_KEY_2` / `WALLET_PRIVATE_KEY_2_FILE` | **The new key** — the delegate of your `DelegationContract`. | @@ -339,9 +334,7 @@ unset or wrong — fix the config. 2. **Fund the delegate EOA — required before the vote.** Send 50% of the current balance of your old guardian EOA to the new delegate EOA (the address returned by `getDelegate()`). Both keys must be able to pay for gas: the old one until DSM v5, the new one after it. Do the same on the DataBus - chain (Gnosis): the delegate EOA needs xDAI there to send Data Bus messages. A delegate with no - funds cannot pause deposits, unvet keys, or send Data Bus messages from the moment DSM v5 is - enacted. + chain (Gnosis): the delegate EOA needs xDAI there to send Data Bus messages. 3. **Restart the daemon.** @@ -357,18 +350,17 @@ Guardian execution mode: edf dsmVersion: 5 ``` -Before the vote, while the DSM is still on v4, the daemon runs in `legacy-eoa` mode and checks the -EDF configuration once at startup, without blocking: +Before the vote (DSM v4) the daemon runs in `legacy-eoa` mode and checks the EDF config once at +startup: -- `EDF preflight passed` — the contract is found, it is not terminated, and its delegate matches one - of the configured keys. -- `EDF setup is not ready` with a `reason` field — fix the config. This is a warning only: the daemon - keeps running on DSM v4 as if nothing is wrong. +- `EDF preflight passed` — the contract is found, not terminated, and its delegate matches one of + the configured keys. +- `EDF setup is not ready` with a `reason` field — fix the config. The daemon keeps running on DSM + v4 anyway. -This warning is the only signal you get before the vote. If you miss it, the daemon fails at the -moment the vote enacts DSM v5: a running daemon does not crash, it logs -`Guardian cycle processing error` on every cycle and signs nothing, so the guardian goes silent. A -restart at that point exits with code 1 and one of the errors below. +This is the only check before the vote. A daemon with a wrong EDF config does not crash when DSM +v5 is enacted: it logs `Guardian cycle processing error` every cycle and signs nothing. A restart +then exits with code 1 and one of the errors below. Errors you may hit, and what they mean: diff --git a/docs/guides/edf/edf-rotation-and-incidents.md b/docs/guides/edf/edf-rotation-and-incidents.md index f90268389..9f03fb2ef 100644 --- a/docs/guides/edf/edf-rotation-and-incidents.md +++ b/docs/guides/edf/edf-rotation-and-incidents.md @@ -45,8 +45,8 @@ is unknown. 2. **Announce** at least **1 day** ahead on the research forum and in the operators' channel. Oracle operators: also send the new delegate address to node operators for their `ORACLE_ADDRESSES_ALLOWLIST`. Node operators must add the new delegate before `activeFrom` and - keep the old one for about 7 days after the switch, until its reports leave the Ejector's - lookback window (`BLOCKS_PRELOAD`, 50000 blocks by default). + keep the old one for about 7 days after the switch (the Ejector lookback window, + `BLOCKS_PRELOAD` = 50000 blocks by default). 3. **Stage it in the daemon**, keeping the current key in place: - **Oracle:** set `MEMBER_PRIV_KEY_2` to the new key. Restart once. - **Council:** set `WALLET_PRIVATE_KEY_2` to the new key, keeping `WALLET_PRIVATE_KEY` as it diff --git a/docs/guides/edf/key-custody-policy-for-edf-operators.md b/docs/guides/edf/key-custody-policy-for-edf-operators.md index 0a883dc01..2db9063f6 100644 --- a/docs/guides/edf/key-custody-policy-for-edf-operators.md +++ b/docs/guides/edf/key-custody-policy-for-edf-operators.md @@ -74,7 +74,7 @@ Treat the owner setup as a long-lived commitment and get it right before deploym 6. **The cooldown MUST be at least 48 hours.** - The `cooldown` passed to `DelegationFactory.deploy()` MUST be at least **48 hours (172800 seconds)**. The cooldown is the only window in which the owner can react to a hostile `DelegateNominated`, and it is fixed at deployment. Neither the contract nor the factory bounds this value, so it is checked at admission: a `DelegationContract` with a shorter cooldown is not accepted for a seat. + The `cooldown` passed to `DelegationFactory.deploy()` MUST be at least **48 hours (172800 seconds)**. The cooldown is the owner's only window to react to a hostile `DelegateNominated`, and it cannot be changed after deployment. The contract does not enforce a minimum, so it is checked at admission: a `DelegationContract` with a shorter cooldown is not accepted for a seat. --- @@ -104,7 +104,7 @@ Treat the owner setup as a long-lived commitment and get it right before deploym 5. **The delegate MUST be a plain externally owned account.** - The delegate address MUST have empty code for the whole time it is the delegate. It MUST NOT be a smart contract, a smart-contract wallet, or an account with an EIP-7702 delegation designator. The `DelegationContract` reads the code length of the delegate to choose between ECDSA recovery and an ERC-1271 call, so code installed on the delegate changes which signatures the seat accepts, while `getDelegate()` still returns the expected address and no event is emitted. Operators SHOULD poll the code of the delegate account and alert when it is not empty (see §7). + The delegate address MUST have empty code for the whole time it is the delegate: no smart contract, no smart-contract wallet, no EIP-7702 delegation designator. The `DelegationContract` checks the delegate's code length to choose between ECDSA recovery and an ERC-1271 call. Code on the delegate therefore changes which signatures the seat accepts, with no event and no change in `getDelegate()`. Operators SHOULD poll the delegate's code and alert when it is not empty (see §7). 6. **The delegate key MUST never sign an EIP-7702 authorization.** @@ -242,10 +242,10 @@ Alongside Lido’s protocol-wide monitoring, each operator SHOULD independently - Unexpected `execute()` targets, including EOA destinations - Unexpected non-zero `msg.value` forwarded through `execute()` - Transactions from the delegate EOA itself - - `execute()` emits no event, so the first two checks need trace-level monitoring (internal transactions), not log or event monitoring + - `execute()` emits no events, so the first two checks need trace-level monitoring (internal transactions) - **Code on the delegate account** - Poll the code of the delegate address (`eth_getCode` / EXTCODESIZE) and alert when it is not empty - - No event marks this transition, so polling is the only detector (see §4.5) + - No event marks this change, so polling is the only detector (see §4.5) ### Emergency contact diff --git a/docs/guides/oracle-operator-manual.md b/docs/guides/oracle-operator-manual.md index 6c9382ca3..dd2bd7a4b 100644 --- a/docs/guides/oracle-operator-manual.md +++ b/docs/guides/oracle-operator-manual.md @@ -13,7 +13,7 @@ Due to the lack of native communication between these two networks, Lido employs 6. [**Optional**] Add alerts to Oracle's Prometheus metrics. 7. In case of mainnet, share your address and intention to join the Oracle set with the public. You need to publish it on Twitter and also write a message with a Twitter link under the Onboarding post on [the Research forum](https://research.lido.fi/). You need to publish it on Twitter and also write a message with a twitter link under the Onboarding post on [the Research forum](https://research.lido.fi/). 8. Propose your Oracle's Ethereum address to the Lido team to vote on adding your address to the Oracle Members. -9. Before the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, deploy your `DelegationContract` and configure the daemon as described in the [EDF Operator Guide](/guides/edf/edf-operator-guide). The vote then moves the seat from your EOA to that contract. A seat that is not prepared before the vote produces no reports from the moment the vote is enacted until the operator finishes the setup. +9. Before the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, deploy your `DelegationContract` and configure the daemon as described in the [EDF Operator Guide](/guides/edf/edf-operator-guide). The vote moves the seat from your EOA to that contract. An unprepared seat produces no reports after the vote until the setup is done. ## Intro diff --git a/docs/guides/validator-ejector-guide.md b/docs/guides/validator-ejector-guide.md index 41196a551..fcb0b8ade 100644 --- a/docs/guides/validator-ejector-guide.md +++ b/docs/guides/validator-ejector-guide.md @@ -196,11 +196,11 @@ On the endpoint, JSON will be POSTed with the following structure: JSON array of Lido Oracle addresses, from which only report transactions will be accepted. -After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, every oracle seat is held by a `DelegationContract` under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). The member's **delegate EOA** sends each report through `DelegationContract.execute(address,bytes)`, and the Ejector verifies a report by recovering the signer of that transaction. This changes what the allowlist must contain: +After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, every oracle seat is held by a `DelegationContract` under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). The member's **delegate EOA** sends each report through `DelegationContract.execute(address,bytes)`, and the Ejector verifies the report by recovering the signer of that transaction. So: -- The allowlist must contain the **delegate EOAs** of the oracle members, not the `DelegationContract` addresses. After the vote, `getMembers()` on `HashConsensus` returns the `DelegationContract` addresses, so do not copy that list as is. Take each `DelegationContract` from the [Lido Oracle members page](/holders/lido-oracle) and read its `getDelegate()` on Etherscan, or use the delegate addresses that oracle operators publish in the LIP-37 forum thread and in rotation announcements (see [EDF Rotation and Incidents](/guides/edf/edf-rotation-and-incidents)). -- Keep the previous member EOAs in the allowlist until their reports leave the lookback window: about 7 days at the default `BLOCKS_PRELOAD` of 50000 blocks. The same rule applies to every later delegate rotation: add the new delegate before it becomes active and remove the old one about 7 days later. -- Only an Ejector build that unwraps `execute(address,bytes)` can verify reports after the vote. Today that is the [2.2.0 pre-release](https://github.com/lidofinance/validator-ejector/releases/tag/2.2.0); there is no stable release with this support yet. The 2.1.0 stable release rejects every report sent through a `DelegationContract` and skips the exit request. +- The allowlist must contain the **delegate EOAs** of the oracle members, not the `DelegationContract` addresses that `getMembers()` on `HashConsensus` returns after the vote. Take each `DelegationContract` from the [Lido Oracle members page](/holders/lido-oracle) and read its `getDelegate()` on Etherscan, or use the delegate addresses that oracle operators publish in the LIP-37 forum thread and in rotation announcements (see [EDF Rotation and Incidents](/guides/edf/edf-rotation-and-incidents)). +- Keep the previous member EOAs in the allowlist until their reports leave the lookback window: about 7 days at the default `BLOCKS_PRELOAD` of 50000 blocks. The same applies to every later delegate rotation. +- Only an Ejector build that unwraps `execute(address,bytes)` can verify reports after the vote. Today that is the [2.2.0 pre-release](https://github.com/lidofinance/validator-ejector/releases/tag/2.2.0); there is no stable release with this support yet. The 2.1.0 release rejects every report sent through a `DelegationContract`. Before the vote, you can get the list from Etherscan on [Hoodi](https://hoodi.etherscan.io/address/0x30308CD8844fb2DB3ec4D056F1d475a802DCA07c#readContract#F16) or [Mainnet](https://etherscan.io/address/0x7FaDB6358950c5fAA66Cb5EB8eE5147De3df355a#readContract#F16) (`HashConsensus` of the Validators Exit Bus Oracle). From 1e80218a63d04ec2ec94fa4d06fa7edd3da312ab Mon Sep 17 00:00:00 2001 From: chasingrainbows Date: Mon, 14 Sep 2026 18:39:28 +0300 Subject: [PATCH 3/8] fix: add DelegationFactory and DelegationContract pages, update DSM to v5 --- docs/contracts/delegation-contract.md | 220 ++++++++++++++++++ docs/contracts/delegation-factory.md | 54 +++++ docs/contracts/deposit-security-module.md | 60 +++-- .../curated-module/exits/tooling-setup.md | 6 +- docs/guides/deposit-security-manual.md | 4 +- docs/guides/edf/edf-operator-guide.md | 19 +- docs/guides/oracle-operator-manual.md | 2 +- docs/guides/validator-ejector-guide.md | 10 +- sidebars.js | 2 + 9 files changed, 333 insertions(+), 44 deletions(-) create mode 100644 docs/contracts/delegation-contract.md create mode 100644 docs/contracts/delegation-factory.md diff --git a/docs/contracts/delegation-contract.md b/docs/contracts/delegation-contract.md new file mode 100644 index 000000000..2e104076c --- /dev/null +++ b/docs/contracts/delegation-contract.md @@ -0,0 +1,220 @@ +# DelegationContract + +- [Source Code](https://github.com/lidofinance/execution-delegation-framework/blob/main/src/DelegationContract.sol) +- [Audit](https://github.com/lidofinance/audits/blob/main/Composable%20Security%20Lido%20EDF%20On-chain%20Audit%20Report%2008-2026.pdf) + +`DelegationContract` is the per-seat contract of the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). It has one owner and one active delegate. The owner is a cold multisig that nominates and revokes the delegate. The delegate is a hot key that does the day-to-day work: it sends transactions through `execute()` (push integration) or signs messages that the protocol verifies through ERC-1271 `isValidSignature()` (pull integration). + +A `DelegationContract` holds a seat in the protocol instead of an EOA: it is a member of `HashConsensus` for the Lido Oracle, a guardian of the [`DepositSecurityModule`](/contracts/deposit-security-module), or the depositor for the depositor bot. The owner can never call `execute()` or sign on behalf of the contract. + +Key properties: + +- **Owner and cooldown are immutable.** Both are set in the constructor. Replacing the owner means deploying a new contract from the [`DelegationFactory`](/contracts/delegation-factory) and passing a governance vote to reassign the seat. +- **Nomination is cooldown-gated.** A new delegate becomes effective only `cooldown` seconds after `nominateDelegate()`. The current delegate stays effective until then, so a hostile nomination by a compromised owner is visible before it takes effect. +- **Revocation and termination are immediate.** `revokeDelegate()` drops the current and pending delegate at once. `terminate()` disables the contract forever. +- **The contract holds no ETH.** It has no `receive()` or `fallback()`. A target that tries to send ETH back to it makes `execute()` revert. + +Contracts deployed from the official factory are listed on the [deployed contracts](/deployed-contracts/#execution-delegation-framework) page and on the [Lido Oracle](/holders/lido-oracle) and [Lido Council Daemon](/holders/lido-council-daemon) member pages. + +## View Methods + +### owner() + +Returns the owner address. This is the ERC-5313 ownership view, so explorers and multisig UIs recognize the controlling party. + +```solidity +function owner() external view returns (address); +``` + +### getDelegate() + +Returns the currently effective delegate, or zero address if there is none. + +A nominated delegate is returned only after its cooldown has elapsed. Before that, the previous delegate is returned. Zero address is returned when no delegate was ever nominated, after `revokeDelegate()`, and after `terminate()`. + +```solidity +function getDelegate() external view returns (address); +``` + +### getPendingDelegate() + +Returns the pending delegate and the timestamp when it becomes effective, or `(address(0), 0)` if there is no pending nomination. + +The result is time-dependent. Once `block.timestamp` reaches `activeFrom`, the pending delegate becomes the effective one: `getDelegate()` starts returning it and this function returns `(address(0), 0)`. No transaction is needed for the transition. + +```solidity +function getPendingDelegate() external view returns (address delegate, uint256 activeFrom); +``` + +### getCooldown() + +Returns the cooldown in seconds between `nominateDelegate()` and the moment the new delegate becomes effective. Set in the constructor and cannot be changed. + +```solidity +function getCooldown() external view returns (uint256); +``` + +### isTerminated() + +Returns whether the contract has been terminated. + +```solidity +function isTerminated() external view returns (bool); +``` + +### isValidSignature() + +ERC-1271 signature validation. Returns the magic value `0x1626ba7e` if `signature` is a valid ECDSA signature over `hash` by the current effective delegate. Returns `0xffffffff` otherwise. + +The delegate is resolved through `getDelegate()`, so validation fails when there is no effective delegate: never nominated, revoked, or terminated. + +```solidity +function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4 magicValue); +``` + +:::note +Unlike a raw ECDSA check, the result depends on the contract state. A signature that is valid at one block can become invalid at the next one, for example after the delegate is rotated or revoked, or the contract is terminated. +::: + +#### Parameters + +| Name | Type | Description | +| ----------- | --------- | ---------------------------- | +| `hash` | `bytes32` | Message hash that was signed | +| `signature` | `bytes` | ECDSA signature bytes | + +### supportsInterface() + +ERC-165 interface detection. Returns `true` for the ERC-165, ERC-1271, ERC-5313 and `IDelegationContract` interface ids. + +```solidity +function supportsInterface(bytes4 interfaceId) external pure returns (bool); +``` + +## Methods + +### nominateDelegate() + +Nominates a new delegate. The new delegate becomes effective after `getCooldown()` seconds (immediately if the cooldown is 0). The current delegate stays effective during the cooldown and is dropped only when the new one activates. + +A second nomination before the cooldown elapses replaces the pending delegate and restarts the cooldown. To drop a delegate immediately, use `revokeDelegate()`. + +```solidity +function nominateDelegate(address delegate) external; +``` + +:::note +Reverts if any of the following is true: + +- `msg.sender` is not the owner; +- the contract is terminated; +- `delegate` is zero address; +- `delegate` is the owner; +- `delegate` is the current effective delegate; +- `delegate` is the pending delegate. +::: + +#### Parameters + +| Name | Type | Description | +| ---------- | --------- | -------------------------------- | +| `delegate` | `address` | Address of the incoming delegate | + +### revokeDelegate() + +Immediately removes the current and the pending delegate. After this call `getDelegate()` returns zero address until a new delegate is nominated and its cooldown elapses. + +```solidity +function revokeDelegate() external; +``` + +:::note +Reverts if any of the following is true: + +- `msg.sender` is not the owner; +- the contract is terminated. +::: + +### terminate() + +Terminates the contract. This permanently disables `execute()`, `isValidSignature()` and `nominateDelegate()`, and clears the current and pending delegate. Intended for the case when the owner itself is suspected to be compromised. Termination is irreversible: the seat has to be reassigned to a new contract through a governance vote. + +```solidity +function terminate() external; +``` + +:::note +Reverts if any of the following is true: + +- `msg.sender` is not the owner; +- the contract is already terminated. +::: + +### execute() + +Executes a call to `target` on behalf of the contract. The target sees the `DelegationContract` as `msg.sender`. `msg.value` is forwarded to the target. The revert reason of the target call is bubbled up. + +```solidity +function execute(address target, bytes calldata data) external payable returns (bytes memory result); +``` + +:::note +Reverts if any of the following is true: + +- `msg.sender` is not the current effective delegate; +- the contract is terminated; +- `target` is zero address; +- `target` is the contract itself; +- the target call reverts. +::: + +#### Parameters + +| Name | Type | Description | +| -------- | --------- | --------------- | +| `target` | `address` | Address to call | +| `data` | `bytes` | Call data | + +#### Returns + +| Name | Type | Description | +| -------- | ------- | ------------------------------ | +| `result` | `bytes` | Return data of the target call | + +## Events + +### InitialDelegateSet() + +Emitted in the constructor when the contract is deployed with a non-zero initial delegate. + +```solidity +event InitialDelegateSet(address indexed newDelegate); +``` + +### DelegateNominated() + +Emitted on `nominateDelegate()`. `activeFrom` is the timestamp when the new delegate becomes effective. + +```solidity +event DelegateNominated(address indexed newDelegate, uint256 activeFrom); +``` + +### DelegateRevoked() + +Emitted on `revokeDelegate()`. `revokedDelegate` is the delegate that was effective at the moment of the call, or zero address if there was none. + +```solidity +event DelegateRevoked(address indexed revokedDelegate); +``` + +### Terminated() + +Emitted on `terminate()`. + +```solidity +event Terminated(); +``` + +:::note +`execute()` emits no event. To monitor delegate activity, use internal transactions of the contract (trace-level monitoring). See the [monitoring section](/guides/edf/edf-operator-guide#14-set-up-your-own-monitoring-and-alerts) of the operator guide. +::: diff --git a/docs/contracts/delegation-factory.md b/docs/contracts/delegation-factory.md new file mode 100644 index 000000000..6e6ad3f5d --- /dev/null +++ b/docs/contracts/delegation-factory.md @@ -0,0 +1,54 @@ +# DelegationFactory + +- [Source Code](https://github.com/lidofinance/execution-delegation-framework/blob/main/src/DelegationFactory.sol) +- [Deployed Contract](https://etherscan.io/address/0xD990770eB2B4b6062EDdB06892fF179C693b46e6) + +`DelegationFactory` deploys [`DelegationContract`](/contracts/delegation-contract) instances for the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). It is a stateless, permissionless factory: anyone can call `deploy()`. All constructor parameters of the new contract are fixed at deployment and cannot be changed later. + +Only contracts deployed from the official factory are accepted for Lido Oracle and Deposit Security Committee seats. The factory address for each network is listed on the [deployed contracts](/deployed-contracts/#execution-delegation-framework) page. + +## Methods + +### deploy() + +Deploys a new `DelegationContract` and emits `DelegationContractDeployed`. + +```solidity +function deploy(address owner, address delegate, uint256 cooldown) external returns (address instance); +``` + +:::note +Reverts if any of the following is true: + +- `owner` is zero address; +- `delegate` is equal to `owner`. +::: + +#### Parameters + +| Name | Type | Description | +| ---------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `owner` | `address` | Owner of the new contract. Fixed for the lifetime of the contract. | +| `delegate` | `address` | Initial delegate, effective immediately. Pass zero address to deploy without a delegate. | +| `cooldown` | `uint256` | Seconds a nominated delegate waits before it becomes effective. Fixed for the lifetime of the contract. The [custody policy](/guides/edf/key-custody-policy-for-edf-operators#3-owner-key-custody) requires at least 172800 (48 hours). | + +#### Returns + +| Name | Type | Description | +| ---------- | --------- | --------------------------------------- | +| `instance` | `address` | Address of the new `DelegationContract` | + +## Events + +### DelegationContractDeployed() + +Emitted for each `DelegationContract` deployed by the factory. + +```solidity +event DelegationContractDeployed( + address indexed instance, + address indexed owner, + address indexed delegate, + uint256 cooldown +); +``` diff --git a/docs/contracts/deposit-security-module.md b/docs/contracts/deposit-security-module.md index 383cc2711..d569d66d4 100644 --- a/docs/contracts/deposit-security-module.md +++ b/docs/contracts/deposit-security-module.md @@ -1,14 +1,14 @@ # DepositSecurityModule -- [Source Code](https://github.com/lidofinance/core/blob/v4.0.0/contracts/0.8.9/DepositSecurityModule.sol) -- [Deployed Contract](https://etherscan.io/address/0xF573E9E3de1f86B085417ab294f56E7920B4e9Be) +- [Source Code](https://github.com/lidofinance/core/blob/06126c70ceb179b1feff29718a6a08cf381c7500/contracts/0.8.9/DepositSecurityModule.sol) +- [Deployed Contract](https://etherscan.io/address/0x39BB5d491e98A44D1bfe8047A737a81E296a63E0) Due to front-running vulnerability, Lido contributors [proposed](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-5.md) to establish the Deposit Security Committee dedicated to ensuring the safety of deposits on the Beacon chain: - monitoring the history of deposits and the set of Lido keys available for the deposit, signing and disseminating messages allowing deposits; - signing the special message allowing anyone to pause deposits once the malicious Node Operator predeposits are detected. -Each member must generate an EOA address to sign messages with their private key. The addresses of the committee members will be added to the smart contract. +Each committee member (guardian) is a contract that supports ERC-1271 `isValidSignature()`. Under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide), the guardian is the member's [`DelegationContract`](/contracts/delegation-contract), and the member's hot key is its delegate. The `DepositSecurityModule` (version 5) verifies every guardian signature through ERC-1271, so a signature is valid only while the signing key is the active delegate of the guardian contract. A guardian can also call `pauseDeposits` and `unvetSigningKeys` directly, without a signature: under EDF the delegate does this through `DelegationContract.execute()`. To make a deposit, we propose to collect a quorum of 4/6 of the signatures of the committee members. Members of the committee can collude with node operators and steal money by signing bad data that contains malicious predeposits. To mitigate this, we propose allowing a single committee member to stop deposits and also enforce space deposits in time (e.g., no more than 150 deposits with 25 blocks in between them) to provide the single honest participant the ability to stop further deposits even if the supermajority colludes. @@ -18,6 +18,19 @@ To prevent a replay attack, the guardians sign the block number when malicious p Values of the parameters `maxDepositsPerBlock` and `minDepositBlockDistance` are controlled by Lido DAO and must be harmonized with `appearedEthAmountPerDayLimit` of [`OracleReportSanityChecker`](/contracts/oracle-report-sanity-checker). These parameters are set in the StakingRouter contract independently for each module. +## Guardian signatures + +Every signed message carries the guardian address, and the signature is passed together with it: + +```solidity +struct GuardianSignature { + address guardian; + bytes signature; +} +``` + +The contract checks that `guardian` is a committee member and that `guardian.isValidSignature(msgHash, signature)` returns the ERC-1271 magic value. The message hash includes the guardian address, so a signature made for one guardian contract cannot be replayed for another. + ## View Methods ### getOwner() @@ -223,7 +236,8 @@ Reverts if any of the following is true: - `msg.sender` is not the owner; - `addr` is zero address; -- `addr` is already a guardian. +- `addr` is already a guardian; +- `addr` does not report ERC-1271 support through ERC-165 `supportsInterface()`. ::: #### Parameters @@ -246,7 +260,8 @@ Reverts if any of the following is true: - `msg.sender` is not the owner; - any of the `addresses` is zero address; -- any of the `addresses` is already a guardian. +- any of the `addresses` is already a guardian; +- any of the `addresses` does not report ERC-1271 support through ERC-165 `supportsInterface()`. ::: #### Parameters @@ -282,15 +297,15 @@ Reverts if any of the following is true: Pauses deposits if both conditions are satisfied (reverts otherwise): -1. The function is called by a guardian OR `sig` is a valid signature by a guardian - of the data defined below. +1. The function is called by a guardian (then `sig` is ignored) OR `sig.guardian` is a guardian + and `sig.signature` is its valid signature of the data defined below. 2. `block.number - blockNumber <= pauseIntentValidityPeriodBlocks` The signature, if present, must be produced for keccak256 hash of the following message (each component taking 32 bytes): -| PAUSE_MESSAGE_PREFIX | blockNumber | +| PAUSE_MESSAGE_PREFIX | guardian | blockNumber | Does nothing if deposits are already paused. In case of an emergency, the function `pauseDeposits` is supposed to be called @@ -298,15 +313,15 @@ by all guardians. Thus, only the first call will do the actual change. So the other calls would be OK operations from the point of view of the protocol logic. ```solidity -function pauseDeposits(uint256 blockNumber, Signature memory sig) external; +function pauseDeposits(uint256 blockNumber, GuardianSignature calldata sig) external; ``` #### Parameters -| Name | Type | Description | -| ------------- | ----------- | ------------------------------------------------------------------------------------------------ | -| `blockNumber` | `uint256` | Block number with malicious predeposits have been observed by the guardian | -| `sig` | `Signature` | Short ECDSA guardian signature as defined in [EIP-2098](https://eips.ethereum.org/EIPS/eip-2098) | +| Name | Type | Description | +| ------------- | ------------------- | -------------------------------------------------------------------------- | +| `blockNumber` | `uint256` | Block number with malicious predeposits have been observed by the guardian | +| `sig` | `GuardianSignature` | Guardian address and its signature (see [Guardian signatures](#guardian-signatures)) | ### unpauseDeposits() @@ -336,7 +351,7 @@ Reverts if any of the following is true: 4. min deposit distance is not passed; 5. `blockHash` is zero or not equal to `blockhash(blockNumber)`; 6. deposits are paused; -7. an invalid or non-guardian signature received; +7. `sig.guardian` is not a guardian, or `sig.signature` is not its valid signature; 8. signatures are not sorted in ascending order by the guardian address. 9. any downstream contract call reverts. See `StakingRouter.deposit` for details. ::: @@ -344,7 +359,7 @@ Reverts if any of the following is true: Signatures must be sorted in ascending order by the address of the guardian. Each signature must be produced for the keccak256 hash of the following message (each component taking 32 bytes): -| ATTEST_MESSAGE_PREFIX | blockNumber | blockHash | depositRoot | stakingModuleId | nonce | +| ATTEST_MESSAGE_PREFIX | guardian | blockNumber | blockHash | depositRoot | stakingModuleId | nonce | ```solidity function depositBufferedEther( @@ -353,7 +368,7 @@ function depositBufferedEther( bytes32 depositRoot, uint256 stakingModuleId, uint256 nonce, - Signature[] calldata sortedGuardianSignatures + GuardianSignature[] calldata sortedGuardianSignatures ) external; ``` @@ -366,7 +381,7 @@ function depositBufferedEther( | `depositRoot` | `bytes32` | Deposit root of the Ethereum DepositContract | | `stakingModuleId` | `uint256` | Id of the staking module to deposit with | | `nonce` | `uint256` | Nonce of key operations of the staking module | -| `sortedGuardianSignatures` | `Signature[]` | Short ECDSA guardians signatures as defined in [EIP-2098](https://eips.ethereum.org/EIPS/eip-2098) | +| `sortedGuardianSignatures` | `GuardianSignature[]` | Guardian addresses and their signatures, sorted by guardian address (see [Guardian signatures](#guardian-signatures)) | ### unvetSigningKeys() @@ -379,12 +394,13 @@ Reverts if any of the following is true: 2. nodeOperatorIds is not packed with 8 bytes per id; 3. vettedSigningKeysCounts is not packed with 16 bytes per count; 4. the number of node operators is greater than maxOperatorsPerUnvetting; -5. the signature is invalid or the signer is not a guardian; +5. the caller is not a guardian, and `sig.guardian` is not a guardian or `sig.signature` is not its valid signature; 6. blockHash is zero or not equal to the blockhash(blockNumber). ::: -The signature, if present, must be produced for the keccak256 hash of the following message: -| UNVET_MESSAGE_PREFIX | blockNumber | blockHash | stakingModuleId | nonce | nodeOperatorIds | vettedSigningKeysCounts | +If the caller is a guardian, `sig` is ignored. Otherwise the signature must be produced for the keccak256 hash of the following message: + +| UNVET_MESSAGE_PREFIX | guardian | blockNumber | blockHash | stakingModuleId | nonce | nodeOperatorIds | vettedSigningKeysCounts | ```solidity function unvetSigningKeys( @@ -394,7 +410,7 @@ function unvetSigningKeys( uint256 nonce, bytes calldata nodeOperatorIds, bytes calldata vettedSigningKeysCounts, - Signature calldata sig + GuardianSignature calldata sig ) external; ``` @@ -408,4 +424,4 @@ function unvetSigningKeys( | `nonce` | `uint256` | Nonce of key operations of the staking module | | `nodeOperatorIds` | `bytes` | The list of node operator IDs packed with 8 bytes per id | | `vettedSigningKeysCounts` | `bytes` | The list of vetted signing keys counts packed with 16 bytes per count | -| `sig` | `Signature` | Short ECDSA guardians signatures as defined in [EIP-2098](https://eips.ethereum.org/EIPS/eip-2098) | +| `sig` | `GuardianSignature` | Guardian address and its signature (see [Guardian signatures](#guardian-signatures)) | diff --git a/docs/guides/curated-module/exits/tooling-setup.md b/docs/guides/curated-module/exits/tooling-setup.md index 5e48ad65b..8bdb8bbf4 100644 --- a/docs/guides/curated-module/exits/tooling-setup.md +++ b/docs/guides/curated-module/exits/tooling-setup.md @@ -40,15 +40,15 @@ Currently, it has only one module ([NodeOperatorsRegistry](https://github.com/li ### Oracle Allowlist -After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, `getMembers()` on `HashConsensus` returns `DelegationContract` addresses. `ORACLE_ADDRESSES_ALLOWLIST` must contain the members' **delegate EOAs** instead. See [ORACLE_ADDRESSES_ALLOWLIST](/guides/validator-ejector-guide#oracle_addresses_allowlist) in the Ejector guide. - -Before the vote, the oracle members are retrievable from the HashConsensus (for the Validator Exit Bus Oracle) contract on-chain, directly from the contract using Etherscan. +The oracle members are retrievable from the HashConsensus (for the Validator Exit Bus Oracle) contract on-chain, directly from the contract using Etherscan. | network | Contract Call | | -------- | ------------- | | Mainnet | [getMembers()](https://etherscan.io/address/0x7FaDB6358950c5fAA66Cb5EB8eE5147De3df355a#readContract#F16) | | Hoodi | [getMembers()](https://hoodi.etherscan.io/address/0x30308CD8844fb2DB3ec4D056F1d475a802DCA07c#readContract#F16) | +Under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide), `getMembers()` returns `DelegationContract` addresses. `ORACLE_ADDRESSES_ALLOWLIST` must contain the members' **delegate EOAs** instead. See [ORACLE_ADDRESSES_ALLOWLIST](/guides/validator-ejector-guide#oracle_addresses_allowlist) in the Ejector guide. + ## Example Infra Setup Lido DevOps team prepared an easy way to get the recommended tooling and its dependencies up and running using [Ansible](https://github.com/ansible/ansible). This is a great way to get familiar with the new tooling. This is an example implementation, and still requires security and hardening by the NO; it can be found on [GitHub](https://github.com/lidofinance/node-operators-setup). diff --git a/docs/guides/deposit-security-manual.md b/docs/guides/deposit-security-manual.md index b853cfad7..05e652cb3 100644 --- a/docs/guides/deposit-security-manual.md +++ b/docs/guides/deposit-security-manual.md @@ -30,11 +30,11 @@ To make a deposit, we propose to collect a quorum of 4/6 of the signatures of th The committee consists of five node operators and the Lido dev team. The current list of guardians and their addresses is published on the [Lido Council Daemon](/holders/lido-council-daemon#mainnet-members) page. In the future, we want to bring as many node operators as possible into the mix, so the expectation will be that while the 6 guardians start the rest of the node operators can also participate via testnet and gradually get pulled into mainnet. -After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, a guardian seat is held by the member's `DelegationContract` under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide), not by an EOA. The member's hot key becomes the delegate of that contract and can be rotated or revoked by the member without a governance vote. +A guardian seat is held by the member's `DelegationContract` under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide), not by an EOA. The member's hot key becomes the delegate of that contract and can be rotated or revoked by the member without a governance vote. ### Members responsibilities -Each member must prepare a hot key to sign the deposit attestation message. In `DepositSecurityModule` v5 the signed message is `(prefix, guardian, blockNumber, blockHash, depositRoot, stakingModuleId, nonce)`, where `guardian` is the member's `DelegationContract` address. The pause and unvet messages include the guardian address in the same way. The address added to the smart contract is the member's `DelegationContract` (before the LIP-37 vote: the member's EOA), and the hot key is its delegate. The `DepositSecurityModule` verifies guardian signatures through ERC-1271, so a signature is valid only while the signing key is the active delegate of the contract. +Each member must prepare a hot key to sign the deposit attestation message. In `DepositSecurityModule` v5 the signed message is `(prefix, guardian, blockNumber, blockHash, depositRoot, stakingModuleId, nonce)`, where `guardian` is the member's `DelegationContract` address. The pause and unvet messages include the guardian address in the same way. The address added to the smart contract is the member's `DelegationContract`, and the hot key is its delegate. The `DepositSecurityModule` verifies guardian signatures through ERC-1271, so a signature is valid only while the signing key is the active delegate of the contract. `pauseDeposits` and `unvetSigningKeys` can also be called directly by a guardian; then the signature argument is ignored. Under EDF the delegate calls `execute()` on its `DelegationContract`, so the contract is the sender. The council daemon uses this path to pause deposits and unvet keys, and also broadcasts the signed message so that anyone can relay it. diff --git a/docs/guides/edf/edf-operator-guide.md b/docs/guides/edf/edf-operator-guide.md index 49a787f21..73b8f7a53 100644 --- a/docs/guides/edf/edf-operator-guide.md +++ b/docs/guides/edf/edf-operator-guide.md @@ -6,6 +6,7 @@ Setup instructions for operators (key holders) of a Lido Oracle seat or a DSM gu - [LIP-37: Execution Delegation Framework](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-37.md) — the proposal - [execution-delegation-framework](https://github.com/lidofinance/execution-delegation-framework) — the contracts, [architecture](https://github.com/lidofinance/execution-delegation-framework/blob/main/docs/architecture.md), [usage guide](https://github.com/lidofinance/execution-delegation-framework/blob/main/docs/usage.md) +- [DelegationFactory](/contracts/delegation-factory) and [DelegationContract](/contracts/delegation-contract) — the contract reference - [EDF Operator Key Custody Policy](./key-custody-policy-for-edf-operators.md) — the rules you must follow - [EDF Rotation and Incidents](./edf-rotation-and-incidents.md) — what to do after the setup @@ -217,8 +218,7 @@ true yet, finish that step first. ## Minimum software versions -Run these versions or newer **before the vote**. Older releases stop working when the vote is -enacted. +Run these versions or newer. Older releases do not support EDF. | Component | Minimum version | | --- | --- | @@ -249,7 +249,7 @@ enacted. MEMBER_PRIV_KEY_2=0xnewdelegatekey # new - takes over after the vote ``` -2. **Fund the delegate EOA — required before the vote.** Send 50% of the current balance of your +2. **Fund the delegate EOA (required).** Send 50% of the current balance of your old member EOA to the new delegate EOA (the address returned by `getDelegate()`). Both keys must be able to pay for gas: the old one until the vote, the new one after it. @@ -321,7 +321,7 @@ unset or wrong — fix the config. | Variable | Value | | --- | --- | - | `DELEGATION_CONTRACT_ADDRESS` | Your `DelegationContract` address. An **empty value is not rejected at startup** while the DSM is on v4. Set it before the vote and check for `EDF preflight passed` in the logs (step 3.2). | + | `DELEGATION_CONTRACT_ADDRESS` | Your `DelegationContract` address. Required. On DSM v4 an **empty value is not rejected at startup**, so check for `EDF preflight passed` in the logs (step 3.2). | | `WALLET_PRIVATE_KEY` / `WALLET_PRIVATE_KEY_FILE` | **The old key** — your existing guardian EOA. Used while the DSM is on v4. | | `WALLET_PRIVATE_KEY_2` / `WALLET_PRIVATE_KEY_2_FILE` | **The new key** — the delegate of your `DelegationContract`. | @@ -331,7 +331,7 @@ unset or wrong — fix the config. WALLET_PRIVATE_KEY_2=0xnewdelegatekey # new - takes over at DSM v5 ``` -2. **Fund the delegate EOA — required before the vote.** Send 50% of the current balance of your +2. **Fund the delegate EOA (required).** Send 50% of the current balance of your old guardian EOA to the new delegate EOA (the address returned by `getDelegate()`). Both keys must be able to pay for gas: the old one until DSM v5, the new one after it. Do the same on the DataBus chain (Gnosis): the delegate EOA needs xDAI there to send Data Bus messages. @@ -350,23 +350,22 @@ Guardian execution mode: edf dsmVersion: 5 ``` -Before the vote (DSM v4) the daemon runs in `legacy-eoa` mode and checks the EDF config once at -startup: +On DSM v4 the daemon runs in `legacy-eoa` mode and checks the EDF config once at startup: - `EDF preflight passed` — the contract is found, not terminated, and its delegate matches one of the configured keys. - `EDF setup is not ready` with a `reason` field — fix the config. The daemon keeps running on DSM v4 anyway. -This is the only check before the vote. A daemon with a wrong EDF config does not crash when DSM -v5 is enacted: it logs `Guardian cycle processing error` every cycle and signs nothing. A restart +This is the only check on DSM v4. A daemon with a wrong EDF config does not crash when the DSM +switches to v5: it logs `Guardian cycle processing error` every cycle and signs nothing. A restart then exits with code 1 and one of the errors below. Errors you may hit, and what they mean: | Error | Meaning | | --- | --- | -| `DELEGATION_CONTRACT_ADDRESS is required for DSM version 5` | Variable not set, and the DSM is already on v5. | +| `DELEGATION_CONTRACT_ADDRESS is required for DSM version 5` | Variable not set, and the DSM is on v5. | | `No contract code at DELEGATION_CONTRACT_ADDRESS 0x…` | Wrong address, or wrong network. | | `DelegationContract 0x… is terminated` | Someone called `terminate()`. The seat is permanently dead. | | `DelegationContract 0x… has no active delegate` | The delegate was revoked, or never set. Expected right after an emergency revocation. | diff --git a/docs/guides/oracle-operator-manual.md b/docs/guides/oracle-operator-manual.md index dd2bd7a4b..f7e83e8c9 100644 --- a/docs/guides/oracle-operator-manual.md +++ b/docs/guides/oracle-operator-manual.md @@ -13,7 +13,7 @@ Due to the lack of native communication between these two networks, Lido employs 6. [**Optional**] Add alerts to Oracle's Prometheus metrics. 7. In case of mainnet, share your address and intention to join the Oracle set with the public. You need to publish it on Twitter and also write a message with a Twitter link under the Onboarding post on [the Research forum](https://research.lido.fi/). You need to publish it on Twitter and also write a message with a twitter link under the Onboarding post on [the Research forum](https://research.lido.fi/). 8. Propose your Oracle's Ethereum address to the Lido team to vote on adding your address to the Oracle Members. -9. Before the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, deploy your `DelegationContract` and configure the daemon as described in the [EDF Operator Guide](/guides/edf/edf-operator-guide). The vote moves the seat from your EOA to that contract. An unprepared seat produces no reports after the vote until the setup is done. +9. Under the Execution Delegation Framework (EDF), the seat is held by a `DelegationContract`, not by an EOA. Deploy it and configure the daemon as described in the [EDF Operator Guide](/guides/edf/edf-operator-guide) before you propose the address in step 8. ## Intro diff --git a/docs/guides/validator-ejector-guide.md b/docs/guides/validator-ejector-guide.md index fcb0b8ade..6cb544c75 100644 --- a/docs/guides/validator-ejector-guide.md +++ b/docs/guides/validator-ejector-guide.md @@ -196,13 +196,11 @@ On the endpoint, JSON will be POSTed with the following structure: JSON array of Lido Oracle addresses, from which only report transactions will be accepted. -After the [LIP-37](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) vote, every oracle seat is held by a `DelegationContract` under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). The member's **delegate EOA** sends each report through `DelegationContract.execute(address,bytes)`, and the Ejector verifies the report by recovering the signer of that transaction. So: +Every oracle seat is held by a `DelegationContract` under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). The member's **delegate EOA** sends each report through `DelegationContract.execute(address,bytes)`, and the Ejector verifies the report by recovering the signer of that transaction. So: -- The allowlist must contain the **delegate EOAs** of the oracle members, not the `DelegationContract` addresses that `getMembers()` on `HashConsensus` returns after the vote. Take each `DelegationContract` from the [Lido Oracle members page](/holders/lido-oracle) and read its `getDelegate()` on Etherscan, or use the delegate addresses that oracle operators publish in the LIP-37 forum thread and in rotation announcements (see [EDF Rotation and Incidents](/guides/edf/edf-rotation-and-incidents)). -- Keep the previous member EOAs in the allowlist until their reports leave the lookback window: about 7 days at the default `BLOCKS_PRELOAD` of 50000 blocks. The same applies to every later delegate rotation. -- Only an Ejector build that unwraps `execute(address,bytes)` can verify reports after the vote. Today that is the [2.2.0 pre-release](https://github.com/lidofinance/validator-ejector/releases/tag/2.2.0); there is no stable release with this support yet. The 2.1.0 release rejects every report sent through a `DelegationContract`. - -Before the vote, you can get the list from Etherscan on [Hoodi](https://hoodi.etherscan.io/address/0x30308CD8844fb2DB3ec4D056F1d475a802DCA07c#readContract#F16) or [Mainnet](https://etherscan.io/address/0x7FaDB6358950c5fAA66Cb5EB8eE5147De3df355a#readContract#F16) (`HashConsensus` of the Validators Exit Bus Oracle). +- The allowlist must contain the **delegate EOAs** of the oracle members, not the `DelegationContract` addresses that `getMembers()` on `HashConsensus` returns ([Hoodi](https://hoodi.etherscan.io/address/0x30308CD8844fb2DB3ec4D056F1d475a802DCA07c#readContract#F16), [Mainnet](https://etherscan.io/address/0x7FaDB6358950c5fAA66Cb5EB8eE5147De3df355a#readContract#F16)). Take each `DelegationContract` from the [Lido Oracle members page](/holders/lido-oracle) and read its `getDelegate()` on Etherscan, or use the delegate addresses that oracle operators publish in the [LIP-37 forum thread](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746) and in rotation announcements (see [EDF Rotation and Incidents](/guides/edf/edf-rotation-and-incidents)). +- After a delegate rotation, keep the previous delegate in the allowlist until its reports leave the lookback window: about 7 days at the default `BLOCKS_PRELOAD` of 50000 blocks. +- Use validator-ejector [2.2.0](https://github.com/lidofinance/validator-ejector/releases/tag/2.2.0) or newer: it unwraps `execute(address,bytes)`. Older releases reject every report sent through a `DelegationContract`. Format: diff --git a/sidebars.js b/sidebars.js index 056759bf2..eb3afc57e 100644 --- a/sidebars.js +++ b/sidebars.js @@ -148,6 +148,8 @@ module.exports = { 'contracts/wsteth', 'contracts/wsteth-staker', 'contracts/deposit-security-module', + 'contracts/delegation-factory', + 'contracts/delegation-contract', 'contracts/data-bus', 'contracts/burner', 'contracts/lido-execution-layer-rewards-vault', From ea6285c5fb17bdb717cbef042ea162e283ef71e0 Mon Sep 17 00:00:00 2001 From: chasingrainbows Date: Mon, 14 Sep 2026 19:08:23 +0300 Subject: [PATCH 4/8] fix: rewrite contract pages --- docs/contracts/delegation-contract.md | 74 ++++++++++++--------------- docs/contracts/delegation-factory.md | 18 +++---- 2 files changed, 41 insertions(+), 51 deletions(-) diff --git a/docs/contracts/delegation-contract.md b/docs/contracts/delegation-contract.md index 2e104076c..184621e7e 100644 --- a/docs/contracts/delegation-contract.md +++ b/docs/contracts/delegation-contract.md @@ -3,24 +3,22 @@ - [Source Code](https://github.com/lidofinance/execution-delegation-framework/blob/main/src/DelegationContract.sol) - [Audit](https://github.com/lidofinance/audits/blob/main/Composable%20Security%20Lido%20EDF%20On-chain%20Audit%20Report%2008-2026.pdf) -`DelegationContract` is the per-seat contract of the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). It has one owner and one active delegate. The owner is a cold multisig that nominates and revokes the delegate. The delegate is a hot key that does the day-to-day work: it sends transactions through `execute()` (push integration) or signs messages that the protocol verifies through ERC-1271 `isValidSignature()` (pull integration). +`DelegationContract` is the per-seat contract of the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). It has one owner and one active delegate. The owner is a cold multisig that nominates and revokes the delegate. The delegate is a hot key that does the daily work: it sends transactions through `execute()` or signs messages that the protocol checks through ERC-1271 `isValidSignature()`. -A `DelegationContract` holds a seat in the protocol instead of an EOA: it is a member of `HashConsensus` for the Lido Oracle, a guardian of the [`DepositSecurityModule`](/contracts/deposit-security-module), or the depositor for the depositor bot. The owner can never call `execute()` or sign on behalf of the contract. +The contract holds a protocol seat instead of an EOA: a `HashConsensus` member for the Lido Oracle, a [`DepositSecurityModule`](/contracts/deposit-security-module) guardian, or the depositor. The owner cannot call `execute()` or sign for the contract. -Key properties: +- Owner and cooldown are set in the constructor and cannot be changed. To change the owner, deploy a new contract from the [`DelegationFactory`](/contracts/delegation-factory) and move the seat by a governance vote. +- A nominated delegate becomes active only after the cooldown. The current delegate stays active until then, so a hostile nomination is visible before it takes effect. +- Revocation and termination are immediate. Termination is permanent. +- The contract cannot receive ETH. If the target sends ETH back, `execute()` reverts. -- **Owner and cooldown are immutable.** Both are set in the constructor. Replacing the owner means deploying a new contract from the [`DelegationFactory`](/contracts/delegation-factory) and passing a governance vote to reassign the seat. -- **Nomination is cooldown-gated.** A new delegate becomes effective only `cooldown` seconds after `nominateDelegate()`. The current delegate stays effective until then, so a hostile nomination by a compromised owner is visible before it takes effect. -- **Revocation and termination are immediate.** `revokeDelegate()` drops the current and pending delegate at once. `terminate()` disables the contract forever. -- **The contract holds no ETH.** It has no `receive()` or `fallback()`. A target that tries to send ETH back to it makes `execute()` revert. - -Contracts deployed from the official factory are listed on the [deployed contracts](/deployed-contracts/#execution-delegation-framework) page and on the [Lido Oracle](/holders/lido-oracle) and [Lido Council Daemon](/holders/lido-council-daemon) member pages. +Deployed contracts are listed on the [deployed contracts](/deployed-contracts/#execution-delegation-framework), [Lido Oracle](/holders/lido-oracle) and [Lido Council Daemon](/holders/lido-council-daemon) pages. ## View Methods ### owner() -Returns the owner address. This is the ERC-5313 ownership view, so explorers and multisig UIs recognize the controlling party. +Returns the owner address (ERC-5313). ```solidity function owner() external view returns (address); @@ -28,9 +26,7 @@ function owner() external view returns (address); ### getDelegate() -Returns the currently effective delegate, or zero address if there is none. - -A nominated delegate is returned only after its cooldown has elapsed. Before that, the previous delegate is returned. Zero address is returned when no delegate was ever nominated, after `revokeDelegate()`, and after `terminate()`. +Returns the active delegate, or zero address if there is none: never nominated, revoked, or terminated. A nominated delegate is returned only after its cooldown has passed. ```solidity function getDelegate() external view returns (address); @@ -38,9 +34,7 @@ function getDelegate() external view returns (address); ### getPendingDelegate() -Returns the pending delegate and the timestamp when it becomes effective, or `(address(0), 0)` if there is no pending nomination. - -The result is time-dependent. Once `block.timestamp` reaches `activeFrom`, the pending delegate becomes the effective one: `getDelegate()` starts returning it and this function returns `(address(0), 0)`. No transaction is needed for the transition. +Returns the pending delegate and the timestamp when it becomes active, or `(address(0), 0)` if there is no pending nomination. After `activeFrom` the pending delegate becomes the active one without any transaction. ```solidity function getPendingDelegate() external view returns (address delegate, uint256 activeFrom); @@ -48,7 +42,7 @@ function getPendingDelegate() external view returns (address delegate, uint256 a ### getCooldown() -Returns the cooldown in seconds between `nominateDelegate()` and the moment the new delegate becomes effective. Set in the constructor and cannot be changed. +Returns the cooldown in seconds between a nomination and the moment the new delegate becomes active. ```solidity function getCooldown() external view returns (uint256); @@ -56,7 +50,7 @@ function getCooldown() external view returns (uint256); ### isTerminated() -Returns whether the contract has been terminated. +Returns whether the contract is terminated. ```solidity function isTerminated() external view returns (bool); @@ -64,16 +58,14 @@ function isTerminated() external view returns (bool); ### isValidSignature() -ERC-1271 signature validation. Returns the magic value `0x1626ba7e` if `signature` is a valid ECDSA signature over `hash` by the current effective delegate. Returns `0xffffffff` otherwise. - -The delegate is resolved through `getDelegate()`, so validation fails when there is no effective delegate: never nominated, revoked, or terminated. +ERC-1271 check. Returns `0x1626ba7e` if `signature` is a valid ECDSA signature of `hash` by the active delegate, and `0xffffffff` otherwise. Always fails when there is no active delegate. ```solidity function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4 magicValue); ``` :::note -Unlike a raw ECDSA check, the result depends on the contract state. A signature that is valid at one block can become invalid at the next one, for example after the delegate is rotated or revoked, or the contract is terminated. +The result depends on the contract state. A signature that is valid now becomes invalid after the delegate is rotated or revoked, or the contract is terminated. ::: #### Parameters @@ -81,11 +73,11 @@ Unlike a raw ECDSA check, the result depends on the contract state. A signature | Name | Type | Description | | ----------- | --------- | ---------------------------- | | `hash` | `bytes32` | Message hash that was signed | -| `signature` | `bytes` | ECDSA signature bytes | +| `signature` | `bytes` | ECDSA signature | ### supportsInterface() -ERC-165 interface detection. Returns `true` for the ERC-165, ERC-1271, ERC-5313 and `IDelegationContract` interface ids. +ERC-165 check. Returns `true` for the ERC-165, ERC-1271, ERC-5313 and `IDelegationContract` interface ids. ```solidity function supportsInterface(bytes4 interfaceId) external pure returns (bool); @@ -95,9 +87,7 @@ function supportsInterface(bytes4 interfaceId) external pure returns (bool); ### nominateDelegate() -Nominates a new delegate. The new delegate becomes effective after `getCooldown()` seconds (immediately if the cooldown is 0). The current delegate stays effective during the cooldown and is dropped only when the new one activates. - -A second nomination before the cooldown elapses replaces the pending delegate and restarts the cooldown. To drop a delegate immediately, use `revokeDelegate()`. +Nominates a new delegate. It becomes active after `getCooldown()` seconds. The current delegate stays active until then. A new nomination during the cooldown replaces the pending delegate and restarts the cooldown. ```solidity function nominateDelegate(address delegate) external; @@ -110,19 +100,19 @@ Reverts if any of the following is true: - the contract is terminated; - `delegate` is zero address; - `delegate` is the owner; -- `delegate` is the current effective delegate; +- `delegate` is the active delegate; - `delegate` is the pending delegate. ::: #### Parameters -| Name | Type | Description | -| ---------- | --------- | -------------------------------- | -| `delegate` | `address` | Address of the incoming delegate | +| Name | Type | Description | +| ---------- | --------- | -------------------- | +| `delegate` | `address` | New delegate address | ### revokeDelegate() -Immediately removes the current and the pending delegate. After this call `getDelegate()` returns zero address until a new delegate is nominated and its cooldown elapses. +Immediately removes the active and the pending delegate. ```solidity function revokeDelegate() external; @@ -137,7 +127,7 @@ Reverts if any of the following is true: ### terminate() -Terminates the contract. This permanently disables `execute()`, `isValidSignature()` and `nominateDelegate()`, and clears the current and pending delegate. Intended for the case when the owner itself is suspected to be compromised. Termination is irreversible: the seat has to be reassigned to a new contract through a governance vote. +Terminates the contract: disables `execute()`, `isValidSignature()` and `nominateDelegate()` forever and removes the active and pending delegate. Intended for the case when the owner itself may be compromised. The seat then has to be moved to a new contract by a governance vote. ```solidity function terminate() external; @@ -152,7 +142,7 @@ Reverts if any of the following is true: ### execute() -Executes a call to `target` on behalf of the contract. The target sees the `DelegationContract` as `msg.sender`. `msg.value` is forwarded to the target. The revert reason of the target call is bubbled up. +Calls `target` with `data` on behalf of the contract. The target sees the contract as `msg.sender`. `msg.value` is forwarded. If the call fails, the revert reason is passed through. ```solidity function execute(address target, bytes calldata data) external payable returns (bytes memory result); @@ -161,7 +151,7 @@ function execute(address target, bytes calldata data) external payable returns ( :::note Reverts if any of the following is true: -- `msg.sender` is not the current effective delegate; +- `msg.sender` is not the active delegate; - the contract is terminated; - `target` is zero address; - `target` is the contract itself; @@ -177,15 +167,15 @@ Reverts if any of the following is true: #### Returns -| Name | Type | Description | -| -------- | ------- | ------------------------------ | -| `result` | `bytes` | Return data of the target call | +| Name | Type | Description | +| -------- | ------- | --------------------------- | +| `result` | `bytes` | Return data of the call | ## Events ### InitialDelegateSet() -Emitted in the constructor when the contract is deployed with a non-zero initial delegate. +Emitted at deployment when the initial delegate is not zero address. ```solidity event InitialDelegateSet(address indexed newDelegate); @@ -193,7 +183,7 @@ event InitialDelegateSet(address indexed newDelegate); ### DelegateNominated() -Emitted on `nominateDelegate()`. `activeFrom` is the timestamp when the new delegate becomes effective. +Emitted on `nominateDelegate()`. `activeFrom` is the timestamp when the new delegate becomes active. ```solidity event DelegateNominated(address indexed newDelegate, uint256 activeFrom); @@ -201,7 +191,7 @@ event DelegateNominated(address indexed newDelegate, uint256 activeFrom); ### DelegateRevoked() -Emitted on `revokeDelegate()`. `revokedDelegate` is the delegate that was effective at the moment of the call, or zero address if there was none. +Emitted on `revokeDelegate()`. `revokedDelegate` is the delegate that was active, or zero address if there was none. ```solidity event DelegateRevoked(address indexed revokedDelegate); @@ -216,5 +206,5 @@ event Terminated(); ``` :::note -`execute()` emits no event. To monitor delegate activity, use internal transactions of the contract (trace-level monitoring). See the [monitoring section](/guides/edf/edf-operator-guide#14-set-up-your-own-monitoring-and-alerts) of the operator guide. +`execute()` emits no event. Monitor delegate activity through internal transactions of the contract, see the [operator guide](/guides/edf/edf-operator-guide#14-set-up-your-own-monitoring-and-alerts). ::: diff --git a/docs/contracts/delegation-factory.md b/docs/contracts/delegation-factory.md index 6e6ad3f5d..1e3874b37 100644 --- a/docs/contracts/delegation-factory.md +++ b/docs/contracts/delegation-factory.md @@ -3,15 +3,15 @@ - [Source Code](https://github.com/lidofinance/execution-delegation-framework/blob/main/src/DelegationFactory.sol) - [Deployed Contract](https://etherscan.io/address/0xD990770eB2B4b6062EDdB06892fF179C693b46e6) -`DelegationFactory` deploys [`DelegationContract`](/contracts/delegation-contract) instances for the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). It is a stateless, permissionless factory: anyone can call `deploy()`. All constructor parameters of the new contract are fixed at deployment and cannot be changed later. +`DelegationFactory` deploys [`DelegationContract`](/contracts/delegation-contract) instances for the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). Anyone can call `deploy()`. The parameters of the new contract cannot be changed after deployment. -Only contracts deployed from the official factory are accepted for Lido Oracle and Deposit Security Committee seats. The factory address for each network is listed on the [deployed contracts](/deployed-contracts/#execution-delegation-framework) page. +Only contracts deployed from this factory are accepted for Lido Oracle and Deposit Security Committee seats. Factory addresses per network are listed on the [deployed contracts](/deployed-contracts/#execution-delegation-framework) page. ## Methods ### deploy() -Deploys a new `DelegationContract` and emits `DelegationContractDeployed`. +Deploys a new `DelegationContract`. ```solidity function deploy(address owner, address delegate, uint256 cooldown) external returns (address instance); @@ -26,11 +26,11 @@ Reverts if any of the following is true: #### Parameters -| Name | Type | Description | -| ---------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `owner` | `address` | Owner of the new contract. Fixed for the lifetime of the contract. | -| `delegate` | `address` | Initial delegate, effective immediately. Pass zero address to deploy without a delegate. | -| `cooldown` | `uint256` | Seconds a nominated delegate waits before it becomes effective. Fixed for the lifetime of the contract. The [custody policy](/guides/edf/key-custody-policy-for-edf-operators#3-owner-key-custody) requires at least 172800 (48 hours). | +| Name | Type | Description | +| ---------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `owner` | `address` | Owner of the new contract. Cannot be changed later. | +| `delegate` | `address` | Initial delegate, active immediately. Zero address deploys the contract without a delegate. | +| `cooldown` | `uint256` | Seconds between a nomination and the moment the new delegate becomes active. Cannot be changed later. The [custody policy](/guides/edf/key-custody-policy-for-edf-operators#3-owner-key-custody) requires at least 172800 (48 hours). | #### Returns @@ -42,7 +42,7 @@ Reverts if any of the following is true: ### DelegationContractDeployed() -Emitted for each `DelegationContract` deployed by the factory. +Emitted for each deployed `DelegationContract`. ```solidity event DelegationContractDeployed( From 30382340a9aa4e313062ae13c63546c5fcf11f2e Mon Sep 17 00:00:00 2001 From: chasingrainbows Date: Mon, 14 Sep 2026 19:27:06 +0300 Subject: [PATCH 5/8] fix: keep the DSM page on v4, mark EDF deployments as proposed --- docs/contracts/delegation-contract.md | 3 +- docs/contracts/delegation-factory.md | 2 +- docs/contracts/deposit-security-module.md | 61 +++++++++-------------- 3 files changed, 25 insertions(+), 41 deletions(-) diff --git a/docs/contracts/delegation-contract.md b/docs/contracts/delegation-contract.md index 184621e7e..6ccb1df86 100644 --- a/docs/contracts/delegation-contract.md +++ b/docs/contracts/delegation-contract.md @@ -2,6 +2,7 @@ - [Source Code](https://github.com/lidofinance/execution-delegation-framework/blob/main/src/DelegationContract.sol) - [Audit](https://github.com/lidofinance/audits/blob/main/Composable%20Security%20Lido%20EDF%20On-chain%20Audit%20Report%2008-2026.pdf) +- \[[proposed](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746/25)\] Deployed instances: [Lido Oracle](/holders/lido-oracle) and [Lido Council Daemon](/holders/lido-council-daemon) members `DelegationContract` is the per-seat contract of the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). It has one owner and one active delegate. The owner is a cold multisig that nominates and revokes the delegate. The delegate is a hot key that does the daily work: it sends transactions through `execute()` or signs messages that the protocol checks through ERC-1271 `isValidSignature()`. @@ -12,8 +13,6 @@ The contract holds a protocol seat instead of an EOA: a `HashConsensus` member f - Revocation and termination are immediate. Termination is permanent. - The contract cannot receive ETH. If the target sends ETH back, `execute()` reverts. -Deployed contracts are listed on the [deployed contracts](/deployed-contracts/#execution-delegation-framework), [Lido Oracle](/holders/lido-oracle) and [Lido Council Daemon](/holders/lido-council-daemon) pages. - ## View Methods ### owner() diff --git a/docs/contracts/delegation-factory.md b/docs/contracts/delegation-factory.md index 1e3874b37..001edb153 100644 --- a/docs/contracts/delegation-factory.md +++ b/docs/contracts/delegation-factory.md @@ -1,7 +1,7 @@ # DelegationFactory - [Source Code](https://github.com/lidofinance/execution-delegation-framework/blob/main/src/DelegationFactory.sol) -- [Deployed Contract](https://etherscan.io/address/0xD990770eB2B4b6062EDdB06892fF179C693b46e6) +- \[[proposed](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746/10)\] [Deployed Contract](https://etherscan.io/address/0xD990770eB2B4b6062EDdB06892fF179C693b46e6) `DelegationFactory` deploys [`DelegationContract`](/contracts/delegation-contract) instances for the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). Anyone can call `deploy()`. The parameters of the new contract cannot be changed after deployment. diff --git a/docs/contracts/deposit-security-module.md b/docs/contracts/deposit-security-module.md index d569d66d4..dbe6ca83c 100644 --- a/docs/contracts/deposit-security-module.md +++ b/docs/contracts/deposit-security-module.md @@ -1,14 +1,15 @@ # DepositSecurityModule -- [Source Code](https://github.com/lidofinance/core/blob/06126c70ceb179b1feff29718a6a08cf381c7500/contracts/0.8.9/DepositSecurityModule.sol) -- [Deployed Contract](https://etherscan.io/address/0x39BB5d491e98A44D1bfe8047A737a81E296a63E0) +- [Source Code](https://github.com/lidofinance/core/blob/v4.0.0/contracts/0.8.9/DepositSecurityModule.sol) +- [Deployed Contract](https://etherscan.io/address/0xF573E9E3de1f86B085417ab294f56E7920B4e9Be) +- \[[proposed](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746/25)\] [Deployed Contract (DSM v5)](https://etherscan.io/address/0x39BB5d491e98A44D1bfe8047A737a81E296a63E0) Due to front-running vulnerability, Lido contributors [proposed](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-5.md) to establish the Deposit Security Committee dedicated to ensuring the safety of deposits on the Beacon chain: - monitoring the history of deposits and the set of Lido keys available for the deposit, signing and disseminating messages allowing deposits; - signing the special message allowing anyone to pause deposits once the malicious Node Operator predeposits are detected. -Each committee member (guardian) is a contract that supports ERC-1271 `isValidSignature()`. Under the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide), the guardian is the member's [`DelegationContract`](/contracts/delegation-contract), and the member's hot key is its delegate. The `DepositSecurityModule` (version 5) verifies every guardian signature through ERC-1271, so a signature is valid only while the signing key is the active delegate of the guardian contract. A guardian can also call `pauseDeposits` and `unvetSigningKeys` directly, without a signature: under EDF the delegate does this through `DelegationContract.execute()`. +Each member must generate an EOA address to sign messages with their private key. The addresses of the committee members will be added to the smart contract. To make a deposit, we propose to collect a quorum of 4/6 of the signatures of the committee members. Members of the committee can collude with node operators and steal money by signing bad data that contains malicious predeposits. To mitigate this, we propose allowing a single committee member to stop deposits and also enforce space deposits in time (e.g., no more than 150 deposits with 25 blocks in between them) to provide the single honest participant the ability to stop further deposits even if the supermajority colludes. @@ -18,19 +19,6 @@ To prevent a replay attack, the guardians sign the block number when malicious p Values of the parameters `maxDepositsPerBlock` and `minDepositBlockDistance` are controlled by Lido DAO and must be harmonized with `appearedEthAmountPerDayLimit` of [`OracleReportSanityChecker`](/contracts/oracle-report-sanity-checker). These parameters are set in the StakingRouter contract independently for each module. -## Guardian signatures - -Every signed message carries the guardian address, and the signature is passed together with it: - -```solidity -struct GuardianSignature { - address guardian; - bytes signature; -} -``` - -The contract checks that `guardian` is a committee member and that `guardian.isValidSignature(msgHash, signature)` returns the ERC-1271 magic value. The message hash includes the guardian address, so a signature made for one guardian contract cannot be replayed for another. - ## View Methods ### getOwner() @@ -236,8 +224,7 @@ Reverts if any of the following is true: - `msg.sender` is not the owner; - `addr` is zero address; -- `addr` is already a guardian; -- `addr` does not report ERC-1271 support through ERC-165 `supportsInterface()`. +- `addr` is already a guardian. ::: #### Parameters @@ -260,8 +247,7 @@ Reverts if any of the following is true: - `msg.sender` is not the owner; - any of the `addresses` is zero address; -- any of the `addresses` is already a guardian; -- any of the `addresses` does not report ERC-1271 support through ERC-165 `supportsInterface()`. +- any of the `addresses` is already a guardian. ::: #### Parameters @@ -297,15 +283,15 @@ Reverts if any of the following is true: Pauses deposits if both conditions are satisfied (reverts otherwise): -1. The function is called by a guardian (then `sig` is ignored) OR `sig.guardian` is a guardian - and `sig.signature` is its valid signature of the data defined below. +1. The function is called by a guardian OR `sig` is a valid signature by a guardian + of the data defined below. 2. `block.number - blockNumber <= pauseIntentValidityPeriodBlocks` The signature, if present, must be produced for keccak256 hash of the following message (each component taking 32 bytes): -| PAUSE_MESSAGE_PREFIX | guardian | blockNumber | +| PAUSE_MESSAGE_PREFIX | blockNumber | Does nothing if deposits are already paused. In case of an emergency, the function `pauseDeposits` is supposed to be called @@ -313,15 +299,15 @@ by all guardians. Thus, only the first call will do the actual change. So the other calls would be OK operations from the point of view of the protocol logic. ```solidity -function pauseDeposits(uint256 blockNumber, GuardianSignature calldata sig) external; +function pauseDeposits(uint256 blockNumber, Signature memory sig) external; ``` #### Parameters -| Name | Type | Description | -| ------------- | ------------------- | -------------------------------------------------------------------------- | -| `blockNumber` | `uint256` | Block number with malicious predeposits have been observed by the guardian | -| `sig` | `GuardianSignature` | Guardian address and its signature (see [Guardian signatures](#guardian-signatures)) | +| Name | Type | Description | +| ------------- | ----------- | ------------------------------------------------------------------------------------------------ | +| `blockNumber` | `uint256` | Block number with malicious predeposits have been observed by the guardian | +| `sig` | `Signature` | Short ECDSA guardian signature as defined in [EIP-2098](https://eips.ethereum.org/EIPS/eip-2098) | ### unpauseDeposits() @@ -351,7 +337,7 @@ Reverts if any of the following is true: 4. min deposit distance is not passed; 5. `blockHash` is zero or not equal to `blockhash(blockNumber)`; 6. deposits are paused; -7. `sig.guardian` is not a guardian, or `sig.signature` is not its valid signature; +7. an invalid or non-guardian signature received; 8. signatures are not sorted in ascending order by the guardian address. 9. any downstream contract call reverts. See `StakingRouter.deposit` for details. ::: @@ -359,7 +345,7 @@ Reverts if any of the following is true: Signatures must be sorted in ascending order by the address of the guardian. Each signature must be produced for the keccak256 hash of the following message (each component taking 32 bytes): -| ATTEST_MESSAGE_PREFIX | guardian | blockNumber | blockHash | depositRoot | stakingModuleId | nonce | +| ATTEST_MESSAGE_PREFIX | blockNumber | blockHash | depositRoot | stakingModuleId | nonce | ```solidity function depositBufferedEther( @@ -368,7 +354,7 @@ function depositBufferedEther( bytes32 depositRoot, uint256 stakingModuleId, uint256 nonce, - GuardianSignature[] calldata sortedGuardianSignatures + Signature[] calldata sortedGuardianSignatures ) external; ``` @@ -381,7 +367,7 @@ function depositBufferedEther( | `depositRoot` | `bytes32` | Deposit root of the Ethereum DepositContract | | `stakingModuleId` | `uint256` | Id of the staking module to deposit with | | `nonce` | `uint256` | Nonce of key operations of the staking module | -| `sortedGuardianSignatures` | `GuardianSignature[]` | Guardian addresses and their signatures, sorted by guardian address (see [Guardian signatures](#guardian-signatures)) | +| `sortedGuardianSignatures` | `Signature[]` | Short ECDSA guardians signatures as defined in [EIP-2098](https://eips.ethereum.org/EIPS/eip-2098) | ### unvetSigningKeys() @@ -394,13 +380,12 @@ Reverts if any of the following is true: 2. nodeOperatorIds is not packed with 8 bytes per id; 3. vettedSigningKeysCounts is not packed with 16 bytes per count; 4. the number of node operators is greater than maxOperatorsPerUnvetting; -5. the caller is not a guardian, and `sig.guardian` is not a guardian or `sig.signature` is not its valid signature; +5. the signature is invalid or the signer is not a guardian; 6. blockHash is zero or not equal to the blockhash(blockNumber). ::: -If the caller is a guardian, `sig` is ignored. Otherwise the signature must be produced for the keccak256 hash of the following message: - -| UNVET_MESSAGE_PREFIX | guardian | blockNumber | blockHash | stakingModuleId | nonce | nodeOperatorIds | vettedSigningKeysCounts | +The signature, if present, must be produced for the keccak256 hash of the following message: +| UNVET_MESSAGE_PREFIX | blockNumber | blockHash | stakingModuleId | nonce | nodeOperatorIds | vettedSigningKeysCounts | ```solidity function unvetSigningKeys( @@ -410,7 +395,7 @@ function unvetSigningKeys( uint256 nonce, bytes calldata nodeOperatorIds, bytes calldata vettedSigningKeysCounts, - GuardianSignature calldata sig + Signature calldata sig ) external; ``` @@ -424,4 +409,4 @@ function unvetSigningKeys( | `nonce` | `uint256` | Nonce of key operations of the staking module | | `nodeOperatorIds` | `bytes` | The list of node operator IDs packed with 8 bytes per id | | `vettedSigningKeysCounts` | `bytes` | The list of vetted signing keys counts packed with 16 bytes per count | -| `sig` | `GuardianSignature` | Guardian address and its signature (see [Guardian signatures](#guardian-signatures)) | +| `sig` | `Signature` | Short ECDSA guardians signatures as defined in [EIP-2098](https://eips.ethereum.org/EIPS/eip-2098) | From 61687ad4c1a34579e4043da2c826e5458113e024 Mon Sep 17 00:00:00 2001 From: chasingrainbows <8927865+chasingrainbows@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:31:37 +0300 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/contracts/delegation-factory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contracts/delegation-factory.md b/docs/contracts/delegation-factory.md index 001edb153..440bd92fd 100644 --- a/docs/contracts/delegation-factory.md +++ b/docs/contracts/delegation-factory.md @@ -3,7 +3,7 @@ - [Source Code](https://github.com/lidofinance/execution-delegation-framework/blob/main/src/DelegationFactory.sol) - \[[proposed](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746/10)\] [Deployed Contract](https://etherscan.io/address/0xD990770eB2B4b6062EDdB06892fF179C693b46e6) -`DelegationFactory` deploys [`DelegationContract`](/contracts/delegation-contract) instances for the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). Anyone can call `deploy()`. The parameters of the new contract cannot be changed after deployment. +`DelegationFactory` deploys [`DelegationContract`](/contracts/delegation-contract) instances for the [Execution Delegation Framework (EDF)](/guides/edf/edf-operator-guide). Anyone can call `deploy()`. The new contract's owner and cooldown cannot be changed after deployment; its initial delegate can later be rotated or revoked. Only contracts deployed from this factory are accepted for Lido Oracle and Deposit Security Committee seats. Factory addresses per network are listed on the [deployed contracts](/deployed-contracts/#execution-delegation-framework) page. From d3e0a1d2a53cc13b9c7e697627251ede917500b7 Mon Sep 17 00:00:00 2001 From: chasingrainbows Date: Mon, 14 Sep 2026 19:34:17 +0300 Subject: [PATCH 7/8] fix: update ejector ver --- docs/guides/tooling.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/guides/tooling.md b/docs/guides/tooling.md index 99345e3b3..3df7358e3 100644 --- a/docs/guides/tooling.md +++ b/docs/guides/tooling.md @@ -24,11 +24,11 @@ Oracle daemon for Lido decentralized staking service. Daemon service which loads LidoOracle events for validator exits and sends out exit messages when necessary. -- **Version**: 2.1.0 -- **Docker image**: sha256:8953a4107d99ab84ff0f2b02cb7dd13b7cd7e5a565cf04fbe36e7911df5983dc, [lidofinance/validator-ejector@sha256-8953a4107d99ab84ff0f2b02cb7dd13b7cd7e5a565cf04fbe36e7911df5983dc](https://hub.docker.com/layers/lidofinance/validator-ejector/2.1.0/images/sha256-8953a4107d99ab84ff0f2b02cb7dd13b7cd7e5a565cf04fbe36e7911df5983dc) -- **Commit hash**: [lidofinance/validator-ejector@ec0992d](https://github.com/lidofinance/validator-ejector/commit/ec0992d9b4454425470b6608336755419ddb94ca) -- **Last update date**: 26 May, 2026 -- [**Repository**](https://github.com/lidofinance/validator-ejector/tree/2.1.0) +- **Version**: 2.2.0 +- **Docker image**: sha256:119841189487da4e049270abf6ed01b82a42c739878894528d4fefb600a471f5, [lidofinance/validator-ejector@sha256-119841189487da4e049270abf6ed01b82a42c739878894528d4fefb600a471f5](https://hub.docker.com/layers/lidofinance/validator-ejector/2.2.0/images/sha256-119841189487da4e049270abf6ed01b82a42c739878894528d4fefb600a471f5) +- **Commit hash**: [lidofinance/validator-ejector@debecf4](https://github.com/lidofinance/validator-ejector/commit/debecf42ac9f5ce7ccbd415274c8ef40f884b124) +- **Last update date**: 21 August, 2026 +- [**Repository**](https://github.com/lidofinance/validator-ejector/tree/2.2.0) - [**Documentation**](/guides/validator-ejector-guide) ## Council daemon From 32b9a5830949f4c86ae17af256473644565c41bc Mon Sep 17 00:00:00 2001 From: chasingrainbows Date: Tue, 15 Sep 2026 12:42:04 +0300 Subject: [PATCH 8/8] fix: mark the old DSM address as proposed to remove in protocol levers --- docs/guides/protocol-levers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/protocol-levers.md b/docs/guides/protocol-levers.md index 0e9035eee..fe8b23a67 100644 --- a/docs/guides/protocol-levers.md +++ b/docs/guides/protocol-levers.md @@ -105,7 +105,7 @@ Key levers on [StakingRouter](/contracts/staking-router/) ([`0xFdDf38947aFB03C62 | Module registry | `addStakingModule()`, `updateStakingModule()`, `setStakingModuleStatus()` | `STAKING_MODULE_MANAGE_ROLE` | StakingRouter | Aragon Agent | Aragon Agent ([`0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c`](https://etherscan.io/address/0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c)) | | Module fees | `setStakingModuleFees()` | `STAKING_MODULE_MANAGE_ROLE` | StakingRouter | Aragon Agent | Aragon Agent ([`0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c`](https://etherscan.io/address/0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c)) | | Withdrawal credentials | `setWithdrawalCredentials()` | `MANAGE_WITHDRAWAL_CREDENTIALS_ROLE` | StakingRouter | Aragon Agent | Unassigned | -| Module unvetting | `decreaseStakingModuleVettedKeysCountByNodeOperator()` | `STAKING_MODULE_UNVETTING_ROLE` | StakingRouter | Aragon Agent | [DepositSecurityModule](/contracts/deposit-security-module/) ([`0xF573E9E3de1f86B085417ab294f56E7920B4e9Be`](https://etherscan.io/address/0xF573E9E3de1f86B085417ab294f56E7920B4e9Be)), \[[proposed](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746/25)\] DSM v5 ([`0x39BB5d491e98A44D1bfe8047A737a81E296a63E0`](https://etherscan.io/address/0x39BB5d491e98A44D1bfe8047A737a81E296a63E0)) | +| Module unvetting | `decreaseStakingModuleVettedKeysCountByNodeOperator()` | `STAKING_MODULE_UNVETTING_ROLE` | StakingRouter | Aragon Agent | \[[proposed to remove](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746/25)\] [DepositSecurityModule](/contracts/deposit-security-module/) ([`0xF573E9E3de1f86B085417ab294f56E7920B4e9Be`](https://etherscan.io/address/0xF573E9E3de1f86B085417ab294f56E7920B4e9Be)), \[[proposed](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746/25)\] DSM v5 ([`0x39BB5d491e98A44D1bfe8047A737a81E296a63E0`](https://etherscan.io/address/0x39BB5d491e98A44D1bfe8047A737a81E296a63E0)) | ### Active staking modules