Skip to content

Commit 260afcd

Browse files
PedroCM96JasonVranek
authored andcommitted
feat: add support for Lido Curated Module v2 (#489)
(cherry picked from commit 2994cee)
1 parent eeff257 commit 260afcd

6 files changed

Lines changed: 129 additions & 5 deletions

File tree

‎config.example.toml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,11 @@ validator_pubkeys = [
140140
# - File: path to a file containing a list of validator pubkeys in JSON format
141141
# - URL: URL to an HTTP endpoint returning a list of validator pubkeys in JSON format
142142
# - Registry: details of a registry to load keys from. Supported registries:
143-
# - Lido: NodeOperatorsRegistry
143+
# - Lido: staking module registries. Requires the PBS `rpc_url` to be set, and accepts a `lido_module_id` in the mux loader
144+
# (defaults to 1). Supported module IDs:
145+
# - mainnet: 1 = Curated, 2 = SimpleDVT, 3 = Community Staking (CSM), 4 = Curated Module v2 (CMv2)
146+
# - hoodi: 1 = Curated, 2 = SimpleDVT, 3 = Sandbox, 4 = Community Staking (CSM), 5 = Curated Module v2 (CMv2)
147+
# - holesky: 1 = Curated, 2 = SimpleDVT, 3 = Sandbox, 4 = Community Staking (CSM)
144148
# - SSV: SSV API
145149
# You can toggle the 'enable_refreshing' flag to let this registry periodically query Lido or SSV and refresh the list of validator pubkeys belonging to the corresponding operator.
146150
# Each of these registry entries must be unique:
@@ -157,6 +161,8 @@ validator_pubkeys = [
157161
loader = "./tests/data/mux_keys.example.json"
158162
# loader = { url = "http://localhost:8000/keys" }
159163
# loader = { registry = "lido", node_operator_id = 8, lido_module_id = 1, enable_refreshing = false }
164+
# Curated Module v2 (CMv2): lido_module_id = 4 on mainnet, 5 on hoodi
165+
# loader = { registry = "lido", node_operator_id = 8, lido_module_id = 4, enable_refreshing = false }
160166
# loader = { registry = "ssv", node_operator_id = 8, enable_refreshing = false }
161167
late_in_slot_time_ms = 1500
162168
timeout_get_header_ms = 900

‎crates/common/src/config/mux.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ async fn fetch_lido_registry_keys(
413413
let rpc_client = RpcClient::new(http, is_local);
414414
let registry_address = lido_registry_address(chain, lido_module_id)?;
415415

416-
if is_csm_module(chain, lido_module_id) {
416+
if uses_csm_registry_interface(chain, lido_module_id) {
417417
fetch_lido_csm_registry_keys(registry_address, rpc_client, node_operator_id).await
418418
} else {
419419
fetch_lido_module_registry_keys(registry_address, rpc_client, node_operator_id).await

‎crates/common/src/interop/lido/types.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ sol! {
77
"src/abi/LidoNORegistry.json"
88
}
99

10+
// Shared by the Community Staking Module and the Curated Module v2
11+
// (`curated-onchain-v2`), which expose the same node operator read interface.
1012
sol! {
1113
#[allow(missing_docs)]
1214
#[sol(rpc)]

‎crates/common/src/interop/lido/utils.rs‎

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ lazy_static! {
3030
MainnetLidoModule::CommunityStaking as u8,
3131
address!("dA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F"),
3232
);
33+
mainnet.insert(
34+
MainnetLidoModule::CuratedV2 as u8,
35+
address!("Da5F930cE326EB5205085D66c72A4E79d60cB8C1"),
36+
);
3337
map.insert(Chain::Mainnet, mainnet);
3438

3539
// --- Holesky ---
@@ -70,6 +74,10 @@ lazy_static! {
7074
HoodiLidoModule::CommunityStaking as u8,
7175
address!("79CEf36D84743222f37765204Bec41E92a93E59d"),
7276
);
77+
hoodi.insert(
78+
HoodiLidoModule::CuratedV2 as u8,
79+
address!("87EB69Ae51317405FD285efD2326a4a11f6173b9"),
80+
);
7381
map.insert(Chain::Hoodi, hoodi);
7482

7583
// --- Sepolia --
@@ -93,11 +101,25 @@ pub fn lido_registry_address(chain: Chain, lido_module_id: u8) -> eyre::Result<A
93101
})
94102
}
95103

96-
pub fn is_csm_module(chain: Chain, module_id: u8) -> bool {
104+
/// Lido staking modules expose one of two node operator registry interfaces.
105+
///
106+
/// The Community Staking Module and the Curated Module v2
107+
/// (`curated-onchain-v2`) share the CSM interface: `getSigningKeys` returns a
108+
/// flat `bytes` blob and the key total is derived from
109+
/// `getNodeOperatorSummary`. Every other module is a `NodeOperatorsRegistry`
110+
/// (curated v1, SimpleDVT, Sandbox), which returns a struct from
111+
/// `getSigningKeys` and exposes `getTotalSigningKeyCount`.
112+
pub fn uses_csm_registry_interface(chain: Chain, module_id: u8) -> bool {
97113
match chain {
98-
Chain::Mainnet => module_id == MainnetLidoModule::CommunityStaking as u8,
114+
Chain::Mainnet => {
115+
module_id == MainnetLidoModule::CommunityStaking as u8 ||
116+
module_id == MainnetLidoModule::CuratedV2 as u8
117+
}
99118
Chain::Holesky => module_id == HoleskyLidoModule::CommunityStaking as u8,
100-
Chain::Hoodi => module_id == HoodiLidoModule::CommunityStaking as u8,
119+
Chain::Hoodi => {
120+
module_id == HoodiLidoModule::CommunityStaking as u8 ||
121+
module_id == HoodiLidoModule::CuratedV2 as u8
122+
}
101123
_ => false,
102124
}
103125
}
@@ -268,4 +290,87 @@ mod tests {
268290

269291
Ok(())
270292
}
293+
294+
#[test]
295+
fn test_lido_curated_v2_registry_address() -> eyre::Result<()> {
296+
assert_eq!(
297+
lido_registry_address(Chain::Mainnet, MainnetLidoModule::CuratedV2 as u8)?,
298+
address!("Da5F930cE326EB5205085D66c72A4E79d60cB8C1")
299+
);
300+
301+
assert_eq!(
302+
lido_registry_address(Chain::Hoodi, HoodiLidoModule::CuratedV2 as u8)?,
303+
address!("87EB69Ae51317405FD285efD2326a4a11f6173b9")
304+
);
305+
306+
// CMv2 shares the CSM read interface, so it must be routed to it
307+
assert!(uses_csm_registry_interface(Chain::Mainnet, MainnetLidoModule::CuratedV2 as u8));
308+
assert!(uses_csm_registry_interface(Chain::Hoodi, HoodiLidoModule::CuratedV2 as u8));
309+
310+
// Curated v1 still uses the NodeOperatorsRegistry interface
311+
assert!(!uses_csm_registry_interface(Chain::Mainnet, MainnetLidoModule::Curated as u8));
312+
assert!(!uses_csm_registry_interface(Chain::Hoodi, HoodiLidoModule::Curated as u8));
313+
314+
// CMv2 is not deployed on Holesky
315+
assert!(lido_registry_address(Chain::Holesky, 5).is_err());
316+
317+
Ok(())
318+
}
319+
320+
/// Reads the first keys of a CMv2 node operator through the same helpers
321+
/// the mux loader uses.
322+
async fn assert_curated_v2_keys(
323+
rpc_url: &str,
324+
chain: Chain,
325+
module_id: u8,
326+
) -> eyre::Result<()> {
327+
let url = Url::parse(rpc_url)?;
328+
let provider = ProviderBuilder::new().connect_http(url);
329+
330+
let registry_address = lido_registry_address(chain, module_id)?;
331+
let registry = get_lido_csm_registry(registry_address, provider);
332+
333+
const LIMIT: u64 = 3;
334+
let node_operator_id = U256::ZERO;
335+
336+
let total_keys = fetch_lido_csm_keys_total(&registry, node_operator_id).await?;
337+
338+
assert!(total_keys >= LIMIT, "expected at least {LIMIT} keys, got {total_keys}");
339+
340+
let pubkeys = fetch_lido_csm_keys_batch(&registry, node_operator_id, 0, LIMIT).await?;
341+
342+
assert_eq!(pubkeys.len(), LIMIT as usize * BLS_PUBLIC_KEY_BYTES_LEN);
343+
344+
let mut vec = Vec::new();
345+
for chunk in pubkeys.chunks(BLS_PUBLIC_KEY_BYTES_LEN) {
346+
vec.push(
347+
BlsPublicKey::deserialize(chunk)
348+
.map_err(|_| eyre::eyre!("invalid BLS public key"))?,
349+
);
350+
}
351+
352+
assert_eq!(vec.len(), LIMIT as usize);
353+
354+
Ok(())
355+
}
356+
357+
#[tokio::test]
358+
async fn test_lido_curated_v2_mainnet_keys() -> eyre::Result<()> {
359+
assert_curated_v2_keys(
360+
"https://ethereum-rpc.publicnode.com",
361+
Chain::Mainnet,
362+
MainnetLidoModule::CuratedV2 as u8,
363+
)
364+
.await
365+
}
366+
367+
#[tokio::test]
368+
async fn test_lido_curated_v2_hoodi_keys() -> eyre::Result<()> {
369+
assert_curated_v2_keys(
370+
"https://ethereum-hoodi-rpc.publicnode.com",
371+
Chain::Hoodi,
372+
HoodiLidoModule::CuratedV2 as u8,
373+
)
374+
.await
375+
}
271376
}

‎crates/common/src/types.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pub enum MainnetLidoModule {
5959
Curated = 1,
6060
SimpleDVT = 2,
6161
CommunityStaking = 3,
62+
CuratedV2 = 4,
6263
}
6364

6465
pub enum HoleskyLidoModule {
@@ -73,6 +74,7 @@ pub enum HoodiLidoModule {
7374
SimpleDVT = 2,
7475
Sandbox = 3,
7576
CommunityStaking = 4,
77+
CuratedV2 = 5,
7678
}
7779

7880
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]

‎examples/configs/pbs_mux.toml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ loader = { registry = "lido", node_operator_id = 8, lido_module_id = 1 }
3939
id = "relay-3"
4040
url = "http://0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745@fgh.xyz"
4141

42+
[[mux]]
43+
id = "lido-cmv2-mux"
44+
# Curated Module v2: module id 4 on mainnet, 5 on hoodi
45+
loader = { registry = "lido", node_operator_id = 8, lido_module_id = 4 }
46+
47+
[[mux.relays]]
48+
id = "relay-6"
49+
url = "http://0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745@fgh.xyz"
50+
4251
[[mux]]
4352
id = "ssv-mux"
4453
loader = { registry = "ssv", node_operator_id = 200 }

0 commit comments

Comments
 (0)