diff --git a/docs/guides/edf/edf-operator-guide.md b/docs/guides/edf/edf-operator-guide.md new file mode 100644 index 000000000..85a2d4f6d --- /dev/null +++ b/docs/guides/edf/edf-operator-guide.md @@ -0,0 +1,373 @@ +# EDF Operator Guide — Lido Oracle & Council Daemon + +Setup instructions for operators (key holders) of a Lido Oracle seat or a DSM guardian seat moving to the **Execution Delegation Framework (EDF)**. + +**Reference material:** + +- [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) +- [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 + +--- + +## What changes + +The protocol permission moves from your hot key to a contract you own — your `DelegationContract`. +Your hot key becomes its **delegate**, and you can rotate or revoke it yourself, with no vote. + +| | Before EDF | After EDF | +| --- | --- | --- | +| Who holds the Oracle / guardian seat | your hot EOA | your `DelegationContract` | +| Who signs and pays gas | your hot EOA | your delegate EOA (still a hot key) | +| Rotating the hot key | governance vote, ~10 days | `nominateDelegate()`, effective after 48 h | +| Killing a stolen hot key | governance vote | `revokeDelegate()`, effective immediately | +| Who can call `nominateDelegate` / `revokeDelegate` | — | **multisig participants** | + +Two key classes: + +- **Owner key** — a **Safe multisig**, 2-of-3 or stronger, with hardware cold wallets recommended + for the signers. Called **the multisig** everywhere below. +- **Delegate key** — the hot key on the daemon host. + +--- + +## Part 0 — Set up the owner multisig + +The owner address and the cooldown are fixed at deployment and **cannot be changed on-chain**. +Redoing them means a new `DelegationContract` and a governance vote. + +### 0.1. Read the custody policy + +Read the [Key Custody Policy](./key-custody-policy-for-edf-operators.md) before you generate +anything. Two of its values are irreversible: + +- the **owner address** — your multisig (step 0.2); +- the **cooldown** — **48 hours = `172800` seconds**. + +### 0.2. Prepare the owner multisig + +Create the multisig that will own your `DelegationContract`: + +| Network | Where to create the multisig | +| --- | --- | +| Ethereum mainnet | [app.safe.global](https://app.safe.global/welcome/spaces) | +| Hoodi | [app.safe.protofire.io](https://app.safe.protofire.io/welcome) — the official Safe UI does not support Hoodi | + +Pick the network first — one multisig per network: + +![Safe creation wizard step 1 on app.safe.global: name and Select Networks with Ethereum chosen](./screenshots/safe-mainnet-network.jpg) + +On Hoodi the wizard is the same, at [app.safe.protofire.io](https://app.safe.protofire.io/welcome): + +![Safe creation wizard step 1 on Protofire: Select Networks with Hoodi Testnet chosen](./screenshots/safe-hoodi-network.jpg) + +Requirements: + +- **at least 3 signers, threshold at least 2** (2-of-3 or stronger); +- this multisig is used for **nothing else** — no treasury, no other roles. + +![Safe creation wizard step 2: three signer addresses and a threshold of 2 out of 3 signers](./screenshots/safe-mainnet-signers-threshold.jpg) + +**Recommended:** every signer a **hardware cold wallet** (Ledger, Trezor). A software wallet is +acceptable. + +Write down who the signers are and how to reach them out of hours. + +--- + +## Part 1 — Set up your seat + +### 1.1. Generate the delegate hot key + +Generate a fresh key. Do not reuse the EOA that holds your seat today. + +### 1.2. Deploy your `DelegationContract` from the factory + +The `DelegationFactory` is already deployed by the Lido contributors — you only call it. + +| Network | `DelegationFactory` address | +| --- | --- | +| Ethereum mainnet | _TBD — will be published before the migration_ | +| Hoodi | [`0xEb49f72DB1546B0E63e1114E2e403edbcE722AE6`](https://hoodi.etherscan.io/address/0xEb49f72DB1546B0E63e1114E2e403edbcE722AE6#code) | + +**Do not accept a factory address from chat or DM.** Take it from the table above or from the +[Deployed Instances table](https://github.com/lidofinance/execution-delegation-framework#deployed-instances), +and check that Etherscan shows it verified under the name `DelegationFactory`. + +The call: + +``` +deploy(address owner, address delegate, uint256 cooldown) +``` + +Copy the multisig address from its dashboard: + +![Safe dashboard with the account address and a Copy address button](./screenshots/safe-address-copy.jpg) + +| Argument | Value | +| --- | --- | +| `owner` | your multisig address from its dashboard (see screenshot) | +| `delegate` | your delegate EOA from step 1.1 | +| `cooldown` | `172800` (48 hours) | + +`owner` and `delegate` must be different addresses, and `delegate` must not be `address(0)`. + +**In Etherscan** (or Blockscout, Otterscan): + +1. Open the factory address → **Contract** → **Write Contract**. +2. **Connect to Web3** with your owner multisig through WalletConnect — the same flow as in the + [Safe + Etherscan example](./edf-rotation-and-incidents.md). Deploying from the multisig also + verifies that you control the owner address. +3. Expand `deploy`, fill in the three values, send the transaction. + + ![Etherscan Write Contract tab with the deploy function expanded, showing the owner, delegate and cooldown fields](./screenshots/etherscan-deploy-form.jpg) + +4. Open the transaction → **Logs** tab → `DelegationContractDeployed(instance, owner, delegate, + cooldown)`. Save the **`instance`** address: that is your `DelegationContract`. + + ![Etherscan Logs tab showing InitialDelegateSet and DelegationContractDeployed with instance, owner, delegate and cooldown 172800](./screenshots/etherscan-deploy-logs-owned.jpg) + +### 1.3. Verify what you deployed + +Open your `DelegationContract` on Etherscan → **Contract** → **Read Contract**: + +| Method | Expected | +| --- | --- | +| `owner()` | your multisig address | +| `getDelegate()` | your delegate EOA | +| `getPendingDelegate()` | `0x0000…0000`, `0` | +| `getCooldown()` | `172800` | +| `isTerminated()` | `false` | + +Press **Expand All** to see every value at once: + +![Etherscan Read Contract tab with all methods expanded, showing getCooldown 172800, getDelegate and isTerminated False](./screenshots/etherscan-read-contract.jpg) + +Open the deploy transaction's **Logs** tab and confirm `InitialDelegateSet(newDelegate)` carries the +delegate address you intended. + +From a terminal: + +```bash +cast call "owner()(address)" --rpc-url $RPC_URL # your multisig +cast call "getDelegate()(address)" --rpc-url $RPC_URL # your delegate EOA +cast call "getPendingDelegate()(address,uint256)" --rpc-url $RPC_URL # 0x0…0, 0 +cast call "getCooldown()(uint256)" --rpc-url $RPC_URL # 172800 +cast call "isTerminated()(bool)" --rpc-url $RPC_URL # false +``` + +If anything does not match, the deploy parameters were wrong — deploy another contract from the +factory. + +### 1.4. Set up your own monitoring and alerts + +Lido runs protocol-wide monitoring; monitor your own contract independently. + +**Should page a human 24/7** — events on your `DelegationContract`: + +- `DelegateNominated(newDelegate, activeFrom)` — if you did not do it, your multisig is compromised. + React before `activeFrom` (48 h). +- `DelegateRevoked(revokedDelegate)` +- `Terminated()` + +Route these to a phone. + +**Should alert** — unusual delegate activity: + +- `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. + +### 1.5. Publish your addresses + +For a testnet seat, the internal operators' Telegram chat is enough. The forum post is for mainnet. + +Post in the LIP-37 thread on the Lido research forum: + +**→ [LIP-37: Execution Delegation Framework (EDF)](https://research.lido.fi/t/lip-37-execution-delegation-framework-edf/11746/6)** + +Copy the block below, fill in the addresses, and keep only the lines that are true. If a line is not +true yet, finish that step first. + +```markdown +**Seat:** Lido Oracle / DSM guardian +**DelegationContract:** +**Owner multisig:** +**Delegate EOA:** + +- [x] I have read the EDF Operator Key Custody Policy and my setup follows it +- [x] I created a dedicated owner multisig, at least 2-of-3, with signers held by different people + on different devices +- [x] The multisig is used for nothing except this delegation contract +- [x] I deployed my DelegationContract from the official DelegationFactory, with a 48-hour + (172800 s) cooldown +- [x] I verified on-chain that owner(), getDelegate(), getCooldown() and isTerminated() are what I + intended +- [x] I set up 24/7 alerts on DelegateNominated, DelegateRevoked and Terminated +``` + +--- + +## Part 2 — Configure the Lido Oracle + +> Follow **Part 2** if you run the Lido Oracle, **Part 3** if you run the Council daemon. + +### 2.1. Configure the oracle + +1. **Set the environment variables.** + + | Variable | Value | + | --- | --- | + | `DELEGATION_CONTRACT_ADDRESS` | Your `DelegationContract` address. When empty, delegation is off. | + | `MEMBER_PRIV_KEY` / `MEMBER_PRIV_KEY_FILE` | **The old key** — your existing member EOA. | + | `MEMBER_PRIV_KEY_2` / `MEMBER_PRIV_KEY_2_FILE` | **The new key** — the delegate of your `DelegationContract`. | + + ```bash + DELEGATION_CONTRACT_ADDRESS=0xYourDelegationContract + MEMBER_PRIV_KEY=0xoldmemberkey # old - active until the vote + MEMBER_PRIV_KEY_2=0xnewdelegatekey # new - takes over after the vote + ``` + +2. **Restart the oracle.** + +### 2.2. Check that the oracle works — in the logs + +At startup: + +- `Initialize delegation contract.` with your address — config is read correctly. +- `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. + +### 2.3. Report your oracle setup in the operators' chat + +After the oracle setup is ready, write an announcement in the holders' Telegram chat, before the +governance vote: + +```markdown +Oracle daemon ready for EDF — DelegationContract + +- [x] DELEGATION_CONTRACT_ADDRESS is set to my DelegationContract +- [x] Both keys are configured: MEMBER_PRIV_KEY (old member EOA) and MEMBER_PRIV_KEY_2 (new + delegate) +- [x] I restarted the oracle and saw no configuration errors in the logs +``` + +### 2.4. After the governance vote + +#### Check the delegated path on Etherscan + +Reports now arrive as **internal transactions**: the delegate calls `execute()` on your +`DelegationContract`, which calls the oracle contract. Check three address pages: + +| Open | Tab | What you must see | +| --- | --- | --- | +| your `DelegationContract` | **Internal Transactions** | outgoing calls to the oracle contracts, starting at the moment the delegate became effective | +| your **old** member EOA | **Transactions** | its calls to the oracle contracts **stopped** at that same moment | +| your **new** delegate EOA | **Transactions** | calls to your `DelegationContract` and **nothing else** | + +If the delegate EOA is calling an oracle contract **directly**, `DELEGATION_CONTRACT_ADDRESS` is +unset or wrong — fix the config. + +#### Retire the old key + +> **⚠ Do this only once the new delegate has produced a successful report** and the checks above +> pass. Not when the vote passes, and not when `getDelegate()` returns the new address. + +1. Move the delegate key into `MEMBER_PRIV_KEY` and clear `MEMBER_PRIV_KEY_2`. +2. Restart the oracle. +3. Delete the old key from your secrets store. +4. Move the old address's remaining balance to the new delegate address. + +--- + +## Part 3 — Configure the Council daemon (DSM guardian) + +### 3.1. Configure the council daemon + +1. **Set the environment variables.** + + | 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. | + | `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`. | + + ```bash + DELEGATION_CONTRACT_ADDRESS=0xYourDelegationContract + WALLET_PRIVATE_KEY=0xoldguardiankey # old - active until DSM v5 + WALLET_PRIVATE_KEY_2=0xnewdelegatekey # new - takes over at DSM v5 + ``` + +2. **Restart the daemon.** + +### 3.2. Check that the daemon works — in the logs + +The daemon reports its mode on every processed block: + +``` +Guardian execution mode: edf + delegateAddress: 0x... ← the hot key actually in use + guardianAddress: 0x... ← your DelegationContract + dsmAddress: 0x... + dsmVersion: 5 +``` + +Errors you may hit, and what they mean: + +| Error | Meaning | +| --- | --- | +| `DELEGATION_CONTRACT_ADDRESS is required for DSM version 5` | Variable not set. | +| `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. | +| `DelegationContract 0x… does not support ERC-1271` | The address is not an EDF delegation contract. | +| `No configured wallet private key matches active delegate 0x…` | The on-chain delegate is neither `WALLET_PRIVATE_KEY` nor `WALLET_PRIVATE_KEY_2`. Add the key and restart. | + +### 3.3. Report your council setup in the operators' chat + +After the council daemon setup is ready, write an announcement in the holders' Telegram chat, before +the governance vote: + +```markdown +Council daemon ready for EDF — DelegationContract + +- [x] DELEGATION_CONTRACT_ADDRESS is set to my DelegationContract +- [x] Both keys are configured: WALLET_PRIVATE_KEY (old guardian EOA) and WALLET_PRIVATE_KEY_2 + (new delegate) +- [x] I restarted the daemon and saw no configuration errors in the logs +``` + +### 3.4. After the governance vote + +#### Check the delegated path on Etherscan + +A guardian writes on-chain rarely — only `pauseDeposits` and `unvetSigningKeys` produce +transactions, and they now arrive as **internal transactions** through your `DelegationContract`. +Check three address pages: + +| Open | Tab | What you must see | +| --- | --- | --- | +| your `DelegationContract` | **Internal Transactions** | calls to the DSM — empty until the first pause or unvet, which is normal | +| your **old** guardian EOA | **Transactions** | its calls to the DSM **stopped** at the cutover | +| your **new** delegate EOA | **Transactions** | calls to your `DelegationContract`, plus messages to the DataBus contract on the DataBus chain — never a direct call to the DSM | + +A transaction sent **directly** from the delegate EOA to the DSM means the daemon is still in +`legacy-eoa` mode, or the delegate key was configured as a plain guardian somewhere. + +#### Retire the old key + +> **⚠ Do this only once the daemon is confirmed running in `edf` mode** — the log reading +> `Guardian execution mode: edf` with `dsmVersion: 5`, and pings and messages still flowing. + +1. Move the delegate key into `WALLET_PRIVATE_KEY` and clear `WALLET_PRIVATE_KEY_2`. +2. Restart the daemon. +3. Delete the old key from your secrets store. +4. Move the old address's remaining balance to the new delegate address. + +--- + +Setup is done. Routine key rotation and emergency procedures are in +**[EDF Rotation and Incidents](./edf-rotation-and-incidents.md)**. diff --git a/docs/guides/edf/edf-rotation-and-incidents.md b/docs/guides/edf/edf-rotation-and-incidents.md new file mode 100644 index 000000000..5308a9512 --- /dev/null +++ b/docs/guides/edf/edf-rotation-and-incidents.md @@ -0,0 +1,132 @@ +# EDF Rotation and Incidents + +Delegate key rotation and emergency procedures for an EDF seat. + +- [EDF Operator Guide](./edf-operator-guide.md) — the setup +- [EDF Operator Key Custody Policy](./key-custody-policy-for-edf-operators.md) — the rules you must + follow + +
+Example: running an Etherscan Hoodi transaction from a Safe wallet + +`nominateDelegate`, `revokeDelegate` and `terminate` are `onlyOwner` — the caller must be the +multisig. A plain MetaMask connection sends them from your own EOA and they revert with `NotOwner`. + +1. Open your `DelegationContract` on Etherscan → **Contract** → **Write Contract** → + **Connect Wallet** → **WalletConnect** → **All Wallets**. A QR code appears — copy the pairing + link (`wc:...`) next to it. Do **not** pick MetaMask. +2. In the Safe UI, click the **WalletConnect** icon in the header, paste the link into + **Pairing code**, and approve the session. The link expires within minutes — paste it right + after copying. + + ![Safe WalletConnect panel with the Pairing code field and Etherscan connected](./screenshots/safe-walletconnect-panel.jpg) + +3. Etherscan's header must now show the **multisig address**, not your EOA. +4. Fill in the method and press **Write**. + + ![Etherscan Write Contract on a DelegationContract: execute, nominateDelegate, revokeDelegate and terminate](./screenshots/etherscan-delegation-methods.jpg) + +5. The call lands in the multisig queue. Signers confirm it with their own wallets, then anyone + executes it and pays the gas. + + ![Safe Confirm transaction screen showing the call from Etherscan to the DelegationContract](./screenshots/safe-confirm-nominate.jpg) + +
+ +--- + +## Routine rotation + +Rotate at least **once a year**; quarterly is recommended. Also rotate when an engineer with host or +secrets access leaves, when the host is rebuilt from an untrusted image, or when the key's history +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`. +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 + is. Restart once. +4. **Nominate** from the owner multisig, a day after the announcement: + + ``` + nominateDelegate() + ``` + +5. **Verify your own nomination.** Read `getPendingDelegate()` on Etherscan, or: + + ```bash + cast call "getPendingDelegate()(address,uint256)" --rpc-url $RPC_URL + ``` + + The address and `activeFrom` must be exactly what you intended. +6. **Fund the new address** — send it half of the current delegate's balance. +7. **At `activeFrom`** the switch happens with no transaction and no restart. Verify: + + ```bash + cast call "getDelegate()(address)" --rpc-url $RPC_URL # == new delegate + ``` + + - Oracle: confirm a successful report in the following frame. + - Council: the log shows the new `delegateAddress`; confirm pings and messages continue. +8. **Only after that succeeds, retire the old key:** + - **Oracle:** move the new key into `MEMBER_PRIV_KEY` and clear `MEMBER_PRIV_KEY_2`. Restart. + - **Council:** move the new key into `WALLET_PRIVATE_KEY` and clear `WALLET_PRIVATE_KEY_2`. + Restart. + - Delete the old key from your secrets store. + - Move the old address's remaining balance to the new delegate address. + +Notes: + +- Calling `nominateDelegate` again during the cooldown **replaces** the pending delegate and + **restarts** the 48 hours. +- It reverts if the address is zero, equals the owner, equals the current delegate, or equals the + pending delegate. +- Never stage a second key on a host you suspect is compromised. + +## Emergency: the delegate hot key may be compromised + +Triggers: signatures or transactions you did not originate, host intrusion indicators, a secrets +store breach, malware on the host, or accidental disclosure (pasted in chat, committed to a repo, +captured in logs). + +**Revoke first, investigate second.** + +1. From the owner multisig, call: + + ``` + revokeDelegate() + ``` + + It takes effect immediately and cancels any rotation in flight. +2. **Notify** the holders' Telegram chat as soon as the transaction is sent: seat, revoked key, + known facts, and as much evidence as you can collect. +3. **Re-key on clean infrastructure**: new key on a rebuilt or verified host, funded, added to the + daemon config, then `nominateDelegate(newKey)` from the multisig. The seat comes back **48 hours + later**. +4. **Publish a post-incident report** (timeline, root cause, exposure window, custody changes) on + the forum, or in the holders' Telegram chat if disclosure is sensitive. + +While revoked, the Council daemon logs `DelegationContract 0x… has no active delegate` every block +and the Oracle logs a warning each cycle. This stops once the new delegate activates. + +## Emergency: the owner multisig may be compromised + +Triggers: unexpected changes to the multisig participants, unexpected multisig activity, or a +compromised signer device with any doubt about the rest of the quorum. + +1. If the owner itself can no longer be trusted, call from the multisig: + + ``` + terminate() + ``` + + **This is irreversible.** It disables `execute()`, fails all signature verification closed, and + clears the delegate forever. +2. **Notify governance and the holders' Telegram chat immediately.** Restoring the seat needs a *new* + `DelegationContract` with a *new* owner multisig **and a governance vote**. + +You have exactly one cooldown (48 h) between a hostile `DelegateNominated` and it becoming +effective. diff --git a/docs/guides/edf/key-custody-policy-for-edf-operators.md b/docs/guides/edf/key-custody-policy-for-edf-operators.md new file mode 100644 index 000000000..bd0285f60 --- /dev/null +++ b/docs/guides/edf/key-custody-policy-for-edf-operators.md @@ -0,0 +1,236 @@ +# EDF Operator Key Custody Policy + +> 🔐 Policy for [LIP-37: Execution Delegation Framework](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-37.md). Applies to operators of permissioned roles behind an EDF `DelegationContract`, initially Lido Oracle committee members and DSM guardians. + +**Version:** 1.0 + +**Applies to:** Operators of permissioned roles behind an EDF `DelegationContract` + +**Maintained:** On the Lido research forum; may be revised without a protocol change + +The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, and **MAY**, when they appear in uppercase, are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). + +--- + +## 1. Purpose and scope + +LIP-37 moves key rotation from a ~10-day governance vote to a local operator action. + +That only improves security if key holders store, rotate, and revoke keys with discipline. This document defines that discipline. + +It covers: + +- The two key classes in the EDF model +- Custody requirements for each key class +- Rotation cadence +- Required response to suspected or confirmed compromise + +--- + +## 2. Key classes + +| Key | Role in EDF | Exposure | Custody class | +| --- | --- | --- | --- | +| **Owner key** | Controls the `DelegationContract`: `nominateDelegate()`, `revokeDelegate()`, `terminate()` | Used rarely for rotations and incidents | **Cold** — Safe multisig; hardware cold-wallet signers RECOMMENDED | +| **Delegate key** | Hot signing key stored in and used by the off-chain daemon | Online continuously; assumed compromisable | **Hot** — machine-resident, minimized blast radius, rotated routinely | + +The two classes have opposite design goals: + +- The **owner key** is the security boundary of the whole model. It must be nearly impossible to steal, even at the cost of being slow to use. +- The **delegate key** is expected to be exposed by its nature. The policy goal is not to make it unstealable, but to keep it worthless quickly through narrow permissions, short lifetime, and instant revocation. + +--- + +## 3. Owner key custody + +The owner address is fixed at deployment and cannot be changed on-chain. Replacing it means deploying a new `DelegationContract` and passing a governance vote to reassign the seat. + +Treat the owner setup as a long-lived commitment and get it right before deployment. + +1. **The owner MUST be a Safe multisig.** + + A bare EOA owner is not acceptable for Oracle or DSM seats. + +2. **The owner MUST be dedicated to its assigned activity.** + + The multisig MUST be responsible only for the single activity it was assigned to perform: key delegation management. It MUST NOT be used for any other purpose. + +3. **The multisig MUST be at least 2-of-3.** + + A higher threshold or more signers is acceptable. + +4. **Every signer SHOULD be a hardware cold wallet.** + + Hardware cold wallets, such as Ledger or Trezor, are RECOMMENDED for all signers. + +5. **Multisig signer set changes MUST be executed promptly.** + + Every signer set change SHOULD be paired with a hot key rotation. + + - **Departure or role change.** No later than the person’s last day of access. Removing the signer MUST NOT delay revocation of their other access. + - **Lost or stolen signer device, or exposed seed backup.** Within **24 hours** of the loss being reported. If the affected signer together with any other doubtful signer would meet the threshold, treat it as a §6.2 event. + - **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**. + + +--- + +## 4. Delegate hot-key custody + +1. **Use one key per seat and environment.** + + A delegate key MUST be unique to a single `DelegationContract` and a single environment. It MUST NOT be reused across mainnet/testnet, across Oracle and Council daemons, or for anything besides its seat’s duties. + +2. **Harden the host.** + + The daemon host SHOULD be dedicated to the role, with: + + - Access limited to named engineers + - Audited access channels + - No shared SSH accounts + - Current OS and daemon versions + - No unrelated internet-facing services +3. **Keep only minimal balance.** + + The delegate address MUST hold only working gas funds. A low-balance alert SHOULD be configured. + +4. **Delegate keys MUST be dedicated to their assigned activity.** + + 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. Rotation policy + +EDF makes rotation seamless: `nominateDelegate(newKey)` keeps the old key effective until the new one activates after the cooldown. + +This section applies to delegate rotations after the EDF migration is complete. During the initial migration, the existing hot EOA is configured as the initial delegate and is effective immediately; the governance action reassigning the seat from that EOA to its `DelegationContract` is the migration cutover. + +A `DelegationContract` authorizes exactly one effective delegate at a time. Before `activeFrom`, the current delegate remains effective. Starting at `activeFrom`, the nominated delegate becomes effective automatically and the previous EOA loses its authority through that `DelegationContract`, although the EOA itself continues to exist. + +1. **Routine cadence** + + The delegate key MUST be rotated at least every **1 year**. Quarterly rotation is RECOMMENDED. + +2. **Event-driven rotation** + + Independent of cadence, the delegate key MUST be rotated when: + + - An engineer with access to the daemon host or secrets store leaves the organization or changes role + - The daemon host is migrated or rebuilt from an untrusted image + - Any dependency or infrastructure incident could have exposed the key + - The key’s age or custody history is unknown + + If exposure is suspected rather than merely possible, this becomes revocation, not rotation. + +3. **Announce rotations** + + Routine rotations MUST be announced on the Lido research forum at least **1 day** before `nominateDelegate()` is executed and in the operators’ coordination channel before execution. This lets monitoring parties distinguish a planned `DelegateNominated` from a hostile one. + +4. **Planned rotation procedure** + 1. Generate the new key. + 2. Publish the pre-nomination announcement on the research forum. + 3. Add the replacement key to the daemon as its staged secondary member key. Keep the current delegate configured and operating. + 4. On behalf of the owner, execute `nominateDelegate(newKey)` on your `DelegationContract`. The old key remains effective during the cooldown. + 5. Watch for your own `DelegateNominated` event and verify that the delegate and `activeFrom` returned by `getPendingDelegate()` match the intended rotation. + 6. Fund the replacement address from the current delegate address with half of its balance. + 7. During the cooldown, the daemon MUST continue using the current delegate. + 8. After activation: + - Verify `getDelegate() == newKey`. + - Confirm that the daemon selected the new key. + - Confirm a successful report or message in the following applicable frame. + 9. Only after successful verification: + - Remove the previous key from the daemon configuration and secrets store. + - Move the previous EOA’s remaining balance to the new delegate address. +5. **Owner rotation** + + Multisig signer keys follow §3.5. Replacing the multisig itself requires a new `DelegationContract` deployment and a governance vote. + + +--- + +## 6. Incident response + +Speed is the point of EDF. The contract lets operators drop a key in one transaction; this section defines when they must. + +### 6.1 Suspected or confirmed delegate hot-key compromise + +Triggers include: + +- Signatures or transactions you did not originate +- Host intrusion indicators +- Secrets-store breach +- Malware on the daemon host +- Accidental key disclosure, such as pasting in chat, committing to a repo, or capturing in logs + +Response: + +1. **Revoke first, investigate second.** + + The owner MUST call `revokeDelegate()` immediately upon suspicion. + + Revocation takes effect immediately: it clears both the current delegate and any pending one, and signature verification through the contract fails closed from that moment on. If a rotation is in flight, revocation cancels it — the staged replacement must be nominated again once the seat is safe to restore. + +2. **Notify security and operators.** + + Notify the holders’ Telegram chat as soon as the revocation transaction is sent. Include: + + - Seat + - Revoked key + - Known facts + - As much evidence as you can collect +3. **Re-key on clean infrastructure.** + + Generate a replacement per §4 on a host you trust, rebuilt or verified clean, and call `nominateDelegate(newKey)`. The seat resumes after the cooldown. + +4. **Publish a post-incident report.** + + Publish a summary to the research forum, or to the holders’ Telegram chat if disclosure is sensitive. Include: + + - Timeline + - Root cause + - Exposure window + - Custody changes made + +### 6.2 Suspected owner cold-key / multisig compromise + +Triggers include: + +- Unexpected changes to the multisig participants +- Unexpected multisig activity +- A compromised signer device combined with any doubt about the rest of the quorum + +Response: + +1. **If the owner itself can no longer be trusted:** + + The owner MUST call `terminate()`. + + Termination is irreversible. It disables `execute()`, fails all signature verification closed, and clears the delegate. A dead seat is strictly better than a stolen one. + +2. **Notify immediately.** + + Notify the holders’ Telegram chat immediately. Governance will need to reassign the seat to a freshly deployed `DelegationContract` with a new owner multisig, so early notice shortens downtime. + + +--- + +## 7. Monitoring + +Alongside Lido’s protocol-wide monitoring, each operator SHOULD independently monitor their own contract. + +### Recommended alerts + +- **`DelegateNominated`, `DelegateRevoked`, and `Terminated` events** on the operator’s `DelegationContract` + - SHOULD alert a human 24/7 + - An unexpected `DelegateNominated` is the primary owner-compromise signal + - The owner MUST react to an unexpected nomination before the cooldown elapses +- **Delegate address activity** outside the daemon’s expected pattern + - Unexpected `execute()` targets, including EOA destinations + - Unexpected non-zero `msg.value` forwarded through `execute()` + - Transactions from the delegate EOA itself + +### Emergency contact + +Each operator MUST provide a fast contact channel for emergencies, where a human can be reached at any time, and MUST keep it current. diff --git a/docs/guides/edf/screenshots/etherscan-delegation-methods.jpg b/docs/guides/edf/screenshots/etherscan-delegation-methods.jpg new file mode 100644 index 000000000..592faa861 Binary files /dev/null and b/docs/guides/edf/screenshots/etherscan-delegation-methods.jpg differ diff --git a/docs/guides/edf/screenshots/etherscan-deploy-form.jpg b/docs/guides/edf/screenshots/etherscan-deploy-form.jpg new file mode 100644 index 000000000..bc00a591a Binary files /dev/null and b/docs/guides/edf/screenshots/etherscan-deploy-form.jpg differ diff --git a/docs/guides/edf/screenshots/etherscan-deploy-logs-owned.jpg b/docs/guides/edf/screenshots/etherscan-deploy-logs-owned.jpg new file mode 100644 index 000000000..29f2e23c4 Binary files /dev/null and b/docs/guides/edf/screenshots/etherscan-deploy-logs-owned.jpg differ diff --git a/docs/guides/edf/screenshots/etherscan-read-contract.jpg b/docs/guides/edf/screenshots/etherscan-read-contract.jpg new file mode 100644 index 000000000..315de1dec Binary files /dev/null and b/docs/guides/edf/screenshots/etherscan-read-contract.jpg differ diff --git a/docs/guides/edf/screenshots/safe-address-copy.jpg b/docs/guides/edf/screenshots/safe-address-copy.jpg new file mode 100644 index 000000000..627cdc9fd Binary files /dev/null and b/docs/guides/edf/screenshots/safe-address-copy.jpg differ diff --git a/docs/guides/edf/screenshots/safe-confirm-nominate.jpg b/docs/guides/edf/screenshots/safe-confirm-nominate.jpg new file mode 100644 index 000000000..ced9e79a4 Binary files /dev/null and b/docs/guides/edf/screenshots/safe-confirm-nominate.jpg differ diff --git a/docs/guides/edf/screenshots/safe-hoodi-network.jpg b/docs/guides/edf/screenshots/safe-hoodi-network.jpg new file mode 100644 index 000000000..dd4690bb2 Binary files /dev/null and b/docs/guides/edf/screenshots/safe-hoodi-network.jpg differ diff --git a/docs/guides/edf/screenshots/safe-mainnet-network.jpg b/docs/guides/edf/screenshots/safe-mainnet-network.jpg new file mode 100644 index 000000000..b00623594 Binary files /dev/null and b/docs/guides/edf/screenshots/safe-mainnet-network.jpg differ diff --git a/docs/guides/edf/screenshots/safe-mainnet-signers-threshold.jpg b/docs/guides/edf/screenshots/safe-mainnet-signers-threshold.jpg new file mode 100644 index 000000000..5b154f1d5 Binary files /dev/null and b/docs/guides/edf/screenshots/safe-mainnet-signers-threshold.jpg differ diff --git a/docs/guides/edf/screenshots/safe-walletconnect-panel.jpg b/docs/guides/edf/screenshots/safe-walletconnect-panel.jpg new file mode 100644 index 000000000..8cad2f23b Binary files /dev/null and b/docs/guides/edf/screenshots/safe-walletconnect-panel.jpg differ diff --git a/sidebars.js b/sidebars.js index 138786393..53a283e35 100644 --- a/sidebars.js +++ b/sidebars.js @@ -45,6 +45,15 @@ module.exports = { ], }, 'guides/deposit-security-manual', + { + type: 'category', + label: 'Execution Delegation Framework', + items: [ + 'guides/edf/edf-operator-guide', + 'guides/edf/edf-rotation-and-incidents', + 'guides/edf/key-custody-policy-for-edf-operators', + ], + }, { type: 'category', label: 'Governance and Voting',