From 1ce0f038b126708082639c125ae3f1648a479114 Mon Sep 17 00:00:00 2001 From: Pedro Carnerero Date: Fri, 14 Aug 2026 12:06:26 +0200 Subject: [PATCH] feat: add support for Lido Curated Module v2 Register Lido's Curated Module v2 as a mux key loader target. Module id 4 on mainnet (`0xDa5F930cE326EB5205085D66c72A4E79d60cB8C1`) and module id 5 for hoodi (`0x87EB69Ae51317405FD285efD2326a4a11f6173b9`). CMv2 exposes the same node operator read interface as the Community Staking Module, so we reuse the CSM contract instead of duplicating it. --- config.example.toml | 8 +- crates/common/src/config/mux.rs | 2 +- crates/common/src/interop/lido/types.rs | 2 + crates/common/src/interop/lido/utils.rs | 111 +++++++++++++++++++++++- crates/common/src/types.rs | 2 + examples/configs/pbs_mux.toml | 9 ++ 6 files changed, 129 insertions(+), 5 deletions(-) diff --git a/config.example.toml b/config.example.toml index 4b6b2853f..d1fefe639 100644 --- a/config.example.toml +++ b/config.example.toml @@ -149,7 +149,11 @@ validator_pubkeys = [ # - File: path to a file containing a list of validator pubkeys in JSON format # - URL: URL to an HTTP endpoint returning a list of validator pubkeys in JSON format # - Registry: details of a registry to load keys from. Supported registries: -# - Lido: NodeOperatorsRegistry +# - Lido: staking module registries. Requires the PBS `rpc_url` to be set, and accepts a `lido_module_id` in the mux loader +# (defaults to 1). Supported module IDs: +# - mainnet: 1 = Curated, 2 = SimpleDVT, 3 = Community Staking (CSM), 4 = Curated Module v2 (CMv2) +# - hoodi: 1 = Curated, 2 = SimpleDVT, 3 = Sandbox, 4 = Community Staking (CSM), 5 = Curated Module v2 (CMv2) +# - holesky: 1 = Curated, 2 = SimpleDVT, 3 = Sandbox, 4 = Community Staking (CSM) # - SSV: SSV API # - Stader: Stader Permissioned/Permissionless NodePool (mainnet only). Requires the PBS `rpc_url` to be set (like Lido), # and a `stader_pool` ("permissioned" or "permissionless") in the mux loader. @@ -169,6 +173,8 @@ validator_pubkeys = [ loader = "./tests/data/mux_keys.example.json" # loader = { url = "http://localhost:8000/keys" } # loader = { registry = "lido", node_operator_id = 8, lido_module_id = 1, enable_refreshing = false } +# Curated Module v2 (CMv2): lido_module_id = 4 on mainnet, 5 on hoodi +# loader = { registry = "lido", node_operator_id = 8, lido_module_id = 4, enable_refreshing = false } # loader = { registry = "ssv", node_operator_id = 8, enable_refreshing = false } # loader = { registry = "stader", node_operator_id = 8, stader_pool = "permissionless", enable_refreshing = false } late_in_slot_time_ms = 1500 diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index e9072ba82..1b1f0c38e 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -414,7 +414,7 @@ async fn fetch_lido_registry_keys( let rpc_client = RpcClient::new(http, is_local); let registry_address = lido_registry_address(chain, lido_module_id)?; - if is_csm_module(chain, lido_module_id) { + if uses_csm_registry_interface(chain, lido_module_id) { fetch_lido_csm_registry_keys(registry_address, rpc_client, node_operator_id).await } else { fetch_lido_module_registry_keys(registry_address, rpc_client, node_operator_id).await diff --git a/crates/common/src/interop/lido/types.rs b/crates/common/src/interop/lido/types.rs index 48aad1226..1bdf9bc25 100644 --- a/crates/common/src/interop/lido/types.rs +++ b/crates/common/src/interop/lido/types.rs @@ -7,6 +7,8 @@ sol! { "src/abi/LidoNORegistry.json" } +// Shared by the Community Staking Module and the Curated Module v2 +// (`curated-onchain-v2`), which expose the same node operator read interface. sol! { #[allow(missing_docs)] #[sol(rpc)] diff --git a/crates/common/src/interop/lido/utils.rs b/crates/common/src/interop/lido/utils.rs index 02ff7c42c..4f85e2db6 100644 --- a/crates/common/src/interop/lido/utils.rs +++ b/crates/common/src/interop/lido/utils.rs @@ -30,6 +30,10 @@ lazy_static! { MainnetLidoModule::CommunityStaking as u8, address!("dA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F"), ); + mainnet.insert( + MainnetLidoModule::CuratedV2 as u8, + address!("Da5F930cE326EB5205085D66c72A4E79d60cB8C1"), + ); map.insert(Chain::Mainnet, mainnet); // --- Holesky --- @@ -70,6 +74,10 @@ lazy_static! { HoodiLidoModule::CommunityStaking as u8, address!("79CEf36D84743222f37765204Bec41E92a93E59d"), ); + hoodi.insert( + HoodiLidoModule::CuratedV2 as u8, + address!("87EB69Ae51317405FD285efD2326a4a11f6173b9"), + ); map.insert(Chain::Hoodi, hoodi); // --- Sepolia -- @@ -93,11 +101,25 @@ pub fn lido_registry_address(chain: Chain, lido_module_id: u8) -> eyre::Result bool { +/// Lido staking modules expose one of two node operator registry interfaces. +/// +/// The Community Staking Module and the Curated Module v2 +/// (`curated-onchain-v2`) share the CSM interface: `getSigningKeys` returns a +/// flat `bytes` blob and the key total is derived from +/// `getNodeOperatorSummary`. Every other module is a `NodeOperatorsRegistry` +/// (curated v1, SimpleDVT, Sandbox), which returns a struct from +/// `getSigningKeys` and exposes `getTotalSigningKeyCount`. +pub fn uses_csm_registry_interface(chain: Chain, module_id: u8) -> bool { match chain { - Chain::Mainnet => module_id == MainnetLidoModule::CommunityStaking as u8, + Chain::Mainnet => { + module_id == MainnetLidoModule::CommunityStaking as u8 || + module_id == MainnetLidoModule::CuratedV2 as u8 + } Chain::Holesky => module_id == HoleskyLidoModule::CommunityStaking as u8, - Chain::Hoodi => module_id == HoodiLidoModule::CommunityStaking as u8, + Chain::Hoodi => { + module_id == HoodiLidoModule::CommunityStaking as u8 || + module_id == HoodiLidoModule::CuratedV2 as u8 + } _ => false, } } @@ -268,4 +290,87 @@ mod tests { Ok(()) } + + #[test] + fn test_lido_curated_v2_registry_address() -> eyre::Result<()> { + assert_eq!( + lido_registry_address(Chain::Mainnet, MainnetLidoModule::CuratedV2 as u8)?, + address!("Da5F930cE326EB5205085D66c72A4E79d60cB8C1") + ); + + assert_eq!( + lido_registry_address(Chain::Hoodi, HoodiLidoModule::CuratedV2 as u8)?, + address!("87EB69Ae51317405FD285efD2326a4a11f6173b9") + ); + + // CMv2 shares the CSM read interface, so it must be routed to it + assert!(uses_csm_registry_interface(Chain::Mainnet, MainnetLidoModule::CuratedV2 as u8)); + assert!(uses_csm_registry_interface(Chain::Hoodi, HoodiLidoModule::CuratedV2 as u8)); + + // Curated v1 still uses the NodeOperatorsRegistry interface + assert!(!uses_csm_registry_interface(Chain::Mainnet, MainnetLidoModule::Curated as u8)); + assert!(!uses_csm_registry_interface(Chain::Hoodi, HoodiLidoModule::Curated as u8)); + + // CMv2 is not deployed on Holesky + assert!(lido_registry_address(Chain::Holesky, 5).is_err()); + + Ok(()) + } + + /// Reads the first keys of a CMv2 node operator through the same helpers + /// the mux loader uses. + async fn assert_curated_v2_keys( + rpc_url: &str, + chain: Chain, + module_id: u8, + ) -> eyre::Result<()> { + let url = Url::parse(rpc_url)?; + let provider = ProviderBuilder::new().connect_http(url); + + let registry_address = lido_registry_address(chain, module_id)?; + let registry = get_lido_csm_registry(registry_address, provider); + + const LIMIT: u64 = 3; + let node_operator_id = U256::ZERO; + + let total_keys = fetch_lido_csm_keys_total(®istry, node_operator_id).await?; + + assert!(total_keys >= LIMIT, "expected at least {LIMIT} keys, got {total_keys}"); + + let pubkeys = fetch_lido_csm_keys_batch(®istry, node_operator_id, 0, LIMIT).await?; + + assert_eq!(pubkeys.len(), LIMIT as usize * BLS_PUBLIC_KEY_BYTES_LEN); + + let mut vec = Vec::new(); + for chunk in pubkeys.chunks(BLS_PUBLIC_KEY_BYTES_LEN) { + vec.push( + BlsPublicKey::deserialize(chunk) + .map_err(|_| eyre::eyre!("invalid BLS public key"))?, + ); + } + + assert_eq!(vec.len(), LIMIT as usize); + + Ok(()) + } + + #[tokio::test] + async fn test_lido_curated_v2_mainnet_keys() -> eyre::Result<()> { + assert_curated_v2_keys( + "https://ethereum-rpc.publicnode.com", + Chain::Mainnet, + MainnetLidoModule::CuratedV2 as u8, + ) + .await + } + + #[tokio::test] + async fn test_lido_curated_v2_hoodi_keys() -> eyre::Result<()> { + assert_curated_v2_keys( + "https://ethereum-hoodi-rpc.publicnode.com", + Chain::Hoodi, + HoodiLidoModule::CuratedV2 as u8, + ) + .await + } } diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 3fbbbde8d..ec416849d 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -59,6 +59,7 @@ pub enum MainnetLidoModule { Curated = 1, SimpleDVT = 2, CommunityStaking = 3, + CuratedV2 = 4, } pub enum HoleskyLidoModule { @@ -73,6 +74,7 @@ pub enum HoodiLidoModule { SimpleDVT = 2, Sandbox = 3, CommunityStaking = 4, + CuratedV2 = 5, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] diff --git a/examples/configs/pbs_mux.toml b/examples/configs/pbs_mux.toml index b26e06239..ddbfe4c12 100644 --- a/examples/configs/pbs_mux.toml +++ b/examples/configs/pbs_mux.toml @@ -39,6 +39,15 @@ loader = { registry = "lido", node_operator_id = 8, lido_module_id = 1 } id = "relay-3" url = "http://0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745@fgh.xyz" +[[mux]] +id = "lido-cmv2-mux" +# Curated Module v2: module id 4 on mainnet, 5 on hoodi +loader = { registry = "lido", node_operator_id = 8, lido_module_id = 4 } + +[[mux.relays]] +id = "relay-6" +url = "http://0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745@fgh.xyz" + [[mux]] id = "ssv-mux" loader = { registry = "ssv", node_operator_id = 200 }